feat: DashScope 模型代理服务初始提交
支持 Anthropic 和 OpenAI 兼容协议的模型池反代,自动切换免费额度耗尽的模型。
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
@@ -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)}...`
|
||||
}
|
||||
Reference in New Issue
Block a user