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
+2
View File
@@ -14,6 +14,8 @@ dist-ssr
# Cloudflare Workers local dev/deploy state
.wrangler
*.tsbuildinfo
worker/env.d.ts
# Editor directories and files
.vscode/*
+58 -70
View File
@@ -1,77 +1,65 @@
# React + TypeScript + Vite
# 9router Config Generator
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Generate configuration files and one-command installers for 9router-compatible model gateways.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
## Local development
```sh
bun install
bun run dev
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
## Quality checks
```sh
bun run verify
```
`verify` runs the production build, all tests, and ESLint. `bun run deploy` runs this validation before publishing the Worker.
## Deploy to Cloudflare Workers
This project deploys a Cloudflare Worker together with the Vite `dist/` assets. It is not a Cloudflare Pages project.
1. Enable **Workers** for the target Cloudflare account and configure its `workers.dev` subdomain.
2. Authenticate Wrangler locally, or create a least-privilege Cloudflare API token for CI with the **Edit Cloudflare Workers** template, restricted to the deployment account.
3. Run:
```sh
bun run deploy
```
4. Open the deployed `https://9router-config-generate.<your-workers-subdomain>.workers.dev` URL and test both `/` and a generated `/i/<token>.sh` install link.
### Optional custom domain
After the first deployment, add a hostname in **Workers & Pages → 9router-config-generate → Settings → Domains & Routes → Add Custom Domain**. The Cloudflare zone must be active and the chosen hostname cannot already have a CNAME record. Cloudflare creates the DNS record and TLS certificate.
Do not protect the app or `/i/*` with Cloudflare Access if users must download install scripts via `curl` or PowerShell without signing in.
## Production model proxy
The browser first requests `<gateway>/models` directly. If that gateway does not allow CORS, the app falls back to `/api/fetch-models` on the Worker.
The Worker proxy is disabled by default and only accepts HTTPS gateway origins explicitly allowlisted through `MODEL_PROXY_ALLOWED_ORIGINS`. This prevents the public endpoint from becoming an open proxy.
Configure it in **Workers & Pages → 9router-config-generate → Settings → Variables and Secrets** as a plaintext variable:
```txt
MODEL_PROXY_ALLOWED_ORIGINS=https://router.example.com,https://backup-router.example.com
```
Each value must be an HTTPS origin only: no path, query string, credentials, or wildcard. Deploy again after changing the variable.
> A Cloudflare Worker cannot reach `localhost` on a visitor's computer. For a local 9router gateway, configure that gateway's CORS policy to allow the deployed app origin instead. Use the Worker proxy only for publicly reachable HTTPS gateways.
The proxy forwards the API key supplied by the user for the single `/models` request. It does not store, log, or cache the key. Do not configure a shared 9router API key as a Worker variable or secret.
## Cloudflare resources used
- Cloudflare Workers
- Workers Static Assets (`ASSETS` binding)
- Optional custom domain
- Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS`
No KV, D1, R2, Queue, Durable Object, or Worker secret is required.
Binary file not shown.
+8
View File
@@ -10,10 +10,12 @@
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@cloudflare/workers-types": "^5.20260815.1",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5",
"@types/bun": "^1.3.14",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -79,6 +81,8 @@
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260815.1", "", {}, "sha512-btpW8rEgeJKcxDlBWKxzjLLUHxzUWqXZY8O+DmM6NcSHadUQN5I71z6Q/yAKCTJyGS7adBpXzlsZSFbosLnECQ=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
@@ -307,6 +311,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -359,6 +365,8 @@
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
BIN
View File
Binary file not shown.
+5 -1
View File
@@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "bun test",
"lint": "eslint .",
"preview": "vite preview",
"deploy": "vite build && wrangler deploy",
"verify": "bun run build && bun run test && bun run lint",
"deploy": "bun run verify && wrangler deploy",
"preview:cf": "vite build && wrangler dev"
},
"dependencies": {
@@ -17,10 +19,12 @@
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@cloudflare/workers-types": "^5.20260815.1",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5",
"@types/bun": "^1.3.14",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+162 -12
View File
@@ -4,7 +4,9 @@ import { Card } from './ui/Card'
import { Button } from './ui/Button'
import { Badge } from './ui/Badge'
import { CodeBlock } from './ui/CodeBlock'
import { Download, Copy, Check, FileCode, Info } from 'lucide-react'
import { buildInstallScript, type ScriptOS } from '../services/installScript'
import { buildOneClickCommand } from '../services/installLink'
import { Download, Copy, Check, FileCode, Info, Terminal, Key, Sparkles, ShieldAlert } from 'lucide-react'
interface ConfigPreviewProps {
tool: ToolMeta
@@ -17,21 +19,39 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
connection,
selectedModels,
}) => {
const [copied, setCopied] = useState(false)
const [copiedConfig, setCopiedConfig] = useState(false)
const [copiedCommand, setCopiedCommand] = useState(false)
const [selectedOS, setSelectedOS] = useState<ScriptOS>('unix')
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173'
const oneClickCommand = buildOneClickCommand(tool, connection, selectedModels, selectedOS, origin)
const configContent = tool.sampleTemplate(connection, selectedModels)
const installScript = buildInstallScript(tool, connection, selectedModels, selectedOS, {
keySource: 'runtime-env',
})
const handleCopy = async () => {
const handleCopyConfig = async () => {
try {
await navigator.clipboard.writeText(configContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
setCopiedConfig(true)
setTimeout(() => setCopiedConfig(false), 2000)
} catch {
// ignore
}
}
const handleDownload = () => {
const handleCopyCommand = async (cmd: string) => {
try {
await navigator.clipboard.writeText(cmd)
setCopiedCommand(true)
setTimeout(() => setCopiedCommand(false), 2000)
} catch {
// ignore
}
}
const handleDownloadConfig = () => {
const blob = new Blob([configContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
@@ -43,8 +63,138 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
URL.revokeObjectURL(url)
}
const handleDownloadScript = () => {
if (!installScript) return
const blob = new Blob([installScript.content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = installScript.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
return (
<div className="lg:sticky lg:top-20 flex flex-col gap-4 min-w-0 w-full">
{/* 1. Install Script Section (if supported by tool) */}
{installScript && (
<Card className="flex flex-col gap-3.5 shadow-md border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/5 min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-zinc-200 dark:border-[#22232c] pb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 shrink-0 rounded-md bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Terminal className="w-4 h-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs sm:text-sm font-bold text-zinc-900 dark:text-zinc-100">
1-Command Auto Install
</span>
<Badge variant="brand" size="sm">
<Sparkles className="w-3 h-3 mr-1" />
Automated
</Badge>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate">
Copy &amp; run script downloads &amp; writes config
</span>
</div>
</div>
{/* OS selector tabs */}
<div className="flex items-center bg-zinc-200/70 dark:bg-zinc-800/80 p-0.5 rounded-lg text-xs">
<button
type="button"
onClick={() => setSelectedOS('unix')}
className={`px-2.5 py-1 rounded-md font-medium transition-all ${
selectedOS === 'unix'
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 shadow-sm'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200'
}`}
>
macOS / Linux (.sh)
</button>
<button
type="button"
onClick={() => setSelectedOS('windows')}
className={`px-2.5 py-1 rounded-md font-medium transition-all ${
selectedOS === 'windows'
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 shadow-sm'
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200'
}`}
>
Windows (.ps1)
</button>
</div>
</div>
{/* Run Command snippet */}
<div className="flex flex-col gap-1.5 text-left">
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-300">
Run this command in your terminal:
</span>
<div className="flex items-center justify-between gap-2 p-2.5 rounded-lg bg-zinc-900 text-zinc-200 font-mono text-xs overflow-x-auto border border-zinc-800">
<span className="break-all selection:bg-amber-500/30">
{oneClickCommand}
</span>
<button
type="button"
onClick={() => handleCopyCommand(oneClickCommand)}
className="shrink-0 p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200 transition-colors"
title="Copy command"
>
{copiedCommand ? (
<Check className="w-4 h-4 text-emerald-400" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Security / Notice */}
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20 text-[11px] text-zinc-600 dark:text-zinc-400 text-left">
<ShieldAlert className="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<span>
The URL and command contain no API key. The installer prompts locally for it, then writes it directly into VS Code configuration.
</span>
</div>
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Need offline installer?
</span>
<Button
variant="outline"
size="sm"
onClick={handleDownloadScript}
icon={<Download className="w-3.5 h-3.5" />}
>
Download Script ({installScript.filename})
</Button>
</div>
</Card>
)}
{/* 2. API Key Helper Note for GitHub Copilot (when no key in form) */}
{tool.id === 'copilot' && !connection.apiKey && (
<div className="flex flex-col gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs text-left">
<div className="flex items-center gap-2 font-semibold text-amber-800 dark:text-amber-300">
<Key className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span>Secure API Key Storage in VS Code</span>
</div>
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed">
No API key entered above. The generated config uses{' '}
<code className="px-1 py-0.5 rounded bg-amber-500/15 font-mono text-[11px] text-amber-700 dark:text-amber-300">
${'{input:9router-api-key}'}
</code>
. When VS Code prompts you for <strong className="font-semibold text-zinc-900 dark:text-zinc-100">9router-api-key</strong>, paste your key.
</p>
</div>
)}
{/* 3. Raw Config Preview */}
<Card className="flex flex-col gap-3.5 shadow-md min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-zinc-200 dark:border-[#22232c] pb-3">
<div className="flex items-center gap-2.5 min-w-0">
@@ -61,7 +211,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
</Badge>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate">
Target for {tool.name}
Manual config for {tool.name}
</span>
</div>
</div>
@@ -70,15 +220,15 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
<Button
variant="outline"
size="sm"
onClick={handleCopy}
icon={copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
onClick={handleCopyConfig}
icon={copiedConfig ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
>
{copied ? 'Copied' : 'Copy'}
{copiedConfig ? 'Copied' : 'Copy'}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDownload}
onClick={handleDownloadConfig}
icon={<Download className="w-3.5 h-3.5" />}
>
Download
@@ -99,7 +249,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
<CodeBlock
code={configContent}
language={tool.language}
className="max-h-[500px]"
className="max-h-96"
/>
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 pt-1">
+2
View File
@@ -49,6 +49,7 @@ export const mockModels: Model[] = [
contextWindow: 200000,
vision: false,
toolCalling: true,
reasoning: true,
description: 'Deep mathematical and algorithmic reasoning specialist',
},
{
@@ -69,6 +70,7 @@ export const mockModels: Model[] = [
contextWindow: 64000,
vision: false,
toolCalling: true,
reasoning: true,
description: 'Open-weights reasoning model with high performance on coding tasks',
},
]
+50 -18
View File
@@ -1,42 +1,74 @@
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types'
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types.ts'
export const tools: Record<ToolId, ToolMeta> = {
copilot: {
id: 'copilot',
name: 'GitHub Copilot',
tagline: 'VS Code chatLanguageModels.json configuration',
tagline: 'VS Code chatLanguageModels.json customendpoint configuration',
targetFilename: 'chatLanguageModels.json',
locationDescription: 'VS Code User settings or global profile directory',
locationDescription: 'VS Code Command Palette → "Chat: Manage Language Models" → Add Models → Custom Endpoint',
language: 'json',
iconName: 'bot',
installTargets: {
unixPath: '$HOME/.config/Code/User/chatLanguageModels.json',
windowsPath: '$env:APPDATA\\Code\\User\\chatLanguageModels.json',
},
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
const activeModels = selectedModels.length > 0 ? selectedModels : [
{
id: 'cc/claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
family: 'claude-sonnet-4-5',
provider: 'Anthropic',
contextWindow: 200000,
vision: true,
toolCalling: true,
}
]
const entries = activeModels.map((m) => ({
id: `9router-${m.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
name: `${m.name || m.id} (9router)`,
vendor: 'customoai',
family: m.family || m.id.split('/').pop() || 'custom',
version: '1.0.0',
maxInputTokens: m.contextWindow || 128000,
maxOutputTokens: 8192,
endpoint: connection.baseUrl.replace(/\/+$/, '') + '/chat/completions',
apiKey: connection.apiKey ? connection.apiKey : '${input:9router-api-key}',
model: m.id,
toolCalling: m.toolCalling ?? true,
vision: m.vision ?? true,
}))
// Group models by provider
const groupsMap = new Map<string, Model[]>()
for (const m of activeModels) {
const provider = m.provider || (m.id.includes('/') ? m.id.split('/')[0] : '9router')
if (!groupsMap.has(provider)) {
groupsMap.set(provider, [])
}
groupsMap.get(provider)!.push(m)
}
return JSON.stringify(entries, null, 2)
const endpointUrl = connection.baseUrl.replace(/\/+$/, '') + '/chat/completions'
const apiKeyVal = connection.apiKey ? connection.apiKey : '${input:9router-api-key}'
const groups = Array.from(groupsMap.entries()).map(([provider, models]) => {
return {
name: `9router · ${provider}`,
vendor: 'customendpoint',
apiKey: apiKeyVal,
apiType: 'chat-completions',
models: models.map((m) => {
const modelObj: Record<string, unknown> = {
id: m.id,
name: m.name || m.id,
url: endpointUrl,
toolCalling: m.toolCalling ?? true,
vision: m.vision ?? true,
maxInputTokens: m.contextWindow || 128000,
// Guidance: maxInputTokens + maxOutputTokens <= context window
maxOutputTokens: 16384,
}
if (m.reasoning) {
modelObj.thinking = true
modelObj.supportsReasoningEffort = ['low', 'medium', 'high']
modelObj.reasoningEffortFormat = 'chat-completions'
}
return modelObj
}),
}
})
return JSON.stringify(groups, null, 2)
},
},
'claude-code': {
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'bun:test'
import { encodeInstallToken, decodeInstallToken, buildOneClickCommand } from './installLink'
import { renderInstallScript } from './installEndpoint'
import { tools } from '../data/tools'
describe('Install Link & Endpoint', () => {
it('encodes and decodes install token correctly', () => {
const payload = {
v: 1 as const,
t: 'copilot' as const,
b: 'http://localhost:20128/v1',
m: [
{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' },
{ id: 'gemini/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
],
}
const token = encodeInstallToken(payload)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(10)
const decoded = decodeInstallToken(token)
expect(decoded).toEqual(payload)
})
it('builds one-click commands without exposing an API key', () => {
const cmdUnix = buildOneClickCommand(
tools.copilot,
{ baseUrl: 'http://localhost:20128/v1', apiKey: 'sk-test-key-123' },
[{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }],
'unix',
'https://9router.example.com'
)
expect(cmdUnix).toContain('curl -fsSL "https://9router.example.com/i/')
expect(cmdUnix).toContain('.sh" | bash')
expect(cmdUnix).not.toContain('sk-test-key-123')
const cmdWin = buildOneClickCommand(
tools.copilot,
{ baseUrl: 'http://localhost:20128/v1', apiKey: 'sk-test-key-123' },
[{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }],
'windows',
'https://9router.example.com'
)
expect(cmdWin).toContain('irm "https://9router.example.com/i/')
expect(cmdWin).toContain('.ps1" | iex')
expect(cmdWin).not.toContain('sk-test-key-123')
})
it('renders install script endpoint output', () => {
const token = encodeInstallToken({
v: 1,
t: 'copilot',
b: 'http://localhost:20128/v1',
m: [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }],
})
const shRes = renderInstallScript(token, 'sh')
expect(shRes.status).toBe(200)
expect(shRes.contentType).toContain('text/x-shellscript')
expect(shRes.body).toContain('__NINEROUTER_API_KEY__')
expect(shRes.body).toContain('NINEROUTER_API_KEY')
const ps1Res = renderInstallScript(token, 'ps1')
expect(ps1Res.status).toBe(200)
expect(ps1Res.contentType).toContain('text/plain')
expect(ps1Res.body).toContain('__NINEROUTER_API_KEY__')
})
it('replaces every API key placeholder in generated runtime scripts', () => {
const token = encodeInstallToken({
v: 1,
t: 'copilot',
b: 'http://localhost:20128/v1',
m: [{ id: 'cc/claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }],
})
const res = renderInstallScript(token, 'sh')
expect(res.status).toBe(200)
expect(res.body).toContain('replace_api_key_placeholder')
expect(res.body).toContain('while [[ "$CONFIG" == *"$API_KEY_PLACEHOLDER"* ]]')
})
it('handles invalid token gracefully', () => {
const res = renderInstallScript('invalid-token', 'sh')
expect(res.status).toBe(400)
expect(res.body).toContain('# Invalid install link')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { tools } from '../data/tools.ts'
import type { ConnectionConfig, Model } from '../types.ts'
import { decodeInstallToken, type MinimalModel } from './installLink.ts'
import { buildInstallScript } from './installScript.ts'
export interface RenderInstallScriptResult {
status: number
contentType: string
body: string
}
export function renderInstallScript(
token: string,
ext: 'sh' | 'ps1'
): RenderInstallScriptResult {
const isUnix = ext === 'sh'
const contentType = isUnix
? 'text/x-shellscript; charset=utf-8'
: 'text/plain; charset=utf-8'
const payload = decodeInstallToken(token)
if (!payload) {
return {
status: 400,
contentType,
body: '# Invalid install link\n',
}
}
const tool = tools[payload.t]
if (!tool || !tool.installTargets) {
return {
status: 400,
contentType,
body: '# Tool not found or install script not supported\n',
}
}
const connection: ConnectionConfig = {
baseUrl: payload.b,
apiKey: '__NINEROUTER_API_KEY__',
}
const models: Model[] = (payload.m || []).map((m: MinimalModel) => ({
id: m.id,
name: m.name || m.id,
provider: m.provider,
contextWindow: m.contextWindow,
vision: m.vision,
toolCalling: m.toolCalling,
reasoning: m.reasoning,
family: m.family,
}))
const script = buildInstallScript(
tool,
connection,
models,
isUnix ? 'unix' : 'windows',
{ keySource: 'runtime-env' }
)
if (!script) {
return {
status: 500,
contentType,
body: '# Failed to generate install script\n',
}
}
return {
status: 200,
contentType,
body: script.content,
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types.ts'
import { type ScriptOS } from './installScript.ts'
export interface MinimalModel {
id: string
name?: string
provider?: string
contextWindow?: number
vision?: boolean
toolCalling?: boolean
reasoning?: boolean
family?: string
}
export interface InstallTokenPayload {
v: 1
t: ToolId
b: string
m: MinimalModel[]
}
/**
* Base64URL encode a string (UTF-8 safe)
*/
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = ''
const len = bytes.byteLength
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* Base64URL decode a string to Uint8Array (UTF-8 safe)
*/
function base64UrlToBytes(base64url: string): Uint8Array {
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
while (base64.length % 4) {
base64 += '='
}
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
export function encodeInstallToken(payload: InstallTokenPayload): string {
const jsonStr = JSON.stringify(payload)
const encoder = new TextEncoder()
const bytes = encoder.encode(jsonStr)
return bytesToBase64Url(bytes)
}
export function decodeInstallToken(token: string): InstallTokenPayload | null {
try {
const bytes = base64UrlToBytes(token)
const decoder = new TextDecoder()
const jsonStr = decoder.decode(bytes)
const payload = JSON.parse(jsonStr) as InstallTokenPayload
if (payload && payload.v === 1 && payload.t && payload.b) {
return payload
}
return null
} catch {
return null
}
}
export function buildOneClickCommand(
tool: ToolMeta,
connection: ConnectionConfig,
models: Model[],
os: ScriptOS,
origin: string
): string {
const minimalModels: MinimalModel[] = models.map((m) => ({
id: m.id,
...(m.name ? { name: m.name } : {}),
...(m.provider ? { provider: m.provider } : {}),
...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
...(m.vision !== undefined ? { vision: m.vision } : {}),
...(m.toolCalling !== undefined ? { toolCalling: m.toolCalling } : {}),
...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
...(m.family ? { family: m.family } : {}),
}))
const token = encodeInstallToken({
v: 1,
t: tool.id,
b: connection.baseUrl,
m: minimalModels,
})
const baseUrl = origin.replace(/\/+$/, '')
if (os === 'unix') {
const scriptUrl = `${baseUrl}/i/${token}.sh`
return `curl -fsSL "${scriptUrl}" | bash`
}
// Windows PowerShell
const scriptUrl = `${baseUrl}/i/${token}.ps1`
return `irm "${scriptUrl}" | iex`
}
+236
View File
@@ -0,0 +1,236 @@
import type { ToolMeta, ConnectionConfig, Model } from '../types.ts'
export type ScriptOS = 'unix' | 'windows'
export interface BuildInstallScriptOptions {
keySource?: 'embedded' | 'runtime-env'
}
export interface GeneratedScript {
filename: string
language: 'bash' | 'powershell'
content: string
runCommand: string
}
export function buildInstallScript(
tool: ToolMeta,
connection: ConnectionConfig,
selectedModels: Model[],
os: ScriptOS,
options?: BuildInstallScriptOptions
): GeneratedScript | null {
if (!tool.installTargets) {
return null
}
const keySource = options?.keySource || 'embedded'
const effectiveConnection: ConnectionConfig =
keySource === 'runtime-env'
? { ...connection, apiKey: '__NINEROUTER_API_KEY__' }
: connection
const configContent = tool.sampleTemplate(effectiveConnection, selectedModels)
const apiKeyHint = connection.apiKey || 'YOUR_API_KEY'
if (os === 'unix') {
const filename = `install-9router-${tool.id}.sh`
const { writeConfigSection, postInstallNotes } =
keySource === 'runtime-env'
? {
writeConfigSection: `API_KEY="\${NINEROUTER_API_KEY:-}"
if [[ -z "$API_KEY" ]]; then
read -rsp "Enter your 9router API key: " API_KEY
echo
fi
CONFIG=$(cat <<'EOF'
${configContent}
EOF
)
API_KEY_PLACEHOLDER='__NINEROUTER_API_KEY__'
json_escape() {
local string="$1"
local output=""
local char
local i
for ((i = 0; i < \${#string}; i++)); do
char="\${string:i:1}"
case "$char" in
'"') output+='\\"' ;;
'\\') output+='\\\\' ;;
$'\b') output+='\\b' ;;
$'\f') output+='\\f' ;;
$'\n') output+='\\n' ;;
$'\r') output+='\\r' ;;
$'\t') output+='\\t' ;;
*) output+="$char" ;;
esac
done
printf '%s' "$output"
}
replace_api_key_placeholder() {
local replacement="$1"
local prefix
local suffix
while [[ "$CONFIG" == *"$API_KEY_PLACEHOLDER"* ]]; do
prefix="\${CONFIG%%"$API_KEY_PLACEHOLDER"*}"
suffix="\${CONFIG#*"$API_KEY_PLACEHOLDER"}"
CONFIG="\${prefix}\${replacement}\${suffix}"
done
}
if [[ -n "$API_KEY" ]]; then
replace_api_key_placeholder "$(json_escape "$API_KEY")"
else
replace_api_key_placeholder '\${input:9router-api-key}'
fi
printf '%s\\n' "$CONFIG" > "$TARGET_FILE"`
,
postInstallNotes: `echo ""
echo "✅ Configuration successfully written to $TARGET_FILE"
echo ""
echo "👉 Next step in VS Code:"
echo " 1. Reload VS Code (Ctrl+Shift+P or Cmd+Shift+P → Developer: Reload Window)."
echo " 2. Open GitHub Copilot Chat and start coding!"`
}
: {
writeConfigSection: `cat <<'EOF' > "$TARGET_FILE"
${configContent}
EOF`,
postInstallNotes: `echo ""
echo "✅ Configuration successfully written to $TARGET_FILE"
echo ""
echo "👉 Next step in VS Code:"
echo " 1. Reload VS Code or open GitHub Copilot Chat."
echo " 2. When prompted for '9router-api-key', paste your key:"
echo " ${apiKeyHint}"`
}
const content = `#!/usr/bin/env bash
# 9router Install Script for ${tool.name}
# Automatically generates and installs ${tool.targetFilename}
set -euo pipefail
# Determine configuration directory based on OS (macOS vs Linux)
if [[ "$(uname)" == "Darwin" ]]; then
TARGET_DIR="$HOME/Library/Application Support/Code/User"
else
TARGET_DIR="\${XDG_CONFIG_HOME:-$HOME/.config}/Code/User"
fi
TARGET_FILE="$TARGET_DIR/${tool.targetFilename}"
echo "🔧 Installing 9router config for ${tool.name}..."
echo "📍 Target: $TARGET_FILE"
# Create destination folder if not present
mkdir -p "$TARGET_DIR"
# Backup existing configuration if present
if [[ -f "$TARGET_FILE" ]]; then
BACKUP_FILE="$TARGET_FILE.backup.$(date +%Y%m%d%H%M%S)"
echo "⚠️ Existing configuration found. Backing up to:"
echo " $BACKUP_FILE"
cp "$TARGET_FILE" "$BACKUP_FILE"
fi
# Write configuration
${writeConfigSection}
${postInstallNotes}
`
return {
filename,
language: 'bash',
content,
runCommand: `bash ${filename}`,
}
}
// Windows PowerShell
const filename = `install-9router-${tool.id}.ps1`
const { psWriteConfigSection, psPostInstallNotes } =
keySource === 'runtime-env'
? {
psWriteConfigSection: `$apiKey = $env:NINEROUTER_API_KEY
if (-not $apiKey) {
$secureApiKey = Read-Host "Enter your 9router API key" -AsSecureString
$apiKey = [System.Net.NetworkCredential]::new('', $secureApiKey).Password
}
$config = @'
${configContent}
'@
if ($apiKey) {
$jsonApiKey = ConvertTo-Json -InputObject $apiKey -Compress
$config = $config.Replace('"__NINEROUTER_API_KEY__"', $jsonApiKey)
} else {
$config = $config.Replace('"__NINEROUTER_API_KEY__"', '"\${input:9router-api-key}"')
}
Set-Content -Path $targetFile -Value $config -Encoding UTF8`,
psPostInstallNotes: `Write-Host ""
Write-Host "✅ Configuration successfully written to $targetFile" -ForegroundColor Green
Write-Host ""
Write-Host "👉 Next step in VS Code:" -ForegroundColor Cyan
Write-Host " 1. Reload VS Code (Ctrl+Shift+P -> Developer: Reload Window)."
Write-Host " 2. Open GitHub Copilot Chat and start coding!" -ForegroundColor Green`
}
: {
psWriteConfigSection: `@'
${configContent}
'@ | Set-Content -Path $targetFile -Encoding UTF8`,
psPostInstallNotes: `Write-Host ""
Write-Host "✅ Configuration successfully written to $targetFile" -ForegroundColor Green
Write-Host ""
Write-Host "👉 Next step in VS Code:" -ForegroundColor Cyan
Write-Host " 1. Reload VS Code or open GitHub Copilot Chat."
Write-Host " 2. When prompted for '9router-api-key', paste your key:"
Write-Host " ${apiKeyHint}" -ForegroundColor Yellow`
}
const content = `# 9router Install Script for ${tool.name}
# Automatically generates and installs ${tool.targetFilename}
$ErrorActionPreference = "Stop"
$targetDir = Join-Path $env:APPDATA "Code\\User"
$targetFile = Join-Path $targetDir "${tool.targetFilename}"
Write-Host "🔧 Installing 9router config for ${tool.name}..." -ForegroundColor Cyan
Write-Host "📍 Target: $targetFile"
# Ensure directory exists
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
}
# Backup existing file if present
if (Test-Path $targetFile) {
$timestamp = Get-Date -Format "yyyyMMddHHmmss"
$backupFile = "$targetFile.backup.$timestamp"
Write-Host "⚠️ Existing configuration found. Backing up to:" -ForegroundColor Yellow
Write-Host " $backupFile"
Copy-Item $targetFile $backupFile
}
# Write configuration file with UTF8 encoding
${psWriteConfigSection}
${psPostInstallNotes}
`
return {
filename,
language: 'powershell',
content,
runCommand: `powershell -ExecutionPolicy Bypass -File .\\${filename}`,
}
}
+13 -34
View File
@@ -59,22 +59,22 @@ function formatModelName(id: string, rawName?: string): string {
export async function fetchRemoteModels(connection: ConnectionConfig): Promise<Model[]> {
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
const isHttpsOrCustom = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
const isHttpUrl = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
let data: RawModelsResponse | null = null
// 1. Direct fetch with ?key= param if remote requires it or headers
// 1. Direct fetch with the standard Authorization header. Never put an API key in a URL.
try {
const urlObj = new URL(`${trimmedBase}/models`)
if (connection.apiKey) {
urlObj.searchParams.set('key', connection.apiKey.trim())
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (connection.apiKey?.trim()) {
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
}
const directRes = await fetch(urlObj.toString(), {
const directRes = await fetch(`${trimmedBase}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
},
headers,
})
if (directRes.ok) {
@@ -84,31 +84,8 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
// If direct CORS/network fails, fallback to standard Authorization header or proxy
}
// 2. If direct query-param fetch did not succeed, try standard Bearer header
if (!data) {
try {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (connection.apiKey?.trim()) {
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
}
const directRes = await fetch(`${trimmedBase}/models`, {
method: 'GET',
headers,
})
if (directRes.ok) {
data = await directRes.json()
}
} catch {
// Direct fetch failed (likely CORS on browser), proceed to fallback proxy
}
}
// 3. Fallback to local Vite / Worker proxy if running in dev or support environment
if (!data && isHttpsOrCustom) {
// 2. Fallback to the local Vite / allowlisted Worker proxy when direct CORS fails.
if (!data && isHttpUrl) {
try {
const proxyRes = await fetch('/api/fetch-models', {
method: 'POST',
@@ -151,6 +128,7 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
const vision = caps.vision ?? (item.vision as boolean | undefined) ?? false
const toolCalling = caps.tools ?? caps.toolCalling ?? (item.toolCalling as boolean | undefined) ?? false
const reasoning = caps.reasoning ?? (item.reasoning as boolean | undefined) ?? false
return {
id: item.id,
@@ -160,6 +138,7 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
contextWindow,
vision,
toolCalling,
reasoning,
description: item.description || (caps.reasoning ? 'Reasoning model with extended thinking capabilities' : undefined),
}
})
+7
View File
@@ -9,6 +9,7 @@ export interface Model {
family?: string
vision?: boolean
toolCalling?: boolean
reasoning?: boolean
}
export interface ConnectionConfig {
@@ -16,6 +17,11 @@ export interface ConnectionConfig {
apiKey: string
}
export interface InstallTargets {
unixPath: string
windowsPath: string
}
export interface ToolMeta {
id: ToolId
name: string
@@ -24,5 +30,6 @@ export interface ToolMeta {
locationDescription: string
language: 'json' | 'toml' | 'bash'
iconName: 'bot' | 'terminal' | 'cpu'
installTargets?: InstallTargets
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => string
}
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"types": ["vite/client", "bun"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./worker/tsconfig.json" }
]
}
+19
View File
@@ -3,6 +3,7 @@ import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { renderInstallScript } from './src/services/installEndpoint.ts'
// https://vite.dev/config/
export default defineConfig({
@@ -13,6 +14,24 @@ export default defineConfig({
{
name: 'router-proxy-middleware',
configureServer(server) {
// Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1)
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
const url = req.url || ''
const m = url.match(/^\/i\/([A-Za-z0-9\-_]+)\.(sh|ps1)(?:\?.*)?$/)
if (m && req.method === 'GET') {
const { status, contentType, body } = renderInstallScript(
m[1],
m[2] as 'sh' | 'ps1'
)
res.statusCode = status
res.setHeader('Content-Type', contentType)
res.setHeader('Cache-Control', 'no-store')
res.end(body)
return
}
next()
})
server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') {
res.statusCode = 405
+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"]
}
+4 -1
View File
@@ -1,9 +1,12 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "9router-config-generate",
"main": "worker/index.ts",
"compatibility_date": "2026-08-15",
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application"
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/i/*", "/api/*"]
}
}