跳到內容

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 即可使用

流程與說明

  1. 設定 Vertex AI 並啟用、下載 service account key
  2. 部署 CloudFlare worker 腳本,將上一步驟中建立 service account 中資訊填入其環境變數中
  3. 設定 Next Chat 使用 CloudFlare worker 連結
  4. 設定 VSCode Continue extension 使用 CloudFlare worker 連結

執行步驟

設定 Vertex AI 並啟用 service account key

啟用 API 與建立 service account key

GCP CloudShell
1
# Set variables
2
PROJECT_ID="YOUR_PROJECT_ID"
3
SERVICE_ACCOUNT_NAME="vertex-ai-user-sa"
4
SERVICE_ACCOUNT_DISPLAY_NAME="Vertex AI User Service Account"
5
KEY_FILE="vertex-ai-user-key.json"
6
7
gcloud services enable aiplatform.googleapis.com
8
9
# Create the service account
10
gcloud iam service-accounts create $SERVICE_ACCOUNT_NAME \
11
--display-name="$SERVICE_ACCOUNT_DISPLAY_NAME" \
12
--project=$PROJECT_ID
13
14
# Get the full service account email
15
SERVICE_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 account
21
gcloud 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 account
26
gcloud iam service-accounts keys create $KEY_FILE \
27
--iam-account=$SERVICE_ACCOUNT_EMAIL \
28
--project=$PROJECT_ID
29
30
cat $KEY_FILE

部署 CloudFlare worker 腳本

使用 vertexai-cf-workers js 腳本部署至 CloudFlare Worker 中

新增 worker

登入 CloudFlare 帳號,建立 worker

create-worker

點選建立 worker

create-worker

新增 worker 名稱後部署

add-worker-name

部署與編輯

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

check-url-edit-code

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

deploy-worker

點選儲存與部署

save-deploy

設定環境變數

點選進入剛剛部署的 worker

check-deployment

點選新增環境變數

add-variable

環境變數主要為 GCP service account 中相關資訊,如果需要也可點選加密

  • API_KEY: 自訂一組被呼叫的 API key
  • CLIENT_EMAIL: GCP service account 中的 email
  • PRIVATE_KEY: GCP service account 中的 private key
  • PROJECT: GCP 中的 project id 設定完成後點選部署

deploy-variable

設定 Next Chat

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

next-chat

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

config-next-chat

接著進行測試

test-next-chat

VSCode Continue 設定

安裝 Continue 將 config.json 中 models 設定修改如下即可使用

~/.continue/config.json
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 程式碼

來源 vertexai-cf-workers js 腳本

worker.js
257 collapsed lines
1
const 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
36
addEventListener("fetch", (event) => {
37
event.respondWith(handleRequest(event.request));
38
});
39
40
async 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
81
async 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, contentType
108
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 += decoded
134
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
167
function 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
178
async 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
223
async 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
245
function 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
254
function 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
}