|
|
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); |
|
|
} |
|
|
} |
|
|
|