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 # Cloudflare Workers local dev/deploy state
.wrangler .wrangler
*.tsbuildinfo
worker/env.d.ts
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+65 -77
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: ## Local development
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ```sh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) bun install
bun run dev
## React Compiler ```
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. ## Quality checks
Note: This will impact Vite dev & build performances. ```sh
bun run verify
## Expanding the ESLint configuration ```
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: `verify` runs the production build, all tests, and ESLint. `bun run deploy` runs this validation before publishing the Worker.
```js ## Deploy to Cloudflare Workers
export default defineConfig([
globalIgnores(['dist']), This project deploys a Cloudflare Worker together with the Vite `dist/` assets. It is not a Cloudflare Pages project.
{
files: ['**/*.{ts,tsx}'], 1. Enable **Workers** for the target Cloudflare account and configure its `workers.dev` subdomain.
extends: [ 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.
// Other configs... 3. Run:
// Remove tseslint.configs.recommended and replace with this ```sh
tseslint.configs.recommendedTypeChecked, bun run deploy
// Alternatively, use this for stricter rules ```
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules 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.
tseslint.configs.stylisticTypeChecked,
### Optional custom domain
// Other configs...
], 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.
languageOptions: {
parserOptions: { Do not protect the app or `/i/*` with Cloudflare Access if users must download install scripts via `curl` or PowerShell without signing in.
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname, ## Production model proxy
},
// other options... 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
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: MODEL_PROXY_ALLOWED_ORIGINS=https://router.example.com,https://backup-router.example.com
```
```js
// eslint.config.js Each value must be an HTTPS origin only: no path, query string, credentials, or wildcard. Deploy again after changing the variable.
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom' > 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.
export default defineConfig([ 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.
globalIgnores(['dist']),
{ ## Cloudflare resources used
files: ['**/*.{ts,tsx}'],
extends: [ - Cloudflare Workers
// Other configs... - Workers Static Assets (`ASSETS` binding)
// Enable lint rules for React - Optional custom domain
reactX.configs['recommended-typescript'], - Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS`
// Enable lint rules for React DOM
reactDom.configs.recommended, No KV, D1, R2, Queue, Durable Object, or Worker secret is required.
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
Binary file not shown.
+8
View File
@@ -10,10 +10,12 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@babel/core": "^7.29.7",
"@cloudflare/workers-types": "^5.20260815.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3", "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4", "@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/bun": "^1.3.14",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@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/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=="], "@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=="], "@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/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/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@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=="], "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=="], "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=="], "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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"test": "bun test",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "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" "preview:cf": "vite build && wrangler dev"
}, },
"dependencies": { "dependencies": {
@@ -17,10 +19,12 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@babel/core": "^7.29.7",
"@cloudflare/workers-types": "^5.20260815.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.3", "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4", "@tailwindcss/vite": "^4",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/bun": "^1.3.14",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@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 { Button } from './ui/Button'
import { Badge } from './ui/Badge' import { Badge } from './ui/Badge'
import { CodeBlock } from './ui/CodeBlock' 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 { interface ConfigPreviewProps {
tool: ToolMeta tool: ToolMeta
@@ -17,21 +19,39 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
connection, connection,
selectedModels, 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 configContent = tool.sampleTemplate(connection, selectedModels)
const installScript = buildInstallScript(tool, connection, selectedModels, selectedOS, {
keySource: 'runtime-env',
})
const handleCopy = async () => { const handleCopyConfig = async () => {
try { try {
await navigator.clipboard.writeText(configContent) await navigator.clipboard.writeText(configContent)
setCopied(true) setCopiedConfig(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopiedConfig(false), 2000)
} catch { } catch {
// ignore // 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 blob = new Blob([configContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
@@ -43,8 +63,138 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
URL.revokeObjectURL(url) 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 ( return (
<div className="lg:sticky lg:top-20 flex flex-col gap-4 min-w-0 w-full"> <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"> <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 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="flex items-center gap-2.5 min-w-0">
@@ -61,7 +211,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
</Badge> </Badge>
</div> </div>
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate"> <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> </span>
</div> </div>
</div> </div>
@@ -70,15 +220,15 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleCopy} onClick={handleCopyConfig}
icon={copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />} 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>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={handleDownload} onClick={handleDownloadConfig}
icon={<Download className="w-3.5 h-3.5" />} icon={<Download className="w-3.5 h-3.5" />}
> >
Download Download
@@ -99,7 +249,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
<CodeBlock <CodeBlock
code={configContent} code={configContent}
language={tool.language} 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"> <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, contextWindow: 200000,
vision: false, vision: false,
toolCalling: true, toolCalling: true,
reasoning: true,
description: 'Deep mathematical and algorithmic reasoning specialist', description: 'Deep mathematical and algorithmic reasoning specialist',
}, },
{ {
@@ -69,6 +70,7 @@ export const mockModels: Model[] = [
contextWindow: 64000, contextWindow: 64000,
vision: false, vision: false,
toolCalling: true, toolCalling: true,
reasoning: true,
description: 'Open-weights reasoning model with high performance on coding tasks', 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> = { export const tools: Record<ToolId, ToolMeta> = {
copilot: { copilot: {
id: 'copilot', id: 'copilot',
name: 'GitHub Copilot', name: 'GitHub Copilot',
tagline: 'VS Code chatLanguageModels.json configuration', tagline: 'VS Code chatLanguageModels.json customendpoint configuration',
targetFilename: 'chatLanguageModels.json', 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', language: 'json',
iconName: 'bot', iconName: 'bot',
installTargets: {
unixPath: '$HOME/.config/Code/User/chatLanguageModels.json',
windowsPath: '$env:APPDATA\\Code\\User\\chatLanguageModels.json',
},
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => { sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
const activeModels = selectedModels.length > 0 ? selectedModels : [ const activeModels = selectedModels.length > 0 ? selectedModels : [
{ {
id: 'cc/claude-sonnet-4.5', id: 'cc/claude-sonnet-4.5',
name: 'Claude Sonnet 4.5', name: 'Claude Sonnet 4.5',
family: 'claude-sonnet-4-5', family: 'claude-sonnet-4-5',
provider: 'Anthropic',
contextWindow: 200000, contextWindow: 200000,
vision: true, vision: true,
toolCalling: true, toolCalling: true,
} }
] ]
const entries = activeModels.map((m) => ({ // Group models by provider
id: `9router-${m.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`, const groupsMap = new Map<string, Model[]>()
name: `${m.name || m.id} (9router)`, for (const m of activeModels) {
vendor: 'customoai', const provider = m.provider || (m.id.includes('/') ? m.id.split('/')[0] : '9router')
family: m.family || m.id.split('/').pop() || 'custom', if (!groupsMap.has(provider)) {
version: '1.0.0', groupsMap.set(provider, [])
maxInputTokens: m.contextWindow || 128000, }
maxOutputTokens: 8192, groupsMap.get(provider)!.push(m)
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,
}))
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': { '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[]> { export async function fetchRemoteModels(connection: ConnectionConfig): Promise<Model[]> {
const trimmedBase = connection.baseUrl.replace(/\/+$/, '') 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 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 { try {
const urlObj = new URL(`${trimmedBase}/models`) const headers: Record<string, string> = {
if (connection.apiKey) { Accept: 'application/json',
urlObj.searchParams.set('key', connection.apiKey.trim()) }
if (connection.apiKey?.trim()) {
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
} }
const directRes = await fetch(urlObj.toString(), { const directRes = await fetch(`${trimmedBase}/models`, {
method: 'GET', method: 'GET',
headers: { headers,
Accept: 'application/json',
},
}) })
if (directRes.ok) { 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 // 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 // 2. Fallback to the local Vite / allowlisted Worker proxy when direct CORS fails.
if (!data) { if (!data && isHttpUrl) {
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) {
try { try {
const proxyRes = await fetch('/api/fetch-models', { const proxyRes = await fetch('/api/fetch-models', {
method: 'POST', 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 vision = caps.vision ?? (item.vision as boolean | undefined) ?? false
const toolCalling = caps.tools ?? caps.toolCalling ?? (item.toolCalling 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 { return {
id: item.id, id: item.id,
@@ -160,6 +138,7 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
contextWindow, contextWindow,
vision, vision,
toolCalling, toolCalling,
reasoning,
description: item.description || (caps.reasoning ? 'Reasoning model with extended thinking capabilities' : undefined), description: item.description || (caps.reasoning ? 'Reasoning model with extended thinking capabilities' : undefined),
} }
}) })
+7
View File
@@ -9,6 +9,7 @@ export interface Model {
family?: string family?: string
vision?: boolean vision?: boolean
toolCalling?: boolean toolCalling?: boolean
reasoning?: boolean
} }
export interface ConnectionConfig { export interface ConnectionConfig {
@@ -16,6 +17,11 @@ export interface ConnectionConfig {
apiKey: string apiKey: string
} }
export interface InstallTargets {
unixPath: string
windowsPath: string
}
export interface ToolMeta { export interface ToolMeta {
id: ToolId id: ToolId
name: string name: string
@@ -24,5 +30,6 @@ export interface ToolMeta {
locationDescription: string locationDescription: string
language: 'json' | 'toml' | 'bash' language: 'json' | 'toml' | 'bash'
iconName: 'bot' | 'terminal' | 'cpu' iconName: 'bot' | 'terminal' | 'cpu'
installTargets?: InstallTargets
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => string sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => string
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "es2023", "target": "es2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "esnext", "module": "esnext",
"types": ["vite/client"], "types": ["vite/client", "bun"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "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 babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import type { IncomingMessage, ServerResponse } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http'
import { renderInstallScript } from './src/services/installEndpoint.ts'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -13,6 +14,24 @@ export default defineConfig({
{ {
name: 'router-proxy-middleware', name: 'router-proxy-middleware',
configureServer(server) { 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) => { server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.statusCode = 405 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", "$schema": "node_modules/wrangler/config-schema.json",
"name": "9router-config-generate", "name": "9router-config-generate",
"main": "worker/index.ts",
"compatibility_date": "2026-08-15", "compatibility_date": "2026-08-15",
"assets": { "assets": {
"directory": "./dist", "directory": "./dist",
"not_found_handling": "single-page-application" "binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/i/*", "/api/*"]
} }
} }