feat: DashScope 模型代理服务初始提交

支持 Anthropic 和 OpenAI 兼容协议的模型池反代,自动切换免费额度耗尽的模型。
This commit is contained in:
oy-paddy
2026-06-11 15:05:25 +08:00
commit d1021a00a0
18 changed files with 3140 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import 'dotenv/config'
export type UpstreamAuthMode = 'authorization' | 'x-api-key' | 'both'
export interface AppConfig {
port: number
proxyApiKey: string
dashscopeApiKeys: string[]
upstreamBaseUrl: string
openAIUpstreamBaseUrl: string
modelIds: string[]
cooldownMs: number
upstreamAuthMode: UpstreamAuthMode
corsOrigin: string | false
statePath: string
}
function requireEnv(name: string): string {
const value = process.env[name]?.trim()
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function parsePositiveNumber(name: string, fallback: number): number {
const raw = process.env[name]?.trim()
if (!raw) return fallback
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number`)
}
return value
}
function parseModelIds(): string[] {
const models = requireEnv('MODEL_IDS')
.split(',')
.map((model) => model.trim())
.filter(Boolean)
if (models.length === 0) {
throw new Error('MODEL_IDS must contain at least one model id')
}
return [...new Set(models)]
}
function parseDashscopeApiKeys(): string[] {
const raw = requireEnv('DASHSCOPE_API_KEYS')
const keys = raw
.split(',')
.map((key) => key.trim())
.filter(Boolean)
if (keys.length === 0) {
throw new Error('DASHSCOPE_API_KEYS must contain at least one key')
}
return [...new Set(keys)]
}
function parseUpstreamAuthMode(): UpstreamAuthMode {
const value = process.env.UPSTREAM_AUTH_MODE?.trim() || 'authorization'
if (value === 'authorization' || value === 'x-api-key' || value === 'both') {
return value
}
throw new Error('UPSTREAM_AUTH_MODE must be authorization, x-api-key, or both')
}
function parseCorsOrigin(): string | false {
const value = process.env.CORS_ORIGIN?.trim()
if (!value) return '*'
if (value.toLowerCase() === 'false') return false
return value
}
export function loadConfig(): AppConfig {
return {
port: parsePositiveNumber('PORT', 3000),
proxyApiKey: requireEnv('PROXY_API_KEY'),
dashscopeApiKeys: parseDashscopeApiKeys(),
upstreamBaseUrl:
process.env.UPSTREAM_BASE_URL?.trim() || 'https://dashscope.aliyuncs.com/apps/anthropic',
openAIUpstreamBaseUrl:
process.env.OPENAI_UPSTREAM_BASE_URL?.trim() ||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelIds: parseModelIds(),
cooldownMs: parsePositiveNumber('MODEL_COOLDOWN_SECONDS', 2592000) * 1000,
upstreamAuthMode: parseUpstreamAuthMode(),
corsOrigin: parseCorsOrigin(),
statePath: process.env.STATE_PATH?.trim() || './data/proxy-state.json',
}
}
+278
View File
@@ -0,0 +1,278 @@
import { serve } from '@hono/node-server'
import { cors } from 'hono/cors'
import { Hono } from 'hono'
import type { Context, Next } from 'hono'
import { loadConfig } from './config.js'
import { ModelPool } from './model-pool.js'
import { StateStore } from './state-store.js'
import { jsonResponse, proxyAnthropicRequest, proxyOpenAIRequest } from './upstream.js'
const config = loadConfig()
const stateStore = new StateStore(config.statePath)
const modelPool = new ModelPool(
config.dashscopeApiKeys,
config.modelIds,
config.cooldownMs,
stateStore,
)
const app = new Hono()
if (config.corsOrigin !== false) {
app.use(
'*',
cors({
origin: config.corsOrigin,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: [
'authorization',
'content-type',
'x-api-key',
'openai-organization',
'openai-project',
'anthropic-version',
'anthropic-beta',
],
exposeHeaders: ['x-proxy-key-hash', 'x-proxy-model', 'x-proxy-attempts'],
}),
)
}
app.use('*', requestLogger)
app.get('/', (c) => {
return c.json({
name: 'dashscope-model-proxy',
endpoints: ['/health', '/models/status', '/v1/models', '/v1/messages', '/v1/chat/completions'],
})
})
app.get('/health', (c) => {
return c.json({
ok: true,
totalKeys: modelPool.totalKeys(),
modelsPerKey: modelPool.totalModelsPerKey(),
totalSlots: modelPool.totalSlots(),
availableSlots: modelPool.availableCount(),
})
})
app.use('/models/status', requireProxyKey)
app.get('/models/status', (c) => {
return c.json({
totalKeys: modelPool.totalKeys(),
modelsPerKey: modelPool.totalModelsPerKey(),
totalSlots: modelPool.totalSlots(),
availableSlots: modelPool.availableCount(),
models: modelPool.snapshot(),
})
})
app.use('/v1/*', requireProxyKey)
app.get('/v1/models', (c) => {
return c.json({
object: 'list',
data: config.modelIds.map((modelId) => ({
id: modelId,
object: 'model',
created: 0,
owned_by: 'dashscope-model-proxy',
})),
})
})
app.post('/v1/chat/completions', async (c) => {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json(
{
error: {
type: 'invalid_request_error',
message: 'Request body must be valid JSON.',
},
},
400,
)
}
if (!isPlainObject(body)) {
return c.json(
{
error: {
type: 'invalid_request_error',
message: 'Request body must be a JSON object.',
},
},
400,
)
}
const url = new URL(c.req.url)
const upstreamPath = url.pathname.replace(/^\/v1/, '') || url.pathname
try {
return await proxyOpenAIRequest({
request: c.req.raw,
body,
pathWithSearch: `${upstreamPath}${url.search}`,
config,
modelPool,
})
} catch (error) {
console.error('[proxy] upstream request failed:', error)
return jsonResponse(502, {
error: {
type: 'upstream_error',
message: error instanceof Error ? error.message : 'Upstream request failed.',
},
})
}
})
app.post('/v1/*', async (c) => {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json(
{
error: {
type: 'invalid_request_error',
message: 'Request body must be valid JSON.',
},
},
400,
)
}
if (!isPlainObject(body)) {
return c.json(
{
error: {
type: 'invalid_request_error',
message: 'Request body must be a JSON object.',
},
},
400,
)
}
const url = new URL(c.req.url)
try {
return await proxyAnthropicRequest({
request: c.req.raw,
body,
pathWithSearch: `${url.pathname}${url.search}`,
config,
modelPool,
})
} catch (error) {
console.error('[proxy] upstream request failed:', error)
return jsonResponse(502, {
error: {
type: 'upstream_error',
message: error instanceof Error ? error.message : 'Upstream request failed.',
},
})
}
})
app.all('/v1/*', (c) => {
return c.json(
{
error: {
type: 'method_not_allowed',
message: 'Only POST requests are proxied under /v1/*.',
},
},
405,
)
})
serve(
{
fetch: app.fetch,
port: config.port,
},
(info) => {
console.log(`[proxy] listening on http://localhost:${info.port}`)
console.log(`[proxy] upstream base: ${config.upstreamBaseUrl}`)
console.log(`[proxy] openai upstream base: ${config.openAIUpstreamBaseUrl}`)
console.log(`[proxy] api keys loaded: ${config.dashscopeApiKeys.length}`)
console.log(`[proxy] models per key: ${config.modelIds.length}`)
console.log('[proxy] applied model ids:')
for (const modelId of config.modelIds) {
console.log(`[proxy] - ${modelId}`)
}
console.log('[proxy] reminder: 请在 DashScope/百炼控制台为以上模型开启“模型用完即停”。')
console.log(`[proxy] state file: ${config.statePath}`)
},
)
async function requireProxyKey(c: Context, next: Next): Promise<Response | void> {
if (!isAuthorized(c.req.raw.headers, config.proxyApiKey)) {
return c.json(
{
error: {
type: 'authentication_error',
message: 'Invalid proxy API key.',
},
},
401,
)
}
await next()
}
async function requestLogger(c: Context, next: Next): Promise<void> {
const startedAt = Date.now()
const url = new URL(c.req.url)
const contentLength = c.req.raw.headers.get('content-length') || '-'
const userAgent = c.req.raw.headers.get('user-agent') || '-'
await next()
const durationMs = Date.now() - startedAt
const status = c.res.status
const proxyKeyHash = c.res.headers.get('x-proxy-key-hash') || '-'
const proxyModel = c.res.headers.get('x-proxy-model') || '-'
const proxyAttempts = c.res.headers.get('x-proxy-attempts') || '-'
console.log(
[
'[request]',
`${c.req.method} ${url.pathname}${url.search}`,
`status=${status}`,
`duration=${durationMs}ms`,
`bytes=${contentLength}`,
`proxyKey=${proxyKeyHash}`,
`model=${proxyModel}`,
`attempts=${proxyAttempts}`,
`ua=${JSON.stringify(userAgent)}`,
].join(' '),
)
}
function isAuthorized(headers: Headers, expectedKey: string): boolean {
const xApiKey = headers.get('x-api-key')?.trim()
if (xApiKey === expectedKey) return true
const authorization = headers.get('authorization')?.trim()
if (!authorization) return false
const bearerPrefix = 'Bearer '
if (authorization.startsWith(bearerPrefix)) {
return authorization.slice(bearerPrefix.length).trim() === expectedKey
}
return authorization === expectedKey
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+256
View File
@@ -0,0 +1,256 @@
import { createHash } from 'node:crypto'
import type { StateStore } from './state-store.js'
export interface ModelCandidate {
apiKey: string
keyHash: string
keyLabel: string
keyIndex: number
modelId: string
modelIndex: number
}
export interface ModelSnapshot {
keyHash: string
keyIndex: number
currentKey: boolean
currentModel: boolean
id: string
available: boolean
cooldownUntil: string | null
failureCount: number
lastError: string | null
lastUsedAt: string | null
}
interface ApiKeySlot {
value: string
hash: string
label: string
index: number
}
const KEY_CURSOR_NAME = 'key_cursor'
export class ModelPool {
private readonly apiKeys: ApiKeySlot[]
constructor(
apiKeys: string[],
private readonly modelIds: string[],
private readonly cooldownMs: number,
private readonly stateStore: StateStore,
) {
this.apiKeys = apiKeys.map((value, index) => {
const hash = hashApiKey(value)
return {
value,
hash,
label: hash.slice(0, 12),
index,
}
})
this.stateStore.ensureModelStates(
this.apiKeys.map((key) => key.hash),
this.modelIds,
)
}
getAttemptModels(now = Date.now()): ModelCandidate[] {
const result: ModelCandidate[] = []
const currentKeyIndex = this.normalizeKeyIndex(this.getKeyCursor())
let firstAvailableKeyIndex: number | null = null
for (let keyOffset = 0; keyOffset < this.apiKeys.length; keyOffset += 1) {
const keyIndex = (currentKeyIndex + keyOffset) % this.apiKeys.length
const key = this.apiKeys[keyIndex]
if (!key) continue
const candidates = this.getAvailableModelsForKey(key, now)
if (candidates.length === 0) continue
if (firstAvailableKeyIndex === null) {
firstAvailableKeyIndex = keyIndex
}
result.push(...candidates)
}
if (firstAvailableKeyIndex !== null && firstAvailableKeyIndex !== currentKeyIndex) {
this.setKeyCursor(firstAvailableKeyIndex, now)
}
return result
}
markSuccess(candidate: ModelCandidate, now = Date.now()): void {
this.stateStore.markSuccess(candidate.keyHash, candidate.modelId, now)
this.setKeyCursor(candidate.keyIndex, now)
this.setModelCursor(candidate.keyHash, candidate.modelIndex, now)
}
markFreeTierExhausted(candidate: ModelCandidate, reason: string, now = Date.now()): void {
this.stateStore.markFreeTierExhausted(
candidate.keyHash,
candidate.modelId,
now + this.cooldownMs,
reason,
now,
)
const nextModelIndex = this.findNextAvailableModelIndex(candidate.keyHash, candidate.modelIndex, now)
if (nextModelIndex !== null) {
this.setModelCursor(candidate.keyHash, nextModelIndex, now)
return
}
const nextKeyIndex = this.findNextAvailableKeyIndex(candidate.keyIndex, now)
if (nextKeyIndex !== null) {
this.setKeyCursor(nextKeyIndex, now)
}
}
availableCount(now = Date.now()): number {
let count = 0
for (const key of this.apiKeys) {
for (const modelId of this.modelIds) {
const state = this.stateStore.getModelState(key.hash, modelId)
if (state.cooldownUntil <= now) count += 1
}
}
return count
}
totalKeys(): number {
return this.apiKeys.length
}
totalModelsPerKey(): number {
return this.modelIds.length
}
totalSlots(): number {
return this.apiKeys.length * this.modelIds.length
}
snapshot(now = Date.now()): ModelSnapshot[] {
const currentKeyIndex = this.normalizeKeyIndex(this.getKeyCursor())
return this.apiKeys.flatMap((key) => {
const currentModelIndex = this.normalizeModelIndex(this.getModelCursor(key.hash))
return this.modelIds.map((modelId, modelIndex) => {
const state = this.stateStore.getModelState(key.hash, modelId)
return {
keyHash: key.label,
keyIndex: key.index,
currentKey: key.index === currentKeyIndex,
currentModel: key.index === currentKeyIndex && modelIndex === currentModelIndex,
id: modelId,
available: state.cooldownUntil <= now,
cooldownUntil:
state.cooldownUntil > now ? new Date(state.cooldownUntil).toISOString() : null,
failureCount: state.failureCount,
lastError: state.lastError,
lastUsedAt: state.lastUsedAt ? new Date(state.lastUsedAt).toISOString() : null,
}
})
})
}
private getAvailableModelsForKey(key: ApiKeySlot, now: number): ModelCandidate[] {
const result: ModelCandidate[] = []
const currentModelIndex = this.normalizeModelIndex(this.getModelCursor(key.hash))
for (let modelOffset = 0; modelOffset < this.modelIds.length; modelOffset += 1) {
const modelIndex = (currentModelIndex + modelOffset) % this.modelIds.length
const modelId = this.modelIds[modelIndex]
if (!modelId) continue
const state = this.stateStore.getModelState(key.hash, modelId)
if (state.cooldownUntil > now) continue
result.push({
apiKey: key.value,
keyHash: key.hash,
keyLabel: key.label,
keyIndex: key.index,
modelId,
modelIndex,
})
}
return result
}
private findNextAvailableModelIndex(
keyHash: string,
afterModelIndex: number,
now: number,
): number | null {
for (let offset = 1; offset <= this.modelIds.length; offset += 1) {
const modelIndex = (afterModelIndex + offset) % this.modelIds.length
const modelId = this.modelIds[modelIndex]
if (!modelId) continue
const state = this.stateStore.getModelState(keyHash, modelId)
if (state.cooldownUntil <= now) return modelIndex
}
return null
}
private findNextAvailableKeyIndex(afterKeyIndex: number, now: number): number | null {
for (let offset = 1; offset <= this.apiKeys.length; offset += 1) {
const keyIndex = (afterKeyIndex + offset) % this.apiKeys.length
const key = this.apiKeys[keyIndex]
if (!key) continue
if (this.getAvailableModelsForKey(key, now).length > 0) return keyIndex
}
return null
}
private getKeyCursor(): number {
return this.stateStore.getRuntimeNumber(KEY_CURSOR_NAME, 0)
}
private setKeyCursor(keyIndex: number, now: number): void {
this.stateStore.setRuntimeNumber(KEY_CURSOR_NAME, this.normalizeKeyIndex(keyIndex), now)
}
private getModelCursor(keyHash: string): number {
return this.stateStore.getRuntimeNumber(modelCursorName(keyHash), 0)
}
private setModelCursor(keyHash: string, modelIndex: number, now: number): void {
this.stateStore.setRuntimeNumber(
modelCursorName(keyHash),
this.normalizeModelIndex(modelIndex),
now,
)
}
private normalizeKeyIndex(index: number): number {
if (this.apiKeys.length === 0) return 0
return index % this.apiKeys.length
}
private normalizeModelIndex(index: number): number {
if (this.modelIds.length === 0) return 0
return index % this.modelIds.length
}
}
function hashApiKey(apiKey: string): string {
return createHash('sha256').update(apiKey).digest('hex')
}
function modelCursorName(keyHash: string): string {
return `model_cursor:${keyHash}`
}
+326
View File
@@ -0,0 +1,326 @@
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
fsyncSync,
writeFileSync,
} from 'node:fs'
import { dirname, resolve } from 'node:path'
interface RuntimeStateEntry {
value: string
updatedAt: number
}
interface StateFile {
version: 1
modelState: Record<string, PersistedModelState>
runtimeState: Record<string, RuntimeStateEntry>
}
export interface PersistedModelState {
keyHash: string
modelId: string
cooldownUntil: number
failureCount: number
lastError: string | null
lastUsedAt: number | null
updatedAt: number
}
export class StateStore {
private readonly statePath: string
private state: StateFile
constructor(statePath: string) {
this.statePath = resolve(statePath)
mkdirSync(dirname(this.statePath), { recursive: true })
this.state = this.load()
}
ensureModelStates(keyHashes: string[], modelIds: string[], now = Date.now()): void {
let changed = false
for (const keyHash of keyHashes) {
for (const modelId of modelIds) {
if (this.ensureModelStateEntry(keyHash, modelId, now)) {
changed = true
}
}
}
if (changed) this.persist()
}
getModelState(keyHash: string, modelId: string): PersistedModelState {
const entry = this.ensureModelStateEntry(keyHash, modelId, Date.now())
if (entry) {
this.persist()
return { ...entry }
}
return { ...this.state.modelState[modelStateKey(keyHash, modelId)] }
}
markSuccess(keyHash: string, modelId: string, now = Date.now()): void {
const entry = this.getOrCreateModelStateEntry(keyHash, modelId, now)
entry.lastError = null
entry.lastUsedAt = now
entry.updatedAt = now
this.persist()
}
markFreeTierExhausted(
keyHash: string,
modelId: string,
cooldownUntil: number,
reason: string,
now = Date.now(),
): void {
const entry = this.getOrCreateModelStateEntry(keyHash, modelId, now)
entry.cooldownUntil = cooldownUntil
entry.failureCount += 1
entry.lastError = reason
entry.updatedAt = now
this.persist()
}
getRuntimeNumber(name: string, fallback: number): number {
const entry = this.state.runtimeState[name]
if (!entry) return fallback
const value = Number(entry.value)
return Number.isInteger(value) && value >= 0 ? value : fallback
}
setRuntimeNumber(name: string, value: number, now = Date.now()): void {
this.state.runtimeState[name] = {
value: String(value),
updatedAt: now,
}
this.persist()
}
close(): void {
// State changes are persisted synchronously when they happen.
}
private load(): StateFile {
if (!existsSync(this.statePath)) return emptyStateFile()
try {
const parsed = JSON.parse(readFileSync(this.statePath, 'utf8')) as unknown
return parseStateFile(parsed)
} catch (error) {
this.backupCorruptStateFile(error)
return emptyStateFile()
}
}
private persist(): void {
const content = `${JSON.stringify(this.state, null, 2)}\n`
const tempPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`
const fd = openSync(tempPath, 'w')
try {
writeFileSync(fd, content, 'utf8')
fsyncSync(fd)
} finally {
closeSync(fd)
}
renameSync(tempPath, this.statePath)
}
private ensureModelStateEntry(
keyHash: string,
modelId: string,
now: number,
): PersistedModelState | null {
const key = modelStateKey(keyHash, modelId)
const existing = this.state.modelState[key]
if (existing) return null
const entry = createModelState(keyHash, modelId, now)
this.state.modelState[key] = entry
return entry
}
private getOrCreateModelStateEntry(
keyHash: string,
modelId: string,
now: number,
): PersistedModelState {
const key = modelStateKey(keyHash, modelId)
const existing = this.state.modelState[key]
if (existing) return existing
const entry = createModelState(keyHash, modelId, now)
this.state.modelState[key] = entry
return entry
}
private backupCorruptStateFile(error: unknown): void {
const backupPath = `${this.statePath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`
try {
renameSync(this.statePath, backupPath)
console.warn(
`[state] invalid state file moved to ${backupPath}: ${formatErrorMessage(error)}`,
)
} catch (backupError) {
console.warn(
`[state] invalid state file could not be backed up: ${formatErrorMessage(backupError)}`,
)
}
}
}
function emptyStateFile(): StateFile {
return {
version: 1,
modelState: {},
runtimeState: {},
}
}
function createModelState(keyHash: string, modelId: string, now: number): PersistedModelState {
return {
keyHash,
modelId,
cooldownUntil: 0,
failureCount: 0,
lastError: null,
lastUsedAt: null,
updatedAt: now,
}
}
function parseStateFile(value: unknown): StateFile {
if (!isRecord(value)) {
throw new Error('state file must be a JSON object')
}
if (value.version !== 1) {
throw new Error('unsupported state file version')
}
if (!isRecord(value.modelState)) {
throw new Error('state file modelState must be an object')
}
if (!isRecord(value.runtimeState)) {
throw new Error('state file runtimeState must be an object')
}
const modelState: Record<string, PersistedModelState> = {}
for (const entry of Object.values(value.modelState)) {
const parsedEntry = parseModelStateEntry(entry)
modelState[modelStateKey(parsedEntry.keyHash, parsedEntry.modelId)] = parsedEntry
}
const runtimeState: Record<string, RuntimeStateEntry> = {}
for (const [name, entry] of Object.entries(value.runtimeState)) {
runtimeState[name] = parseRuntimeStateEntry(entry)
}
return {
version: 1,
modelState,
runtimeState,
}
}
function parseModelStateEntry(value: unknown): PersistedModelState {
if (!isRecord(value)) {
throw new Error('modelState entries must be objects')
}
if (!isNonEmptyString(value.keyHash)) {
throw new Error('modelState entry keyHash must be a non-empty string')
}
if (!isNonEmptyString(value.modelId)) {
throw new Error('modelState entry modelId must be a non-empty string')
}
if (!isNonNegativeInteger(value.cooldownUntil)) {
throw new Error('modelState entry cooldownUntil must be a non-negative integer')
}
if (!isNonNegativeInteger(value.failureCount)) {
throw new Error('modelState entry failureCount must be a non-negative integer')
}
if (value.lastError !== null && typeof value.lastError !== 'string') {
throw new Error('modelState entry lastError must be a string or null')
}
if (value.lastUsedAt !== null && !isNonNegativeInteger(value.lastUsedAt)) {
throw new Error('modelState entry lastUsedAt must be a non-negative integer or null')
}
if (!isNonNegativeInteger(value.updatedAt)) {
throw new Error('modelState entry updatedAt must be a non-negative integer')
}
return {
keyHash: value.keyHash,
modelId: value.modelId,
cooldownUntil: value.cooldownUntil,
failureCount: value.failureCount,
lastError: value.lastError,
lastUsedAt: value.lastUsedAt,
updatedAt: value.updatedAt,
}
}
function parseRuntimeStateEntry(value: unknown): RuntimeStateEntry {
if (!isRecord(value)) {
throw new Error('runtimeState entries must be objects')
}
if (typeof value.value !== 'string') {
throw new Error('runtimeState entry value must be a string')
}
if (!isNonNegativeInteger(value.updatedAt)) {
throw new Error('runtimeState entry updatedAt must be a non-negative integer')
}
return {
value: value.value,
updatedAt: value.updatedAt,
}
}
function modelStateKey(keyHash: string, modelId: string): string {
return `${keyHash}:${modelId}`
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
function isNonNegativeInteger(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+376
View File
@@ -0,0 +1,376 @@
import type { AppConfig } from './config.js'
import type { ModelPool } from './model-pool.js'
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'content-length',
'host',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
])
interface FreeTierExhaustion {
request_id?: string
code?: string
message?: string
}
interface FreeTierExhaustionCheck {
exhaustion: FreeTierExhaustion | null
parseMode: 'empty' | 'json' | 'sse' | 'text' | 'unreadable'
rawText: string
}
export interface ProxyRequestInput {
request: Request
body: Record<string, unknown>
pathWithSearch: string
config: AppConfig
modelPool: ModelPool
}
export async function proxyAnthropicRequest(input: ProxyRequestInput): Promise<Response> {
return proxyModelPoolRequest({
...input,
upstreamBaseUrl: input.config.upstreamBaseUrl,
})
}
export async function proxyOpenAIRequest(input: ProxyRequestInput): Promise<Response> {
return proxyModelPoolRequest({
...input,
upstreamBaseUrl: input.config.openAIUpstreamBaseUrl,
blockedRequestHeaders: ['anthropic-beta', 'anthropic-version'],
})
}
interface ModelPoolProxyInput extends ProxyRequestInput {
upstreamBaseUrl: string
blockedRequestHeaders?: string[]
}
async function proxyModelPoolRequest(input: ModelPoolProxyInput): Promise<Response> {
const candidates = input.modelPool.getAttemptModels()
if (candidates.length === 0) {
return jsonResponse(503, {
error: {
type: 'all_models_in_cooldown',
message: 'All configured models are currently cooling down.',
},
models: input.modelPool.snapshot(),
})
}
const attempts: Array<{ keyHash: string, model: string }> = []
let lastExhaustion: FreeTierExhaustion | null = null
for (const candidate of candidates) {
attempts.push({
keyHash: candidate.keyLabel,
model: candidate.modelId,
})
const upstreamResponse = await fetch(buildUpstreamUrl(input.upstreamBaseUrl, input.pathWithSearch), {
method: input.request.method,
headers: buildUpstreamHeaders(
input.request.headers,
input.config,
candidate.apiKey,
input.blockedRequestHeaders,
),
body: JSON.stringify({
...input.body,
model: candidate.modelId,
}),
})
const exhaustionCheck = await readFreeTierExhaustion(upstreamResponse.clone())
logUpstreamRetryDecision(input.pathWithSearch, candidate, upstreamResponse, exhaustionCheck)
if (exhaustionCheck.exhaustion) {
lastExhaustion = exhaustionCheck.exhaustion
input.modelPool.markFreeTierExhausted(
candidate,
exhaustionCheck.exhaustion.message || 'DashScope free tier exhausted',
)
console.warn(
[
'[proxy] free-tier exhausted; trying next model',
`path=${input.pathWithSearch}`,
`key=${candidate.keyLabel}`,
`model=${candidate.modelId}`,
`attempt=${attempts.length}/${candidates.length}`,
].join(' '),
)
continue
}
input.modelPool.markSuccess(candidate)
if (attempts.length > 1) {
console.log(
[
'[proxy] upstream accepted after retry',
`path=${input.pathWithSearch}`,
`key=${candidate.keyLabel}`,
`model=${candidate.modelId}`,
`attempts=${attempts.length}`,
].join(' '),
)
}
const headers = filterResponseHeaders(upstreamResponse.headers)
headers.set('x-proxy-key-hash', candidate.keyLabel)
headers.set('x-proxy-model', candidate.modelId)
headers.set('x-proxy-attempts', String(attempts.length))
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers,
})
}
return jsonResponse(503, {
error: {
type: 'all_models_exhausted',
message: 'Every available model returned DashScope free-tier exhaustion.',
lastUpstreamError: lastExhaustion,
},
attempts,
models: input.modelPool.snapshot(),
})
}
export function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
},
})
}
function buildUpstreamUrl(baseUrl: string, pathWithSearch: string): string {
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return `${normalizedBaseUrl}${pathWithSearch.startsWith('/') ? '' : '/'}${pathWithSearch}`
}
function buildUpstreamHeaders(
source: Headers,
config: AppConfig,
apiKey: string,
blockedRequestHeaders: string[] = [],
): Headers {
const headers = new Headers()
const blockedHeaderNames = new Set(blockedRequestHeaders.map((header) => header.toLowerCase()))
for (const [name, value] of source.entries()) {
const lowerName = name.toLowerCase()
if (
HOP_BY_HOP_HEADERS.has(lowerName) ||
blockedHeaderNames.has(lowerName) ||
lowerName === 'authorization' ||
lowerName === 'x-api-key'
) {
continue
}
headers.set(name, value)
}
headers.set('content-type', 'application/json')
if (config.upstreamAuthMode === 'authorization' || config.upstreamAuthMode === 'both') {
headers.set('authorization', `Bearer ${apiKey}`)
}
if (config.upstreamAuthMode === 'x-api-key' || config.upstreamAuthMode === 'both') {
headers.set('x-api-key', apiKey)
}
return headers
}
function filterResponseHeaders(source: Headers): Headers {
const headers = new Headers()
for (const [name, value] of source.entries()) {
const lowerName = name.toLowerCase()
if (HOP_BY_HOP_HEADERS.has(lowerName) || lowerName === 'content-encoding') continue
headers.set(name, value)
}
return headers
}
async function readFreeTierExhaustion(response: Response): Promise<FreeTierExhaustionCheck> {
if (response.status !== 403) {
return {
exhaustion: null,
parseMode: 'empty',
rawText: '',
}
}
const payload = await readErrorPayload(response)
if (!payload.payload) {
return {
exhaustion: null,
parseMode: payload.parseMode,
rawText: payload.rawText,
}
}
return {
exhaustion: extractFreeTierExhaustion(payload.payload),
parseMode: payload.parseMode,
rawText: payload.rawText,
}
}
async function readErrorPayload(response: Response): Promise<{
payload: unknown | null
parseMode: FreeTierExhaustionCheck['parseMode']
rawText: string
}> {
let text: string
try {
text = await response.text()
} catch {
return {
payload: null,
parseMode: 'unreadable',
rawText: '',
}
}
const trimmed = text.trim()
if (!trimmed) {
return {
payload: null,
parseMode: 'empty',
rawText: '',
}
}
if (trimmed.startsWith('{')) {
try {
return {
payload: JSON.parse(trimmed) as unknown,
parseMode: 'json',
rawText: trimmed,
}
} catch {
return {
payload: null,
parseMode: 'text',
rawText: trimmed,
}
}
}
// DashScope can return streaming errors as:
// event:error
// data:{"code":"AccessDenied",...}
for (const line of trimmed.split(/\r?\n/)) {
const currentLine = line.trim()
if (!currentLine.startsWith('data:')) continue
const rawData = currentLine.slice('data:'.length).trim()
if (!rawData || rawData === '[DONE]') continue
try {
return {
payload: JSON.parse(rawData) as unknown,
parseMode: 'sse',
rawText: trimmed,
}
} catch {
return {
payload: null,
parseMode: 'text',
rawText: trimmed,
}
}
}
return {
payload: null,
parseMode: 'text',
rawText: trimmed,
}
}
function extractFreeTierExhaustion(payload: unknown): FreeTierExhaustion | null {
if (!isRecord(payload)) return null
const error = isRecord(payload.error) ? payload.error : null
const code = getString(payload.code) || getString(error?.code)
const type = getString(payload.type) || getString(error?.type)
const message = getString(payload.message) || getString(error?.message) || ''
const requestId =
getString(payload.request_id) ||
getString(payload.requestId) ||
getString(error?.request_id) ||
getString(error?.requestId)
const exhausted = /free\s+tier/i.test(message) && /exhausted/i.test(message)
const freeTierOnly = code === 'AllocationQuota.FreeTierOnly' || type === 'AllocationQuota.FreeTierOnly'
if ((code === 'AccessDenied' && exhausted) || freeTierOnly) {
return {
code,
message,
request_id: requestId,
}
}
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function logUpstreamRetryDecision(
pathWithSearch: string,
candidate: { keyLabel: string, modelId: string },
response: Response,
check: FreeTierExhaustionCheck,
): void {
if (response.status < 400) return
const contentType = response.headers.get('content-type') || '-'
const rawText = check.rawText ? previewText(check.rawText, 500) : '-'
console.warn(
[
'[proxy] upstream non-2xx',
`path=${pathWithSearch}`,
`status=${response.status}`,
`key=${candidate.keyLabel}`,
`model=${candidate.modelId}`,
`contentType=${JSON.stringify(contentType)}`,
`parse=${check.parseMode}`,
`freeTierExhausted=${check.exhaustion ? 'yes' : 'no'}`,
`body=${JSON.stringify(rawText)}`,
].join(' '),
)
}
function previewText(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLength) return normalized
return `${normalized.slice(0, maxLength)}...`
}