mirror of
https://github.com/Nezumi-2711/9router-config-generate.git
synced 2026-09-22 20:00:47 +00:00
feat: add setup script for models
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user