feat: add setup script for models

This commit is contained in:
2026-08-15 12:08:10 +07:00
parent 12cafef56f
commit 96759eb34c
23 changed files with 1140 additions and 145 deletions
+35
View File
@@ -0,0 +1,35 @@
import { renderInstallScript } from '../src/services/installEndpoint.ts'
import { proxyModelRequest } from './modelProxy.ts'
interface WorkerEnv {
ASSETS: {
fetch(request: Request): Promise<Response>
}
MODEL_PROXY_ALLOWED_ORIGINS?: string
}
export default {
async fetch(request: Request, env: WorkerEnv): Promise<Response> {
const url = new URL(request.url)
const m = url.pathname.match(/^\/i\/([A-Za-z0-9\-_]+)\.(sh|ps1)$/)
if (m) {
const { status, contentType, body } = renderInstallScript(
m[1],
m[2] as 'sh' | 'ps1'
)
return new Response(body, {
status,
headers: {
'content-type': contentType,
'cache-control': 'no-store',
},
})
}
if (url.pathname === '/api/fetch-models') {
return proxyModelRequest(request, env)
}
return env.ASSETS.fetch(request)
},
}
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { proxyModelRequest } from './modelProxy.ts'
const originalFetch = globalThis.fetch
function createRequest(body: Record<string, unknown>, method = 'POST'): Request {
return new Request('https://config.example.com/api/fetch-models', {
method,
headers: { 'content-type': 'application/json' },
body: method === 'POST' ? JSON.stringify(body) : undefined,
})
}
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('model proxy', () => {
it('does not enable the proxy without an allowlist', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({ baseUrl: 'https://router.example.com/v1' }),
{}
)
expect(response.status).toBe(503)
expect(fetchMock).not.toHaveBeenCalled()
})
it('ignores allowlist entries that are not plain HTTPS origins', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({ baseUrl: 'https://router.example.com/v1' }),
{ MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com/v1' }
)
expect(response.status).toBe(503)
expect(fetchMock).not.toHaveBeenCalled()
})
it('proxies an allowlisted gateway request with a bearer token', async () => {
const fetchMock = mock(() =>
Promise.resolve(Response.json({ data: [{ id: 'example-model' }] }))
)
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({
baseUrl: 'https://router.example.com/v1/',
apiKey: 'test-key',
}),
{ MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' }
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: [{ id: 'example-model' }] })
expect(fetchMock).toHaveBeenCalledWith(
'https://router.example.com/v1/models',
expect.objectContaining({
headers: expect.any(Headers),
redirect: 'error',
})
)
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]
expect((requestInit.headers as Headers).get('Authorization')).toBe('Bearer test-key')
})
it('rejects unallowlisted and non-HTTPS gateways without fetching them', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const env = { MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' }
const unallowlisted = await proxyModelRequest(
createRequest({ baseUrl: 'https://other.example.com/v1' }),
env
)
const nonHttps = await proxyModelRequest(
createRequest({ baseUrl: 'http://router.example.com/v1' }),
env
)
expect(unallowlisted.status).toBe(403)
expect(nonHttps.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
})
+147
View File
@@ -0,0 +1,147 @@
const MAX_REQUEST_BODY_BYTES = 10_240
const MAX_BASE_URL_LENGTH = 2_048
const MAX_API_KEY_LENGTH = 4_096
interface ModelProxyEnvironment {
MODEL_PROXY_ALLOWED_ORIGINS?: string
}
interface ModelProxyRequestBody {
baseUrl?: unknown
apiKey?: unknown
}
function jsonError(status: number, error: string): Response {
return Response.json(
{ error },
{
status,
headers: { 'cache-control': 'no-store' },
}
)
}
function getAllowedOrigins(value: string | undefined): Set<string> {
const origins = new Set<string>()
for (const candidate of value?.split(',') ?? []) {
try {
const url = new URL(candidate.trim())
if (
url.protocol === 'https:' &&
url.pathname === '/' &&
!url.search &&
!url.hash &&
!url.username &&
!url.password
) {
origins.add(url.origin)
}
} catch {
// Ignore malformed configuration entries rather than widening access.
}
}
return origins
}
export async function proxyModelRequest(
request: Request,
env: ModelProxyEnvironment
): Promise<Response> {
if (request.method !== 'POST') {
return jsonError(405, 'Method not allowed')
}
const contentLength = Number(request.headers.get('content-length'))
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_BYTES) {
return jsonError(413, 'Request body is too large')
}
if (!request.headers.get('content-type')?.includes('application/json')) {
return jsonError(415, 'Content-Type must be application/json')
}
const requestText = await request.text()
if (requestText.length > MAX_REQUEST_BODY_BYTES) {
return jsonError(413, 'Request body is too large')
}
let body: ModelProxyRequestBody
try {
body = JSON.parse(requestText) as ModelProxyRequestBody
} catch {
return jsonError(400, 'Request body must be valid JSON')
}
if (
typeof body.baseUrl !== 'string' ||
body.baseUrl.length === 0 ||
body.baseUrl.length > MAX_BASE_URL_LENGTH
) {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
if (typeof body.apiKey !== 'undefined' && typeof body.apiKey !== 'string') {
return jsonError(400, 'apiKey must be a string')
}
if (body.apiKey && body.apiKey.length > MAX_API_KEY_LENGTH) {
return jsonError(400, 'apiKey is too long')
}
const normalizedBaseUrl = body.baseUrl.trim().replace(/\/+$/, '')
let baseUrl: URL
try {
baseUrl = new URL(normalizedBaseUrl)
} catch {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
if (
baseUrl.protocol !== 'https:' ||
baseUrl.username ||
baseUrl.password ||
baseUrl.search ||
baseUrl.hash
) {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
const allowedOrigins = getAllowedOrigins(env.MODEL_PROXY_ALLOWED_ORIGINS)
if (allowedOrigins.size === 0) {
return jsonError(
503,
'Model proxy is not configured. Ask the site administrator to configure MODEL_PROXY_ALLOWED_ORIGINS.'
)
}
if (!allowedOrigins.has(baseUrl.origin)) {
return jsonError(403, 'This gateway origin is not allowed by the model proxy')
}
const headers = new Headers({ Accept: 'application/json' })
if (body.apiKey?.trim()) {
headers.set('Authorization', `Bearer ${body.apiKey.trim()}`)
}
let upstreamResponse: Response
try {
upstreamResponse = await fetch(`${normalizedBaseUrl}/models`, {
method: 'GET',
headers,
redirect: 'error',
})
} catch {
return jsonError(502, 'Unable to contact the configured gateway')
}
const upstreamBody = await upstreamResponse.json().catch(() => ({
error: 'Gateway returned an invalid JSON response',
}))
return Response.json(upstreamBody, {
status: upstreamResponse.status,
headers: { 'cache-control': 'no-store' },
})
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force"
},
"include": ["index.ts", "modelProxy.ts"]
}