使用cf worker,sub2api一个key调用全部模型

sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。 使用方法: /** * Cloudflare Worker: model-based API key ro...
使用cf worker,sub2api一个key调用全部模型
使用cf worker,sub2api一个key调用全部模型

sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。

使用方法:

Screenshot| 100%

/**
 * Cloudflare Worker: model-based API key router for sub2api.
 *
 * Features:
 * - Authenticate clients with a dedicated Worker API key.
 * - Select upstream sub2api key by request model.
 * - Stream upstream responses through without buffering.
 * - No retries.
 *
 * Required env vars:
 * - WORKER_API_KEY: client-facing secret for this Worker.
 * - SUB2API_BASE_URL: e.g. https://sub2api.example.com
 * - MODEL_KEY_RULES_JSON: JSON array like:
 *   [
 *     {"pattern":"gpt-*","key_env":"SUB2API_KEY_OPENAI"},
 *     {"pattern":"glm-*","key_env":"SUB2API_KEY_GLM"}
 *   ]
 * - DEFAULT_KEY_ENV: env var name used for endpoints without model
 *   (e.g. "SUB2API_KEY_OPENAI")
 *
 * Each key_env in MODEL_KEY_RULES_JSON must exist as an env var containing
 * a real sub2api API key. Example:
 * - SUB2API_KEY_OPENAI=sk-xxx
 * - SUB2API_KEY_GLM=sk-yyy
 */

const HOP_BY_HOP_HEADERS = new Set([
  'connection',
  'keep-alive',
  'proxy-authenticate',
  'proxy-authorization',
  'te',
  'trailer',
  'transfer-encoding',
  'upgrade',
]);

export default {
  async fetch(request, env) {
    if (!isAuthorized(request, env.WORKER_API_KEY)) {
      return jsonError(401, 'unauthorized', 'Invalid worker API key');
    }

    if (!env.SUB2API_BASE_URL || !env.MODEL_KEY_RULES_JSON) {
      return jsonError(500, 'server_error', 'Worker is not configured');
    }

    const rules = parseRules(env.MODEL_KEY_RULES_JSON);
    if (rules.length === 0) {
      return jsonError(500, 'server_error', 'No model routing rules configured (check MODEL_KEY_RULES_JSON format/type)');
    }

    const routing = await resolveRouting(request, env, rules);
    if (routing.errorResponse) return routing.errorResponse;

    const upstreamUrl = buildUpstreamURL(request.url, env.SUB2API_BASE_URL);
    const upstreamHeaders = buildUpstreamHeaders(request.headers, routing.upstreamApiKey);
    const upstreamBody = shouldForwardBody(request.method) ? request.body : null;
    const websocketUpgrade = isWebSocketUpgrade(request);

    const upstreamResp = await fetch(upstreamUrl, {
      method: request.method,
      headers: upstreamHeaders,
      body: upstreamBody,
      redirect: 'manual',
    });

    if (websocketUpgrade) {
      if (upstreamResp.status === 101 && upstreamResp.webSocket) {
        return new Response(null, {
          status: 101,
          webSocket: upstreamResp.webSocket,
          headers: sanitizeResponseHeaders(upstreamResp.headers),
        });
      }
      return jsonError(502, 'websocket_upgrade_failed', 'Upstream did not accept websocket upgrade');
    }

    const responseHeaders = sanitizeResponseHeaders(upstreamResp.headers);
    return new Response(upstreamResp.body, {
      status: upstreamResp.status,
      headers: responseHeaders,
    });
  },
};

async function resolveRouting(request, env, rules) {
  const contentType = (request.headers.get('content-type') || '').toLowerCase();
  const defaultKeyEnvName = String(env.DEFAULT_KEY_ENV || '').trim();

  // Prefer model-based routing when body is JSON and has model.
  if (isJSONContentType(contentType) && shouldForwardBody(request.method)) {
    let payload;
    try {
      payload = await request.clone().json();
    } catch {
      return {
        errorResponse: jsonError(400, 'invalid_json', 'Request body must be valid JSON'),
      };
    }

    const model = extractModel(payload);
    if (model) {
      const matchedRule = matchRule(model, rules);
      if (!matchedRule) {
        return {
          errorResponse: jsonError(400, 'model_not_matched', `Model not matched by any rule: ${model}`),
        };
      }
      const upstreamApiKey = env[matchedRule.key_env];
      if (!upstreamApiKey) {
        return {
          errorResponse: jsonError(500, 'server_error', `Missing upstream key env: ${matchedRule.key_env}`),
        };
      }
      return { upstreamApiKey };
    }
  }

  // Endpoints without model (e.g. list models) use DEFAULT_KEY_ENV.
  if (!defaultKeyEnvName) {
    return {
      errorResponse: jsonError(400, 'missing_default_key_env', 'DEFAULT_KEY_ENV is required for non-model endpoints'),
    };
  }
  const upstreamApiKey = env[defaultKeyEnvName];
  if (!upstreamApiKey) {
    return {
      errorResponse: jsonError(500, 'server_error', `Missing upstream key env: ${defaultKeyEnvName}`),
    };
  }
  return { upstreamApiKey };
}

function isAuthorized(request, workerApiKey) {
  if (!workerApiKey) return false;
  const auth = request.headers.get('authorization') || '';
  const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
  return token !== '' && token === workerApiKey;
}

function parseRules(raw) {
  let parsed = raw;

  // Cloudflare variable "JSON" type may already provide object/array values.
  if (typeof raw === 'string') {
    const text = raw.trim();
    if (text === '') return [];
    try {
      parsed = JSON.parse(text);
    } catch {
      return [];
    }
  }

  // Support optional object wrapper: { rules: [...] }
  if (!Array.isArray(parsed) && parsed && typeof parsed === 'object' && Array.isArray(parsed.rules)) {
    parsed = parsed.rules;
  }
  if (!Array.isArray(parsed)) return [];

  return parsed
    .map((r) => ({
      pattern: String(r?.pattern || '').trim(),
      key_env: String(r?.key_env || '').trim(),
    }))
    .filter((r) => r.pattern !== '' && r.key_env !== '');
}

function extractModel(payload) {
  if (!payload || typeof payload !== 'object') return '';
  const model = payload.model;
  return typeof model === 'string' ? model.trim() : '';
}

function matchRule(model, rules) {
  const modelLower = model.toLowerCase();

  // 1) Exact match first
  for (const rule of rules) {
    const p = rule.pattern.toLowerCase();
    if (!p.includes('*') && modelLower === p) {
      return rule;
    }
  }

  // 2) Prefix wildcard match (e.g. gpt-*)
  for (const rule of rules) {
    const p = rule.pattern.toLowerCase();
    if (p.endsWith('*')) {
      const prefix = p.slice(0, -1);
      if (prefix && modelLower.startsWith(prefix)) {
        return rule;
      }
    }
  }

  return null;
}

function shouldForwardBody(method) {
  const m = (method || '').toUpperCase();
  return !(m === 'GET' || m === 'HEAD');
}

function isWebSocketUpgrade(request) {
  const upgrade = request.headers.get('upgrade') || '';
  return upgrade.toLowerCase() === 'websocket';
}

function isJSONContentType(contentType) {
  return contentType.includes('application/json');
}

function buildUpstreamURL(requestURL, upstreamBaseURL) {
  const incoming = new URL(requestURL);
  const base = new URL(upstreamBaseURL);
  base.pathname = joinPath(base.pathname, incoming.pathname);
  base.search = incoming.search;
  return base.toString();
}

function joinPath(basePath, incomingPath) {
  const left = (basePath || '').replace(/\/+$/, '');
  const right = (incomingPath || '').replace(/^\/+/, '');
  if (!left) return `/${right}`;
  if (!right) return left;
  return `${left}/${right}`;
}

function buildUpstreamHeaders(incomingHeaders, upstreamApiKey) {
  const h = new Headers();

  for (const [k, v] of incomingHeaders.entries()) {
    const lower = k.toLowerCase();
    if (HOP_BY_HOP_HEADERS.has(lower)) continue;
    if (lower === 'authorization') continue;
    if (lower === 'host') continue;
    h.set(k, v);
  }

  h.set('authorization', `Bearer ${upstreamApiKey}`);
  return h;
}

function sanitizeResponseHeaders(upstreamHeaders) {
  const out = new Headers();
  for (const [k, v] of upstreamHeaders.entries()) {
    if (!HOP_BY_HOP_HEADERS.has(k.toLowerCase())) {
      out.set(k, v);
    }
  }
  return out;
}

function jsonError(status, code, message) {
  return new Response(JSON.stringify({ error: { code, message } }), {
    status,
    headers: {
      'content-type': 'application/json; charset=utf-8',
      'cache-control': 'no-store',
    },
  });
}

1 个帖子 - 1 位参与者

阅读完整话题

来源: linux.do查看原文