misroute 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {misroute-0.2.2 → misroute-0.2.3}/PKG-INFO +1 -1
- {misroute-0.2.2 → misroute-0.2.3}/pyproject.toml +1 -1
- {misroute-0.2.2 → misroute-0.2.3}/pyproject.toml.orig +1 -1
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/config.py +17 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/openai_compat.py +8 -1
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/app.py +31 -1
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/index.html +118 -1
- misroute-0.2.3/src/misroute/ui/setup.py +128 -0
- {misroute-0.2.2 → misroute-0.2.3}/LICENSE +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/README.md +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/agent_server.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/check.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/cli.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/codemode.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/confusion.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/hedge.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/nearmiss.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/rank.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/removal.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/clause.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/stats.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/proxy.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/rules.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/wrapper.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/lint/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/lint/descriptions.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/anthropic.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/base.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/mock.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/code_mode.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/json_mode.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/stubs.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/project.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/py.typed +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/html.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/templates/report.html.j2 +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/gate.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/generate.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/validate.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/fake_tools.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/monty_runner.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/catalog.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/connect.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/openapi.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/build.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/templates/index.html.j2 +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/cache.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/runs.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/testing/__init__.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/testing/fake_server.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/trial.py +0 -0
- {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/__init__.py +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import Any, Literal
|
|
3
4
|
import yaml
|
|
@@ -91,6 +92,22 @@ class Config(BaseModel):
|
|
|
91
92
|
return self.model(name)
|
|
92
93
|
|
|
93
94
|
|
|
95
|
+
def env_path(config: str | Path) -> Path:
|
|
96
|
+
return Path(config).resolve().parent / ".env"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_env(config: str | Path) -> None:
|
|
100
|
+
"""Read KEY=VALUE lines from .env next to the config into the environment, without overriding set values."""
|
|
101
|
+
path = env_path(config)
|
|
102
|
+
if not path.exists():
|
|
103
|
+
return
|
|
104
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
105
|
+
key, sep, value = line.partition("=")
|
|
106
|
+
if sep and key.strip() and not key.lstrip().startswith("#"):
|
|
107
|
+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
108
|
+
|
|
109
|
+
|
|
94
110
|
def load(path: str | Path) -> Config:
|
|
111
|
+
load_env(path)
|
|
95
112
|
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
96
113
|
return Config.model_validate(data)
|
|
@@ -86,7 +86,14 @@ class OpenAICompatAdapter:
|
|
|
86
86
|
kw["tools"] = to_openai(tools)
|
|
87
87
|
if extra := self.cfg.options.get("extra"):
|
|
88
88
|
kw["extra_body"] = extra
|
|
89
|
-
|
|
89
|
+
try:
|
|
90
|
+
resp = await self.client.chat.completions.create(**kw)
|
|
91
|
+
except openai.BadRequestError as e:
|
|
92
|
+
if "temperature" not in kw or "temperature" not in str(e).lower():
|
|
93
|
+
raise
|
|
94
|
+
self.cfg.options["temperature"] = None
|
|
95
|
+
kw.pop("temperature")
|
|
96
|
+
resp = await self.client.chat.completions.create(**kw)
|
|
90
97
|
msg = resp.choices[0].message
|
|
91
98
|
calls = [Call(c.function.name, parse_args(c.function.arguments), c.id) for c in msg.tool_calls or []]
|
|
92
99
|
text = msg.content or ""
|
|
@@ -7,10 +7,12 @@ from starlette.applications import Starlette
|
|
|
7
7
|
from starlette.requests import Request
|
|
8
8
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
9
9
|
from starlette.routing import Route
|
|
10
|
+
from misroute.config import load_env
|
|
10
11
|
from misroute.guard.wrapper import approve
|
|
11
12
|
from misroute.models.base import BudgetExceeded
|
|
12
13
|
from misroute.project import Project
|
|
13
14
|
from misroute.report.html import html
|
|
15
|
+
from misroute.ui import setup
|
|
14
16
|
|
|
15
17
|
PHASES = ("lint", "discover", "probe", "fix")
|
|
16
18
|
|
|
@@ -104,10 +106,17 @@ def create(config: Path) -> Starlette:
|
|
|
104
106
|
return HTMLResponse(page)
|
|
105
107
|
|
|
106
108
|
async def report(request: Request) -> Response:
|
|
109
|
+
if not config.exists():
|
|
110
|
+
return HTMLResponse("<p style='font-family:system-ui;color:#6b6b66'>Set up a server and a model first</p>")
|
|
107
111
|
return HTMLResponse(html(runner.project().store))
|
|
108
112
|
|
|
109
113
|
async def state(request: Request) -> Response:
|
|
110
|
-
|
|
114
|
+
if not config.exists():
|
|
115
|
+
return JSONResponse({"configured": False, "running": False, **runner.state, "models": [], "modes": []})
|
|
116
|
+
try:
|
|
117
|
+
cfg = runner.project().cfg
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return JSONResponse({"configured": False, "config_error": str(e)[:400], "running": False, **runner.state})
|
|
111
120
|
return JSONResponse(
|
|
112
121
|
{
|
|
113
122
|
"running": runner.running,
|
|
@@ -117,9 +126,27 @@ def create(config: Path) -> Starlette:
|
|
|
117
126
|
],
|
|
118
127
|
"modes": list(cfg.modes),
|
|
119
128
|
"budget": cfg.budget_usd,
|
|
129
|
+
"configured": True,
|
|
120
130
|
}
|
|
121
131
|
)
|
|
122
132
|
|
|
133
|
+
async def setup_get(request: Request) -> Response:
|
|
134
|
+
load_env(config)
|
|
135
|
+
return JSONResponse(setup.current(config))
|
|
136
|
+
|
|
137
|
+
async def setup_test(request: Request) -> Response:
|
|
138
|
+
if bad := guarded(request):
|
|
139
|
+
return bad
|
|
140
|
+
return JSONResponse(await setup.test(config, await request.json()))
|
|
141
|
+
|
|
142
|
+
async def setup_save(request: Request) -> Response:
|
|
143
|
+
if bad := guarded(request):
|
|
144
|
+
return bad
|
|
145
|
+
try:
|
|
146
|
+
return JSONResponse(setup.save(config, await request.json()))
|
|
147
|
+
except Exception as e:
|
|
148
|
+
return JSONResponse({"error": str(e)[:600]}, status_code=400)
|
|
149
|
+
|
|
123
150
|
async def estimate(request: Request) -> Response:
|
|
124
151
|
if bad := guarded(request):
|
|
125
152
|
return bad
|
|
@@ -150,6 +177,9 @@ def create(config: Path) -> Starlette:
|
|
|
150
177
|
Route("/", index),
|
|
151
178
|
Route("/report", report),
|
|
152
179
|
Route("/api/state", state),
|
|
180
|
+
Route("/api/setup", setup_get),
|
|
181
|
+
Route("/api/setup/test", setup_test, methods=["POST"]),
|
|
182
|
+
Route("/api/setup/save", setup_save, methods=["POST"]),
|
|
153
183
|
Route("/api/estimate", estimate, methods=["POST"]),
|
|
154
184
|
Route("/api/run", run, methods=["POST"]),
|
|
155
185
|
Route("/api/guard", guard),
|
|
@@ -37,6 +37,7 @@ code { font-size: 12px; }
|
|
|
37
37
|
<header>
|
|
38
38
|
<b>misroute</b>
|
|
39
39
|
<nav>
|
|
40
|
+
<button data-tab="setup">Setup</button>
|
|
40
41
|
<button data-tab="results" class="on">Results</button>
|
|
41
42
|
<button data-tab="run">Run</button>
|
|
42
43
|
<button data-tab="guard">Guard</button>
|
|
@@ -44,6 +45,53 @@ code { font-size: 12px; }
|
|
|
44
45
|
<span id="status"></span>
|
|
45
46
|
</header>
|
|
46
47
|
<main>
|
|
48
|
+
<section id="setup">
|
|
49
|
+
<div class="card">
|
|
50
|
+
<h3 style="margin-top:0">MCP server</h3>
|
|
51
|
+
<div class="row">
|
|
52
|
+
<div><label for="server_kind">Connect with</label>
|
|
53
|
+
<select id="server_kind"><option value="url">URL</option><option value="command">Local command</option><option value="openapi">OpenAPI file</option></select></div>
|
|
54
|
+
<div style="flex:1;min-width:260px"><label for="server_value" id="server_label">URL</label><input id="server_value" style="width:100%" placeholder="https://your-server/mcp"></div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="row">
|
|
57
|
+
<div style="flex:1;min-width:220px"><label for="server_token">Bearer token, optional</label><input id="server_token" type="password" style="width:100%" autocomplete="off"></div>
|
|
58
|
+
<div style="flex:1;min-width:220px"><label for="include_tools">Only these tools, optional</label><input id="include_tools" style="width:100%" placeholder="*issue*, *pull_request*"></div>
|
|
59
|
+
</div>
|
|
60
|
+
<p class="muted" id="token_note"></p>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="card">
|
|
63
|
+
<h3 style="margin-top:0">Model</h3>
|
|
64
|
+
<div class="row">
|
|
65
|
+
<div><label for="provider">Provider</label>
|
|
66
|
+
<select id="provider">
|
|
67
|
+
<option value="openai-compatible">OpenAI compatible (OpenRouter, vLLM, Ollama, relay)</option>
|
|
68
|
+
<option value="anthropic">Anthropic API</option>
|
|
69
|
+
<option value="anthropic-foundry">Anthropic on Azure Foundry</option>
|
|
70
|
+
<option value="azure-openai">Azure OpenAI</option>
|
|
71
|
+
<option value="bedrock">Anthropic on Bedrock</option>
|
|
72
|
+
</select></div>
|
|
73
|
+
<div><label for="name">Name</label><input id="name" value="main" style="width:120px"></div>
|
|
74
|
+
<div style="flex:1;min-width:200px"><label for="model">Model or deployment</label><input id="model" style="width:100%"></div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="row">
|
|
77
|
+
<div style="flex:1;min-width:260px"><label for="base_url">Endpoint</label><input id="base_url" style="width:100%"></div>
|
|
78
|
+
<div id="api_version_box" hidden><label for="api_version">API version</label><input id="api_version" value="2024-10-21" style="width:130px"></div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="row">
|
|
81
|
+
<div style="flex:1;min-width:220px"><label for="api_key">API key</label><input id="api_key" type="password" style="width:100%" autocomplete="off"></div>
|
|
82
|
+
<div><label for="price_in">Dollars per million input tokens</label><input id="price_in" type="number" step="0.01" style="width:120px"></div>
|
|
83
|
+
<div><label for="price_out">Per million output tokens</label><input id="price_out" type="number" step="0.01" style="width:120px"></div>
|
|
84
|
+
<div><label for="budget_usd">Budget per run, dollars</label><input id="budget_usd" type="number" step="0.5" value="5" style="width:110px"></div>
|
|
85
|
+
</div>
|
|
86
|
+
<p class="muted" id="key_note"></p>
|
|
87
|
+
<label>Modes</label>
|
|
88
|
+
<div class="checks"><label><input type="checkbox" class="smode" value="json" checked>json</label><label><input type="checkbox" class="smode" value="code">code</label></div>
|
|
89
|
+
<button class="ghost" id="test">Test connection</button>
|
|
90
|
+
<button class="primary" id="save">Save</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="card" id="setupResult" hidden></div>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
47
95
|
<section id="results" class="on"><iframe id="report" src="/report" title="report"></iframe></section>
|
|
48
96
|
|
|
49
97
|
<section id="run">
|
|
@@ -99,7 +147,7 @@ function body() {
|
|
|
99
147
|
|
|
100
148
|
$("estimate").onclick = async () => {
|
|
101
149
|
const r = await post("/api/estimate", body());
|
|
102
|
-
$("estimateText").textContent = r.error ? r.error :
|
|
150
|
+
$("estimateText").textContent = r.error ? r.error : `up to ${r.calls} model calls, up to $${r.cost.toFixed(2)}`;
|
|
103
151
|
$("start").disabled = !!r.error;
|
|
104
152
|
};
|
|
105
153
|
["phase", "top", "trials"].forEach((id) => $(id).onchange = () => { $("start").disabled = true; $("estimateText").textContent = ""; });
|
|
@@ -113,6 +161,11 @@ $("start").onclick = async () => {
|
|
|
113
161
|
|
|
114
162
|
async function poll() {
|
|
115
163
|
const s = await fetch("/api/state").then((r) => r.json());
|
|
164
|
+
if (!s.configured) {
|
|
165
|
+
$("status").textContent = s.config_error ? "config problem, see Setup" : "not set up";
|
|
166
|
+
if (!location.hash) location.hash = "setup";
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
116
169
|
if (!$("models").children.length) {
|
|
117
170
|
$("models").innerHTML = s.models.map((m) => `<label><input type="checkbox" value="${esc(m.name)}" ${m.tested ? "checked" : ""}>${esc(m.name)}</label>`).join("");
|
|
118
171
|
$("modes").innerHTML = ["json", "code"].map((m) => `<label><input type="checkbox" value="${m}" ${s.modes.includes(m) ? "checked" : ""}>${m}</label>`).join("");
|
|
@@ -142,6 +195,70 @@ async function loadGuard() {
|
|
|
142
195
|
<td>${esc(e.tool)}</td><td>${esc(e.other)}</td><td class="muted">${new Date(e.at * 1000).toLocaleTimeString()}</td></tr>`).join("");
|
|
143
196
|
}
|
|
144
197
|
|
|
198
|
+
const LABELS = { url: ["URL", "https://your-server/mcp"], command: ["Command", "npx your-mcp-server"], openapi: ["Path to OpenAPI file", "api.json"] };
|
|
199
|
+
const ENDPOINTS = {
|
|
200
|
+
"openai-compatible": "https://openrouter.ai/api/v1",
|
|
201
|
+
anthropic: "empty for the Anthropic API",
|
|
202
|
+
"anthropic-foundry": "https://your-resource.services.ai.azure.com/anthropic",
|
|
203
|
+
"azure-openai": "https://your-resource.openai.azure.com",
|
|
204
|
+
bedrock: "empty",
|
|
205
|
+
};
|
|
206
|
+
function refreshSetup() {
|
|
207
|
+
const [label, hint] = LABELS[$("server_kind").value];
|
|
208
|
+
$("server_label").textContent = label;
|
|
209
|
+
$("server_value").placeholder = hint;
|
|
210
|
+
$("base_url").placeholder = ENDPOINTS[$("provider").value];
|
|
211
|
+
$("api_version_box").hidden = $("provider").value !== "azure-openai";
|
|
212
|
+
}
|
|
213
|
+
$("server_kind").onchange = refreshSetup;
|
|
214
|
+
$("provider").onchange = refreshSetup;
|
|
215
|
+
|
|
216
|
+
const FIELDS = ["server_kind", "server_value", "server_token", "include_tools", "provider", "name", "model", "base_url", "api_version", "api_key", "price_in", "price_out", "budget_usd"];
|
|
217
|
+
function setupForm() {
|
|
218
|
+
const f = Object.fromEntries(FIELDS.map((id) => [id, $(id).value.trim()]));
|
|
219
|
+
f.modes = [...document.querySelectorAll(".smode:checked")].map((i) => i.value);
|
|
220
|
+
f.keep_token = true;
|
|
221
|
+
return f;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function showSetup(html) { $("setupResult").hidden = false; $("setupResult").innerHTML = html; }
|
|
225
|
+
|
|
226
|
+
$("test").onclick = async () => {
|
|
227
|
+
showSetup('<span class="muted">Testing</span>');
|
|
228
|
+
const r = await post("/api/setup/test", setupForm());
|
|
229
|
+
if (r.error) return showSetup(`<span class="bad">${esc(r.error)}</span>`);
|
|
230
|
+
const t = r.tools.error ? `<span class="bad">Server: ${esc(r.tools.error)}</span>`
|
|
231
|
+
: `<span class="good">Server: ${r.tools.count} tools</span> <span class="muted">${esc(r.tools.names.join(", "))}</span>`;
|
|
232
|
+
const m = r.model.error ? `<span class="bad">Model: ${esc(r.model.error)}</span>`
|
|
233
|
+
: `<span class="good">Model replied ${esc(JSON.stringify(r.model.reply))}</span> <span class="muted">${r.model.tokens_in} tokens in, ${r.model.tokens_out} out</span>`;
|
|
234
|
+
showSetup(`<p style="margin:0 0 8px">${t}</p><p style="margin:0">${m}</p>`);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
$("save").onclick = async () => {
|
|
238
|
+
const r = await post("/api/setup/save", setupForm());
|
|
239
|
+
if (r.error) return showSetup(`<span class="bad">${esc(r.error)}</span>`);
|
|
240
|
+
const keys = r.env.length ? ` <span class="muted">keys in .env: ${esc(r.env.join(", "))}</span>` : "";
|
|
241
|
+
showSetup(`<span class="good">Saved ${esc(r.saved)}</span>${keys}`);
|
|
242
|
+
$("models").innerHTML = "";
|
|
243
|
+
$("report").src = "/report";
|
|
244
|
+
$("server_token").value = "";
|
|
245
|
+
$("api_key").value = "";
|
|
246
|
+
loadSetup();
|
|
247
|
+
poll();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
async function loadSetup() {
|
|
251
|
+
const c = await fetch("/api/setup").then((r) => r.json());
|
|
252
|
+
if (c.configured) {
|
|
253
|
+
FIELDS.filter((id) => id !== "server_token" && id !== "api_key").forEach((id) => { if (c[id] !== undefined) $(id).value = c[id]; });
|
|
254
|
+
document.querySelectorAll(".smode").forEach((i) => { i.checked = c.modes.includes(i.value); });
|
|
255
|
+
}
|
|
256
|
+
$("token_note").textContent = c.has_token ? "A token is saved, leave the field empty to keep it" : "";
|
|
257
|
+
$("key_note").textContent = c.has_key ? "A key is saved, leave the field empty to keep it" : "";
|
|
258
|
+
refreshSetup();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
loadSetup();
|
|
145
262
|
show(location.hash.slice(1) || "results");
|
|
146
263
|
poll();
|
|
147
264
|
</script>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shlex
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
import yaml
|
|
6
|
+
from misroute.config import Config, env_path
|
|
7
|
+
from misroute.models import build
|
|
8
|
+
from misroute.project import Project
|
|
9
|
+
|
|
10
|
+
TOKEN_VAR = "MISROUTE_MCP_TOKEN"
|
|
11
|
+
KEY_VAR = "MISROUTE_MODEL_KEY"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def server_block(form: dict) -> dict[str, Any]:
|
|
15
|
+
kind, value = form.get("server_kind", "url"), str(form.get("server_value", "")).strip()
|
|
16
|
+
if not value:
|
|
17
|
+
raise ValueError("server is empty")
|
|
18
|
+
out: dict[str, Any] = {"command": shlex.split(value, posix=os.name != "nt")} if kind == "command" else {kind: value}
|
|
19
|
+
if form.get("server_token") or (form.get("keep_token") and os.environ.get(TOKEN_VAR)):
|
|
20
|
+
out["auth"] = f"bearer_env:{TOKEN_VAR}"
|
|
21
|
+
include = [x.strip() for x in str(form.get("include_tools", "")).split(",") if x.strip()]
|
|
22
|
+
if include:
|
|
23
|
+
out["include_tools"] = include
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def model_block(form: dict) -> dict[str, Any]:
|
|
28
|
+
provider = form.get("provider", "openai-compatible")
|
|
29
|
+
out: dict[str, Any] = {"name": form.get("name") or "main", "provider": provider, "model": form.get("model")}
|
|
30
|
+
if form.get("base_url"):
|
|
31
|
+
out["base_url"] = form["base_url"]
|
|
32
|
+
if provider != "bedrock":
|
|
33
|
+
out["api_key_env"] = KEY_VAR
|
|
34
|
+
for k in ("price_in", "price_out"):
|
|
35
|
+
if form.get(k) not in (None, ""):
|
|
36
|
+
out[k] = float(form[k])
|
|
37
|
+
options: dict[str, Any] = {}
|
|
38
|
+
if provider == "azure-openai" and form.get("api_version"):
|
|
39
|
+
options["api_version"] = form["api_version"]
|
|
40
|
+
if options:
|
|
41
|
+
out["options"] = options
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def merged(config: Path, form: dict) -> dict[str, Any]:
|
|
46
|
+
data = yaml.safe_load(config.read_text(encoding="utf-8")) if config.exists() else {}
|
|
47
|
+
data = data or {}
|
|
48
|
+
model = model_block(form)
|
|
49
|
+
others = [m for m in data.get("models", []) if m.get("name") != model["name"]]
|
|
50
|
+
data.update(server=server_block(form), models=[model, *others])
|
|
51
|
+
names = {m["name"] for m in data["models"]}
|
|
52
|
+
for role in ("generator", "validator", "writer"):
|
|
53
|
+
if data.get(role) not in names:
|
|
54
|
+
data.pop(role, None)
|
|
55
|
+
data["modes"] = form.get("modes") or data.get("modes") or ["json"]
|
|
56
|
+
if form.get("budget_usd") not in (None, ""):
|
|
57
|
+
data["budget_usd"] = float(form["budget_usd"])
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def secrets(form: dict) -> dict[str, str]:
|
|
62
|
+
out = {}
|
|
63
|
+
if form.get("server_token"):
|
|
64
|
+
out[TOKEN_VAR] = form["server_token"]
|
|
65
|
+
if form.get("api_key"):
|
|
66
|
+
out[KEY_VAR] = form["api_key"]
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save(config: Path, form: dict) -> dict[str, Any]:
|
|
71
|
+
data = merged(config, form)
|
|
72
|
+
Config.model_validate(data)
|
|
73
|
+
config.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
74
|
+
new = secrets(form)
|
|
75
|
+
if new:
|
|
76
|
+
path = env_path(config)
|
|
77
|
+
lines = [ln for ln in (path.read_text(encoding="utf-8").splitlines() if path.exists() else [])]
|
|
78
|
+
lines = [ln for ln in lines if ln.split("=", 1)[0].strip() not in new]
|
|
79
|
+
path.write_text("\n".join([*lines, *(f"{k}={v}" for k, v in new.items())]) + "\n", encoding="utf-8")
|
|
80
|
+
os.environ.update(new)
|
|
81
|
+
return {"saved": str(config), "env": sorted(new)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test(config: Path, form: dict) -> dict[str, Any]:
|
|
85
|
+
"""List the server's tools, which is free, and make one tiny model call to check the key."""
|
|
86
|
+
os.environ.update(secrets(form))
|
|
87
|
+
out: dict[str, Any] = {}
|
|
88
|
+
try:
|
|
89
|
+
cfg = Config.model_validate(merged(config, form))
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return {"error": f"config: {e}"}
|
|
92
|
+
try:
|
|
93
|
+
tools = await Project(cfg, root=config.parent / ".misroute").tools()
|
|
94
|
+
out["tools"] = {"count": len(tools), "names": [t.name for t in tools[:15]]}
|
|
95
|
+
except Exception as e:
|
|
96
|
+
out["tools"] = {"error": f"{type(e).__name__}: {e}"[:600]}
|
|
97
|
+
try:
|
|
98
|
+
turn = await build(cfg.models[0]).chat("Reply with the single word ok.", [])
|
|
99
|
+
out["model"] = {"reply": turn.text[:80], "tokens_in": turn.tokens_in, "tokens_out": turn.tokens_out}
|
|
100
|
+
except Exception as e:
|
|
101
|
+
out["model"] = {"error": f"{type(e).__name__}: {e}"[:600]}
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def current(config: Path) -> dict[str, Any]:
|
|
106
|
+
if not config.exists():
|
|
107
|
+
return {"configured": False}
|
|
108
|
+
data = yaml.safe_load(config.read_text(encoding="utf-8")) or {}
|
|
109
|
+
server, model = data.get("server", {}), (data.get("models") or [{}])[0]
|
|
110
|
+
kind = next((k for k in ("url", "command", "openapi") if server.get(k)), "url")
|
|
111
|
+
value = " ".join(server["command"]) if kind == "command" else server.get(kind, "")
|
|
112
|
+
return {
|
|
113
|
+
"configured": True,
|
|
114
|
+
"server_kind": kind,
|
|
115
|
+
"server_value": value,
|
|
116
|
+
"include_tools": ", ".join(server.get("include_tools", [])),
|
|
117
|
+
"has_token": bool(os.environ.get(TOKEN_VAR)),
|
|
118
|
+
"has_key": bool(os.environ.get(model.get("api_key_env") or KEY_VAR)),
|
|
119
|
+
"provider": model.get("provider", "openai-compatible"),
|
|
120
|
+
"name": model.get("name", "main"),
|
|
121
|
+
"model": model.get("model", ""),
|
|
122
|
+
"base_url": model.get("base_url", ""),
|
|
123
|
+
"price_in": model.get("price_in", ""),
|
|
124
|
+
"price_out": model.get("price_out", ""),
|
|
125
|
+
"api_version": (model.get("options") or {}).get("api_version", ""),
|
|
126
|
+
"budget_usd": data.get("budget_usd", 5),
|
|
127
|
+
"modes": data.get("modes", ["json"]),
|
|
128
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|