From c6c5230e499e98fd107c4d30fe790a0480fd5122 Mon Sep 17 00:00:00 2001 From: klemflex Date: Tue, 2 Jun 2026 15:16:00 +0300 Subject: [PATCH 1/2] feat: per-model provider routing and truecolor banner --- README.md | 32 ++++++ config.example.json | 12 ++- src copy/banner.js | 124 ++++++++++++++++++++++ src copy/config.js | 47 +++++++++ src copy/logo.svg | 19 ++++ src copy/server.js | 246 ++++++++++++++++++++++++++++++++++++++++++++ src copy/wizard.js | 51 +++++++++ 7 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 src copy/banner.js create mode 100644 src copy/config.js create mode 100644 src copy/logo.svg create mode 100644 src copy/server.js create mode 100644 src copy/wizard.js diff --git a/README.md b/README.md index 757f6be..7ba70a6 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ npm start "order": ["OpenAI", "Anthropic"], "allow_fallbacks": true } + }, + "modelProviders": { + "deepseek/deepseek-v4-pro": { + "only": ["DeepSeek"], + "allow_fallbacks": false + } } } ``` @@ -91,6 +97,7 @@ npm start | `host` | string | `"127.0.0.1"` | Интерфейс. Поставь `"0.0.0.0"` чтобы слушать все. | | `polzaApiKey` | string | `""` | Fallback API-ключ. Используется, если клиент не прислал `Authorization`. | | `inject` | object | `{}` | Поля, дописываемые в JSON-тело на `INJECT_PATHS`. Клиентское значение никогда не перетирается. Подробнее → [Инъекции](#инъекции). | +| `modelProviders` | object | `{}` | Per-model provider selection. Ключ — имя модели, значение — объект `provider` (те же поля: `only`, `order`, `allow_fallbacks`). Если модель запроса найдена здесь, её `provider` **полностью заменяет** глобальный `inject.provider`. Остальные ключи из `inject` работают как обычно. Подробнее → [Model-specific providers](#model-specific-providers). | Захардкожено в `src/config.js` и **не меняется** через конфиг: `UPSTREAM_BASE_URL` и `INJECT_PATHS`. Чтобы поменять — правь исходник. @@ -117,6 +124,31 @@ npm start Варианты: `order`, `only`, `allow_fallbacks`, и т. д. — как в доке Polza. +### Model-specific providers + +`modelProviders` позволяет привязать конкретную модель к конкретному провайдеру, не затрагивая глобальные настройки для остальных моделей. + +```json +{ + "modelProviders": { + "deepseek/deepseek-v4-pro": { + "only": ["DeepSeek"], + "allow_fallbacks": false + }, + "xiaomi/mimo-v2.5-pro": { + "only": ["Xiaomi", "DeepInfra"], + "allow_fallbacks": true + } + } +} +``` + +**Как работает:** +1. Если в теле запроса есть поле `model` и оно найдено в `modelProviders` — для этой модели инжектится **свой** `provider`, полностью заменяя глобальный `inject.provider`. +2. Если модели нет в `modelProviders` — работает глобальный `inject.provider` (текущее поведение). +3. Если клиент **уже прислал** `provider` в теле запроса — ни глобальная, ни per-model настройка не применяется (клиентское значение всегда в приоритете). +4. Все остальные поля из `inject` (кроме `provider`) инжектятся одинаково для всех моделей. + ## Переменные окружения | Переменная | Назначение | diff --git a/config.example.json b/config.example.json index 4c91e50..2366100 100644 --- a/config.example.json +++ b/config.example.json @@ -7,5 +7,15 @@ "only": ["OpenAI"], "allow_fallbacks": true } + }, + "modelProviders": { + "deepseek/deepseek-v4-pro": { + "only": ["DeepSeek"], + "allow_fallbacks": false + }, + "xiaomi/mimo-v2.5-pro": { + "only": ["Xiaomi", "DeepInfra"], + "allow_fallbacks": true + } } -} +} \ No newline at end of file diff --git a/src copy/banner.js b/src copy/banner.js new file mode 100644 index 0000000..3ff0f13 --- /dev/null +++ b/src copy/banner.js @@ -0,0 +1,124 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SVG_PATH = resolve(__dirname, 'logo.svg'); + +const COLORTERM = (process.env.COLORTERM ?? '').toLowerCase(); +const USE_TRUECOLOR = COLORTERM === 'truecolor' || COLORTERM === '24bit'; +const GREEN = USE_TRUECOLOR ? '#39FF14' : '#B2F72A'; +const GREEN_RGB = USE_TRUECOLOR ? { r: 57, g: 255, b: 20 } : { r: 178, g: 247, b: 42 }; +const WHITE_RGB = { r: 255, g: 255, b: 255 }; + +const BANNER_LINES = [ + '██████╗ ██████╗ ██╗ ███████╗ █████╗ █████╗ ██╗', + '██╔══██╗██╔═══██╗██║ ╚══███╔╝██╔══██╗ ██╔══██╗██║', + '██████╔╝██║ ██║██║ ███╔╝ ███████║ ███████║██║', + '██╔═══╝ ██║ ██║██║ ███╔╝ ██╔══██║ ██╔══██║██║', + '██║ ╚██████╔╝███████╗███████╗██║ ██║ ██║ ██║██║', + '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝', +]; + +function classifyPixel(r, g, b, a) { + if (a < 50) return null; + const dGreen = Math.abs(r - GREEN_RGB.r) + Math.abs(g - GREEN_RGB.g) + Math.abs(b - GREEN_RGB.b); + const dWhite = Math.abs(r - WHITE_RGB.r) + Math.abs(g - WHITE_RGB.g) + Math.abs(b - WHITE_RGB.b); + return dGreen <= dWhite ? 'green' : 'white'; +} + +function colorChar(char, fg, bg, chk) { + let c = chk.hex(fg === 'green' ? GREEN : '#FFFFFF'); + if (bg) c = c.bgHex(bg === 'green' ? GREEN : '#FFFFFF'); + return c(char); +} + +// Render at SCALE× resolution, then majority-vote downsample → eliminates antialiasing +const SCALE = 4; +const TARGET_H = 24; // target pixel rows (→ TARGET_H/2 = 12 char rows) + +function classifyRegion(pixels, srcW, rowStart, colStart, blockH, blockW) { + let green = 0; + let white = 0; + for (let r = rowStart; r < rowStart + blockH; r++) { + for (let c = colStart; c < colStart + blockW; c++) { + const i = (r * srcW + c) * 4; + const a = pixels[i + 3]; + if (a < 128) continue; + const d = classifyPixel(pixels[i], pixels[i + 1], pixels[i + 2], a); + if (d === 'green') green++; + else if (d === 'white') white++; + } + } + const threshold = Math.ceil((blockH * blockW) / 2); // 50% majority → clean pixel art + if (green >= threshold || white >= threshold) { + return green >= white ? 'green' : 'white'; + } + return null; +} + +async function renderLogoLines(chk) { + const { Resvg } = await import('@resvg/resvg-js'); + + const svgRaw = readFileSync(SVG_PATH, 'utf8'); + const patched = svgRaw.replaceAll('#171717', '#FFFFFF'); + + const renderH = TARGET_H * SCALE; + const resvg = new Resvg(patched, { fitTo: { mode: 'height', value: renderH } }); + const rendered = resvg.render(); + const { pixels, width: srcW, height: srcH } = rendered; + + const colCount = Math.floor(srcW / SCALE); + const charRows = Math.floor(srcH / (SCALE * 2)); // 2 pixel rows per character row + const lines = []; + + for (let row = 0; row < charRows; row++) { + let line = ''; + for (let col = 0; col < colCount; col++) { + const topRowStart = row * 2 * SCALE; + const botRowStart = topRowStart + SCALE; + const colStart = col * SCALE; + + const top = classifyRegion(pixels, srcW, topRowStart, colStart, SCALE, SCALE); + const bot = classifyRegion(pixels, srcW, botRowStart, colStart, SCALE, SCALE); + + if (!top && !bot) { + line += ' '; + } else if (top && bot && top === bot) { + line += colorChar('█', top, null, chk); + } else if (top && bot) { + line += colorChar('▀', top, bot, chk); + } else if (top) { + line += colorChar('▀', top, null, chk); + } else { + line += colorChar('▄', bot, null, chk); + } + } + lines.push(line); + } + + return lines; +} + +export async function printBanner({ version, host, port, upstream }) { + const infoLine = `version: ${version} | listening on http://${host}:${port} | upstream: ${upstream}`; + + try { + const { Chalk } = await import('chalk'); + const chk = new Chalk({ level: process.stdout.isTTY && !process.env.NO_COLOR ? 3 : 0 }); + + if (!existsSync(SVG_PATH)) throw new Error('logo.svg not found'); + + const lines = await renderLogoLines(chk); + process.stdout.write('\n'); + for (const line of lines) process.stdout.write(line + '\n'); + process.stdout.write('\n' + chk.hex(GREEN)(infoLine) + '\n\n'); + } catch { + const open = process.stdout.isTTY && !process.env.NO_COLOR + ? `\x1b[38;2;${GREEN_RGB.r};${GREEN_RGB.g};${GREEN_RGB.b}m` + : ''; + const close = open ? '\x1b[0m' : ''; + for (const line of BANNER_LINES) process.stdout.write(open + line + close + '\n'); + process.stdout.write(open + infoLine + close + '\n\n'); + } +} diff --git a/src copy/config.js b/src copy/config.js new file mode 100644 index 0000000..3b40f4a --- /dev/null +++ b/src copy/config.js @@ -0,0 +1,47 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runFirstRunWizard } from './wizard.js'; + +export const UPSTREAM_BASE_URL = 'https://polza.ai/api/v1'; +export const INJECT_PATHS = ['/chat/completions', '/completions', '/responses']; + +function configPath() { + if (process.env.POLZA_PROXY_CONFIG) { + return resolve(process.env.POLZA_PROXY_CONFIG); + } + return resolve(process.cwd(), 'config.json'); +} + +function validate(cfg) { + if (!Number.isInteger(cfg.port) || cfg.port < 1 || cfg.port > 65535) { + throw new Error(`Invalid port ${cfg.port}. Must be an integer between 1 and 65535.`); + } +} + +export async function loadConfig() { + const path = configPath(); + + let raw; + if (existsSync(path)) { + raw = JSON.parse(readFileSync(path, 'utf8')); + } else { + raw = await runFirstRunWizard(path); + } + + const envKey = process.env.POLZA_API_KEY?.trim(); + const fileKey = typeof raw.polzaApiKey === 'string' ? raw.polzaApiKey.trim() : ''; + + const cfg = { + port: raw.port ?? 8787, + host: raw.host ?? '127.0.0.1', + upstreamBaseUrl: UPSTREAM_BASE_URL, + polzaApiKey: fileKey || envKey || '', + inject: raw.inject ?? {}, + modelProviders: raw.modelProviders ?? {}, + injectPaths: INJECT_PATHS, + sourcePath: path, + }; + + validate(cfg); + return cfg; +} diff --git a/src copy/logo.svg b/src copy/logo.svg new file mode 100644 index 0000000..304b40d --- /dev/null +++ b/src copy/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src copy/server.js b/src copy/server.js new file mode 100644 index 0000000..c188719 --- /dev/null +++ b/src copy/server.js @@ -0,0 +1,246 @@ +import Fastify from 'fastify'; +import { Readable } from 'node:stream'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from './config.js'; +import { printBanner } from './banner.js'; + +function readVersion() { + try { + const here = dirname(fileURLToPath(import.meta.url)); + return JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')).version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', + 'content-length', + 'accept-encoding', +]); + +function warnIfNoApiKey(config) { + if (config.polzaApiKey) return; + const useColor = process.stdout.isTTY && !process.env.NO_COLOR; + const open = useColor ? '\x1b[33m' : ''; + const close = useColor ? '\x1b[0m' : ''; + process.stdout.write( + `${open}\u26A0 No API key configured. Clients must send their own Authorization header.${close}\n`, + ); +} + +async function main() { + const config = await loadConfig(); + warnIfNoApiKey(config); + + const injectPaths = config.injectPaths.map((p) => p.replace(/\/+$/, '')); + + const fastify = Fastify({ + logger: { + level: process.env.LOG_LEVEL ?? 'info', + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.headers.cookie', + 'headers.authorization', + 'headers["x-api-key"]', + 'headers.cookie', + ], + censor: '[redacted]', + }, + }, + bodyLimit: 50 * 1024 * 1024, + }); + + fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => { + done(null, body); + }); + + function buildUpstreamHeaders(requestHeaders) { + const headers = {}; + for (const [k, v] of Object.entries(requestHeaders)) { + if (HOP_BY_HOP.has(k.toLowerCase())) continue; + headers[k] = Array.isArray(v) ? v.join(', ') : v; + } + if (!headers.authorization && config.polzaApiKey) { + headers.authorization = `Bearer ${config.polzaApiKey}`; + } + return headers; + } + + function shouldInject(subPath) { + return injectPaths.some( + (p) => subPath === p || subPath.startsWith(p + '/') || subPath.startsWith(p + '?'), + ); + } + + function applyInjections(subPath, body, log) { + if (!body || typeof body !== 'object') return body; + if (!shouldInject(subPath)) return body; + + const modelProvider = body.model && config.modelProviders[body.model]; + + const injected = []; + const skipped = []; + for (const [key, value] of Object.entries(config.inject ?? {})) { + const effectiveValue = key === 'provider' && modelProvider ? modelProvider : value; + if (body[key] === undefined) { + body[key] = effectiveValue; + injected.push(key); + } else { + skipped.push(key); + } + } + if (modelProvider && body.provider === undefined && !(config.inject && 'provider' in config.inject)) { + body.provider = modelProvider; + injected.push('provider'); + } + if (injected.length || skipped.length) { + log.info( + { injected, skippedByClient: skipped, path: subPath, modelProvider: !!modelProvider }, + 'inject', + ); + } + + return body; + } + + fastify.all('/*', async (request, reply) => { + const subPath = request.url.replace(/^\/v1/, '').split('?')[0]; + const query = request.url.includes('?') ? '?' + request.url.split('?')[1] : ''; + const upstreamUrl = config.upstreamBaseUrl + subPath + query; + + const headers = buildUpstreamHeaders(request.headers); + + let body = request.body; + let serializedBody; + + const methodHasBody = !['GET', 'HEAD'].includes(request.method); + if (methodHasBody && body !== undefined && body !== null) { + if (Buffer.isBuffer(body)) { + serializedBody = body.length > 0 ? body : undefined; + } else { + body = applyInjections(subPath, body, request.log); + serializedBody = typeof body === 'string' ? body : JSON.stringify(body); + headers['content-type'] = headers['content-type'] ?? 'application/json'; + } + } + + const debugBodies = ['1', 'true', 'yes'].includes( + (process.env.DEBUG_BODIES ?? '').trim().toLowerCase(), + ); + + if (debugBodies) { + const logBody = Buffer.isBuffer(serializedBody) + ? `[binary ${serializedBody.length} bytes]` + : serializedBody; + request.log.info({ upstreamUrl, outgoingBody: logBody }, 'upstream request'); + } + + let upstream; + try { + upstream = await fetch(upstreamUrl, { + method: request.method, + headers, + body: serializedBody, + }); + } catch (err) { + request.log.error({ err, upstreamUrl }, 'upstream fetch failed'); + return reply.code(502).send({ error: { message: 'Upstream request failed', detail: String(err) } }); + } + + reply.status(upstream.status); + upstream.headers.forEach((value, key) => { + if (HOP_BY_HOP.has(key.toLowerCase())) return; + reply.header(key, value); + }); + + if (!upstream.body) return reply.send(); + + const contentType = upstream.headers.get('content-type') ?? ''; + const isStream = contentType.includes('text/event-stream'); + + const isJsonResponse = contentType.includes('application/json'); + if (debugBodies && !isStream && isJsonResponse) { + const text = await upstream.text(); + try { + const parsed = JSON.parse(text); + request.log.info( + { provider: parsed?.provider, model: parsed?.model, id: parsed?.id, status: upstream.status }, + 'upstream response', + ); + } catch { + request.log.info({ status: upstream.status, raw: text.slice(0, 500) }, 'upstream response (non-JSON)'); + } + return reply.send(text); + } + + if (debugBodies && !isStream && !isJsonResponse) { + request.log.info( + { status: upstream.status, contentType }, + 'upstream response (binary, body not logged)', + ); + } + + if (debugBodies && isStream) { + const nodeStream = Readable.fromWeb(upstream.body); + let buf = ''; + let logged = false; + nodeStream.on('data', (chunk) => { + if (logged) return; + buf += chunk.toString('utf8'); + if (buf.length > 4000) { + request.log.info({ streamHead: buf.slice(0, 2000) }, 'upstream stream head'); + logged = true; + } + }); + nodeStream.on('end', () => { + if (!logged && buf.length) { + request.log.info({ streamHead: buf.slice(0, 2000) }, 'upstream stream head (short)'); + } + }); + return reply.send(nodeStream); + } + + return reply.send(Readable.fromWeb(upstream.body)); + }); + + fastify.get('/health', async () => ({ ok: true })); + + await printBanner({ + version: readVersion(), + host: config.host, + port: config.port, + upstream: config.upstreamBaseUrl, + }); + + try { + await fastify.listen({ port: config.port, host: config.host }); + } catch (err) { + if (err && err.code === 'EADDRINUSE') { + process.stderr.write( + `\nPort ${config.port} is already in use. Set a different port in config.json ` + + `(${config.sourcePath}).\n\n`, + ); + process.exit(1); + } + fastify.log.error(err); + process.exit(1); + } +} + +main().catch((err) => { + process.stderr.write(`\n${err?.message ?? err}\n\n`); + process.exit(1); +}); diff --git a/src copy/wizard.js b/src copy/wizard.js new file mode 100644 index 0000000..81cad46 --- /dev/null +++ b/src copy/wizard.js @@ -0,0 +1,51 @@ +import { createInterface } from 'node:readline'; +import { writeFileSync } from 'node:fs'; + +function ask(rl, question) { + return new Promise((resolve) => rl.question(question, resolve)); +} + +export async function runFirstRunWizard(targetPath) { + if (!process.stdin.isTTY) { + throw new Error( + `No config.json found and stdin is not a TTY. ` + + `Create a config.json at ${targetPath}, or point POLZA_PROXY_CONFIG at one.`, + ); + } + + process.stdout.write('\n'); + process.stdout.write('No config.json found — starting first-run setup.\n'); + process.stdout.write('\n'); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const apiKey = (await ask(rl, 'Polza API key (press Enter to skip): ')).trim(); + const portRaw = (await ask(rl, 'Port [8787]: ')).trim(); + const port = portRaw.length ? Number(portRaw) : 8787; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port "${portRaw}" — must be an integer between 1 and 65535.`); + } + + const config = { + port, + host: '127.0.0.1', + polzaApiKey: apiKey, + inject: { + provider: { + order: ['OpenAI', 'Anthropic'], + allow_fallbacks: true, + }, + }, + }; + + writeFileSync(targetPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + + process.stdout.write('\n'); + process.stdout.write(`Config saved to ${targetPath}. Edit it to customize further.\n`); + process.stdout.write('\n'); + + return config; + } finally { + rl.close(); + } +} From 0c4baafbf4e766921520f5be1c4fa070c7b92eb5 Mon Sep 17 00:00:00 2001 From: klemflex Date: Tue, 2 Jun 2026 15:18:06 +0300 Subject: [PATCH 2/2] feat: per-model provider routing and truecolor banner last commit per PR --- src copy/banner.js | 124 ----------------------- src copy/config.js | 47 --------- src copy/logo.svg | 19 ---- src copy/server.js | 246 --------------------------------------------- src copy/wizard.js | 51 ---------- src/banner.js | 10 +- src/config.js | 1 + src/server.js | 14 ++- 8 files changed, 20 insertions(+), 492 deletions(-) delete mode 100644 src copy/banner.js delete mode 100644 src copy/config.js delete mode 100644 src copy/logo.svg delete mode 100644 src copy/server.js delete mode 100644 src copy/wizard.js diff --git a/src copy/banner.js b/src copy/banner.js deleted file mode 100644 index 3ff0f13..0000000 --- a/src copy/banner.js +++ /dev/null @@ -1,124 +0,0 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SVG_PATH = resolve(__dirname, 'logo.svg'); - -const COLORTERM = (process.env.COLORTERM ?? '').toLowerCase(); -const USE_TRUECOLOR = COLORTERM === 'truecolor' || COLORTERM === '24bit'; -const GREEN = USE_TRUECOLOR ? '#39FF14' : '#B2F72A'; -const GREEN_RGB = USE_TRUECOLOR ? { r: 57, g: 255, b: 20 } : { r: 178, g: 247, b: 42 }; -const WHITE_RGB = { r: 255, g: 255, b: 255 }; - -const BANNER_LINES = [ - '██████╗ ██████╗ ██╗ ███████╗ █████╗ █████╗ ██╗', - '██╔══██╗██╔═══██╗██║ ╚══███╔╝██╔══██╗ ██╔══██╗██║', - '██████╔╝██║ ██║██║ ███╔╝ ███████║ ███████║██║', - '██╔═══╝ ██║ ██║██║ ███╔╝ ██╔══██║ ██╔══██║██║', - '██║ ╚██████╔╝███████╗███████╗██║ ██║ ██║ ██║██║', - '╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝', -]; - -function classifyPixel(r, g, b, a) { - if (a < 50) return null; - const dGreen = Math.abs(r - GREEN_RGB.r) + Math.abs(g - GREEN_RGB.g) + Math.abs(b - GREEN_RGB.b); - const dWhite = Math.abs(r - WHITE_RGB.r) + Math.abs(g - WHITE_RGB.g) + Math.abs(b - WHITE_RGB.b); - return dGreen <= dWhite ? 'green' : 'white'; -} - -function colorChar(char, fg, bg, chk) { - let c = chk.hex(fg === 'green' ? GREEN : '#FFFFFF'); - if (bg) c = c.bgHex(bg === 'green' ? GREEN : '#FFFFFF'); - return c(char); -} - -// Render at SCALE× resolution, then majority-vote downsample → eliminates antialiasing -const SCALE = 4; -const TARGET_H = 24; // target pixel rows (→ TARGET_H/2 = 12 char rows) - -function classifyRegion(pixels, srcW, rowStart, colStart, blockH, blockW) { - let green = 0; - let white = 0; - for (let r = rowStart; r < rowStart + blockH; r++) { - for (let c = colStart; c < colStart + blockW; c++) { - const i = (r * srcW + c) * 4; - const a = pixels[i + 3]; - if (a < 128) continue; - const d = classifyPixel(pixels[i], pixels[i + 1], pixels[i + 2], a); - if (d === 'green') green++; - else if (d === 'white') white++; - } - } - const threshold = Math.ceil((blockH * blockW) / 2); // 50% majority → clean pixel art - if (green >= threshold || white >= threshold) { - return green >= white ? 'green' : 'white'; - } - return null; -} - -async function renderLogoLines(chk) { - const { Resvg } = await import('@resvg/resvg-js'); - - const svgRaw = readFileSync(SVG_PATH, 'utf8'); - const patched = svgRaw.replaceAll('#171717', '#FFFFFF'); - - const renderH = TARGET_H * SCALE; - const resvg = new Resvg(patched, { fitTo: { mode: 'height', value: renderH } }); - const rendered = resvg.render(); - const { pixels, width: srcW, height: srcH } = rendered; - - const colCount = Math.floor(srcW / SCALE); - const charRows = Math.floor(srcH / (SCALE * 2)); // 2 pixel rows per character row - const lines = []; - - for (let row = 0; row < charRows; row++) { - let line = ''; - for (let col = 0; col < colCount; col++) { - const topRowStart = row * 2 * SCALE; - const botRowStart = topRowStart + SCALE; - const colStart = col * SCALE; - - const top = classifyRegion(pixels, srcW, topRowStart, colStart, SCALE, SCALE); - const bot = classifyRegion(pixels, srcW, botRowStart, colStart, SCALE, SCALE); - - if (!top && !bot) { - line += ' '; - } else if (top && bot && top === bot) { - line += colorChar('█', top, null, chk); - } else if (top && bot) { - line += colorChar('▀', top, bot, chk); - } else if (top) { - line += colorChar('▀', top, null, chk); - } else { - line += colorChar('▄', bot, null, chk); - } - } - lines.push(line); - } - - return lines; -} - -export async function printBanner({ version, host, port, upstream }) { - const infoLine = `version: ${version} | listening on http://${host}:${port} | upstream: ${upstream}`; - - try { - const { Chalk } = await import('chalk'); - const chk = new Chalk({ level: process.stdout.isTTY && !process.env.NO_COLOR ? 3 : 0 }); - - if (!existsSync(SVG_PATH)) throw new Error('logo.svg not found'); - - const lines = await renderLogoLines(chk); - process.stdout.write('\n'); - for (const line of lines) process.stdout.write(line + '\n'); - process.stdout.write('\n' + chk.hex(GREEN)(infoLine) + '\n\n'); - } catch { - const open = process.stdout.isTTY && !process.env.NO_COLOR - ? `\x1b[38;2;${GREEN_RGB.r};${GREEN_RGB.g};${GREEN_RGB.b}m` - : ''; - const close = open ? '\x1b[0m' : ''; - for (const line of BANNER_LINES) process.stdout.write(open + line + close + '\n'); - process.stdout.write(open + infoLine + close + '\n\n'); - } -} diff --git a/src copy/config.js b/src copy/config.js deleted file mode 100644 index 3b40f4a..0000000 --- a/src copy/config.js +++ /dev/null @@ -1,47 +0,0 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { runFirstRunWizard } from './wizard.js'; - -export const UPSTREAM_BASE_URL = 'https://polza.ai/api/v1'; -export const INJECT_PATHS = ['/chat/completions', '/completions', '/responses']; - -function configPath() { - if (process.env.POLZA_PROXY_CONFIG) { - return resolve(process.env.POLZA_PROXY_CONFIG); - } - return resolve(process.cwd(), 'config.json'); -} - -function validate(cfg) { - if (!Number.isInteger(cfg.port) || cfg.port < 1 || cfg.port > 65535) { - throw new Error(`Invalid port ${cfg.port}. Must be an integer between 1 and 65535.`); - } -} - -export async function loadConfig() { - const path = configPath(); - - let raw; - if (existsSync(path)) { - raw = JSON.parse(readFileSync(path, 'utf8')); - } else { - raw = await runFirstRunWizard(path); - } - - const envKey = process.env.POLZA_API_KEY?.trim(); - const fileKey = typeof raw.polzaApiKey === 'string' ? raw.polzaApiKey.trim() : ''; - - const cfg = { - port: raw.port ?? 8787, - host: raw.host ?? '127.0.0.1', - upstreamBaseUrl: UPSTREAM_BASE_URL, - polzaApiKey: fileKey || envKey || '', - inject: raw.inject ?? {}, - modelProviders: raw.modelProviders ?? {}, - injectPaths: INJECT_PATHS, - sourcePath: path, - }; - - validate(cfg); - return cfg; -} diff --git a/src copy/logo.svg b/src copy/logo.svg deleted file mode 100644 index 304b40d..0000000 --- a/src copy/logo.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src copy/server.js b/src copy/server.js deleted file mode 100644 index c188719..0000000 --- a/src copy/server.js +++ /dev/null @@ -1,246 +0,0 @@ -import Fastify from 'fastify'; -import { Readable } from 'node:stream'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { loadConfig } from './config.js'; -import { printBanner } from './banner.js'; - -function readVersion() { - try { - const here = dirname(fileURLToPath(import.meta.url)); - return JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')).version ?? '0.0.0'; - } catch { - return '0.0.0'; - } -} - -const HOP_BY_HOP = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'host', - 'content-length', - 'accept-encoding', -]); - -function warnIfNoApiKey(config) { - if (config.polzaApiKey) return; - const useColor = process.stdout.isTTY && !process.env.NO_COLOR; - const open = useColor ? '\x1b[33m' : ''; - const close = useColor ? '\x1b[0m' : ''; - process.stdout.write( - `${open}\u26A0 No API key configured. Clients must send their own Authorization header.${close}\n`, - ); -} - -async function main() { - const config = await loadConfig(); - warnIfNoApiKey(config); - - const injectPaths = config.injectPaths.map((p) => p.replace(/\/+$/, '')); - - const fastify = Fastify({ - logger: { - level: process.env.LOG_LEVEL ?? 'info', - redact: { - paths: [ - 'req.headers.authorization', - 'req.headers["x-api-key"]', - 'req.headers.cookie', - 'headers.authorization', - 'headers["x-api-key"]', - 'headers.cookie', - ], - censor: '[redacted]', - }, - }, - bodyLimit: 50 * 1024 * 1024, - }); - - fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => { - done(null, body); - }); - - function buildUpstreamHeaders(requestHeaders) { - const headers = {}; - for (const [k, v] of Object.entries(requestHeaders)) { - if (HOP_BY_HOP.has(k.toLowerCase())) continue; - headers[k] = Array.isArray(v) ? v.join(', ') : v; - } - if (!headers.authorization && config.polzaApiKey) { - headers.authorization = `Bearer ${config.polzaApiKey}`; - } - return headers; - } - - function shouldInject(subPath) { - return injectPaths.some( - (p) => subPath === p || subPath.startsWith(p + '/') || subPath.startsWith(p + '?'), - ); - } - - function applyInjections(subPath, body, log) { - if (!body || typeof body !== 'object') return body; - if (!shouldInject(subPath)) return body; - - const modelProvider = body.model && config.modelProviders[body.model]; - - const injected = []; - const skipped = []; - for (const [key, value] of Object.entries(config.inject ?? {})) { - const effectiveValue = key === 'provider' && modelProvider ? modelProvider : value; - if (body[key] === undefined) { - body[key] = effectiveValue; - injected.push(key); - } else { - skipped.push(key); - } - } - if (modelProvider && body.provider === undefined && !(config.inject && 'provider' in config.inject)) { - body.provider = modelProvider; - injected.push('provider'); - } - if (injected.length || skipped.length) { - log.info( - { injected, skippedByClient: skipped, path: subPath, modelProvider: !!modelProvider }, - 'inject', - ); - } - - return body; - } - - fastify.all('/*', async (request, reply) => { - const subPath = request.url.replace(/^\/v1/, '').split('?')[0]; - const query = request.url.includes('?') ? '?' + request.url.split('?')[1] : ''; - const upstreamUrl = config.upstreamBaseUrl + subPath + query; - - const headers = buildUpstreamHeaders(request.headers); - - let body = request.body; - let serializedBody; - - const methodHasBody = !['GET', 'HEAD'].includes(request.method); - if (methodHasBody && body !== undefined && body !== null) { - if (Buffer.isBuffer(body)) { - serializedBody = body.length > 0 ? body : undefined; - } else { - body = applyInjections(subPath, body, request.log); - serializedBody = typeof body === 'string' ? body : JSON.stringify(body); - headers['content-type'] = headers['content-type'] ?? 'application/json'; - } - } - - const debugBodies = ['1', 'true', 'yes'].includes( - (process.env.DEBUG_BODIES ?? '').trim().toLowerCase(), - ); - - if (debugBodies) { - const logBody = Buffer.isBuffer(serializedBody) - ? `[binary ${serializedBody.length} bytes]` - : serializedBody; - request.log.info({ upstreamUrl, outgoingBody: logBody }, 'upstream request'); - } - - let upstream; - try { - upstream = await fetch(upstreamUrl, { - method: request.method, - headers, - body: serializedBody, - }); - } catch (err) { - request.log.error({ err, upstreamUrl }, 'upstream fetch failed'); - return reply.code(502).send({ error: { message: 'Upstream request failed', detail: String(err) } }); - } - - reply.status(upstream.status); - upstream.headers.forEach((value, key) => { - if (HOP_BY_HOP.has(key.toLowerCase())) return; - reply.header(key, value); - }); - - if (!upstream.body) return reply.send(); - - const contentType = upstream.headers.get('content-type') ?? ''; - const isStream = contentType.includes('text/event-stream'); - - const isJsonResponse = contentType.includes('application/json'); - if (debugBodies && !isStream && isJsonResponse) { - const text = await upstream.text(); - try { - const parsed = JSON.parse(text); - request.log.info( - { provider: parsed?.provider, model: parsed?.model, id: parsed?.id, status: upstream.status }, - 'upstream response', - ); - } catch { - request.log.info({ status: upstream.status, raw: text.slice(0, 500) }, 'upstream response (non-JSON)'); - } - return reply.send(text); - } - - if (debugBodies && !isStream && !isJsonResponse) { - request.log.info( - { status: upstream.status, contentType }, - 'upstream response (binary, body not logged)', - ); - } - - if (debugBodies && isStream) { - const nodeStream = Readable.fromWeb(upstream.body); - let buf = ''; - let logged = false; - nodeStream.on('data', (chunk) => { - if (logged) return; - buf += chunk.toString('utf8'); - if (buf.length > 4000) { - request.log.info({ streamHead: buf.slice(0, 2000) }, 'upstream stream head'); - logged = true; - } - }); - nodeStream.on('end', () => { - if (!logged && buf.length) { - request.log.info({ streamHead: buf.slice(0, 2000) }, 'upstream stream head (short)'); - } - }); - return reply.send(nodeStream); - } - - return reply.send(Readable.fromWeb(upstream.body)); - }); - - fastify.get('/health', async () => ({ ok: true })); - - await printBanner({ - version: readVersion(), - host: config.host, - port: config.port, - upstream: config.upstreamBaseUrl, - }); - - try { - await fastify.listen({ port: config.port, host: config.host }); - } catch (err) { - if (err && err.code === 'EADDRINUSE') { - process.stderr.write( - `\nPort ${config.port} is already in use. Set a different port in config.json ` + - `(${config.sourcePath}).\n\n`, - ); - process.exit(1); - } - fastify.log.error(err); - process.exit(1); - } -} - -main().catch((err) => { - process.stderr.write(`\n${err?.message ?? err}\n\n`); - process.exit(1); -}); diff --git a/src copy/wizard.js b/src copy/wizard.js deleted file mode 100644 index 81cad46..0000000 --- a/src copy/wizard.js +++ /dev/null @@ -1,51 +0,0 @@ -import { createInterface } from 'node:readline'; -import { writeFileSync } from 'node:fs'; - -function ask(rl, question) { - return new Promise((resolve) => rl.question(question, resolve)); -} - -export async function runFirstRunWizard(targetPath) { - if (!process.stdin.isTTY) { - throw new Error( - `No config.json found and stdin is not a TTY. ` + - `Create a config.json at ${targetPath}, or point POLZA_PROXY_CONFIG at one.`, - ); - } - - process.stdout.write('\n'); - process.stdout.write('No config.json found — starting first-run setup.\n'); - process.stdout.write('\n'); - - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const apiKey = (await ask(rl, 'Polza API key (press Enter to skip): ')).trim(); - const portRaw = (await ask(rl, 'Port [8787]: ')).trim(); - const port = portRaw.length ? Number(portRaw) : 8787; - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid port "${portRaw}" — must be an integer between 1 and 65535.`); - } - - const config = { - port, - host: '127.0.0.1', - polzaApiKey: apiKey, - inject: { - provider: { - order: ['OpenAI', 'Anthropic'], - allow_fallbacks: true, - }, - }, - }; - - writeFileSync(targetPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - - process.stdout.write('\n'); - process.stdout.write(`Config saved to ${targetPath}. Edit it to customize further.\n`); - process.stdout.write('\n'); - - return config; - } finally { - rl.close(); - } -} diff --git a/src/banner.js b/src/banner.js index 2f8f6ec..3ff0f13 100644 --- a/src/banner.js +++ b/src/banner.js @@ -5,8 +5,10 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SVG_PATH = resolve(__dirname, 'logo.svg'); -const GREEN = '#B2F72A'; -const GREEN_RGB = { r: 178, g: 247, b: 42 }; +const COLORTERM = (process.env.COLORTERM ?? '').toLowerCase(); +const USE_TRUECOLOR = COLORTERM === 'truecolor' || COLORTERM === '24bit'; +const GREEN = USE_TRUECOLOR ? '#39FF14' : '#B2F72A'; +const GREEN_RGB = USE_TRUECOLOR ? { r: 57, g: 255, b: 20 } : { r: 178, g: 247, b: 42 }; const WHITE_RGB = { r: 255, g: 255, b: 255 }; const BANNER_LINES = [ @@ -112,7 +114,9 @@ export async function printBanner({ version, host, port, upstream }) { for (const line of lines) process.stdout.write(line + '\n'); process.stdout.write('\n' + chk.hex(GREEN)(infoLine) + '\n\n'); } catch { - const open = process.stdout.isTTY && !process.env.NO_COLOR ? '\x1b[38;2;178;247;42m' : ''; + const open = process.stdout.isTTY && !process.env.NO_COLOR + ? `\x1b[38;2;${GREEN_RGB.r};${GREEN_RGB.g};${GREEN_RGB.b}m` + : ''; const close = open ? '\x1b[0m' : ''; for (const line of BANNER_LINES) process.stdout.write(open + line + close + '\n'); process.stdout.write(open + infoLine + close + '\n\n'); diff --git a/src/config.js b/src/config.js index 1f4cb8a..3b40f4a 100644 --- a/src/config.js +++ b/src/config.js @@ -37,6 +37,7 @@ export async function loadConfig() { upstreamBaseUrl: UPSTREAM_BASE_URL, polzaApiKey: fileKey || envKey || '', inject: raw.inject ?? {}, + modelProviders: raw.modelProviders ?? {}, injectPaths: INJECT_PATHS, sourcePath: path, }; diff --git a/src/server.js b/src/server.js index 620ffb4..c188719 100644 --- a/src/server.js +++ b/src/server.js @@ -89,18 +89,28 @@ async function main() { if (!body || typeof body !== 'object') return body; if (!shouldInject(subPath)) return body; + const modelProvider = body.model && config.modelProviders[body.model]; + const injected = []; const skipped = []; for (const [key, value] of Object.entries(config.inject ?? {})) { + const effectiveValue = key === 'provider' && modelProvider ? modelProvider : value; if (body[key] === undefined) { - body[key] = value; + body[key] = effectiveValue; injected.push(key); } else { skipped.push(key); } } + if (modelProvider && body.provider === undefined && !(config.inject && 'provider' in config.inject)) { + body.provider = modelProvider; + injected.push('provider'); + } if (injected.length || skipped.length) { - log.info({ injected, skippedByClient: skipped, path: subPath }, 'inject'); + log.info( + { injected, skippedByClient: skipped, path: subPath, modelProvider: !!modelProvider }, + 'inject', + ); } return body;