751 lines
24 KiB
TypeScript
Executable File
751 lines
24 KiB
TypeScript
Executable File
import type {
|
|
ChatCompletionRequest,
|
|
ImageGenerationRequest,
|
|
ImageGenerationResponse,
|
|
} from "../../contracts/src/mod.ts";
|
|
import type { IProviderAdapter, ProviderContext } from "./types.ts";
|
|
import { ProviderClient, ProviderError } from "./client.ts";
|
|
import {
|
|
createSSEResponse,
|
|
NormalizationPipeline,
|
|
} from "../../core/src/mod.ts";
|
|
import { OpenAIStreamTransformer } from "./openai.ts";
|
|
import {
|
|
buildImagenPredictBody,
|
|
type ImagenPrediction,
|
|
mapImagenResponse,
|
|
resolveImagenModel,
|
|
} from "./imagen.ts";
|
|
import { detectAudioMimeType, pcmToWav } from "./audio.ts";
|
|
import {
|
|
bareResourceId,
|
|
buildBatchCreateBody,
|
|
decodeBase64ToBytes,
|
|
encodeBytesToBase64,
|
|
GEMINI_BATCH_ACTIVE_STATES,
|
|
type GeminiBatchJob,
|
|
type GeminiBatchResultLine,
|
|
type GeminiFile,
|
|
type GeminiGenerateContentResponse,
|
|
mapBatchResultLine,
|
|
mapGeminiBatch,
|
|
mapGeminiFile,
|
|
qualifyResourceId,
|
|
} from "./gemini_advanced.ts";
|
|
|
|
/** Flattens OpenAI message content (string or parts array) to plain text. */
|
|
function contentText(content: unknown): string {
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((p) =>
|
|
typeof (p as { text?: unknown }).text === "string"
|
|
? (p as { text: string }).text
|
|
: ""
|
|
)
|
|
.join("");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Throws the frosty ProviderError convention for a non-OK upstream reply. */
|
|
async function ensureOk(response: Response): Promise<void> {
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new ProviderError(response.status, response.statusText, text);
|
|
}
|
|
}
|
|
|
|
function jsonBody(value: unknown): Response {
|
|
return new Response(JSON.stringify(value), {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function concatBytes(chunks: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
let total = 0;
|
|
for (const chunk of chunks) total += chunk.length;
|
|
const out = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
out.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Gemini through Google's OpenAI-compatible endpoint. */
|
|
export class GeminiAdapter implements IProviderAdapter {
|
|
constructor(
|
|
private apiKey: string,
|
|
private baseUrl: string =
|
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
|
private client: ProviderClient = new ProviderClient(),
|
|
) {}
|
|
|
|
async chatCompletions(
|
|
req: ChatCompletionRequest,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.baseUrl}/chat/completions`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify(req),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new ProviderError(response.status, response.statusText, body);
|
|
}
|
|
|
|
if (req.stream && response.body) {
|
|
return createSSEResponse(
|
|
NormalizationPipeline.create(
|
|
new OpenAIStreamTransformer(),
|
|
response.body,
|
|
),
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Embeddings via Google's OpenAI-compatible /embeddings surface. The body
|
|
* and response are already OpenAI-shaped, so this forwards verbatim (the
|
|
* same contract as OpenAIAdapter.embeddings).
|
|
*/
|
|
async embeddings(req: unknown, context?: ProviderContext): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.baseUrl}/embeddings`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${this.apiKey}`,
|
|
},
|
|
body: JSON.stringify(req),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new ProviderError(response.status, response.statusText, text);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Native image generation via Google Imagen. The OpenAI-compat base
|
|
* (".../v1beta/openai") is reduced to the native base (".../v1beta") — the
|
|
* same derivation as countTokens — and the model's `:predict` verb is called
|
|
* with the API key in x-goog-api-key. The OpenAI-shaped request is mapped to
|
|
* Imagen instances/parameters and the base64 predictions to `b64_json`.
|
|
*/
|
|
async generateImage(
|
|
req: ImageGenerationRequest,
|
|
context?: ProviderContext,
|
|
): Promise<ImageGenerationResponse> {
|
|
const nativeBase = this.baseUrl.replace(/\/openai\/?$/, "");
|
|
const model = resolveImagenModel(req.model);
|
|
// fetchGuarded: an image render is paid work, so a retried 429/5xx is spend
|
|
// the caller never asked for.
|
|
const response = await this.client.fetchGuarded(
|
|
`${nativeBase}/models/${encodeURIComponent(model)}:predict`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-goog-api-key": this.apiKey,
|
|
},
|
|
body: JSON.stringify(buildImagenPredictBody(req)),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new ProviderError(response.status, response.statusText, text);
|
|
}
|
|
const body = await response.json() as { predictions?: ImagenPrediction[] };
|
|
return mapImagenResponse(body.predictions ?? []);
|
|
}
|
|
|
|
/**
|
|
* Native token pre-flight via the generativelanguage models:countTokens
|
|
* endpoint. The OpenAI-compat base (".../v1beta/openai") is reduced to the
|
|
* native base (".../v1beta"); auth is the API key in x-goog-api-key.
|
|
*/
|
|
async countTokens(
|
|
req: ChatCompletionRequest,
|
|
context?: ProviderContext,
|
|
): Promise<{ input_tokens: number; estimated: boolean }> {
|
|
const nativeBase = this.baseUrl.replace(/\/openai\/?$/, "");
|
|
const model = req.model.replace(/^models\//, "");
|
|
const contents = req.messages
|
|
.filter((m) => m.role !== "function")
|
|
.map((m) => ({
|
|
role: m.role === "assistant" ? "model" : "user",
|
|
parts: [{ text: contentText(m.content) }],
|
|
}));
|
|
const response = await this.client.fetchWithRetry(
|
|
`${nativeBase}/models/${encodeURIComponent(model)}:countTokens`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-goog-api-key": this.apiKey,
|
|
},
|
|
body: JSON.stringify({ contents }),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new ProviderError(response.status, response.statusText, text);
|
|
}
|
|
const body = await response.json() as { totalTokens?: number };
|
|
return { input_tokens: body.totalTokens ?? 0, estimated: false };
|
|
}
|
|
|
|
/**
|
|
* Translating passthrough for the native (non-OpenAI-compat) Gemini
|
|
* surfaces: /audio/speech and /audio/transcriptions ride models/*:
|
|
* generateContent, /files* the Files API (plain multipart upload — no
|
|
* resumable protocol), /batches* the Batch API. Auth is x-goog-api-key on
|
|
* the native base (the ElevenLabs translation precedent).
|
|
*/
|
|
async rawProxy(
|
|
path: string,
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
if (path === "/audio/speech") {
|
|
return await this.speech(req, context);
|
|
}
|
|
if (path === "/audio/transcriptions") {
|
|
return await this.transcribe(req, context);
|
|
}
|
|
if (path === "/files" || path.startsWith("/files/")) {
|
|
return await this.filesProxy(path, req, context);
|
|
}
|
|
if (path === "/batches" || path.startsWith("/batches/")) {
|
|
return await this.batchesProxy(path, req, context);
|
|
}
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
`Gemini passthrough supports /audio/*, /files* and /batches* only, ` +
|
|
`got "${path}".`,
|
|
);
|
|
}
|
|
|
|
/** Native base per the generateImage/countTokens derivation. */
|
|
private nativeBase(): string {
|
|
return this.baseUrl.replace(/\/openai\/?$/, "");
|
|
}
|
|
|
|
private nativeHeaders(json: boolean): Record<string, string> {
|
|
return json
|
|
? { "Content-Type": "application/json", "x-goog-api-key": this.apiKey }
|
|
: { "x-goog-api-key": this.apiKey };
|
|
}
|
|
|
|
/**
|
|
* TTS via generateContent with responseModalities AUDIO. Gemini returns raw
|
|
* PCM (s16le, 24 kHz, mono); by default (and for response_format "wav")
|
|
* it is wrapped in a RIFF/WAVE container so the reply is self-describing —
|
|
* "pcm" returns the raw bytes. Anything else is a 400.
|
|
*/
|
|
private async speech(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const body = await req.json() as {
|
|
model?: string;
|
|
input?: string;
|
|
voice?: string;
|
|
response_format?: string;
|
|
};
|
|
const format = body.response_format;
|
|
if (format && format !== "wav" && format !== "pcm") {
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
`gemini supports only wav/pcm response_format, got "${format}".`,
|
|
);
|
|
}
|
|
const model = (body.model ?? "").replace(/^models\//, "");
|
|
const generationConfig: Record<string, unknown> = {
|
|
responseModalities: ["AUDIO"],
|
|
};
|
|
if (body.voice) {
|
|
generationConfig.speechConfig = {
|
|
voiceConfig: { prebuiltVoiceConfig: { voiceName: body.voice } },
|
|
};
|
|
}
|
|
// fetchGuarded: synthesising speech is paid work, so a retried 429/5xx is
|
|
// spend the caller never asked for.
|
|
const response = await this.client.fetchGuarded(
|
|
`${this.nativeBase()}/models/${encodeURIComponent(model)}` +
|
|
`:generateContent`,
|
|
{
|
|
method: "POST",
|
|
headers: this.nativeHeaders(true),
|
|
body: JSON.stringify({
|
|
contents: [{ parts: [{ text: body.input ?? "" }] }],
|
|
generationConfig,
|
|
}),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
await ensureOk(response);
|
|
const result = await response.json() as GeminiGenerateContentResponse;
|
|
// Gemini TTS is token-priced while OpenAI TTS is character-priced, and this
|
|
// reply is audio bytes - no JSON body an accounting site could sniff. Report
|
|
// the usage block rather than dropping it; the route bounds the numbers.
|
|
const reported = result.usageMetadata;
|
|
if (reported && context?.onUsage) {
|
|
context.onUsage({
|
|
prompt: reported.promptTokenCount,
|
|
completion: reported.candidatesTokenCount,
|
|
total: reported.totalTokenCount,
|
|
});
|
|
}
|
|
const chunks: Uint8Array[] = [];
|
|
for (const part of result.candidates?.[0]?.content?.parts ?? []) {
|
|
const inline = part.inlineData;
|
|
if (inline?.data && (inline.mimeType ?? "").startsWith("audio/")) {
|
|
chunks.push(decodeBase64ToBytes(inline.data));
|
|
}
|
|
}
|
|
const pcm = concatBytes(chunks);
|
|
if (format === "pcm") {
|
|
return new Response(pcm, { headers: { "Content-Type": "audio/pcm" } });
|
|
}
|
|
// pcmToWav's declared Uint8Array is ArrayBuffer-backed (fresh allocation);
|
|
// the assertion just narrows it to what BodyInit requires.
|
|
return new Response(pcmToWav(pcm) as Uint8Array<ArrayBuffer>, {
|
|
headers: { "Content-Type": "audio/wav" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* STT via generateContent: the multipart file is inlined (sniffed MIME +
|
|
* base64) after the prompt part, and the reply is mapped to the OpenAI
|
|
* transcription JSON shape.
|
|
*/
|
|
private async transcribe(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const form = await req.formData();
|
|
const file = form.get("file");
|
|
if (!(file instanceof Blob)) {
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
"gemini transcription requires a multipart `file` field.",
|
|
);
|
|
}
|
|
const model = String(form.get("model") ?? "").replace(/^models\//, "");
|
|
if (!model) {
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
"gemini transcription requires a `model` field.",
|
|
);
|
|
}
|
|
const promptField = form.get("prompt");
|
|
const prompt = typeof promptField === "string" && promptField !== ""
|
|
? promptField
|
|
: "Generate a transcript of the speech.";
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const parts: Array<Record<string, unknown>> = [{ text: prompt }];
|
|
if (bytes.length > 0) {
|
|
parts.push({
|
|
inlineData: {
|
|
mimeType: detectAudioMimeType(bytes),
|
|
data: encodeBytesToBase64(bytes),
|
|
},
|
|
});
|
|
}
|
|
// fetchGuarded: transcription is paid work, and the inlined audio makes a
|
|
// retry an expensive re-upload as well as unrequested spend.
|
|
const response = await this.client.fetchGuarded(
|
|
`${this.nativeBase()}/models/${encodeURIComponent(model)}` +
|
|
`:generateContent`,
|
|
{
|
|
method: "POST",
|
|
headers: this.nativeHeaders(true),
|
|
body: JSON.stringify({ contents: [{ parts }] }),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
await ensureOk(response);
|
|
const result = await response.json() as GeminiGenerateContentResponse;
|
|
const text = (result.candidates?.[0]?.content?.parts ?? [])
|
|
.map((p) => p.text ?? "")
|
|
.join("");
|
|
const out: Record<string, unknown> = { text };
|
|
if (text !== "") {
|
|
out.task = "transcribe";
|
|
const usage = result.usageMetadata;
|
|
out.usage = {
|
|
type: "tokens",
|
|
input_tokens: usage?.promptTokenCount ?? 0,
|
|
output_tokens: usage?.candidatesTokenCount ?? 0,
|
|
total_tokens: usage?.totalTokenCount ?? 0,
|
|
};
|
|
}
|
|
return jsonBody(out);
|
|
}
|
|
|
|
private async filesProxy(
|
|
path: string,
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const method = req.method.toUpperCase();
|
|
const segments = path.split("/").filter((s) => s !== "");
|
|
if (segments.length === 3 && segments[2] === "content") {
|
|
// Go-exact behavior: the consumer Files API has no download endpoint.
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
"Gemini Files API doesn't support direct content download. " +
|
|
"Use the file URI in your requests instead.",
|
|
);
|
|
}
|
|
if (segments.length === 1 && method === "POST") {
|
|
return await this.uploadFile(req, context);
|
|
}
|
|
if (segments.length === 1 && method === "GET") {
|
|
return await this.listFiles(req, context);
|
|
}
|
|
if (segments.length === 2 && method === "GET") {
|
|
return await this.retrieveFile(segments[1], context);
|
|
}
|
|
if (segments.length === 2 && method === "DELETE") {
|
|
return await this.deleteFile(segments[1], context);
|
|
}
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
`unsupported gemini files operation: ${method} ${path}`,
|
|
);
|
|
}
|
|
|
|
private async uploadFile(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const form = await req.formData();
|
|
const file = form.get("file");
|
|
if (!(file instanceof Blob)) {
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
"gemini file upload requires a multipart `file` field.",
|
|
);
|
|
}
|
|
const purpose = String(form.get("purpose") ?? "");
|
|
const filename = file instanceof File ? file.name : "";
|
|
// Plain multipart (no resumable protocol, no X-Goog-Upload-* headers):
|
|
// a `metadata` JSON field naming the display name, then the raw bytes.
|
|
const upload = new FormData();
|
|
upload.append(
|
|
"metadata",
|
|
JSON.stringify({
|
|
file: { displayName: filename },
|
|
}),
|
|
);
|
|
upload.append("file", file, filename || "file.bin");
|
|
const uploadBase = this.nativeBase().replace("/v1beta", "/upload/v1beta");
|
|
const response = await this.client.fetchWithRetry(`${uploadBase}/files`, {
|
|
method: "POST",
|
|
headers: this.nativeHeaders(false),
|
|
body: upload,
|
|
signal: context?.signal,
|
|
});
|
|
await ensureOk(response);
|
|
// Upload responses are wrapped in {"file": {...}}; get/list are not.
|
|
const wrapper = await response.json() as { file?: GeminiFile };
|
|
return jsonBody(mapGeminiFile(wrapper.file ?? {}, purpose));
|
|
}
|
|
|
|
private async listFiles(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/files${listQuery(req)}`,
|
|
{ headers: this.nativeHeaders(false), signal: context?.signal },
|
|
);
|
|
await ensureOk(response);
|
|
const body = await response.json() as {
|
|
files?: GeminiFile[];
|
|
nextPageToken?: string;
|
|
};
|
|
const out: Record<string, unknown> = {
|
|
object: "list",
|
|
data: (body.files ?? []).map((f) => mapGeminiFile(f, "vision")),
|
|
has_more: !!body.nextPageToken,
|
|
};
|
|
if (body.nextPageToken) out.last_id = body.nextPageToken;
|
|
return jsonBody(out);
|
|
}
|
|
|
|
private async retrieveFile(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/${qualifyResourceId(id, "files/")}`,
|
|
{ headers: this.nativeHeaders(false), signal: context?.signal },
|
|
);
|
|
await ensureOk(response);
|
|
const file = await response.json() as GeminiFile;
|
|
return jsonBody(mapGeminiFile(file, "vision"));
|
|
}
|
|
|
|
private async deleteFile(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/${qualifyResourceId(id, "files/")}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: this.nativeHeaders(false),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
await ensureOk(response);
|
|
await response.body?.cancel();
|
|
return jsonBody({
|
|
id: bareResourceId(id, "files/"),
|
|
object: "file",
|
|
deleted: true,
|
|
});
|
|
}
|
|
|
|
private async batchesProxy(
|
|
path: string,
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const method = req.method.toUpperCase();
|
|
const segments = path.split("/").filter((s) => s !== "");
|
|
if (segments.length === 1 && method === "POST") {
|
|
return await this.createBatch(req, context);
|
|
}
|
|
if (segments.length === 1 && method === "GET") {
|
|
return await this.listBatches(req, context);
|
|
}
|
|
if (segments.length === 2 && method === "GET") {
|
|
return await this.retrieveBatch(segments[1], context);
|
|
}
|
|
if (
|
|
segments.length === 3 && segments[2] === "cancel" && method === "POST"
|
|
) {
|
|
return await this.cancelBatch(segments[1], context);
|
|
}
|
|
if (
|
|
segments.length === 3 && segments[2] === "results" && method === "GET"
|
|
) {
|
|
return await this.batchResults(segments[1], context);
|
|
}
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
`unsupported gemini batches operation: ${method} ${path}`,
|
|
);
|
|
}
|
|
|
|
private async createBatch(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const raw = await req.json();
|
|
const { model, payload } = buildBatchCreateBody(raw);
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/models/${
|
|
encodeURIComponent(model.replace(/^models\//, ""))
|
|
}:batchGenerateContent`,
|
|
{
|
|
method: "POST",
|
|
headers: this.nativeHeaders(true),
|
|
body: JSON.stringify(payload),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
await ensureOk(response);
|
|
const job = await response.json() as GeminiBatchJob;
|
|
const body = raw as { input_file_id?: unknown; endpoint?: unknown };
|
|
return jsonBody(mapGeminiBatch(job, {
|
|
inputFileId: typeof body.input_file_id === "string"
|
|
? body.input_file_id
|
|
: undefined,
|
|
endpoint: typeof body.endpoint === "string" ? body.endpoint : undefined,
|
|
}));
|
|
}
|
|
|
|
private async listBatches(
|
|
req: Request,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/batches${listQuery(req)}`,
|
|
{ headers: this.nativeHeaders(false), signal: context?.signal },
|
|
);
|
|
// Batch listing may be unavailable on the consumer API: an empty list is
|
|
// the documented reply for 404/405, not an error (Go parity).
|
|
if (response.status === 404 || response.status === 405) {
|
|
await response.body?.cancel();
|
|
return jsonBody({ object: "list", data: [] });
|
|
}
|
|
await ensureOk(response);
|
|
const body = await response.json() as {
|
|
operations?: GeminiBatchJob[];
|
|
nextPageToken?: string;
|
|
};
|
|
const out: Record<string, unknown> = {
|
|
object: "list",
|
|
data: (body.operations ?? []).map((op) => mapGeminiBatch(op)),
|
|
has_more: !!body.nextPageToken,
|
|
};
|
|
if (body.nextPageToken) out.last_id = body.nextPageToken;
|
|
return jsonBody(out);
|
|
}
|
|
|
|
/** Raw native batch job fetch shared by retrieve and results. */
|
|
private async fetchBatchJob(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<GeminiBatchJob> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/${qualifyResourceId(id, "batches/")}`,
|
|
{ headers: this.nativeHeaders(false), signal: context?.signal },
|
|
);
|
|
await ensureOk(response);
|
|
return await response.json() as GeminiBatchJob;
|
|
}
|
|
|
|
private async retrieveBatch(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
return jsonBody(mapGeminiBatch(await this.fetchBatchJob(id, context)));
|
|
}
|
|
|
|
private async cancelBatch(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const response = await this.client.fetchWithRetry(
|
|
`${this.nativeBase()}/${qualifyResourceId(id, "batches/")}:cancel`,
|
|
{
|
|
method: "POST",
|
|
headers: this.nativeHeaders(true),
|
|
signal: context?.signal,
|
|
},
|
|
);
|
|
await ensureOk(response);
|
|
await response.body?.cancel();
|
|
return jsonBody({
|
|
id: bareResourceId(id, "batches/"),
|
|
object: "batch",
|
|
status: "cancelling",
|
|
cancelling_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Batch results as OpenAI-style output JSONL. File-based jobs download the
|
|
* results file from the /download base with :download?alt=media; inline
|
|
* jobs map dest.inlinedResponses directly.
|
|
*/
|
|
private async batchResults(
|
|
id: string,
|
|
context?: ProviderContext,
|
|
): Promise<Response> {
|
|
const job = await this.fetchBatchJob(id, context);
|
|
const state = job.metadata?.state ?? "";
|
|
if ((GEMINI_BATCH_ACTIVE_STATES as readonly string[]).includes(state)) {
|
|
throw new ProviderError(
|
|
400,
|
|
"Bad Request",
|
|
`batch ${id} is still processing (state: ${state}), ` +
|
|
`results not yet available`,
|
|
);
|
|
}
|
|
const lines: Array<Record<string, unknown>> = [];
|
|
if (job.dest?.fileName) {
|
|
const downloadBase = this.nativeBase().replace(
|
|
"/v1beta",
|
|
"/download/v1beta",
|
|
);
|
|
const download = await this.client.fetchWithRetry(
|
|
`${downloadBase}/${qualifyResourceId(job.dest.fileName, "files/")}` +
|
|
`:download?alt=media`,
|
|
{ headers: this.nativeHeaders(false), signal: context?.signal },
|
|
);
|
|
await ensureOk(download);
|
|
for (const line of (await download.text()).split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
let parsed: GeminiBatchResultLine;
|
|
try {
|
|
parsed = JSON.parse(trimmed) as GeminiBatchResultLine;
|
|
} catch {
|
|
continue; // Go skips (and only logs) unparseable result lines.
|
|
}
|
|
lines.push(mapBatchResultLine(parsed, lines.length));
|
|
}
|
|
} else if (job.dest?.inlinedResponses?.length) {
|
|
job.dest.inlinedResponses.forEach((entry, i) => {
|
|
lines.push(mapBatchResultLine(entry, i));
|
|
});
|
|
}
|
|
if (
|
|
lines.length === 0 &&
|
|
(state === "BATCH_STATE_SUCCEEDED" || state === "BATCH_STATE_FAILED")
|
|
) {
|
|
lines.push({
|
|
custom_id: "info",
|
|
response: {
|
|
status_code: 200,
|
|
body: {
|
|
message:
|
|
`Batch completed with state: ${state}. No results available.`,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
const body = lines.map((l) => JSON.stringify(l)).join("\n");
|
|
return new Response(lines.length > 0 ? `${body}\n` : body, {
|
|
headers: { "Content-Type": "application/jsonl" },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Maps the gateway's ?limit=/&after= list params to pageSize/pageToken. */
|
|
function listQuery(req: Request): string {
|
|
const params = new URL(req.url).searchParams;
|
|
const query = new URLSearchParams();
|
|
const limit = params.get("limit");
|
|
if (limit) query.set("pageSize", limit);
|
|
const after = params.get("after");
|
|
if (after) query.set("pageToken", after);
|
|
const qs = query.toString();
|
|
return qs ? `?${qs}` : "";
|
|
}
|