| | import { Buffer } from "node:buffer"; |
| |
|
| | export default { |
| | async fetch (request) { |
| | if (request.method === "OPTIONS") { |
| | return handleOPTIONS(); |
| | } |
| | const errHandler = (err) => { |
| | console.error(err); |
| | return new Response(err.message, fixCors({ status: err.status ?? 500 })); |
| | }; |
| | try { |
| | const auth = request.headers.get("Authorization"); |
| | const apiKey = auth?.split(" ")[1]; |
| | const assert = (success) => { |
| | if (!success) { |
| | throw new HttpError("The specified HTTP method is not allowed for the requested resource", 400); |
| | } |
| | }; |
| | const { pathname } = new URL(request.url); |
| | switch (true) { |
| | case pathname.endsWith("/chat/completions"): |
| | assert(request.method === "POST"); |
| | return handleCompletions(await request.json(), apiKey) |
| | .catch(errHandler); |
| | case pathname.endsWith("/embeddings"): |
| | assert(request.method === "POST"); |
| | return handleEmbeddings(await request.json(), apiKey) |
| | .catch(errHandler); |
| | case pathname.endsWith("/models"): |
| | assert(request.method === "GET"); |
| | return handleModels(apiKey) |
| | .catch(errHandler); |
| | default: |
| | throw new HttpError("404 Not Found", 404); |
| | } |
| | } catch (err) { |
| | return errHandler(err); |
| | } |
| | } |
| | }; |
| |
|
| | class HttpError extends Error { |
| | constructor(message, status) { |
| | super(message); |
| | this.name = this.constructor.name; |
| | this.status = status; |
| | } |
| | } |
| |
|
| | const fixCors = ({ headers, status, statusText }) => { |
| | headers = new Headers(headers); |
| | headers.set("Access-Control-Allow-Origin", "*"); |
| | return { headers, status, statusText }; |
| | }; |
| |
|
| | const handleOPTIONS = async () => { |
| | return new Response(null, { |
| | headers: { |
| | "Access-Control-Allow-Origin": "*", |
| | "Access-Control-Allow-Methods": "*", |
| | "Access-Control-Allow-Headers": "*", |
| | } |
| | }); |
| | }; |
| |
|
| | const BASE_URL = "https://generativelanguage.googleapis.com"; |
| | const API_VERSION = "v1beta"; |
| |
|
| | |
| | const API_CLIENT = "genai-js/0.21.0"; |
| | const makeHeaders = (apiKey, more) => ({ |
| | "x-goog-api-client": API_CLIENT, |
| | ...(apiKey && { "x-goog-api-key": apiKey }), |
| | ...more |
| | }); |
| |
|
| | async function handleModels (apiKey) { |
| | const response = await fetch(`${BASE_URL}/${API_VERSION}/models`, { |
| | headers: makeHeaders(apiKey), |
| | }); |
| | let { body } = response; |
| | if (response.ok) { |
| | const { models } = JSON.parse(await response.text()); |
| | body = JSON.stringify({ |
| | object: "list", |
| | data: models.map(({ name }) => ({ |
| | id: name.replace("models/", ""), |
| | object: "model", |
| | created: 0, |
| | owned_by: "", |
| | })), |
| | }, null, " "); |
| | } |
| | return new Response(body, fixCors(response)); |
| | } |
| |
|
| | const DEFAULT_EMBEDDINGS_MODEL = "text-embedding-004"; |
| | async function handleEmbeddings (req, apiKey) { |
| | if (typeof req.model !== "string") { |
| | throw new HttpError("model is not specified", 400); |
| | } |
| | if (!Array.isArray(req.input)) { |
| | req.input = [ req.input ]; |
| | } |
| | let model; |
| | if (req.model.startsWith("models/")) { |
| | model = req.model; |
| | } else { |
| | req.model = DEFAULT_EMBEDDINGS_MODEL; |
| | model = "models/" + req.model; |
| | } |
| | const response = await fetch(`${BASE_URL}/${API_VERSION}/${model}:batchEmbedContents`, { |
| | method: "POST", |
| | headers: makeHeaders(apiKey, { "Content-Type": "application/json" }), |
| | body: JSON.stringify({ |
| | "requests": req.input.map(text => ({ |
| | model, |
| | content: { parts: { text } }, |
| | outputDimensionality: req.dimensions, |
| | })) |
| | }) |
| | }); |
| | let { body } = response; |
| | if (response.ok) { |
| | const { embeddings } = JSON.parse(await response.text()); |
| | body = JSON.stringify({ |
| | object: "list", |
| | data: embeddings.map(({ values }, index) => ({ |
| | object: "embedding", |
| | index, |
| | embedding: values, |
| | })), |
| | model: req.model, |
| | }, null, " "); |
| | } |
| | return new Response(body, fixCors(response)); |
| | } |
| |
|
| | const DEFAULT_MODEL = "gemini-1.5-pro-latest"; |
| | async function handleCompletions (req, apiKey) { |
| | let model = DEFAULT_MODEL; |
| | switch(true) { |
| | case typeof req.model !== "string": |
| | break; |
| | case req.model.startsWith("models/"): |
| | model = req.model.substring(7); |
| | break; |
| | case req.model.startsWith("gemini-"): |
| | case req.model.startsWith("learnlm-"): |
| | model = req.model; |
| | } |
| | const TASK = req.stream ? "streamGenerateContent" : "generateContent"; |
| | let url = `${BASE_URL}/${API_VERSION}/models/${model}:${TASK}`; |
| | if (req.stream) { url += "?alt=sse"; } |
| | const response = await fetch(url, { |
| | method: "POST", |
| | headers: makeHeaders(apiKey, { "Content-Type": "application/json" }), |
| | body: JSON.stringify(await transformRequest(req)), |
| | }); |
| |
|
| | let body = response.body; |
| | if (response.ok) { |
| | let id = generateChatcmplId(); |
| | if (req.stream) { |
| | body = response.body |
| | .pipeThrough(new TextDecoderStream()) |
| | .pipeThrough(new TransformStream({ |
| | transform: parseStream, |
| | flush: parseStreamFlush, |
| | buffer: "", |
| | })) |
| | .pipeThrough(new TransformStream({ |
| | transform: toOpenAiStream, |
| | flush: toOpenAiStreamFlush, |
| | streamIncludeUsage: req.stream_options?.include_usage, |
| | model, id, last: [], |
| | })) |
| | .pipeThrough(new TextEncoderStream()); |
| | } else { |
| | body = await response.text(); |
| | body = processCompletionsResponse(JSON.parse(body), model, id); |
| | } |
| | } |
| | return new Response(body, fixCors(response)); |
| | } |
| |
|
| | const harmCategory = [ |
| | "HARM_CATEGORY_HATE_SPEECH", |
| | "HARM_CATEGORY_SEXUALLY_EXPLICIT", |
| | "HARM_CATEGORY_DANGEROUS_CONTENT", |
| | "HARM_CATEGORY_HARASSMENT", |
| | "HARM_CATEGORY_CIVIC_INTEGRITY", |
| | ]; |
| | const safetySettings = harmCategory.map(category => ({ |
| | category, |
| | threshold: "BLOCK_NONE", |
| | })); |
| | const fieldsMap = { |
| | stop: "stopSequences", |
| | n: "candidateCount", |
| | max_tokens: "maxOutputTokens", |
| | max_completion_tokens: "maxOutputTokens", |
| | temperature: "temperature", |
| | top_p: "topP", |
| | top_k: "topK", |
| | frequency_penalty: "frequencyPenalty", |
| | presence_penalty: "presencePenalty", |
| | }; |
| | const transformConfig = (req) => { |
| | let cfg = {}; |
| | |
| | for (let key in req) { |
| | const matchedKey = fieldsMap[key]; |
| | if (matchedKey) { |
| | cfg[matchedKey] = req[key]; |
| | } |
| | } |
| | if (req.response_format) { |
| | switch(req.response_format.type) { |
| | case "json_schema": |
| | cfg.responseSchema = req.response_format.json_schema?.schema; |
| | if (cfg.responseSchema && "enum" in cfg.responseSchema) { |
| | cfg.responseMimeType = "text/x.enum"; |
| | break; |
| | } |
| | |
| | case "json_object": |
| | cfg.responseMimeType = "application/json"; |
| | break; |
| | case "text": |
| | cfg.responseMimeType = "text/plain"; |
| | break; |
| | default: |
| | throw new HttpError("Unsupported response_format.type", 400); |
| | } |
| | } |
| | return cfg; |
| | }; |
| |
|
| | const parseImg = async (url) => { |
| | let mimeType, data; |
| | if (url.startsWith("http://") || url.startsWith("https://")) { |
| | try { |
| | const response = await fetch(url); |
| | if (!response.ok) { |
| | throw new Error(`${response.status} ${response.statusText} (${url})`); |
| | } |
| | mimeType = response.headers.get("content-type"); |
| | data = Buffer.from(await response.arrayBuffer()).toString("base64"); |
| | } catch (err) { |
| | throw new Error("Error fetching image: " + err.toString()); |
| | } |
| | } else { |
| | const match = url.match(/^data:(?<mimeType>.*?)(;base64)?,(?<data>.*)$/); |
| | if (!match) { |
| | throw new Error("Invalid image data: " + url); |
| | } |
| | ({ mimeType, data } = match.groups); |
| | } |
| | return { |
| | inlineData: { |
| | mimeType, |
| | data, |
| | }, |
| | }; |
| | }; |
| |
|
| | const transformMsg = async ({ role, content }) => { |
| | const parts = []; |
| | if (!Array.isArray(content)) { |
| | |
| | |
| | parts.push({ text: content }); |
| | return { role, parts }; |
| | } |
| | |
| | |
| | |
| | |
| | for (const item of content) { |
| | switch (item.type) { |
| | case "text": |
| | parts.push({ text: item.text }); |
| | break; |
| | case "image_url": |
| | parts.push(await parseImg(item.image_url.url)); |
| | break; |
| | case "input_audio": |
| | parts.push({ |
| | inlineData: { |
| | mimeType: "audio/" + item.input_audio.format, |
| | data: item.input_audio.data, |
| | } |
| | }); |
| | break; |
| | default: |
| | throw new TypeError(`Unknown "content" item type: "${item.type}"`); |
| | } |
| | } |
| | if (content.every(item => item.type === "image_url")) { |
| | parts.push({ text: "" }); |
| | } |
| | return { role, parts }; |
| | }; |
| |
|
| | const transformMessages = async (messages) => { |
| | if (!messages) { return; } |
| | const contents = []; |
| | let system_instruction; |
| | for (const item of messages) { |
| | if (item.role === "system") { |
| | delete item.role; |
| | system_instruction = await transformMsg(item); |
| | } else { |
| | item.role = item.role === "assistant" ? "model" : "user"; |
| | contents.push(await transformMsg(item)); |
| | } |
| | } |
| | if (system_instruction && contents.length === 0) { |
| | contents.push({ role: "model", parts: { text: " " } }); |
| | } |
| | |
| | return { system_instruction, contents }; |
| | }; |
| |
|
| | const transformRequest = async (req) => ({ |
| | ...await transformMessages(req.messages), |
| | safetySettings, |
| | generationConfig: transformConfig(req), |
| | }); |
| |
|
| | const generateChatcmplId = () => { |
| | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| | const randomChar = () => characters[Math.floor(Math.random() * characters.length)]; |
| | return "chatcmpl-" + Array.from({ length: 29 }, randomChar).join(""); |
| | }; |
| |
|
| | const reasonsMap = { |
| | |
| | "STOP": "stop", |
| | "MAX_TOKENS": "length", |
| | "SAFETY": "content_filter", |
| | "RECITATION": "content_filter", |
| | |
| | |
| | }; |
| | const SEP = "\n\n|>"; |
| | const transformCandidates = (key, cand) => ({ |
| | index: cand.index || 0, |
| | [key]: { |
| | role: "assistant", |
| | content: cand.content?.parts.map(p => p.text).join(SEP) }, |
| | logprobs: null, |
| | finish_reason: reasonsMap[cand.finishReason] || cand.finishReason, |
| | }); |
| | const transformCandidatesMessage = transformCandidates.bind(null, "message"); |
| | const transformCandidatesDelta = transformCandidates.bind(null, "delta"); |
| |
|
| | const transformUsage = (data) => ({ |
| | completion_tokens: data.candidatesTokenCount, |
| | prompt_tokens: data.promptTokenCount, |
| | total_tokens: data.totalTokenCount |
| | }); |
| |
|
| | const processCompletionsResponse = (data, model, id) => { |
| | return JSON.stringify({ |
| | id, |
| | choices: data.candidates.map(transformCandidatesMessage), |
| | created: Math.floor(Date.now()/1000), |
| | model, |
| | |
| | object: "chat.completion", |
| | usage: transformUsage(data.usageMetadata), |
| | }); |
| | }; |
| |
|
| | const responseLineRE = /^data: (.*)(?:\n\n|\r\r|\r\n\r\n)/; |
| | async function parseStream (chunk, controller) { |
| | chunk = await chunk; |
| | if (!chunk) { return; } |
| | this.buffer += chunk; |
| | do { |
| | const match = this.buffer.match(responseLineRE); |
| | if (!match) { break; } |
| | controller.enqueue(match[1]); |
| | this.buffer = this.buffer.substring(match[0].length); |
| | } while (true); |
| | } |
| | async function parseStreamFlush (controller) { |
| | if (this.buffer) { |
| | console.error("Invalid data:", this.buffer); |
| | controller.enqueue(this.buffer); |
| | } |
| | } |
| |
|
| | function transformResponseStream (data, stop, first) { |
| | const item = transformCandidatesDelta(data.candidates[0]); |
| | if (stop) { item.delta = {}; } else { item.finish_reason = null; } |
| | if (first) { item.delta.content = ""; } else { delete item.delta.role; } |
| | const output = { |
| | id: this.id, |
| | choices: [item], |
| | created: Math.floor(Date.now()/1000), |
| | model: this.model, |
| | |
| | object: "chat.completion.chunk", |
| | }; |
| | if (data.usageMetadata && this.streamIncludeUsage) { |
| | output.usage = stop ? transformUsage(data.usageMetadata) : null; |
| | } |
| | return "data: " + JSON.stringify(output) + delimiter; |
| | } |
| | const delimiter = "\n\n"; |
| | async function toOpenAiStream (chunk, controller) { |
| | const transform = transformResponseStream.bind(this); |
| | const line = await chunk; |
| | if (!line) { return; } |
| | let data; |
| | try { |
| | data = JSON.parse(line); |
| | } catch (err) { |
| | console.error(line); |
| | console.error(err); |
| | const length = this.last.length || 1; |
| | const candidates = Array.from({ length }, (_, index) => ({ |
| | finishReason: "error", |
| | content: { parts: [{ text: err }] }, |
| | index, |
| | })); |
| | data = { candidates }; |
| | } |
| | const cand = data.candidates[0]; |
| | console.assert(data.candidates.length === 1, "Unexpected candidates count: %d", data.candidates.length); |
| | cand.index = cand.index || 0; |
| | if (!this.last[cand.index]) { |
| | controller.enqueue(transform(data, false, "first")); |
| | } |
| | this.last[cand.index] = data; |
| | if (cand.content) { |
| | controller.enqueue(transform(data)); |
| | } |
| | } |
| | async function toOpenAiStreamFlush (controller) { |
| | const transform = transformResponseStream.bind(this); |
| | if (this.last.length > 0) { |
| | for (const data of this.last) { |
| | controller.enqueue(transform(data, "stop")); |
| | } |
| | controller.enqueue("data: [DONE]" + delimiter); |
| | } |
| | } |
| |
|