Enable GCP Claude to be used on the Next Chat platform
由於 GCP 上有較完整 UI 的聊天機器人,僅限於自家 Gemini 系列,雖然有支援其他第三方 LLM 模型,但僅限於呼叫 API 功能。
為了能讓 Claude 體驗良好,需額外串接第三方 LLM 用的 Web UI 以提升便利性,於此選擇 Next Chat 作為範例,其中 Next Chat 也可自建在自己環境中。
Scenario
- 使用 GCP 上的 Vertex AI Claude API
- 透過在 CloudFlare Worker 上部署 js 腳本來
模擬Claude 官方 API - 使用 Next Chat 或 Continue 設定
CloudFlare 模擬 Claude API即可使用
流程與說明
- 設定 Vertex AI 並啟用、下載 service account key
- 部署 CloudFlare worker 腳本,將上一步驟中建立 service account 中資訊填入其環境變數中
- 設定 Next Chat 使用 CloudFlare worker 連結
- 設定 VSCode Continue extension 使用 CloudFlare worker 連結
執行步驟
設定 Vertex AI 並啟用 service account key
啟用 API 與建立 service account key
1# Set variables2PROJECT_ID="YOUR_PROJECT_ID"3SERVICE_ACCOUNT_NAME="vertex-ai-user-sa"4SERVICE_ACCOUNT_DISPLAY_NAME="Vertex AI User Service Account"5KEY_FILE="vertex-ai-user-key.json"6
7gcloud services enable aiplatform.googleapis.com8
9# Create the service account10gcloud iam service-accounts create $SERVICE_ACCOUNT_NAME \11 --display-name="$SERVICE_ACCOUNT_DISPLAY_NAME" \12 --project=$PROJECT_ID13
14# Get the full service account email15SERVICE_ACCOUNT_EMAIL=$(gcloud iam service-accounts list \16 --filter="displayName:$SERVICE_ACCOUNT_DISPLAY_NAME" \17 --format='value(email)' \18 --project=$PROJECT_ID)19
20# Grant Vertex AI User role to the service account21gcloud projects add-iam-policy-binding $PROJECT_ID \22 --member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \23 --role="roles/aiplatform.user"24
25# Create and download a JSON key for the service account26gcloud iam service-accounts keys create $KEY_FILE \27 --iam-account=$SERVICE_ACCOUNT_EMAIL \28 --project=$PROJECT_ID29
30cat $KEY_FILE部署 CloudFlare worker 腳本
使用 vertexai-cf-workers js 腳本部署至 CloudFlare Worker 中
新增 worker
登入 CloudFlare 帳號,建立 worker

點選建立 worker

新增 worker 名稱後部署

部署與編輯
可點選部署後的連結查看,確認沒問題後再點選 Edit Code

左方程式碼編輯區貼上 worker.js 程式碼後部署

點選儲存與部署

設定環境變數
點選進入剛剛部署的 worker

點選新增環境變數

環境變數主要為 GCP service account 中相關資訊,如果需要也可點選加密
API_KEY: 自訂一組被呼叫的 API keyCLIENT_EMAIL: GCP service account 中的 emailPRIVATE_KEY: GCP service account 中的 private keyPROJECT: GCP 中的 project id 設定完成後點選部署

設定 Next Chat
點我進入 Next Chat 介面,接著點選設定

- 開啟自訂 API Endpoint
- 選擇 Anthropic 模型供應商
- 輸入 CloudFlare 中 worker URL
- 輸入前面所建立的 API_KEY
- 選擇 claude-3.5-sonnet-20240620(Anthropic) 最後按右上角 “X” 關閉即可

接著進行測試

VSCode Continue 設定
安裝 Continue 將 config.json 中 models 設定修改如下即可使用
1 "models": [2 {3 "model": "claude-3-5-sonnet-20240620",4 "contextLength": 200000,5 "title": "Claude 3 Sonnet",6 "apiKey": "YOUR_API_KEY",7 "apiBase": "https://YOUR_CLOUDFLARE_WORKER_URL/v1/",8 "provider": "anthropic"9 }10 ],參考資料
worker.js 程式碼
257 collapsed lines
1const MODELS = {2 "claude-3-opus": {3 vertexName: "claude-3-opus@20240229",4 region: "us-east5",5 },6 "claude-3-sonnet": {7 vertexName: "claude-3-sonnet@20240229",8 region: "us-central1",9 },10 "claude-3-haiku": {11 vertexName: "claude-3-haiku@20240307",12 region: "us-central1",13 },14 "claude-3-5-sonnet": {15 vertexName: "claude-3-5-sonnet@20240620",16 region: "us-east5",17 },18 "claude-3-opus-20240229": {19 vertexName: "claude-3-opus@20240229",20 region: "us-east5",21 },22 "claude-3-sonnet-20240229": {23 vertexName: "claude-3-sonnet@20240229",24 region: "us-central1",25 },26 "claude-3-haiku-20240307": {27 vertexName: "claude-3-haiku@20240307",28 region: "us-central1",29 },30 "claude-3-5-sonnet-20240620": {31 vertexName: "claude-3-5-sonnet@20240620",32 region: "us-east5",33 },34};35
36addEventListener("fetch", (event) => {37 event.respondWith(handleRequest(event.request));38});39
40async function handleRequest(request) {41 let headers = new Headers({42 "Access-Control-Allow-Origin": "*",43 "Access-Control-Allow-Headers": "*",44 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",45 });46 if (request.method === "OPTIONS") {47 return new Response(null, { headers });48 } else if (request.method === "GET") {49 return createErrorResponse(405, "invalid_request_error", "GET method is not allowed");50 }51
52 const apiKey = request.headers.get("x-api-key");53 if (!API_KEY || API_KEY !== apiKey) {54 return createErrorResponse(401, "authentication_error", "invalid x-api-key");55 }56
57 const signedJWT = await createSignedJWT(CLIENT_EMAIL, PRIVATE_KEY)58 const [token, err] = await exchangeJwtForAccessToken(signedJWT)59 if (token === null) {60 console.log(`Invalid jwt token: ${err}`)61 return createErrorResponse(500, "api_error", "invalid authentication credentials");62 }63
64 try {65 const url = new URL(request.url);66 const normalizedPathname = url.pathname.replace(/^(\/)+/, '/');67 switch(normalizedPathname) {68 case "/v1/v1/messages":69 case "/v1/messages":70 case "/messages":71 return handleMessagesEndpoint(request, token);72 default:73 return createErrorResponse(404, "not_found_error", "Not Found");74 }75 } catch (error) {76 console.error(error);77 return createErrorResponse(500, "api_error", "An unexpected error occurred");78 }79}80
81async function handleMessagesEndpoint(request, api_token) {82 const anthropicVersion = request.headers.get('anthropic-version');83 if (anthropicVersion && anthropicVersion !== '2023-06-01') {84 return createErrorResponse(400, "invalid_request_error", "API version not supported");85 }86
87 let payload;88 try {89 payload = await request.json();90 } catch (err) {91 return createErrorResponse(400, "invalid_request_error", "The request body is not valid JSON.");92 }93
94 payload.anthropic_version = "vertex-2023-10-16";95
96 if (!payload.model) {97 return createErrorResponse(400, "invalid_request_error", "Missing model in the request payload.");98 } else if (!MODELS[payload.model]) {99 return createErrorResponse(400, "invalid_request_error", `Model \`${payload.model}\` not found.`);100 }101
102 const stream = payload.stream || false;103 const model = MODELS[payload.model];104 const url = `https://${model.region}-aiplatform.googleapis.com/v1/projects/${PROJECT}/locations/${model.region}/publishers/anthropic/models/${model.vertexName}:streamRawPredict`;105 delete payload.model;106
107 let response, contentType108 try {109 response = await fetch(url, {110 method: 'POST',111 headers: {112 'Content-Type': 'application/json',113 'Authorization': `Bearer ${api_token}`114 },115 body: JSON.stringify(payload)116 });117 contentType = response.headers.get("Content-Type") || "application/json";118 } catch (error) {119 return createErrorResponse(500, "api_error", "Server Error");120 }121
122 if (stream && contentType.startsWith('text/event-stream')) {123 if (!(response.body instanceof ReadableStream)) {124 return createErrorResponse(500, "api_error", "Server Error");125 }126
127 const encoder = new TextEncoder();128 const decoder = new TextDecoder("utf-8");129 let buffer = '';130 let { readable, writable } = new TransformStream({131 transform(chunk, controller) {132 let decoded = decoder.decode(chunk, { stream: true });133 buffer += decoded134 let eventList = buffer.split(/\r\n\r\n|\r\r|\n\n/g);135 if (eventList.length === 0) return;136 buffer = eventList.pop();137
138 for (let event of eventList) {139 controller.enqueue(encoder.encode(`${event}\n\n`));140 }141 },142 });143 response.body.pipeTo(writable);144 return new Response(readable, {145 status: response.status,146 headers: {147 "Content-Type": response.headers.get("Content-Type") || "text/event-stream",148 "Access-Control-Allow-Origin": "*",149 },150 });151 } else {152 try {153 let data = await response.text();154 return new Response(data, {155 status: response.status,156 headers: {157 "Content-Type": contentType,158 "Access-Control-Allow-Origin": "*",159 },160 });161 } catch (error) {162 return createErrorResponse(500, "api_error", "Server Error");163 }164 }165}166
167function createErrorResponse(status, errorType, message) {168 const errorObject = { type: "error", error: { type: errorType, message: message } };169 return new Response(JSON.stringify(errorObject), {170 status: status,171 headers: {172 "Content-Type": "application/json",173 "Access-Control-Allow-Origin": "*",174 },175 });176}177
178async function createSignedJWT(email, pkey) {179 pkey = pkey.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\r|\n|\\n/g, "");180 let cryptoKey = await crypto.subtle.importKey(181 "pkcs8",182 str2ab(atob(pkey)),183 {184 name: "RSASSA-PKCS1-v1_5",185 hash: { name: "SHA-256" },186 },187 false,188 ["sign"]189 );190
191 const authUrl = "https://www.googleapis.com/oauth2/v4/token";192 const issued = Math.floor(Date.now() / 1000);193 const expires = issued + 600;194
195 const header = {196 alg: "RS256",197 typ: "JWT",198 };199
200 const payload = {201 iss: email,202 aud: authUrl,203 iat: issued,204 exp: expires,205 scope: "https://www.googleapis.com/auth/cloud-platform",206 };207
208 const encodedHeader = urlSafeBase64Encode(JSON.stringify(header));209 const encodedPayload = urlSafeBase64Encode(JSON.stringify(payload));210
211 const unsignedToken = `${encodedHeader}.${encodedPayload}`;212
213 const signature = await crypto.subtle.sign(214 "RSASSA-PKCS1-v1_5",215 cryptoKey,216 str2ab(unsignedToken)217 );218
219 const encodedSignature = urlSafeBase64Encode(signature);220 return `${unsignedToken}.${encodedSignature}`;221}222
223async function exchangeJwtForAccessToken(signed_jwt) {224 const auth_url = "https://www.googleapis.com/oauth2/v4/token";225 const params = {226 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",227 assertion: signed_jwt,228 };229
230 const r = await fetch(auth_url, {231 method: "POST",232 headers: { "Content-Type": "application/x-www-form-urlencoded" },233 body: Object.entries(params)234 .map(([k, v]) => k + "=" + v)235 .join("&"),236 }).then((res) => res.json());237
238 if (r.access_token) {239 return [r.access_token, ""];240 }241
242 return [null, JSON.stringify(r)];243}244
245function str2ab(str) {246 const buffer = new ArrayBuffer(str.length);247 let bufferView = new Uint8Array(buffer);248 for (let i = 0; i < str.length; i++) {249 bufferView[i] = str.charCodeAt(i);250 }251 return buffer;252}253
254function urlSafeBase64Encode(data) {255 let base64 = typeof data === "string" ? btoa(encodeURIComponent(data).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode(parseInt("0x" + p1)))) : btoa(String.fromCharCode(...new Uint8Array(data)));256 return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");257}