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