PublicAffairs / openai-gemini

Gemini ➜ OpenAI API proxy. Serverless!
https://my-openai-gemini-demo.vercel.app/
MIT License
285 stars 164 forks source link

Requesting support for deno deploy #8

Closed zhu327 closed 2 months ago

zhu327 commented 2 months ago
import {
  serve
} from "https://deno.land/std@0.181.0/http/server.ts";
import {
  Buffer
} from "node:buffer";
async function handler(request) {
  if (request.method === "OPTIONS") {
    return handleOPTIONS();
  }
  const url = new URL(request.url);
  if (!url.pathname.endsWith("/v1/chat/completions") || request.method !== "POST") {
    return new Response("404 Not Found", {
      status: 404
    });
  }
  const auth = request.headers.get("Authorization");
  let apiKey = auth && auth.split(" ")[1];
  if (!apiKey) {
    return new Response("Bad credentials", {
      status: 401
    });
  }
  let json;
  try {
    json = await request.json();
    if (!Array.isArray(json.messages)) {
      throw SyntaxError(".messages array required");
    }
  } catch (err) {
    console.error(err.toString());
    return new Response(err, {
      status: 400
    });
  }
  return handleRequest(json, apiKey);
};
var handleOPTIONS = async () => {
  return new Response(null, {
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "*",
      "Access-Control-Allow-Headers": "*"
    }
  });
};
var BASE_URL = "https://generativelanguage.googleapis.com";
var API_VERSION = "v1beta";
var API_CLIENT = "genai-js/0.5.0";
async function handleRequest(req, apiKey) {
  const MODEL = "gemini-1.5-pro-latest";
  const TASK = req.stream ? "streamGenerateContent" : "generateContent";
  let url = `${BASE_URL}/${API_VERSION}/models/${MODEL}:${TASK}`;
  if (req.stream) {
    url += "?alt=sse";
  }
  let response;
  try {
    response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-goog-api-key": apiKey,
        "x-goog-api-client": API_CLIENT
      },
      body: JSON.stringify(await transformRequest(req))
      // try
    });
  } catch (err) {
    console.error(err);
    return new Response(err, {
      status: 400,
      headers: {
        "Access-Control-Allow-Origin": "*"
      }
    });
  }
  let body;
  const headers = new Headers(response.headers);
  headers.set("Access-Control-Allow-Origin", "*");
  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,
        MODEL,
        id,
        last: []
      })).pipeThrough(new TextEncoderStream());
    } else {
      body = await response.text();
      try {
        body = await processResponse(JSON.parse(body).candidates, MODEL, id);
      } catch (err) {
        console.error(err);
        response = {
          status: 500
        };
        headers.set("Content-Type", "text/plain");
      }
    }
  } else {
    body = await response.text();
    try {
      const {
        code,
        status,
        message
      } = JSON.parse(body).error;
      body = `Error: [${code} ${status}] ${message}`;
    } catch (err) {}
    headers.set("Content-Type", "text/plain");
  }
  return new Response(body, {
    status: response.status,
    statusText: response.statusText,
    headers
  });
}
var harmCategory = [
  "HARM_CATEGORY_HATE_SPEECH",
  "HARM_CATEGORY_SEXUALLY_EXPLICIT",
  "HARM_CATEGORY_DANGEROUS_CONTENT",
  "HARM_CATEGORY_HARASSMENT"
];
var safetySettings = harmCategory.map((category) => ({
  category,
  threshold: "BLOCK_NONE"
}));
var fieldsMap = {
  stop: "stopSequences",
  // n: "candidateCount", // { "error": { "code": 400, "message": "Only one candidate can be specified", "status": "INVALID_ARGUMENT" } }
  max_tokens: "maxOutputTokens",
  temperature: "temperature",
  top_p: "topP"
  //..."topK"
};
var transformConfig = (req) => {
  let cfg = {};
  for (let key in req) {
    const matchedKey = fieldsMap[key];
    if (matchedKey) {
      cfg[matchedKey] = req[key];
    }
  }
  if (req.response_format?.type === "json_object") {
    cfg.response_mime_type = "application/json";
  }
  return cfg;
};
var parseImg = async (url) => {
  let mimeType, data;
  if (url.startsWith("http://") || url.startsWith("https://")) {
    try {
      const response = await fetch(url);
      mimeType = response.headers.get("content-type");
      data = Buffer.from(await response.arrayBuffer()).toString("base64");
    } catch (err) {
      throw Error("Error fetching image: " + err.toString());
    }
  } else {
    const match = url.match(/^data:(?<mimeType>.*?)(;base64)?,(?<data>.*)$/);
    if (!match) {
      throw Error("Invalid image data: " + url);
    }
    ({
      mimeType,
      data
    } = match.groups);
  }
  return {
    inlineData: {
      mimeType,
      data
    }
  };
};
var 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;
      default:
        throw TypeError(`Unknown "content" item type: "${item.type}"`);
    }
  }
  return {
    role,
    parts
  };
};
var transformMessages = async (messages) => {
  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: "user",
      parts: {
        text: ""
      }
    });
  }
  return {
    system_instruction,
    contents
  };
};
var transformRequest = async (req) => ({
  ...await transformMessages(req.messages),
  safetySettings,
  generationConfig: transformConfig(req)
});
var generateChatcmplId = () => {
  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  let result = "chatcmpl-";
  for (let i = 0; i <= 29; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
};
var reasonsMap = {
  //https://ai.google.dev/api/rest/v1/GenerateContentResponse#finishreason
  //"FINISH_REASON_UNSPECIFIED": // Default value. This value is unused.
  "STOP": "stop",
  "MAX_TOKENS": "length",
  "SAFETY": "content_filter",
  "RECITATION": "content_filter"
  //"OTHER": "OTHER",
  // :"function_call",
};
var transformCandidates = (key, cand) => ({
  index: cand.index,
  [key]: {
    role: "assistant",
    content: cand.content?.parts[0].text
  },
  logprobs: null,
  finish_reason: reasonsMap[cand.finishReason] || cand.finishReason
});
var transformCandidatesMessage = transformCandidates.bind(null, "message");
var transformCandidatesDelta = transformCandidates.bind(null, "delta");
var processResponse = async (candidates, model, id) => {
  return JSON.stringify({
    id,
    object: "chat.completion",
    created: Math.floor(Date.now() / 1e3),
    model,
    // system_fingerprint: "fp_69829325d0",
    choices: candidates.map(transformCandidatesMessage)
  });
};
var 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(cand, stop, first) {
  const item = transformCandidatesDelta(cand);
  if (stop) {
    item.delta = {};
  } else {
    item.finish_reason = null;
  }
  if (first) {
    item.delta.content = "";
  } else {
    delete item.delta.role;
  }
  const data = {
    id: this.id,
    object: "chat.completion.chunk",
    created: Math.floor(Date.now() / 1e3),
    model: this.MODEL,
    // system_fingerprint: "fp_69829325d0",
    choices: [item]
  };
  return "data: " + JSON.stringify(data) + delimiter;
}
var delimiter = "\n\n";
async function toOpenAiStream(chunk, controller) {
  const transform = transformResponseStream.bind(this);
  const line = await chunk;
  if (!line) {
    return;
  }
  let candidates;
  try {
    candidates = JSON.parse(line).candidates;
  } catch (err) {
    console.error(line);
    console.error(err);
    const length = this.last.length || 1;
    candidates = Array.from({
      length
    }, (_, index) => ({
      finishReason: "error",
      content: {
        parts: [{
          text: err
        }]
      },
      index
    }));
  }
  for (const cand of candidates) {
    if (!this.last[cand.index]) {
      controller.enqueue(transform(cand, false, "first"));
    }
    this.last[cand.index] = cand;
    if (cand.content) {
      controller.enqueue(transform(cand));
    }
  }
}
async function toOpenAiStreamFlush(controller) {
  const transform = transformResponseStream.bind(this);
  if (this.last.length > 0) {
    for (const cand of this.last) {
      controller.enqueue(transform(cand, "stop"));
    }
    controller.enqueue("data: [DONE]" + delimiter);
  }
}
serve(handler);
zhu327 commented 2 months ago

Just made a few code changes and adapted from Cloudflare Workers to Deno Deploy.