使用cf worker,sub2api一个key调用全部模型
- 内容介绍
- 文章标签
- 相关推荐
sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。
使用方法:
Screenshot| 100%2560×4686 552 KB
/**
* 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',
},
});
}
网友解答:
--【壹】--:
sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。
使用方法:
Screenshot| 100%2560×4686 552 KB
/**
* 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',
},
});
}
sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。
使用方法:
Screenshot| 100%2560×4686 552 KB
/**
* 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',
},
});
}
网友解答:
--【壹】--:
sub2api的性能很好,但是路由规则比较死板,一个key只能对应一种协议,我喜欢在cc里面同时用gpt和glm,所以搓了了一个 cf worker,进行自动路由,一个Key可以调用多个模型。
使用方法:
Screenshot| 100%2560×4686 552 KB
/**
* 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',
},
});
}

