GCP150-Claude无服务器部署实践

我是实践了 Cloudflare 反代 GCP API 的方法

首先是学习了各个大佬的教程:

然后部署CF代码,但我测试发现部署在Vercel的 NextChat 会有跨域的错误:
Access to fetch at 'https://xxx' from origin 'https://app.nextchat.dev' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

然后反复进行了修改,添加了跨域支持才能正常使用
(PS:后面检查代码发现原来是大佬忘记添加 handleOptions() 函数的实现了 )

另外,自定义接口我以为端点域名后要添加 /v1/messages 其实不需要,NextChat 默认会给端点添加。(PS:这里我调试了半天,幸亏克劳德帮我发现原因:joy:

CF反代代码(修复了跨域BUG)
// 设定 API Keys 数组
const TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token';
let tokenCache = {};

// 设置随机账号密钥
let randomkay = 'sk-xxx';

// 设置管理员的密码
let correctPassword = 'xxxx';

// MODELS 数组仍然需要保留,因为它定义了允许使用的模型列表
const MODELS = ['claude-3-sonnet', 'claude-3-5-sonnet', 'claude-3-opus', 'claude-3-haiku'];

let ACCOUNTS = {}; // 全局变量存储 ACCOUNTS

let MODEL_CONFIG = {}; // 全局变量存储 ACCOUNTS

let API_KEY_ACCOUNT_MAPPING = {}; // 全局变量存储 ACCOUNTS
let ATS = {};

async function getAccessToken(account, ACCOUNTS) {
  let ATS = JSON.parse(await gcp_project_id.get('ats') || '{}');
  const now = Date.now() / 1000;

  if (ATS[account] && ATS[account].accessToken && now < ATS[account].expiry - 120) {
    return ATS[account].accessToken;
  }


  try {
    const accountConfig = ACCOUNTS[account];
    const response = await fetch(TOKEN_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        client_id: accountConfig.CLIENT_ID,
        client_secret: accountConfig.CLIENT_SECRET,
        refresh_token: accountConfig.REFRESH_TOKEN,
        grant_type: 'refresh_token'
      })
    });

    if (response.status === 401) {
      await removeAccount(account);
      throw new Error(`Account ${account} removed due to unauthorized error.`);
    }

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();

    if (data.error) {
      throw new Error(data.error_description || data.error);
    }

    ATS[account] = {
      accessToken: data.access_token,
      expiry: now + data.expires_in
    };


    await gcp_project_id.put('ats', JSON.stringify(ATS));

    return ATS[account].accessToken;
  } catch (error) {
    throw error;
  }
}

async function removeAccount(account) {
  try {
    let accounts = JSON.parse(await gcp_project_id.get('account') || '{}');
    let keys = JSON.parse(await gcp_project_id.get('keys') || '{}');
    let model_config = JSON.parse(await gcp_project_id.get('model_config') || '{}');
    let ATS = JSON.parse(await gcp_project_id.get('ats') || '{}');

    // Remove from accounts
    delete accounts[account];

    // Remove from keys
    for (let key in keys) {
      if (keys[key] === account) {
        delete keys[key];
      }
    }

    // Remove from model_config
    Object.keys(model_config).forEach(model => {
      const index = model_config[model].accounts.indexOf(account);
      if (index > -1) {
        model_config[model].accounts.splice(index, 1);
      }
    });

    // Remove from ATS
    delete ATS[account];

    // Save updated data
    await gcp_project_id.put('account', JSON.stringify(accounts));
    await gcp_project_id.put('keys', JSON.stringify(keys));
    await gcp_project_id.put('model_config', JSON.stringify(model_config));
    await gcp_project_id.put('ats', JSON.stringify(ATS));

  } catch (error) {
    throw error;
  }
}


function getLocation(model, MODEL_CONFIG) {
  const config = MODEL_CONFIG[model];
  const locations = config.locations;
  const randomIndex = Math.floor(Math.random() * locations.length);
  return locations[randomIndex];
}

function constructApiUrl(location, model, account, ACCOUNTS) {
  const accountConfig = ACCOUNTS[account];
  return `https://${location}-aiplatform.googleapis.com/v1/projects/${accountConfig.PROJECT_ID}/locations/${location}/publishers/anthropic/models/${model}:streamRawPredict`;
}



async function handleApiRequest(request) {
  
  API_KEY_ACCOUNT_MAPPING = JSON.parse(await gcp_project_id.get('keys') || '{}');
  ACCOUNTS = JSON.parse(await gcp_project_id.get('account') || '{}');
  MODEL_CONFIG = JSON.parse(await gcp_project_id.get('model_config') || '{}');
  
  const apiKey = request.headers.get('x-api-key');
  if (!apiKey || (apiKey !== 'sk-freegcpclaude' && !API_KEY_ACCOUNT_MAPPING[apiKey])) {
    console.warn(`API key "${apiKey}" not found in API_KEY`);
    const errorResponse = new Response(JSON.stringify({
      type: "error",
      error: {
        type: "permission_error",
        message: "Your API key does not have permission to use the specified resource."
      } 
    }), {
      status: 403,
      headers: {
        'Content-Type': 'application/json'
      }
    });

    return errorResponse;
  }

  let requestBody = await request.json();
  const modelWithVersion = requestBody.model;
  const baseModel = modelWithVersion.replace(/-\d{8}$/, '');
  let model;

  if (MODELS.includes(baseModel)) {
    if (modelWithVersion.match(/^\w+-\d{8}$/)) {
        model = modelWithVersion.replace(/-(\d{8})$/, '@$1');
    } else {
        model = baseModel;
    }
  } else {
    return new Response(JSON.stringify({
      type: "error",
      error: {
        type: "invalid_model",
        message: "The specified model is not in the allowed list."
      }
    }), {
      status: 400,
      headers: {
        'Content-Type': 'application/json'
      }
    });
  }

  const location = getLocation(model, MODEL_CONFIG);
  let account = API_KEY_ACCOUNT_MAPPING[apiKey];

  // 如果 apiKey 为公共的,则随机选择一个账户
  if (apiKey === randomkay) {
    const shareableAccounts = Object.keys(ACCOUNTS).filter(key => ACCOUNTS[key].SHAREABLE);
    
    if (shareableAccounts.length === 0) {
      return new Response(JSON.stringify({
        type: "error",
        error: {
          type: "no_shareable_account",
          message: "没有可用的共享账号。"
        }
      }), {
        status: 400,
        headers: {
          'Content-Type': 'application/json'
        }
      });
    }

    account = shareableAccounts[Math.floor(Math.random() * shareableAccounts.length)];
  }

  const accessToken = await getAccessToken(account, ACCOUNTS);
  const apiUrl = constructApiUrl(location, model, account, ACCOUNTS);

  if (requestBody.anthropic_version) {
    delete requestBody.anthropic_version;
  }
  
  if (requestBody.model) {
    delete requestBody.model;
  }
  
  requestBody.anthropic_version = "vertex-2023-10-16";

  const modifiedHeaders = new Headers(request.headers);
  modifiedHeaders.set('Authorization', `Bearer ${accessToken}`);
  modifiedHeaders.set('Content-Type', 'application/json; charset=utf-8');
  modifiedHeaders.delete('anthropic-version');

  const modifiedRequest = new Request(apiUrl, {
    headers: modifiedHeaders,
    method: request.method,
    body: JSON.stringify(requestBody),
    redirect: 'follow'
  });

  const response = await fetch(modifiedRequest);
  const modifiedResponse = new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers
  });

  return modifiedResponse;
}

async function handleTokenRequest(request) {
  if (request.method === 'POST') {
    try {
      let ACCOUNTS = JSON.parse(await gcp_project_id.get('account') || '{}');
      let keys = JSON.parse(await gcp_project_id.get('keys') || '{}');
      let model_config = JSON.parse(await gcp_project_id.get('model_config') || '{}');
      let ATS = JSON.parse(await gcp_project_id.get('ats') || '{}');

      const requestBody = await request.json();
      const { name, projectId, clientId, clientSecret, refreshToken, apiKey, shareable } = requestBody;

      if (!name || !projectId || !clientId || !clientSecret || !refreshToken || !apiKey) {
        return new Response(JSON.stringify({ message: '所有字段均为必填项。' }), {
          headers: { 'Content-Type': 'application/json' },
          status: 400,
        });
      }

      if (ACCOUNTS[name]) {
        return new Response(JSON.stringify({ message: '名称已存在。' }), {
          headers: { 'Content-Type': 'application/json' },
          status: 400,
        });
      }

      if (keys[apiKey]) {
        return new Response(JSON.stringify({ message: 'API 密钥已存在。' }), {
          headers: { 'Content-Type': 'application/json' },
          status: 400,
        });
      }

      // 尝试获取 AccessToken
      try {
        const tokenResponse = await fetch(TOKEN_URL, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            client_id: clientId,
            client_secret: clientSecret,
            refresh_token: refreshToken,
            grant_type: 'refresh_token'
          })
        });

        if (tokenResponse.status === 401) {
          return new Response(JSON.stringify({ message: '获取AccessToken失败: 未授权(401)。请检查您的凭据。' }), {
            headers: { 'Content-Type': 'application/json' },
            status: 400,
          });
        }

        const tokenData = await tokenResponse.json();

        if (tokenData.error) {
          throw new Error(tokenData.error_description || tokenData.error);
        }

        // AccessToken 获取成功,保存账号信息
        ACCOUNTS[name] = {
          "PROJECT_ID": projectId,
          "CLIENT_ID": clientId,
          "CLIENT_SECRET": clientSecret,
          "REFRESH_TOKEN": refreshToken,
          "SHAREABLE": shareable || false
        };

        // 保存 AccessToken 到 ATS
        const now = Date.now() / 1000;
        ATS[name] = {
          accessToken: tokenData.access_token,
          expiry: now + tokenData.expires_in,
          refreshPromise: null
        };

        keys[apiKey] = name;

        // 更新 model_config
        Object.keys(model_config).forEach(model => {
          if (!model_config[model].accounts.includes(name)) {
            model_config[model].accounts.push(name);
          }
        });

        // 保存更新后的数据到 KV 存储
        await gcp_project_id.put('account', JSON.stringify(ACCOUNTS));
        await gcp_project_id.put('keys', JSON.stringify(keys));
        await gcp_project_id.put('model_config', JSON.stringify(model_config));
        await gcp_project_id.put('ats', JSON.stringify(ATS));

        return new Response(JSON.stringify({ message: '数据添加成功。' }), {
          headers: { 'Content-Type': 'application/json' },
          status: 200,
        });

      } catch (tokenError) {
        return new Response(JSON.stringify({ message: `获取AccessToken失败: ${tokenError.message}` }), {
          headers: { 'Content-Type': 'application/json' },
          status: 400,
        });
      }

    } catch (error) {
      return new Response(JSON.stringify({ message: '添加数据时出错。', error: error.message }), {
        headers: { 'Content-Type': 'application/json' },
        status: 500,
      });
    }
  } else if (request.method === 'GET') {
    return new Response(`
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>GCP</title>
  <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container mt-5">
    <h1 class="text-center">添加GCP账号</h1>
    <form id="configForm" class="mt-4">
      <div class="form-group">
        <label for="name">名称(唯一):</label>
        <input type="text" class="form-control" id="name" name="name" required>
      </div>
      <div class="form-group">
        <label for="projectId">PROJECT_ID:</label>
        <input type="text" class="form-control" id="projectId" name="projectId" required>
      </div>
      <div class="form-group">
        <label for="clientId">CLIENT_ID:</label>
        <input type="text" class="form-control" id="clientId" name="clientId" required>
      </div>
      <div class="form-group">
        <label for="clientSecret">CLIENT_SECRET:</label>
        <input type="text" class="form-control" id="clientSecret" name="clientSecret" required>
      </div>
      <div class="form-group">
        <label for="refreshToken">REFRESH_TOKEN:</label>
        <input type="text" class="form-control" id="refreshToken" name="refreshToken" required>
      </div>
      <div class="form-group">
        <label for="apiKey">API_KEY(调用使用):</label>
        <input type="text" class="form-control" id="apiKey" name="apiKey" required>
      </div>
      <div class="form-group form-check">
        <input type="checkbox" class="form-check-input" id="shareable" name="shareable">
        <label class="form-check-label" for="shareable">是否共享</label>
      </div>
      <button type="submit" class="btn btn-primary btn-block">提交</button>
    </form>
    <div id="message" class="mt-3 text-center"></div>
  </div>

  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@popperjs/[email protected]/dist/umd/popper.min.js"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>

  <script>
    document.getElementById('configForm').addEventListener('submit', async function(event) {
      event.preventDefault();

      const formData = {
        name: document.getElementById('name').value,
        projectId: document.getElementById('projectId').value,
        clientId: document.getElementById('clientId').value,
        clientSecret: document.getElementById('clientSecret').value,
        refreshToken: document.getElementById('refreshToken').value,
        apiKey: document.getElementById('apiKey').value,
        shareable: document.getElementById('shareable').checked
      };

      const response = await fetch('/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(formData)
      });

      const result = await response.json();
      const messageDiv = document.getElementById('message');
      messageDiv.innerText = result.message;
      if (response.status === 200) {
        messageDiv.classList.remove('text-danger');
        messageDiv.classList.add('text-success');
      } else {
        messageDiv.classList.remove('text-success');
        messageDiv.classList.add('text-danger');
      }
    });
  </script>
</body>
</html>
  `, {
      headers: {
        'Content-Type': 'text/html'
      }
    });
  } else {
    return new Response('Method Not Allowed', { status: 405 });
  }
}

async function handleAdminRequest(request) {
  const url = new URL(request.url);
  const params = new URLSearchParams(url.search);
  const password = params.get('password');
  
  if (request.method === 'GET') {
    if (password !== correctPassword) {
      return new Response(`
        <!DOCTYPE html>
        <html lang="zh">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>管理员登录</title>
          <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
        </head>
        <body>
          <div class="container mt-5">
            <h1 class="text-center">管理员登录</h1>
            <form id="loginForm" class="mt-4">
              <div class="form-group">
                <label for="password">密码:</label>
                <input type="password" class="form-control" id="password" name="password" required>
              </div>
              <button type="submit" class="btn btn-primary btn-block">登录</button>
            </form>
            <div id="message" class="mt-3 text-center text-danger"></div>
          </div>
          <script>
            document.getElementById('loginForm').addEventListener('submit', function(event) {
              event.preventDefault();
              const password = document.getElementById('password').value;
              window.location.href = '/admin?password=' + encodeURIComponent(password);
            });
            ${password ? "document.getElementById('message').textContent = '密码错误,请重试。';" : ""}
          </script>
        </body>
        </html>
      `, {
        headers: { 'Content-Type': 'text/html' }
      });
    }

    // 如果密码正确,显示账户管理页面
    let ACCOUNTS = JSON.parse(await gcp_project_id.get('account') || '{}');
    let accountsHtml = '';
    for (let [name, account] of Object.entries(ACCOUNTS)) {
      accountsHtml += `
        <tr>
          <td>${name}</td>
          <td>
            <input type="checkbox" class="shareableCheckbox" data-name="${name}" ${account.SHAREABLE ? 'checked' : ''}>
          </td>
          <td>
            <button class="btn btn-danger btn-sm deleteBtn" data-name="${name}">删除</button>
          </td>
        </tr>
      `;
    }

    return new Response(`
      <!DOCTYPE html>
      <html lang="zh">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>账户管理</title>
        <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
        <style>
          #notification {
            position: fixed;
            top: 20px;
            right: 20px;
            padding: 15px;
            background-color: #28a745;
            color: white;
            border-radius: 5px;
            display: none;
          }
        </style>
      </head>
      <body>
        <div class="container mt-5">
          <h1 class="text-center">账户管理</h1>
          <table class="table mt-4">
            <thead>
              <tr>
                <th>名称</th>
                <th>可共享</th>
                <th>操作</th>
              </tr>
            </thead>
            <tbody>
              ${accountsHtml}
            </tbody>
          </table>
        </div>
        <div id="notification"></div>
        <script>
          function showNotification(message) {
            const notification = document.getElementById('notification');
            notification.textContent = message;
            notification.style.display = 'block';
            setTimeout(() => {
              notification.style.display = 'none';
            }, 3000);
          }

          document.querySelectorAll('.shareableCheckbox').forEach(checkbox => {
            checkbox.addEventListener('change', async function() {
              const name = this.dataset.name;
              const shareable = this.checked;
              const response = await fetch('/admin', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ action: 'updateShareable', name, shareable, password: '${correctPassword}' })
              });
              const result = await response.json();
              showNotification(result.message);
            });
          });

          document.querySelectorAll('.deleteBtn').forEach(btn => {
            btn.addEventListener('click', async function() {
              if (confirm('您确定要删除这个账户吗?')) {
                const name = this.dataset.name;
                const response = await fetch('/admin', {
                  method: 'POST',
                  headers: { 'Content-Type': 'application/json' },
                  body: JSON.stringify({ action: 'deleteAccount', name, password: '${correctPassword}' })
                });
                const result = await response.json();
                showNotification(result.message);
                if (result.success) {
                  this.closest('tr').remove();
                }
              }
            });
          });
        </script>
      </body>
      </html>
    `, {
      headers: { 'Content-Type': 'text/html' }
    });
  } else if (request.method === 'POST') {
    const { action, name, shareable, password } = await request.json();
    
    if (password !== correctPassword) {
      return new Response(JSON.stringify({ success: false, message: '密码无效' }), {
        headers: { 'Content-Type': 'application/json' },
        status: 403
      });
    }

    if (action === 'updateShareable') {
      let ACCOUNTS = JSON.parse(await gcp_project_id.get('account') || '{}');
      if (ACCOUNTS[name]) {
        ACCOUNTS[name].SHAREABLE = shareable;
        await gcp_project_id.put('account', JSON.stringify(ACCOUNTS));
        return new Response(JSON.stringify({ success: true, message: '账户更新成功' }), {
          headers: { 'Content-Type': 'application/json' }
        });
      }
    } else if (action === 'deleteAccount') {
      await removeAccount(name);
      return new Response(JSON.stringify({ success: true, message: '账户删除成功' }), {
        headers: { 'Content-Type': 'application/json' }
      });
    }

    return new Response(JSON.stringify({ success: false, message: '无效的操作' }), {
      headers: { 'Content-Type': 'application/json' },
      status: 400
    });
  }
}

/* 准备响应,追加跨域许可头 */
function prepareResponse(response) {
  const modifiedResponse = new Response(response.body, response);
  modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');
  modifiedResponse.headers.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
  modifiedResponse.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key, anthropic-version, model');
  return modifiedResponse;
}

/* 处理OPTIONS请求 
* 预取时添加 Access-Control-Allow 头,避免出现跨域问题
*/
function handleOptions() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, model'
    }
  });
}


async function handleRequest(request) {

  // 处理 CORS 预检请求
  if (request.method === 'OPTIONS') {
    return handleOptions();
  }

  const url = new URL(request.url);

  if (url.pathname === '/v1/messages') {
    let response = await handleApiRequest(request);
    return prepareResponse(response);
  } else if (url.pathname === '/admin') {
    return await handleAdminRequest(request);
  } else {
    return await handleTokenRequest(request);
  }

}

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

最后,很多大佬都提到可以直接用 One-API, 确实更方便 :joy:,不用自己折腾了。
找到一个帖子无需服务器就可以搭建,可以尝试一下(之前我都不知道 :cry:
https://linux.do/t/topic/56649

16 个赞

新版本one-api支持了,用one-api方便点

2 个赞

不用折腾cf啦,one更新vertexAi了

1 个赞

很详细

1 个赞

不断学习

1 个赞

官方改了嘛?怎么直接用

2 个赞

one-api支持了

1 个赞

oneapi走起

1 个赞

new api更新了吗

1 个赞

好的好的 :joy:

1 个赞

我装了最新one-api,没看到vertexai渠道。

1 个赞

怎么升级到新版本?

1 个赞

没看到新渠道。

1 个赞

不瞒你说,我用的是newapi

newapi不是不支持吗

是的,但迟早的事嘛

二开的one,估计很快都会更新的了

请问你是用的哪种方式部署的呀,抱抱脸吗

我用的 Cloudflare 反代,没部署oneapi

那你是用的可以修改gcp账号那个代码吗