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
|
||||
.wrangler
|
||||
*.tsbuildinfo
|
||||
worker/env.d.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -1,77 +1,65 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
# 9router Config Generator
|
||||
|
||||
Generate configuration files and one-command installers for 9router-compatible model gateways.
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Quality checks
|
||||
|
||||
```sh
|
||||
bun run verify
|
||||
```
|
||||
|
||||
`verify` runs the production build, all tests, and ESLint. `bun run deploy` runs this validation before publishing the Worker.
|
||||
|
||||
## Deploy to Cloudflare Workers
|
||||
|
||||
This project deploys a Cloudflare Worker together with the Vite `dist/` assets. It is not a Cloudflare Pages project.
|
||||
|
||||
1. Enable **Workers** for the target Cloudflare account and configure its `workers.dev` subdomain.
|
||||
2. Authenticate Wrangler locally, or create a least-privilege Cloudflare API token for CI with the **Edit Cloudflare Workers** template, restricted to the deployment account.
|
||||
3. Run:
|
||||
|
||||
```sh
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
4. Open the deployed `https://9router-config-generate.<your-workers-subdomain>.workers.dev` URL and test both `/` and a generated `/i/<token>.sh` install link.
|
||||
|
||||
### Optional custom domain
|
||||
|
||||
After the first deployment, add a hostname in **Workers & Pages → 9router-config-generate → Settings → Domains & Routes → Add Custom Domain**. The Cloudflare zone must be active and the chosen hostname cannot already have a CNAME record. Cloudflare creates the DNS record and TLS certificate.
|
||||
|
||||
Do not protect the app or `/i/*` with Cloudflare Access if users must download install scripts via `curl` or PowerShell without signing in.
|
||||
|
||||
## Production model proxy
|
||||
|
||||
The browser first requests `<gateway>/models` directly. If that gateway does not allow CORS, the app falls back to `/api/fetch-models` on the Worker.
|
||||
|
||||
The Worker proxy is disabled by default and only accepts HTTPS gateway origins explicitly allowlisted through `MODEL_PROXY_ALLOWED_ORIGINS`. This prevents the public endpoint from becoming an open proxy.
|
||||
|
||||
Configure it in **Workers & Pages → 9router-config-generate → Settings → Variables and Secrets** as a plaintext variable:
|
||||
|
||||
```txt
|
||||
MODEL_PROXY_ALLOWED_ORIGINS=https://router.example.com,https://backup-router.example.com
|
||||
```
|
||||
|
||||
Each value must be an HTTPS origin only: no path, query string, credentials, or wildcard. Deploy again after changing the variable.
|
||||
|
||||
> A Cloudflare Worker cannot reach `localhost` on a visitor's computer. For a local 9router gateway, configure that gateway's CORS policy to allow the deployed app origin instead. Use the Worker proxy only for publicly reachable HTTPS gateways.
|
||||
|
||||
The proxy forwards the API key supplied by the user for the single `/models` request. It does not store, log, or cache the key. Do not configure a shared 9router API key as a Worker variable or secret.
|
||||
|
||||
## Cloudflare resources used
|
||||
|
||||
- Cloudflare Workers
|
||||
- Workers Static Assets (`ASSETS` binding)
|
||||
- Optional custom domain
|
||||
- Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS`
|
||||
|
||||
No KV, D1, R2, Queue, Durable Object, or Worker secret is required.
|
||||
|
||||
Binary file not shown.
@@ -10,10 +10,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@cloudflare/workers-types": "^5.20260815.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -79,6 +81,8 @@
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260815.1", "", {}, "sha512-btpW8rEgeJKcxDlBWKxzjLLUHxzUWqXZY8O+DmM6NcSHadUQN5I71z6Q/yAKCTJyGS7adBpXzlsZSFbosLnECQ=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
@@ -307,6 +311,8 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -359,6 +365,8 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
+5
-1
@@ -6,9 +6,11 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "bun test",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"verify": "bun run build && bun run test && bun run lint",
|
||||
"deploy": "bun run verify && wrangler deploy",
|
||||
"preview:cf": "vite build && wrangler dev"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,10 +19,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@cloudflare/workers-types": "^5.20260815.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Card } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
import { Badge } from './ui/Badge'
|
||||
import { CodeBlock } from './ui/CodeBlock'
|
||||
import { Download, Copy, Check, FileCode, Info } from 'lucide-react'
|
||||
import { buildInstallScript, type ScriptOS } from '../services/installScript'
|
||||
import { buildOneClickCommand } from '../services/installLink'
|
||||
import { Download, Copy, Check, FileCode, Info, Terminal, Key, Sparkles, ShieldAlert } from 'lucide-react'
|
||||
|
||||
interface ConfigPreviewProps {
|
||||
tool: ToolMeta
|
||||
@@ -17,21 +19,39 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
|
||||
connection,
|
||||
selectedModels,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [copiedCommand, setCopiedCommand] = useState(false)
|
||||
const [selectedOS, setSelectedOS] = useState<ScriptOS>('unix')
|
||||
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173'
|
||||
const oneClickCommand = buildOneClickCommand(tool, connection, selectedModels, selectedOS, origin)
|
||||
|
||||
const configContent = tool.sampleTemplate(connection, selectedModels)
|
||||
const installScript = buildInstallScript(tool, connection, selectedModels, selectedOS, {
|
||||
keySource: 'runtime-env',
|
||||
})
|
||||
|
||||
const handleCopy = async () => {
|
||||
const handleCopyConfig = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(configContent)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
setCopiedConfig(true)
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleCopyCommand = async (cmd: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd)
|
||||
setCopiedCommand(true)
|
||||
setTimeout(() => setCopiedCommand(false), 2000)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
const blob = new Blob([configContent], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
@@ -43,8 +63,138 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleDownloadScript = () => {
|
||||
if (!installScript) return
|
||||
const blob = new Blob([installScript.content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = installScript.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg:sticky lg:top-20 flex flex-col gap-4 min-w-0 w-full">
|
||||
{/* 1. Install Script Section (if supported by tool) */}
|
||||
{installScript && (
|
||||
<Card className="flex flex-col gap-3.5 shadow-md border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/5 min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-zinc-200 dark:border-[#22232c] pb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-7 h-7 shrink-0 rounded-md bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
|
||||
<Terminal className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs sm:text-sm font-bold text-zinc-900 dark:text-zinc-100">
|
||||
1-Command Auto Install
|
||||
</span>
|
||||
<Badge variant="brand" size="sm">
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Automated
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate">
|
||||
Copy & 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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-zinc-200 dark:border-[#22232c] pb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
@@ -61,7 +211,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 block text-left truncate">
|
||||
Target for {tool.name}
|
||||
Manual config for {tool.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,15 +220,15 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
icon={copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
onClick={handleCopyConfig}
|
||||
icon={copiedConfig ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
{copiedConfig ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
onClick={handleDownloadConfig}
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Download
|
||||
@@ -99,7 +249,7 @@ export const ConfigPreview: React.FC<ConfigPreviewProps> = ({
|
||||
<CodeBlock
|
||||
code={configContent}
|
||||
language={tool.language}
|
||||
className="max-h-[500px]"
|
||||
className="max-h-96"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 pt-1">
|
||||
|
||||
@@ -49,6 +49,7 @@ export const mockModels: Model[] = [
|
||||
contextWindow: 200000,
|
||||
vision: false,
|
||||
toolCalling: true,
|
||||
reasoning: true,
|
||||
description: 'Deep mathematical and algorithmic reasoning specialist',
|
||||
},
|
||||
{
|
||||
@@ -69,6 +70,7 @@ export const mockModels: Model[] = [
|
||||
contextWindow: 64000,
|
||||
vision: false,
|
||||
toolCalling: true,
|
||||
reasoning: true,
|
||||
description: 'Open-weights reasoning model with high performance on coding tasks',
|
||||
},
|
||||
]
|
||||
|
||||
+50
-18
@@ -1,42 +1,74 @@
|
||||
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types'
|
||||
import type { ToolId, ToolMeta, ConnectionConfig, Model } from '../types.ts'
|
||||
|
||||
export const tools: Record<ToolId, ToolMeta> = {
|
||||
copilot: {
|
||||
id: 'copilot',
|
||||
name: 'GitHub Copilot',
|
||||
tagline: 'VS Code chatLanguageModels.json configuration',
|
||||
tagline: 'VS Code chatLanguageModels.json customendpoint configuration',
|
||||
targetFilename: 'chatLanguageModels.json',
|
||||
locationDescription: 'VS Code User settings or global profile directory',
|
||||
locationDescription: 'VS Code Command Palette → "Chat: Manage Language Models" → Add Models → Custom Endpoint',
|
||||
language: 'json',
|
||||
iconName: 'bot',
|
||||
installTargets: {
|
||||
unixPath: '$HOME/.config/Code/User/chatLanguageModels.json',
|
||||
windowsPath: '$env:APPDATA\\Code\\User\\chatLanguageModels.json',
|
||||
},
|
||||
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => {
|
||||
const activeModels = selectedModels.length > 0 ? selectedModels : [
|
||||
{
|
||||
id: 'cc/claude-sonnet-4.5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
family: 'claude-sonnet-4-5',
|
||||
provider: 'Anthropic',
|
||||
contextWindow: 200000,
|
||||
vision: true,
|
||||
toolCalling: true,
|
||||
}
|
||||
]
|
||||
|
||||
const entries = activeModels.map((m) => ({
|
||||
id: `9router-${m.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
|
||||
name: `${m.name || m.id} (9router)`,
|
||||
vendor: 'customoai',
|
||||
family: m.family || m.id.split('/').pop() || 'custom',
|
||||
version: '1.0.0',
|
||||
maxInputTokens: m.contextWindow || 128000,
|
||||
maxOutputTokens: 8192,
|
||||
endpoint: connection.baseUrl.replace(/\/+$/, '') + '/chat/completions',
|
||||
apiKey: connection.apiKey ? connection.apiKey : '${input:9router-api-key}',
|
||||
model: m.id,
|
||||
toolCalling: m.toolCalling ?? true,
|
||||
vision: m.vision ?? true,
|
||||
}))
|
||||
// Group models by provider
|
||||
const groupsMap = new Map<string, Model[]>()
|
||||
for (const m of activeModels) {
|
||||
const provider = m.provider || (m.id.includes('/') ? m.id.split('/')[0] : '9router')
|
||||
if (!groupsMap.has(provider)) {
|
||||
groupsMap.set(provider, [])
|
||||
}
|
||||
groupsMap.get(provider)!.push(m)
|
||||
}
|
||||
|
||||
return JSON.stringify(entries, null, 2)
|
||||
const endpointUrl = connection.baseUrl.replace(/\/+$/, '') + '/chat/completions'
|
||||
const apiKeyVal = connection.apiKey ? connection.apiKey : '${input:9router-api-key}'
|
||||
|
||||
const groups = Array.from(groupsMap.entries()).map(([provider, models]) => {
|
||||
return {
|
||||
name: `9router · ${provider}`,
|
||||
vendor: 'customendpoint',
|
||||
apiKey: apiKeyVal,
|
||||
apiType: 'chat-completions',
|
||||
models: models.map((m) => {
|
||||
const modelObj: Record<string, unknown> = {
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
url: endpointUrl,
|
||||
toolCalling: m.toolCalling ?? true,
|
||||
vision: m.vision ?? true,
|
||||
maxInputTokens: m.contextWindow || 128000,
|
||||
// Guidance: maxInputTokens + maxOutputTokens <= context window
|
||||
maxOutputTokens: 16384,
|
||||
}
|
||||
|
||||
if (m.reasoning) {
|
||||
modelObj.thinking = true
|
||||
modelObj.supportsReasoningEffort = ['low', 'medium', 'high']
|
||||
modelObj.reasoningEffortFormat = 'chat-completions'
|
||||
}
|
||||
|
||||
return modelObj
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return JSON.stringify(groups, null, 2)
|
||||
},
|
||||
},
|
||||
'claude-code': {
|
||||
|
||||
@@ -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[]> {
|
||||
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
|
||||
const isHttpsOrCustom = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
|
||||
const isHttpUrl = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
|
||||
|
||||
let data: RawModelsResponse | null = null
|
||||
|
||||
// 1. Direct fetch with ?key= param if remote requires it or headers
|
||||
// 1. Direct fetch with the standard Authorization header. Never put an API key in a URL.
|
||||
try {
|
||||
const urlObj = new URL(`${trimmedBase}/models`)
|
||||
if (connection.apiKey) {
|
||||
urlObj.searchParams.set('key', connection.apiKey.trim())
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (connection.apiKey?.trim()) {
|
||||
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
|
||||
}
|
||||
|
||||
const directRes = await fetch(urlObj.toString(), {
|
||||
const directRes = await fetch(`${trimmedBase}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (directRes.ok) {
|
||||
@@ -84,31 +84,8 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
|
||||
// If direct CORS/network fails, fallback to standard Authorization header or proxy
|
||||
}
|
||||
|
||||
// 2. If direct query-param fetch did not succeed, try standard Bearer header
|
||||
if (!data) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (connection.apiKey?.trim()) {
|
||||
headers['Authorization'] = `Bearer ${connection.apiKey.trim()}`
|
||||
}
|
||||
|
||||
const directRes = await fetch(`${trimmedBase}/models`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
|
||||
if (directRes.ok) {
|
||||
data = await directRes.json()
|
||||
}
|
||||
} catch {
|
||||
// Direct fetch failed (likely CORS on browser), proceed to fallback proxy
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to local Vite / Worker proxy if running in dev or support environment
|
||||
if (!data && isHttpsOrCustom) {
|
||||
// 2. Fallback to the local Vite / allowlisted Worker proxy when direct CORS fails.
|
||||
if (!data && isHttpUrl) {
|
||||
try {
|
||||
const proxyRes = await fetch('/api/fetch-models', {
|
||||
method: 'POST',
|
||||
@@ -151,6 +128,7 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
|
||||
|
||||
const vision = caps.vision ?? (item.vision as boolean | undefined) ?? false
|
||||
const toolCalling = caps.tools ?? caps.toolCalling ?? (item.toolCalling as boolean | undefined) ?? false
|
||||
const reasoning = caps.reasoning ?? (item.reasoning as boolean | undefined) ?? false
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -160,6 +138,7 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
|
||||
contextWindow,
|
||||
vision,
|
||||
toolCalling,
|
||||
reasoning,
|
||||
description: item.description || (caps.reasoning ? 'Reasoning model with extended thinking capabilities' : undefined),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Model {
|
||||
family?: string
|
||||
vision?: boolean
|
||||
toolCalling?: boolean
|
||||
reasoning?: boolean
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
@@ -16,6 +17,11 @@ export interface ConnectionConfig {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface InstallTargets {
|
||||
unixPath: string
|
||||
windowsPath: string
|
||||
}
|
||||
|
||||
export interface ToolMeta {
|
||||
id: ToolId
|
||||
name: string
|
||||
@@ -24,5 +30,6 @@ export interface ToolMeta {
|
||||
locationDescription: string
|
||||
language: 'json' | 'toml' | 'bash'
|
||||
iconName: 'bot' | 'terminal' | 'cpu'
|
||||
installTargets?: InstallTargets
|
||||
sampleTemplate: (connection: ConnectionConfig, selectedModels: Model[]) => string
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "bun"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "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 tailwindcss from '@tailwindcss/vite'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { renderInstallScript } from './src/services/installEndpoint.ts'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -13,6 +14,24 @@ export default defineConfig({
|
||||
{
|
||||
name: 'router-proxy-middleware',
|
||||
configureServer(server) {
|
||||
// Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1)
|
||||
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||
const url = req.url || ''
|
||||
const m = url.match(/^\/i\/([A-Za-z0-9\-_]+)\.(sh|ps1)(?:\?.*)?$/)
|
||||
if (m && req.method === 'GET') {
|
||||
const { status, contentType, body } = renderInstallScript(
|
||||
m[1],
|
||||
m[2] as 'sh' | 'ps1'
|
||||
)
|
||||
res.statusCode = status
|
||||
res.setHeader('Content-Type', contentType)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.end(body)
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405
|
||||
|
||||
@@ -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",
|
||||
"name": "9router-config-generate",
|
||||
"main": "worker/index.ts",
|
||||
"compatibility_date": "2026-08-15",
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
"not_found_handling": "single-page-application"
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["/i/*", "/api/*"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user