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.
Files changed (64) hide show
  1. {misroute-0.2.2 → misroute-0.2.3}/PKG-INFO +1 -1
  2. {misroute-0.2.2 → misroute-0.2.3}/pyproject.toml +1 -1
  3. {misroute-0.2.2 → misroute-0.2.3}/pyproject.toml.orig +1 -1
  4. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/config.py +17 -0
  5. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/openai_compat.py +8 -1
  6. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/app.py +31 -1
  7. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/index.html +118 -1
  8. misroute-0.2.3/src/misroute/ui/setup.py +128 -0
  9. {misroute-0.2.2 → misroute-0.2.3}/LICENSE +0 -0
  10. {misroute-0.2.2 → misroute-0.2.3}/README.md +0 -0
  11. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/__init__.py +0 -0
  12. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/agent_server.py +0 -0
  13. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/check.py +0 -0
  14. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/cli.py +0 -0
  15. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/__init__.py +0 -0
  16. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/codemode.py +0 -0
  17. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/confusion.py +0 -0
  18. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/hedge.py +0 -0
  19. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/nearmiss.py +0 -0
  20. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/rank.py +0 -0
  21. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/detectors/removal.py +0 -0
  22. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/__init__.py +0 -0
  23. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/clause.py +0 -0
  24. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/fix/stats.py +0 -0
  25. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/__init__.py +0 -0
  26. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/proxy.py +0 -0
  27. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/rules.py +0 -0
  28. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/guard/wrapper.py +0 -0
  29. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/lint/__init__.py +0 -0
  30. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/lint/descriptions.py +0 -0
  31. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/__init__.py +0 -0
  32. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/anthropic.py +0 -0
  33. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/base.py +0 -0
  34. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/models/mock.py +0 -0
  35. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/__init__.py +0 -0
  36. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/code_mode.py +0 -0
  37. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/json_mode.py +0 -0
  38. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/modes/stubs.py +0 -0
  39. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/project.py +0 -0
  40. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/py.typed +0 -0
  41. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/__init__.py +0 -0
  42. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/html.py +0 -0
  43. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/report/templates/report.html.j2 +0 -0
  44. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/__init__.py +0 -0
  45. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/gate.py +0 -0
  46. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/generate.py +0 -0
  47. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/requests/validate.py +0 -0
  48. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/__init__.py +0 -0
  49. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/fake_tools.py +0 -0
  50. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/sandbox/monty_runner.py +0 -0
  51. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/__init__.py +0 -0
  52. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/catalog.py +0 -0
  53. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/connect.py +0 -0
  54. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/server/openapi.py +0 -0
  55. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/__init__.py +0 -0
  56. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/build.py +0 -0
  57. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/site/templates/index.html.j2 +0 -0
  58. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/__init__.py +0 -0
  59. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/cache.py +0 -0
  60. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/store/runs.py +0 -0
  61. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/testing/__init__.py +0 -0
  62. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/testing/fake_server.py +0 -0
  63. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/trial.py +0 -0
  64. {misroute-0.2.2 → misroute-0.2.3}/src/misroute/ui/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: misroute
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Find the tools your agent confuses and test whether a fix helps, against a neutral edit
5
5
  Keywords: mcp,agents,llm,tool-use,evaluation
6
6
  Author: philippyt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "misroute"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Find the tools your agent confuses and test whether a fix helps, against a neutral edit"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "misroute"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Find the tools your agent confuses and test whether a fix helps, against a neutral edit"
5
5
  authors = [
6
6
  { name = "philippyt", email = "philipaha@proton.me" }
@@ -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
- resp = await self.client.chat.completions.create(**kw)
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
- cfg = runner.project().cfg
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 : `${r.calls} model calls, est $${r.cost.toFixed(2)}`;
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