mcp-model-router
README.md
# mcp-model-router
MCP-palvelin, joka **täydentää LiteLLM:ää eikä korvaa sitä**. LiteLLM/OpenRouter
hoitavat jo agenttikohtaiset avaimet ja kulukatot - tämä palvelin ei rakenna
niitä uudelleen. Se tekee kolme asiaa:
1. **Mallin valinta tehtävätyypin mukaan** - lukee `config.yaml`:sta
tehtävätyyppikohtaiset mallit (esim. `general`, `code`, `writing`,
`orchestration`) ja kutsuu niitä OpenAI-yhteensopivan `/chat/completions`
-rajapinnan kautta. Base URL ja avain tulevat ympäristömuuttujista, ja
rajapinta voi olla joko LiteLLM-proxy tai suoraan OpenRouter. Ei suoria
Anthropic-kutsuja. Jos päämalli epäonnistuu, käytetään konfiguroitua
varamallia.
2. **Kulutus ja budjetti** - lukee ne suoraan LiteLLM:n (`/user/info`) tai
OpenRouterin (`/credits`) rajapinnasta. Ei omaa kulutuskirjanpitoa.
3. **Eval-vertailu** - ajaa saman prompt-sarjan (JSON-tiedosto) usealla
mallilla ja raportoi hinnan, viiveen ja vastauksen rinnakkain, jotta
halvempi malli voidaan valita mitattuun laatuun perustuen. Laatu
arvioidaan yksinkertaisella sisältää/vastaa-tarkistuksella ja
valinnaisella LLM-tuomarilla.
SQLite-tietokantaa käytetään **vain** kutsulokille ja eval-tuloksille - ei
kulutuksen tai budjetin kirjanpitoon.
## Nopea kokeilu
Neljällä komennolla ensimmäinen `route_and_complete`-kutsu HTTP-tilassa
(oleta, että LiteLLM pyörii jo osoitteessa `http://localhost:4000` ja siinä
on ainakin `general`-tehtävän malli konfiguroitu):
```bash
npm install && npm run build
cp config.example.yaml config.yaml # muokkaa tarpeen mukaan
LLM_BASE_URL=http://localhost:4000 LLM_API_KEY=sk-... MCP_TRANSPORT=http MCP_HTTP_PORT=3100 npm start &
curl -s -X POST http://127.0.0.1:3100/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"route_and_complete","arguments":{"taskType":"general","prompt":"Sano hei suomeksi"}}}'
```
Neljäs komento tekee `tools/call`-pyynnön palvelimen `/mcp`-päätepisteeseen ja
tulostaa vastauksen (streamable HTTP -tapahtumavirtana). Tarkemmat
ympäristömuuttujat, `stdio`-siirtotapa ja `GET /healthz` -terveystarkastus:
ks. [Asennus](#asennus) ja [Ajaminen](#ajaminen).
## Arkkitehtuuri
```
┌─────────────────────────┐
AgentScope-agentti ──▶│ mcp-model-router (MCP) │
(tai muu MCP-klientti) │ │
│ route_and_complete │
│ list_models │
│ get_usage │──▶ LiteLLM-proxy / OpenRouter
│ run_eval │ (OpenAI-yhteensopiva API)
└───────────┬──────────────┘
│
▼
SQLite (call_log, eval_result)
```
- **Siirtotavat**: stdio (paikalliseen käyttöön, esim. Claude Desktop/Cursor)
ja streamable HTTP (jotta muut kontit tavoittavat palvelimen Tailscale-
verkossa). HTTP-tila on tilaton (`sessionIdGenerator: undefined`) - jokainen
pyyntö saa oman transport-instanssinsa, joten useat agentit voivat kutsua
palvelinta yhtäaikaisesti. Palvelin ei toteuta omaa kirjautumista - pääsy
rajataan verkkotasolla (Tailscale ACL:t).
- **Konfiguraatio**: `config.yaml` (ympäristömuuttujaviittauksin, ei
salaisuuksia tiedostossa) + varsinaiset arvot ympäristömuuttujista.
- **Kontekstin kokoa ei rajoiteta keinotekoisesti** - `maxTokens` on
valinnainen parametri per kutsu, ei kovakoodattu yläraja.
## Asennus
Vaatii Node.js 20, 22 tai 24.
```bash
npm install
cp config.example.yaml config.yaml # muokkaa tarpeen mukaan
npm run build
```
Ympäristömuuttujat (esimerkkiarvot, ei salaisuuksia repossa):
| Muuttuja | Kuvaus | Oletus |
|---------------------|----------------------------------------------------------------|----------------------------|
| `LLM_BASE_URL` | OpenAI-yhteensopivan rajapinnan base URL | `http://localhost:4000` |
| `LLM_API_KEY` | Avain edelliseen rajapintaan | (tyhjä) |
| `LLM_PROVIDER_KIND` | `litellm` tai `openrouter` - katso [Mallitunnisteet: LiteLLM vs. suora OpenRouter](#mallitunnisteet-litellm-vs-suora-openrouter) | `litellm` |
| `USAGE_BASE_URL` | Kulutusrajapinnan base URL (yleensä sama kuin `LLM_BASE_URL`) | `http://localhost:4000` |
| `USAGE_API_KEY` | Avain kulutusrajapintaan | (tyhjä) |
| `CONFIG_PATH` | Polku `config.yaml`-tiedostoon | `config.yaml` |
| `DB_PATH` | SQLite-tiedoston polku | `./data/mcp-model-router.sqlite` |
| `MCP_TRANSPORT` | `stdio` tai `http` | `stdio` |
| `MCP_HTTP_HOST` | HTTP-siirtotavan bind-osoite | `127.0.0.1` |
| `MCP_HTTP_PORT` | HTTP-siirtotavan portti (oletus vaihdettu 3100:aan, koska 3000 on usein varattu esim. Open WebUI:lle) | `3100` |
| `MCP_ALLOWED_HOSTS` | Pilkulla eroteltu lista sallittuja Host-otsikoita (DNS-rebinding-suojaus) | (ei asetettu) |
### Ajaminen
```bash
# stdio (esim. Claude Desktop / Cursor)
npm start
# streamable HTTP (muut kontit Tailscale-verkossa)
MCP_TRANSPORT=http MCP_HTTP_HOST=0.0.0.0 MCP_HTTP_PORT=3100 npm start
```
HTTP-tilassa palvelin vastaa osoitteessa `http://<host>:<port>/mcp` ja
tarjoaa lisäksi `GET /healthz` -terveystarkastuksen. Stdio-tilassa vastaava
`tools/call`-pyyntö syötetään palvelimelle stdinistä yksittäisenä
JSON-RPC-viestinä (ks. [Nopea kokeilu](#nopea-kokeilu) HTTP-versiosta).
## config.yaml-esimerkki
Ks. koko esimerkki tiedostossa [`config.example.yaml`](./config.example.yaml).
Tiivistettynä:
```yaml
provider:
baseUrl: ${LLM_BASE_URL:-http://localhost:4000}
apiKey: ${LLM_API_KEY:-}
kind: ${LLM_PROVIDER_KIND:-litellm} # litellm | openrouter
tasks:
general:
model: openrouter/deepseek/deepseek-v4.1-flash
fallbackModel: openrouter/qwen/qwen3-flash
code:
model: openrouter/qwen/qwen3-coder
fallbackModel: openrouter/deepseek/deepseek-v4.1-flash
orchestration:
model: openrouter/openai/gpt-4o
timeoutMs: 120000 # ohittaa provider.timeoutMs:n tälle tehtävätyypille
usage:
provider: litellm # litellm | openrouter
baseUrl: ${USAGE_BASE_URL:-http://localhost:4000}
apiKey: ${USAGE_API_KEY:-}
eval:
judge:
enabled: false
model: openrouter/openai/gpt-4o-mini # eri malli kuin arvioitavat mallit, ks. alla
concurrency: 4 # montako (malli, prompt) -kutsua ajetaan samanaikaisesti
pricing:
openrouter/deepseek/deepseek-v4.1-flash:
inputPerMillionUsd: 0.2
outputPerMillionUsd: 0.8
database:
path: ${DB_PATH:-./data/mcp-model-router.sqlite}
```
### Mallitunnisteet: LiteLLM vs. suora OpenRouter
`tasks`-lohkon mallitunnisteet kirjoitetaan aina LiteLLM:n käyttämässä
muodossa, esim. `openrouter/deepseek/deepseek-v4.1-flash`. `openrouter/`-
etuliite on LiteLLM:n oma tapa kertoa, että pyyntö reititetään OpenRouterin
kautta - se ei ole osa OpenRouterin omaa mallitunnistetta.
- **`provider.kind: litellm`** (oletus, `LLM_BASE_URL` osoittaa LiteLLM-
proxyyn): tunniste lähetetään sellaisenaan, etuliite mukaan lukien.
- **`provider.kind: openrouter`** (`LLM_BASE_URL` osoittaa suoraan
`https://openrouter.ai/api/v1`): reititin karsii `openrouter/`-etuliitteen
automaattisesti ennen rajapintakutsua (ks. `src/lib/modelId.ts`), joten
sama `config.yaml` toimii sellaisenaan molemmilla - vain
`LLM_BASE_URL`/`LLM_PROVIDER_KIND` vaihtuvat.
### Tehtävätyyppikohtainen aikakatkaisu (`timeoutMs`)
`tasks.<taskType>.timeoutMs` on valinnainen ja ohittaa `provider.timeoutMs`:n
vain kyseiselle tehtävätyypille (vaikuttaa `route_and_complete`-kutsuihin,
sekä päämalliin että varamalliin). Oletuksena kaikki tehtävätyypit käyttävät
`provider.timeoutMs`:ää (oletus 60000 ms).
Tämä on hyödyllinen erityisesti päättelymalleille (reasoning-malleilla), jotka
voivat kestää huomattavasti kauemmin monimutkaisissa pilkkomis- tai
orkestrointitehtävissä kuin yksinkertaisissa yleistiedon tai lyhyen koodin
tehtävissä - ks. [Esimerkki: mitattu vertailu](#esimerkki-mitattu-vertailu),
jossa "pilkkominen"-tehtävätyyppi aiheutti aikakatkaisuja 60 s
oletusaikakatkaisulla:
```yaml
tasks:
orchestration:
model: openrouter/deepseek/deepseek-v4.1-flash
timeoutMs: 120000 # 2 min, oletuksen sijaan 60000 ms (1 min)
```
## MCP-työkalut
| Työkalu | Kuvaus |
|-----------------------|-------------------------------------------------------------------------|
| `route_and_complete` | Valitsee mallin `taskType`-parametrin mukaan ja täydentää promptin. Käyttää varamallia tarvittaessa. |
| `list_models` | Listaa `config.yaml`:n tehtävätyypit ja niihin liitetyt mallit. |
| `get_usage` | Palauttaa kulutuksen ja budjetin tilan LiteLLM:stä/OpenRouterista. |
| `run_eval` | Ajaa JSON-prompt-sarjan usealla mallilla ja raportoi hinnan/viiveen/laadun. |
Eval-prompt-tiedoston muoto, ks. esimerkki
[`examples/eval-prompts.example.json`](./examples/eval-prompts.example.json):
```json
[
{ "id": "p1", "prompt": "Mikä on Suomen pääkaupunki?", "expectedContains": "Helsinki" }
]
```
Laajempi, 15-20 suomenkielisen promptin esimerkkisarja neljälle tehtävätyypille
(yleinen tieto, koodaus, kirjoitus/tiivistys, tehtävän pilkkominen) löytyy
tiedostosta [`examples/eval-prompts.fi.json`](./examples/eval-prompts.fi.json).
### Eval-ajo (`run_eval`)
`run_eval` on MCP-työkalu, ei erillinen CLI-komento - se kutsutaan samalla
tavalla kuin muutkin työkalut (`tools/call`, ks. [Nopea
kokeilu](#nopea-kokeilu)). Esimerkki HTTP-tilassa, kahdella mallilla ja
`eval-prompts.fi.json`-sarjalla:
```bash
curl -s -X POST http://127.0.0.1:3100/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{
"name":"run_eval",
"arguments":{
"promptSetPath":"examples/eval-prompts.fi.json",
"models":["openrouter/deepseek/deepseek-v4.1-flash","openrouter/qwen/qwen3-flash"],
"judge": false
}
}
}'
```
- `models`-lista käyttää samoja LiteLLM `model_name`-aliaksia kuin
`config.yaml`:n `tasks`-lohko (ks. [Tunnetut
rajoitukset](#tunnetut-rajoitukset)) - ei tarvitse olla sama malli kuin
jonkin `taskType`:n oletusmalli.
- `judge`-parametri (valinnainen) ohittaa `config.yaml`:n
`eval.judge.enabled`-asetuksen yksittäiselle ajolle. Kun tuomari on
päällä, jokainen vastaus lähetetään lisäksi `eval.judge.model`:lle
arvioitavaksi asteikolla 1-5 (`config.yaml`:n `eval.judge.systemPrompt`
tai oletusarvo) - tämä tekee saman määrän ylimääräisiä mallikutsuja kuin
on prompt-tapauksia, joten se maksaa ja kestää enemmän.
- Jos `db`-instanssi on käytössä (näin on aina, kun palvelin käynnistetään
`dist/index.js`:llä - ks. `src/index.ts`), jokainen ajo ja tulosrivi
tallennetaan SQLiteen (`eval_run`/`eval_result`-taulut) `DB_PATH`:n
osoittamaan tiedostoon, joten ajoja voi vertailla jälkikäteen myös
suoraan tietokannasta.
- Eval voi tehdä paljon mallikutsuja: esim. 2 mallia × 20 promptia = 40
(malli, prompt) -paria, ja jokainen kutsu voi kestää useita sekunteja (tai
`provider.timeoutMs`/tehtävätyypin oman `timeoutMs`:n verran, jos malli
jumiutuu) - ks. [Esimerkki: mitattu vertailu](#esimerkki-mitattu-vertailu).
`judge: true` lähes tuplaa ajoajan, koska jokainen vastaus arvioidaan vielä
erikseen ylimääräisellä mallikutsulla.
**Rinnakkaisajo (`eval.concurrency`)**
`run_eval` ajaa (malli, prompt) -parit rinnakkain enintään
`config.yaml`:n `eval.concurrency`-asetuksen verran samanaikaisesti
(oletus 4, minimi 1). Toteutus on yksinkertainen rajoitettu työjono
(`mapWithConcurrency`, ks. `src/eval/runEval.ts`) ilman uusia riippuvuuksia:
kiinteä määrä "workereita" nostaa jonosta seuraavan käsittelemättömän
tehtävän kerrallaan, kunnes jono on tyhjä.
Kutsut ovat käytännössä koko ajan I/O-odotusta (verkkovastaus mallilta),
eivät CPU-työtä, joten rinnakkaisajo lyhentää kokonaisajoaikaa suoraan
suhteessa `concurrency`-arvoon - esim. 2 mallia × 20 promptia × judge
päällä (40 vastauskutsua + 40 judge-kutsua = 80 kutsua) kesti aiemmin
peräkkäin ajettuna noin 12 minuuttia; `concurrency: 4`:llä samat kutsut
menevät neljä kerrallaan, mikä lyhentää ajoaikaa merkittävästi samalla
prompt-sarjalla ja mallivalinnalla.
Huomioita:
- **Tulosten järjestys säilyy** riippumatta siitä, missä järjestyksessä
yksittäiset kutsut valmistuvat: `cases`-taulukko on aina samassa
järjestyksessä kuin peräkkäin ajettuna (malli ulompana, prompt sisempänä,
`input.models`/prompt-tiedoston järjestyksessä).
- **Virheenkäsittely on edelleen per kutsu**: yhden kutsun aikakatkaisu tai
muu virhe (myös judge-kutsun virhe) ei keskeytä muita rinnakkaisia tai
jonossa odottavia kutsuja - se näkyy vain kyseisen tapauksen `error`-
kentässä, kuten peräkkäisessäkin ajossa.
- **Miksi oletus on 4, ei enemmän**: liian suuri rinnakkaisuus voi
ylikuormittaa LiteLLM-proxyn tai OpenRouterin rate limitit, jolloin osa
kutsuista alkaa epäonnistua 429-virheillä sen sijaan, että eval oikeasti
nopeutuisi. Nosta arvoa varovasti oman proxyn/API-avaimen rajojen
mukaan, tai laske se `1`:een, jos haluat täysin peräkkäisen (deterministisen
ajoituksen kannalta yksinkertaisimman) ajon esim. vianetsintää varten.
**Tulosten lukeminen** (`EvalReport`, ks. `src/eval/runEval.ts`):
- `cases`: yksi rivi per (malli, prompt) - sisältää `latencyMs`,
`response`-tekstin, `passed` (`true`/`false`/`null` jos
`expectedContains`-kenttää ei annettu kyseiselle promptille),
`judgeScore` (1-5 tai `null`), `priceUsd` (`null`, jos mallille ei ole
hinnastoa `config.yaml`:n `eval.pricing`-lohkossa) ja `error`
(virheviesti tai `null`).
- `perModel`: yhteenveto mallia kohden - `avgLatencyMs`, `passRate`
(`expectedContains`-läpäisyosuus 0-1 **virheelliset kutsut pois
laskettuna**, `null` jos yhdessäkään promptissa ei ollut
`expectedContains`-kenttää), `avgJudgeScore`, `avgPriceUsd` ja
`errorCount`/`caseCount`. Tämä on nopein tapa verrata malleja: matalampi
`avgPriceUsd` ja `avgLatencyMs` samalla kun `passRate`/`avgJudgeScore`
pysyy riittävän korkeana kertoo, mikä malli kannattaa valita
`config.yaml`:n `tasks`-lohkoon.
- `perTaskType`: sama yhteenveto ryhmiteltynä (malli, tehtävätyyppi)
-pareittain. Tehtävätyyppi päätellään promptin `id`:n etuliitteestä
pudottamalla lopusta `-<numero>`, esim. `"yleinen-01"` → `"yleinen"`,
`"pilkkominen-05"` → `"pilkkominen"` (ks.
`taskTypeFromPromptId` tiedostossa `src/eval/runEval.ts`) - tämä ei liity
`config.yaml`:n `tasks`-avaimiin, vaan on pelkkä nimeämiskäytäntö
prompt-tiedostossa. Jokainen rivi sisältää:
- `caseCount`, `errorCount` - montako tapausta ja niistä montako virheitä.
- `passedCount`/`expectedCount` - montako läpäisi avainsanatarkistuksen
niistä tapauksista, joissa `expectedContains` oli annettu (= "läpäisty/n").
- `passRateExcludingErrors` - läpäisyosuus laskettuna vain onnistuneista
kutsuista (virheelliset kutsut pois sekä osoittajasta että
nimittäjästä). Kertoo vastauksen laadun, kun malli ylipäätään vastasi.
- `passRateIncludingErrors` - läpäisyosuus laskettuna niin, että virheet
(esim. aikakatkaisu) lasketaan läpäisemättömiksi (mukana nimittäjässä).
Kertoo tehtävätyypin kokonaisluotettavuuden mallilla.
- `avgLatencyMs`, `avgPriceUsd` - kuten `perModel`:ssa, mutta rajattuna
tähän tehtävätyyppiin.
- MCP-työkalun tekstivastaus tiivistää sekä `perModel`- että
`perTaskType`-rivit ihmisluettavaksi; koko `EvalReport` (mukaan lukien
`cases`) on saatavilla ohjelmallisesti `structuredContent`-kentässä.
**Judge-tila (`eval.judge`) - luotettavampi laatumittari**
`expectedContains`-avainsanatarkistus on karkea: se hylkää hyvänkin
vastauksen, jos se ei sisällä täsmälleen odotettua sanaa (ks. [Esimerkki:
mitattu vertailu](#esimerkki-mitattu-vertailu) - erityisesti
kirjoitus/tiivistystehtävissä tämä antaa harhaanjohtavan matalan
läpäisyprosentin). LLM-tuomari arvioi vastauksen laadun asteikolla 1-5
riippumatta siitä, osuuko vastaus täsmälleen odotettuun sanamuotoon, ja on
siksi luotettavampi mittari erityisesti avoimissa (kirjoitus-, tiivistys-,
pilkkomis-) tehtävissä.
Ota judge käyttöön joko pysyvästi `config.yaml`:ssa tai yksittäiselle ajolle:
```yaml
eval:
judge:
enabled: true
model: openrouter/openai/gpt-4o-mini # tuomarina toimiva malli
systemPrompt: "Olet tiukka arvioija. Anna vastauksen laadulle asteikolla 1-5 pelkkä numero."
```
tai välitä `"judge": true` `run_eval`-kutsun `arguments`-kenttään (ks.
esimerkki yllä) - tämä ohittaa `config.yaml`:n asetuksen vain kyseiselle
ajolle. Tuloksena `EvalCaseResult.judgeScore`/`EvalModelSummary.avgJudgeScore`
täyttyvät `null`:n sijaan. Huomaa hintavaikutus: judge tekee yhden
ylimääräisen mallikutsun jokaista prompt-tapausta kohden, joten se sekä
maksaa että kestää suunnilleen kaksinkertaisesti verrattuna
avainsanatarkistukseen.
**Valitse `eval.judge.model` eri malliksi kuin arvioitavat mallit.** Malli
suosii tyypillisesti omaa vastaustyyliään (esim. omaa tapaansa jäsentää
vastaus tai muotoilla lauseet), jolloin sama malli tuomarina antaisi
korkeampia pisteitä omaa tyyliään muistuttaville vastauksille - tämä vinouttaa
vertailua juuri sitä mallia suosivaksi. Käytä siis tuomarina mallia, joka ei
ole yksikään `run_eval`-kutsun `models`-listalla olevista.
## Esimerkki: mitattu vertailu
Alla yhden oikean `run_eval`-ajon tulokset (`examples/eval-prompts.fi.json`,
20 promptia, judge pois päältä), joissa verrattiin kahta OpenRouterin kautta
LiteLLM-proxyn taakse ajettua mallia: `deepseek-v4.1-flash` vs.
`qwen3-coder`.
**Kokonaisuus:**
| Malli | Läpäisy | Viive ka. | Hinta ka./kutsu |
|---------------------|---------------|-----------|------------------|
| deepseek-v4.1-flash | 83 % (15/18, 2 aikakatkaisua) | 14 802 ms | 0,000744 USD |
| qwen3-coder | 85 % (17/20) | 9 887 ms | 0,000466 USD |
**Tehtävätyypeittäin** (läpäisty/n, viive ka., hinta ka. USD/kutsu):
| Tehtävätyyppi | deepseek-v4.1-flash | qwen3-coder |
|---|---|---|
| yleinen | 5/5, 2 190 ms, 0,000119 | 5/5, 5 600 ms, 0,000159 |
| koodaus | 5/5, 4 025 ms, 0,000278 | 5/5, 3 742 ms, 0,000188 |
| kirjoitus | 3/5, 7 841 ms, 0,000416 | 3/5, 1 567 ms, 0,000121 |
| pilkkominen | 2/5 (+2 aikakatkaisua), 45 151 ms, 0,001867 | 4/5, 28 640 ms, 0,001395 |
**Tulkinta:** kokonaisläpäisy on lähellä molemmilla malleilla, mutta
`qwen3-coder` on sekä nopeampi että halvempi joka tehtävätyypissä tässä
ajossa, ja se selvisi pilkkomistehtävistä ilman aikakatkaisuja.
`deepseek-v4.1-flash`:n kaksi aikakatkaisua osuivat molemmat
pilkkomistehtäviin (60 s oletusaikakatkaisulla) - ks.
[Tehtävätyyppikohtainen aikakatkaisu](#tehtävätyyppikohtainen-aikakatkaisu-timeoutms),
joka on juuri tätä varten.
**Rajoitukset - lue nämä luvut varoen:**
- **Pieni otos**: vain 5 promptia per tehtävätyyppi (20 yhteensä). Yksikin
tapaus muuttaa prosenttilukua 20 %-yksiköllä; tuloksia ei pidä yleistää
suoraan muihin promptteihin tai käyttötapauksiin.
- **Avainsanatarkistus arvioi kirjoitustehtäviä huonosti**: `expectedContains`
hylkää muuten hyvän vastauksen, jos se ei sisällä täsmälleen odotettua
sanaa - kirjoitus/tiivistys-sarakkeen 3/5-luvut molemmilla malleilla
todennäköisesti aliarvioivat todellisen laadun. Ks. [Judge-tila](#eval-ajo-run_eval)
luotettavampaa vaihtoehtoa varten.
- **Deepseekin aikakatkaisut** nostivat sen keskiviivettä ja hintaa
pilkkomis-sarakkeessa merkittävästi (60 s per aikakatkaisu lasketaan
mukaan viiveeseen) - ilman niitä keskiviive olisi ollut matalampi.
- **Tulokset ovat yhdestä ajosta** (run `be440639-9aa7-4330-8c66-8cb2ed27b9ef`)
tiettynä ajankohtana ja riippuvat mallien senhetkisistä versioista sekä
OpenRouterin kuormasta/saatavuudesta - ne eivät ole pysyvä benchmark-tulos,
vaan esimerkki siitä, miten `run_eval`:n tulosraporttia luetaan. Aja oma
eval omilla prompteillasi ennen tuotantopäätöksiä.
## Claude Desktop / Cursor -asetus
`claude_desktop_config.json` (tai vastaava Cursorin MCP-asetustiedosto),
stdio-siirtotavalla:
```json
{
"mcpServers": {
"model-router": {
"command": "node",
"args": ["/polku/mcp-model-router/dist/index.js"],
"env": {
"CONFIG_PATH": "/polku/mcp-model-router/config.yaml",
"LLM_BASE_URL": "http://localhost:4000",
"LLM_API_KEY": "sk-...",
"DB_PATH": "/polku/mcp-model-router/data/mcp-model-router.sqlite"
}
}
}
}
```
Jos palvelin pyörii jo HTTP-tilassa (esim. omassa LXC-kontissa), monet
MCP-klientit tukevat myös suoraa streamable HTTP -yhteyttä osoitteeseen
`http://<tailscale-host>:3100/mcp` ilman `command`-käynnistystä - katso oman
klienttisi dokumentaatio.
## Ajaminen Proxmox-LXC:ssä
Ei Dockeria - palvelin ajetaan suoraan Node-prosessina systemd-yksikkönä.
1. Luo (tai käytä) Debian/Ubuntu-pohjainen LXC-kontti, asenna Node.js 20, 22 tai 24.
2. Kloonaa repo `/opt/mcp-model-router`-hakemistoon ja aja:
```bash
cd /opt/mcp-model-router
npm ci
npm run build
cp config.example.yaml config.yaml # muokkaa
```
3. Luo palvelulle oma käyttäjä ja data-hakemisto:
```bash
useradd --system --home /opt/mcp-model-router --shell /usr/sbin/nologin mcp-model-router
mkdir -p /opt/mcp-model-router/data
chown -R mcp-model-router:mcp-model-router /opt/mcp-model-router
```
4. Kopioi ympäristötiedosto ja täytä arvot (ei versionhallintaan):
```bash
cp systemd/mcp-model-router.env.example /opt/mcp-model-router/mcp-model-router.env
chmod 600 /opt/mcp-model-router/mcp-model-router.env
```
5. Asenna systemd-yksikkö, ks. valmis esimerkki
[`systemd/mcp-model-router.service`](./systemd/mcp-model-router.service):
```bash
cp systemd/mcp-model-router.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now mcp-model-router
systemctl status mcp-model-router
```
Yksikkö kuuntelee `MCP_HTTP_HOST`-osoitteessa (aseta env-tiedostoon kontin
Tailscale-osoite `tailscale ip -4`), jolloin muut LXC-kontit tavoittavat
palvelimen Tailscale-verkon yli ilman erillistä autentikointia - pääsy
rajataan Tailscale ACL:eillä.
## Kehitys
```bash
npm run dev # tsx, stdio-siirtotapa suoraan lähdekoodista
npm run typecheck # tsc --noEmit
npm test # vitest, HTTP-kutsut mockattu undicilla
```
## Tunnetut rajoitukset
- Ei omaa autentikointia HTTP-siirtotavalle (luotetaan verkkotason
rajaukseen, esim. Tailscale).
- Ei automaattista mallilistan hakua LiteLLM:n `/models`-rajapinnasta -
`list_models` lukee vain `config.yaml`:n.
- Eval-hinta-arvio perustuu `config.yaml`:n `eval.pricing`-hinnastoon, ei
automaattiseen hintojen hakuun OpenRouterista.
- `get_usage` vaatii, että LiteLLM on käynnistetty tietokannan (Postgres)
kanssa ja että `USAGE_API_KEY` on LiteLLM:n **pääavain** (`LITELLM_MASTER_KEY`),
koska `/user/info` on hallintarajapinta. Ilman tietokantaa LiteLLM palauttaa
`"Database not connected"` (HTTP 500), ja `get_usage` välittää tämän virheen
sellaisenaan kutsujalle. Pelkkä OpenRouter-käyttö (`usage.provider: openrouter`)
ei vaadi tietokantaa.
- `tasks`-lohkon ja `run_eval`:n mallitunnisteet ovat LiteLLM:n
`model_name`-aliaksia (LiteLLM-konfiguraatiossa/proxy-mallilistassa
määriteltyjä nimiä), eivät suoraan OpenRouterin tai muun taustapalvelun
omia mallitunnisteita - ks. [Mallitunnisteet: LiteLLM vs. suora
OpenRouter](#mallitunnisteet-litellm-vs-suora-openrouter).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues