diff --git a/src/decompnet/app/routes/setup.py b/src/decompnet/app/routes/setup.py index a00ad4c3d..b991932df 100644 --- a/src/decompnet/app/routes/setup.py +++ b/src/decompnet/app/routes/setup.py @@ -7,6 +7,7 @@ import shutil import subprocess +import httpx from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -78,6 +79,29 @@ async def api_agents_detect(request: Request) -> Response: manager.refresh_detected() return JSONResponse({"detecting": True}) + @mcp.custom_route("/api/endpoints/models", methods=["GET"]) + async def api_endpoint_models(request: Request) -> Response: + """List models from one saved endpoint, using its own key and never returning connection details.""" + if (bad := guard(request)): + return bad + entry = settings.endpoint(request.query_params.get("id", "")) + if entry is None: + return fail(ValueError("that API endpoint is no longer saved; choose it again")) + headers = {"Authorization": f"Bearer {entry['api_key']}"} if entry.get("api_key") else {} + try: + response = await call(lambda: httpx.get(entry["base_url"].rstrip("/") + "/models", headers=headers, + timeout=10)) + response.raise_for_status() + payload = response.json() + rows = payload.get("data", []) if isinstance(payload, dict) else [] + if not isinstance(rows, list): + rows = [] + models = [{"id": str(row["id"]), "name": str(row.get("name") or row["id"])} + for row in rows if isinstance(row, dict) and row.get("id")] + return JSONResponse({"models": models}) + except (httpx.HTTPError, httpx.InvalidURL, ValueError, TypeError): + return JSONResponse({"models": [], "unavailable": True}) + @mcp.custom_route("/api/agents/start", methods=["POST"]) async def api_agents_start(request: Request) -> Response: if (bad := guard(request, write=True)): @@ -85,9 +109,29 @@ async def api_agents_start(request: Request) -> Response: data = await body(request) kind, slug = str(data.get("kind", "")), str(data.get("slug", "")) given = data.get("options") or {} - options = {k: v for k, v in given.items() if k in ("model", "per_session", "task")} # task: "readable" polishes + requested_models = given.get("models", []) + if not isinstance(requested_models, list) or any( + not isinstance(model, (str, dict)) or + (not model if isinstance(model, str) else + set(model) != {"endpoint", "model"} or + any(not isinstance(model.get(key), str) or not model[key] for key in ("endpoint", "model"))) + for model in requested_models): + return fail(ValueError("models must be a list of model names")) + seen_models = set() + unique_models = [] + for model in requested_models: + key = (model["endpoint"], model["model"]) if isinstance(model, dict) else model + if key not in seen_models: + seen_models.add(key) + unique_models.append(model) + requested_models = unique_models + if given.get("multiple_models") and not requested_models: + return fail(ValueError("choose at least one model")) + options = {k: v for k, v in given.items() if k in ("model", "per_session", "task", "endpoints")} # task: "readable" polishes options.setdefault("per_session", settings.data.get("per_session", agents.PER_SESSION)) count = max(1, min(int(data.get("count", 1)), agents.MAX_AT_ONCE)) + if requested_models and options.get("model"): + return fail(ValueError("choose specific models or one model, not both")) picked: list[str] = [] try: net.project_row(slug) @@ -95,17 +139,62 @@ async def api_agents_start(request: Request) -> Response: if given.get("effort") in kinds.get(kind).efforts: # an endpoint helper's (the others' rides on the model) options["effort"] = given["effort"] if kinds.get(kind).uses_endpoints: - picked = [str(e) for e in (given.get("endpoints") or ([given["endpoint"]] if given.get("endpoint") else []))] - plan = spread_sessions(settings, manager, picked, count, given) - ids = [sid for entry, n in plan for sid in manager.start(kind, slug, n, options | session_options(entry))] + if requested_models: + if any(not isinstance(model, dict) or not model.get("endpoint") for model in requested_models): + raise ValueError("choose API models from a saved endpoint") + choices = [(str(model["endpoint"]), str(model["model"])) for model in requested_models] + entries = {eid: settings.endpoint(eid) for eid, _ in choices} + if any(entry is None for entry in entries.values()): + raise ValueError("an endpoint you picked isn't saved any more: pick again") + per_endpoint = {eid: sum(1 for chosen, _ in choices if chosen == eid) * count for eid in entries} + for eid, needed in per_endpoint.items(): + entry = entries[eid] + active = sum(1 for sess in manager.sessions.values() if sess.options.get("endpoint") == eid + and sess.state in agents.LIVE and not sess.stop.is_set()) + if entry.get("max_sessions") and active + needed > int(entry["max_sessions"]): + raise ValueError(f"{entry['name']} allows {entry['max_sessions']} sessions at once; reduce the selected models or stop a session") + ids = [] + for eid, model in choices: + entry = entries[eid] + ids.extend(manager.start(kind, slug, count, options | session_options(entry) | {"model": model})) + picked = list(dict.fromkeys(eid for eid, _ in choices)) + else: + picked = [str(e) for e in (given.get("endpoints") or ([given["endpoint"]] if given.get("endpoint") else []))] + plan = spread_sessions(settings, manager, picked, count, given) + ids = [] + for entry, n in plan: + entry_options = options | session_options(entry) + if given.get("model"): + entry_options["model"] = str(given["model"]) + ids.extend(manager.start(kind, slug, n, entry_options)) + elif requested_models: + if any(not isinstance(model, str) for model in requested_models): + raise ValueError("API endpoint/model selections are only valid for API agents") + found = next((row for row in manager.detected() if row["id"] == kind), None) + model_ids = {str(model["id"]) for model in (found or {}).get("models", []) + if isinstance(model, dict) and model.get("id")} + missing = [model for model in requested_models if model not in model_ids] + if missing and model_ids: + raise ValueError("some selected models are no longer available; look again for installed agents and their models") + ids = [] + for model in requested_models: + if isinstance(model, dict): + raise ValueError("API model selections are only valid for API agents") + if options.get("effort"): + model = f"{model.split('#')[0]}#{options['effort']}" + ids.extend(manager.start(kind, slug, count, options | {"model": model})) else: ids = manager.start(kind, slug, count, options) except (NetworkError, ValueError) as e: return fail(e) # remembered, so the next start is one click; models typed in come back as suggestions model = str(options.get("model") or "") - settings.data["launcher"] = {"kind": kind, "model": model, "count": count} | ({"endpoints": picked} if picked else {}) - if model and not kinds.get(kind).uses_endpoints: + settings.data["launcher"] = {"kind": kind, "model": model, "models": requested_models, + "multiple": bool(given.get("multiple_models")), "count": count} | ( + {"endpoints": picked} if picked else {}) + if kinds.get(kind).uses_endpoints and model and picked: + settings.data["launcher"]["api_model"] = {"endpoint": picked[0], "model": model} + if not requested_models and model and not kinds.get(kind).uses_endpoints: recent = settings.data.setdefault("recent_models", {}).setdefault(kind, []) settings.data["recent_models"][kind] = [model] + [m for m in recent if m != model][:7] settings.save() diff --git a/src/decompnet/app/static/app.css b/src/decompnet/app/static/app.css index 8ad435a17..f6829c7cc 100644 --- a/src/decompnet/app/static/app.css +++ b/src/decompnet/app/static/app.css @@ -431,6 +431,7 @@ a.ghost, a.primary { text-decoration: none; } .lz-body { display: grid; gap: 12px; } .lz-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .lz-fields .wide { grid-column: 1 / -1; } +.lz-models { display: grid; gap: 6px; max-height: 190px; overflow: auto; padding: 9px 10px; border: 1px solid var(--line); border-radius: var(--r); } .ep-pick { display: grid; gap: 8px; } .ep-card { display: grid; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--raise); margin: 0 0 12px; } .lz-row { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } diff --git a/src/decompnet/app/static/js/agents.js b/src/decompnet/app/static/js/agents.js index 5b70f6b28..164e50ae7 100644 --- a/src/decompnet/app/static/js/agents.js +++ b/src/decompnet/app/static/js/agents.js @@ -43,6 +43,7 @@ const FEED_KEEP = 400; // lines of each helper's log kept here (the app keeps a const ACTIVITY_ROWS = 300; // lines "What's happening" keeps: older ones scroll away const SAID_SHOWN = 1600, THOUGHT_SHOWN = 1200; // characters of a helper's message and thought its log shows const MAX_PER_SESSION = 20; // functions a helper takes in one session, at most +const MAX_AT_ONCE = 8; const hhmm = (t) => clock(t).slice(0, 5); const sentence = (text) => (text ? text[0].toUpperCase() + text.slice(1) : ""); @@ -128,7 +129,7 @@ $("stopAll").onclick = async () => { }; /* ------------------------------------------------------------------ the launcher: agent, model, how many */ -const LZ = { kind: null, model: "", count: 1, per: null, effort: "", task: "" }; +const LZ = { kind: null, model: "", customModel: false, apiSingleModel: null, apiCustomModel: "", models: [], multiple: false, endpointModels: {}, byKind: {}, count: 1, per: null, effort: "", task: "" }; const EFFORT_NAMES = { minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max" }; const effortField = (a) => (a.takes_effort ? `` : ""); // an agent that takes an effort (opencode 2, Claude Code) gets it with the model's name: provider/model#high. The @@ -143,7 +144,10 @@ function launcherInit() { // can break it) const first = S.st.agents.find((a) => a.recommended) || S.st.agents[0]; LZ.kind = known ? known.id : first ? first.id : null; - LZ.model = known ? (l.model || "") : ""; + LZ.model = known && l.model !== "__all_models" ? (l.model || "") : ""; + LZ.models = known && Array.isArray(l.models) ? l.models.map((m) => typeof m === "string" ? m : ({ endpoint: String(m.endpoint), model: String(m.model) })) : []; + LZ.multiple = !!(known && (l.multiple || l.model === "__all_models" || LZ.models.length)); + LZ.apiSingleModel = known && l.api_model ? { endpoint: String(l.api_model.endpoint), model: String(l.api_model.model) } : null; LZ.count = l.count || 1; const eps = S.st.settings.endpoints || []; LZ.eps = (l.endpoints || []).filter((id) => eps.some((e) => e.id === id)); @@ -152,16 +156,43 @@ function launcherInit() { function modelOptions(a) { const recent = (S.st.settings.recent_models || {})[a.id] || []; const opts = [{ id: "", name: a.default_model ? `Default · ${a.default_model}` : "Default", note: "" }]; - for (const m of a.models || []) opts.push(m); + for (const m of a.models || []) opts.push({ id: m.id, name: m.name, note: m.note || "" }); for (const r of recent) if (!opts.some((o) => o.id === r)) opts.push({ id: r, name: r, note: "used before" }); + opts.push({ id: "__multiple", name: "Multiple models", note: "" }); + opts.push({ id: "__other", name: "Another model…", note: "" }); return opts; } +function selectableModels(a) { + if (a.uses_endpoints) { + const eps = (S.st.settings.endpoints || []).filter((e) => (LZ.eps || []).includes(e.id)); + const listed = eps.flatMap((e) => (LZ.endpointModels[e.id] || []).map((m) => ({ + id: `${e.id}::${m.id}`, endpoint: e.id, model: m.id, name: `${e.name} · ${m.name}`, + }))); + for (const chosen of LZ.models) if (typeof chosen === "object" && eps.some((e) => e.id === chosen.endpoint) && + !listed.some((m) => m.endpoint === chosen.endpoint && m.model === chosen.model)) { + const endpoint = eps.find((e) => e.id === chosen.endpoint); + listed.push({ id: `${chosen.endpoint}::${chosen.model}`, endpoint: chosen.endpoint, model: chosen.model, + name: `${endpoint.name} · ${chosen.model}`, note: "typed model" }); + } + return listed; + } + const listed = [...(a.models || [])]; + if (!listed.length) for (const model of LZ.models) if (typeof model === "string" && !listed.some((m) => m.id === model)) { + listed.push({ id: model, name: model, note: "typed model" }); + } + return listed; +} function modelName(a, model) { if (!model) return ""; const m = (a.models || []).find((x) => x.id === model); return m ? m.name : model; } -function launchLabel(a, model, count) { +function launchLabel(a, model, count, models = []) { + if (models.length) { + const total = models.length * count; + const effort = effortOf(a) ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""; + return `Start ${models.length} ${models.length === 1 ? "model" : "models"}${effort} × ${count} = ${total} ${total === 1 ? "agent" : "agents"}`; + } if (a.uses_endpoints) { const picked = (S.st.settings.endpoints || []).filter((e) => (LZ.eps || []).includes(e.id)); const eff = effortOf(a) ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""; @@ -171,23 +202,28 @@ function launchLabel(a, model, count) { return `Start ${a.name}${model ? ` · ${modelName(a, model)}` : ""}${effortOf(a) && model ? ` · ${EFFORT_NAMES[effortOf(a)] || effortOf(a)}` : ""}${count > 1 ? ` × ${count}` : ""}`; } -async function startAgents(a, model, count, btn, extra = {}) { +async function startAgents(a, model, count, btn, extra = {}, models = []) { if (btn) btn.disabled = true; try { const options = { ...extra }; - if (model && !a.uses_endpoints) options.model = withEffort(a, model); - if (a.uses_endpoints && effortOf(a)) options.effort = effortOf(a); // its model is the endpoint's - else if (effortOf(a) && !model) throw new Error("pick a model to set its effort"); + if (models.length) options.models = models; + if (LZ.multiple) options.multiple_models = true; + else if (model && !a.uses_endpoints) options.model = withEffort(a, model); + if ((a.uses_endpoints || models.length) && effortOf(a)) options.effort = effortOf(a); + else if (effortOf(a) && !model && !models.length) throw new Error("pick a model to set its effort"); if (LZ.per) options.per_session = LZ.per; if (LZ.task) options.task = LZ.task; await api("/api/agents/start", { method: "POST", body: { kind: a.id, slug: S.slug, count, options } }); - S.st.settings.launcher = { kind: a.id, model: model || "", count }; + S.st.settings.launcher = { kind: a.id, model: model || "", models, multiple: LZ.multiple, count, + ...(a.uses_endpoints && LZ.apiSingleModel ? { api_model: LZ.apiSingleModel } : {}), + ...(a.uses_endpoints ? { endpoints: LZ.eps || [] } : {}) }; if (model && !a.uses_endpoints) { const rec = (S.st.settings.recent_models ||= {}); rec[a.id] = [model, ...(rec[a.id] || []).filter((m) => m !== model)].slice(0, 8); } hideLaunch(); - toast(`${esc(a.uses_endpoints ? "Your API model" : a.name)} ${count > 1 ? `× ${count} are` : "is"} starting. ${LZ.task === "readable" ? "Matched functions get real names, types and a comment, one by one" : "Watch the map fill in"}; Agents says what each one does.`, "match"); + const total = LZ.multiple ? models.length * count : count; + toast(`${esc(a.uses_endpoints ? "Your API model" : a.name)}: ${total} ${total === 1 ? "agent is" : "agents are"} starting. ${LZ.task === "readable" ? "Matched functions get real names, types and a comment, one by one" : "Watch the map fill in"}; Agents says what each one does.`, "match"); poll(false); loadNav(); } catch (e) { toast(esc(e.message), "err"); } finally { if (btn) btn.disabled = false; } @@ -219,18 +255,37 @@ function renderLauncher(box, compact = true) { const eps = S.st.settings.endpoints || []; const why = `

The app talks to the model itself: nothing else to install, and no other program's update can break it. Any OpenAI-compatible provider works (OpenAI, DeepSeek, OpenRouter) or a model on this computer; you pay your provider for what it uses.

`; LZ.eps = (LZ.eps || []).filter((id) => eps.some((e) => e.id === id)); - if (!LZ.eps.length && eps.length) LZ.eps = [eps[0].id]; + for (const e of eps) if (LZ.eps.includes(e.id) && !LZ.endpointModels[`loaded:${e.id}`] && !LZ.endpointModels[`loading:${e.id}`]) { + LZ.endpointModels[`loading:${e.id}`] = true; + api(`/api/endpoints/models?id=${encodeURIComponent(e.id)}`).then((result) => { + LZ.endpointModels[e.id] = result.models || []; + LZ.endpointModels[`loaded:${e.id}`] = true; + if (box.isConnected) renderLauncher(box, compact); + }).catch(() => { LZ.endpointModels[e.id] = []; LZ.endpointModels[`loaded:${e.id}`] = true; if (box.isConnected) renderLauncher(box, compact); }); + } body = eps.length && !LZ.adding ? `
${eps.map((e) => ``).join("")}

${eps.length > 1 ? "Tick several to spread the sessions across them. " : ""}Manage endpoints ·

` : `
${endpointFields({ provider: "deepseek" }, compact)}
${eps.length ? `

` : ""}`; - body = why + body + (eps.length && !LZ.adding ? effortField(a) : ""); + const apiModels = selectableModels(a); + const singleOptions = [{ id: "", name: `Default · ${eps.find((e) => LZ.eps.includes(e.id))?.model || "endpoint model"}` }, + ...apiModels.map((m) => ({ id: m.id, name: m.name })), { id: "__multiple", name: "Multiple models" }, { id: "__other", name: "Another model…" }]; + const selectedApi = LZ.apiSingleModel ? `${LZ.apiSingleModel.endpoint}::${LZ.apiSingleModel.model}` : ""; + const singleCustom = LZ.apiSingleModel && !apiModels.some((m) => m.id === selectedApi); + body = why + body + (eps.length && !LZ.adding ? ` + ${LZ.multiple ? `
Choose models
${apiModels.map((m) => ``).join("")}
${apiModels.length ? `Each selected model gets ${LZ.count} session${LZ.count === 1 ? "" : "s"}.` : `Model listing unavailable; type a model name below and add it for the selected endpoints.`}
` : ""} + ${!LZ.multiple && (singleCustom || LZ.apiCustomModel) ? `` : ""} + ${LZ.multiple ? `
` : ""}${effortField(a)}` : ""); } else { - const opts = modelOptions(a); + const opts = modelOptions(a), available = selectableModels(a); const known = opts.some((o) => o.id === LZ.model); - body = ` - + const custom = LZ.customModel || (!known && !!LZ.model); + LZ.models = LZ.models.filter((id) => available.some((m) => m.id === id)); + body = ` + ${LZ.multiple ? `
Choose models
${available.map((m) => ``).join("")}
+ Each selected model gets ${LZ.count} session${LZ.count === 1 ? "" : "s"}. + ${!(a.models || []).length ? `
` : ""}
` : ""} + ${effortField(a)}`; } const ready = a.installed; @@ -245,24 +300,106 @@ function renderLauncher(box, compact = true) {
How many${LZ.count}
- +

${a.uses_endpoints ? "A built-in agent loop drives the model through the decomp tools." : esc(a.signin)} Each agent takes a different function; a fresh session every few functions keeps its context small.

` : ""} `; wireCopies(box); const rerender = () => renderLauncher(box, compact); box.querySelectorAll("[data-task]").forEach((b) => { b.onclick = () => { LZ.task = b.dataset.task; rerender(); }; }); - box.querySelectorAll(".lz-kind").forEach((b) => { b.onclick = () => { LZ.kind = b.dataset.k; LZ.model = (S.st.settings.launcher || {}).kind === LZ.kind ? S.st.settings.launcher.model || "" : ""; rerender(); }; }); + box.querySelectorAll(".lz-kind").forEach((b) => { b.onclick = () => { + LZ.byKind[LZ.kind] = { model: LZ.model, customModel: LZ.customModel, apiSingleModel: LZ.apiSingleModel, + apiCustomModel: LZ.apiCustomModel, models: LZ.models, multiple: LZ.multiple, eps: LZ.eps }; + LZ.kind = b.dataset.k; + const cached = LZ.byKind[LZ.kind]; + if (cached) { + Object.assign(LZ, cached); + rerender(); + return; + } + const saved = S.st.settings.launcher || {}; + LZ.model = saved.kind === LZ.kind && saved.model !== "__all_models" ? saved.model || "" : ""; + LZ.customModel = false; + LZ.apiSingleModel = saved.kind === LZ.kind && saved.api_model ? { ...saved.api_model } : null; + LZ.models = saved.kind === LZ.kind && Array.isArray(saved.models) ? saved.models.map((m) => typeof m === "string" ? m : ({ endpoint: String(m.endpoint), model: String(m.model) })) : []; + LZ.multiple = saved.kind === LZ.kind && !!(saved.multiple || LZ.models.length); + rerender(); + }; }); const sel = box.querySelector('select[data-f="model"]'), custom = box.querySelector('[data-f="custom"]'); - const label = () => { const btn = box.querySelector('[data-act="start"]'); if (btn) btn.textContent = launchLabel(a, LZ.model, LZ.count); }; - if (sel) sel.onchange = () => { if (sel.value === "__other") { custom.hidden = false; custom.focus(); LZ.model = custom.value.trim(); } else { custom.hidden = true; LZ.model = sel.value; } label(); }; - if (custom) custom.oninput = () => { LZ.model = custom.value.trim(); label(); }; + const apiSel = box.querySelector('[data-f="api-model"]'); + const label = () => { + const btn = box.querySelector('[data-act="start"]'); + if (btn) { + btn.textContent = launchLabel(a, LZ.model, LZ.count, LZ.models); + btn.disabled = LZ.multiple && !LZ.models.length; + } + const limit = box.querySelector("[data-model-limit]"); + if (limit) limit.textContent = `Each selected model gets ${LZ.count} session${LZ.count === 1 ? "" : "s"}.`; + box.querySelectorAll("[data-model-choice]").forEach((c) => { c.disabled = !!LZ.model; }); + }; + label(); + if (sel) sel.onchange = () => { + LZ.multiple = sel.value === "__multiple"; + if (!LZ.multiple) LZ.models = []; + if (sel.value === "__other") { LZ.customModel = true; LZ.model = ""; } + else { LZ.customModel = false; LZ.model = LZ.multiple ? "" : sel.value; } + rerender(); + label(); + }; + if (custom) custom.oninput = () => { LZ.customModel = true; LZ.model = custom.value.trim(); label(); }; + if (apiSel) apiSel.onchange = () => { + LZ.multiple = apiSel.value === "__multiple"; + LZ.model = ""; + LZ.models = []; + LZ.apiCustomModel = ""; + if (apiSel.value === "__other") LZ.apiSingleModel = { endpoint: (LZ.eps || [])[0] || "", model: "" }; + else if (apiSel.value) { + const item = selectableModels(a).find((m) => m.id === apiSel.value); + LZ.apiSingleModel = item ? { endpoint: item.endpoint, model: item.model } : null; + } else LZ.apiSingleModel = null; + rerender(); + }; + const apiCustom = box.querySelector('[data-f="api-custom"]'); + if (apiCustom && !LZ.multiple) apiCustom.oninput = () => { + LZ.apiCustomModel = apiCustom.value.trim(); + if (LZ.apiSingleModel) LZ.apiSingleModel.model = LZ.apiCustomModel; + }; + box.querySelectorAll("[data-model-choice]").forEach((c) => { + c.onchange = () => { + LZ.models = [...box.querySelectorAll("[data-model-choice]:checked")].map((x) => { + const item = selectableModels(a).find((m) => m.id === x.dataset.modelChoice); + return a.uses_endpoints ? { endpoint: item.endpoint, model: item.model } : x.dataset.modelChoice; + }); + label(); + }; + }); const eff = box.querySelector('[data-f="effort"]'); if (eff) eff.onchange = () => { LZ.effort = eff.value; label(); }; - box.querySelectorAll("[data-step]").forEach((b) => { b.onclick = () => { LZ.count = Math.max(1, Math.min(8, LZ.count + Number(b.dataset.step))); box.querySelector(".stepper output").textContent = LZ.count; label(); }; }); + box.querySelectorAll("[data-step]").forEach((b) => { b.onclick = () => { LZ.count = Math.max(1, Math.min(MAX_AT_ONCE, LZ.count + Number(b.dataset.step))); box.querySelector(".stepper output").textContent = LZ.count; label(); }; }); const per = box.querySelector('[data-f="per"]'); if (per) per.onchange = () => { LZ.per = Math.max(1, Math.min(MAX_PER_SESSION, Number(per.value) || S.st.settings.per_session)); }; wireEndpointFields(box); - box.querySelectorAll("[data-ep]").forEach((c) => { c.onchange = () => { LZ.eps = [...box.querySelectorAll("[data-ep]:checked")].map((x) => x.dataset.ep); label(); }; }); + box.querySelectorAll("[data-ep]").forEach((c) => { c.onchange = () => { + LZ.eps = [...box.querySelectorAll("[data-ep]:checked")].map((x) => x.dataset.ep); + LZ.models = LZ.models.filter((m) => LZ.eps.includes(m.endpoint)); + if (LZ.apiSingleModel && !LZ.eps.includes(LZ.apiSingleModel.endpoint)) LZ.apiSingleModel = null; + rerender(); + }; }); + const addModel = box.querySelector('[data-act="add-api-model"]'); + if (addModel) addModel.onclick = () => { + const name = box.querySelector('[data-f="api-custom"]').value.trim(); + if (!name) return toast("Type an API model name first.", "err"); + for (const endpoint of LZ.eps || []) if (!LZ.models.some((m) => m.endpoint === endpoint && m.model === name)) LZ.models.push({ endpoint, model: name }); + const input = box.querySelector('[data-f="api-custom"]'); + if (input) input.value = ""; + rerender(); + }; + const addCliModel = box.querySelector('[data-act="add-cli-model"]'); + if (addCliModel) addCliModel.onclick = () => { + const input = box.querySelector('[data-f="manual-model"]'), model = input.value.trim(); + if (!model) return toast("Type a model name first.", "err"); + if (!LZ.models.includes(model)) LZ.models.push(model); + rerender(); + }; const addEp = box.querySelector('[data-act="add-ep"]'), pickEp = box.querySelector('[data-act="pick-ep"]'); if (addEp) addEp.onclick = () => { LZ.adding = true; rerender(); }; if (pickEp) pickEp.onclick = () => { LZ.adding = false; rerender(); }; @@ -296,9 +433,14 @@ function renderLauncher(box, compact = true) { LZ.adding = false; } if (!(LZ.eps || []).length) return toast("Tick an endpoint to start on.", "err"); + if (LZ.multiple) return startAgents(a, "", LZ.count, go, {}, LZ.models); + if (LZ.apiSingleModel) { + if (!LZ.apiSingleModel.model.trim()) return toast("Choose or type an API model name.", "err"); + return startAgents(a, "", LZ.count, go, { endpoints: [LZ.apiSingleModel.endpoint], model: LZ.apiSingleModel.model }); + } return startAgents(a, "", LZ.count, go, { endpoints: LZ.eps }); } - startAgents(a, LZ.model, LZ.count, go); + startAgents(a, LZ.model, LZ.count, go, {}, LZ.models); }; } @@ -587,4 +729,3 @@ function addThoughts(sessions, first) { } if (first) renderLog(); } - diff --git a/tests/gui_launcher.js b/tests/gui_launcher.js new file mode 100644 index 000000000..e5450503b --- /dev/null +++ b/tests/gui_launcher.js @@ -0,0 +1,211 @@ +// Exercise the launcher's model controls and events without a browser or an installed agent. +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +class Element { + constructor(tag = "div", attrs = {}) { + this.tag = tag; + this.dataset = {}; + this.attributes = attrs; + for (const [key, value] of Object.entries(attrs)) { + if (key.startsWith("data-")) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value; + else this[key] = value; + } + this.checked = Object.hasOwn(attrs, "checked"); + this.hidden = Object.hasOwn(attrs, "hidden"); + this.disabled = Object.hasOwn(attrs, "disabled"); + this.value = attrs.value || ""; + this.textContent = ""; + this.isConnected = true; + } + focus() {} + setAttribute(key, value) { this.attributes[key] = value; } + querySelectorAll(selector) { return []; } + querySelector() { return null; } +} + +class Box extends Element { + set innerHTML(html) { + this.html = html; + this.elements = []; + const tagPattern = /<(button|input|select|option|output|a|span)\b([^>]*)>/g; + for (const match of html.matchAll(tagPattern)) { + const attrs = {}; + for (const item of match[2].matchAll(/([\w-]+)(?:="([^"]*)")?/g)) attrs[item[1]] = item[2] ?? ""; + const element = new Element(match[1], attrs); + if (match[1] === "span") element.textContent = html.slice(match.index + match[0].length, html.indexOf("", match.index)); + this.elements.push(element); + } + for (const select of this.elements.filter((element) => element.tag === "select")) { + const start = html.indexOf(`", start)); + select.options = [...tail.matchAll(/]*value="([^"]*)"[^>]*>/g)].map(([, value]) => value); + const chosen = tail.match(/]*selected)[^>]*value="([^"]*)"/); + select.value = chosen ? chosen[1] : (select.options[0] || ""); + } + } + get innerHTML() { return this.html || ""; } + querySelectorAll(selector) { + const checked = selector.endsWith(":checked"); + const base = checked ? selector.slice(0, -8) : selector; + return this.elements.filter((element) => { + let found = false; + const attr = base.match(/^(\w+)?\[data-([\w-]+)(?:="([^"]*)")?\]$/); + if (attr) { + const key = attr[2].replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + found = (!attr[1] || element.tag === attr[1]) && Object.hasOwn(element.dataset, key) && + (attr[3] === undefined || element.dataset[key] === attr[3]); + } else if (base.startsWith(".")) found = (element.attributes.class || "").split(" ").includes(base.slice(1)); + else if (base.includes(",")) found = base.split(",").map((part) => part.trim()).includes(element.tag); + return found && (!checked || element.checked); + }); + } + querySelector(selector) { + if (selector === ".stepper output") return this.elements.find((e) => e.tag === "output") || null; + if (selector === "button, input, select") return this.elements.find((e) => ["button", "input", "select"].includes(e.tag)) || null; + return this.querySelectorAll(selector)[0] || null; + } +} + +const nodes = new Map(); +const get = (id) => { + if (!nodes.has(id)) nodes.set(id, new Element()); + return nodes.get(id); +}; +const box = new Box(); +nodes.set("launchPopBody", box); +const calls = []; +const endpoint = { id: "e1", name: "Local", model: "base", base_url: "http://127.0.0.1:8080/v1", max_sessions: 4 }; +const ctx = { + console, Promise, Map, Set, JSON, Math, Date, Object, Array, String, Number, Symbol, Error, RegExp, encodeURIComponent, + document: { addEventListener() {}, getElementById: get }, + $: get, S: { slug: "game", settings: {}, st: { settings: { endpoints: [endpoint], per_session: 4 }, agents: [] } }, + AUTO: "autopilot", esc: (value) => String(value ?? ""), setHtml: (el, html) => { el.innerHTML = html; }, + wireCopies() {}, wireEndpointFields() {}, endpointFields: () => "", hostOf: () => "127.0.0.1", + polishOffered: () => false, cFunctions: () => 0, cmdBox: () => "", effortOf: () => "", EFFORT_NAMES: {}, + MAX_PER_SESSION: 20, MAX_AT_ONCE: 8, poll() {}, loadNav() {}, hideLaunch() {}, toast() {}, + beginWork() { return () => {}; }, showJobError() {}, followJob: async () => {}, + api: async (url, options = {}) => { + if (url.startsWith("/api/endpoints/models")) return { models: [{ id: "m-one", name: "Model one" }, { id: "m-two", name: "Model two" }] }; + calls.push({ url, body: options.body }); + return { started: ["helper"] }; + }, + TASKS: [["", "Decompile", "decompile"]], location: { hash: "" }, +}; +ctx.window = ctx; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, "..", "src", "decompnet", "app", "static", "js", "agents.js"), "utf8"), ctx); + +const apiAgent = { id: "api", name: "API", uses_endpoints: true, installed: true, recommended: true, takes_effort: false }; +const cliAgent = { id: "claude", name: "Claude Code", installed: true, recommended: false, takes_effort: false, + models: [{ id: "opus", name: "Opus" }, { id: "sonnet", name: "Sonnet" }, { id: "haiku", name: "Haiku" }] }; +const manualAgent = { id: "manual", name: "Manual provider", installed: true, recommended: false, takes_effort: false, models: [] }; +ctx.S.st.agents = [apiAgent, cliAgent, manualAgent]; +ctx.renderLauncher(box); + +async function settle() { for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); } +function requireOk(test, message) { if (!test) throw new Error(message); } + +(async () => { + await settle(); + requireOk(box.html.includes("Multiple models") && box.html.includes("Default · base"), "API provider must show a model mode dropdown"); + const mode = box.querySelector('[data-f="api-model"]'); + requireOk(mode.options.includes("__multiple"), "API dropdown must expose multiple models"); + mode.value = "__multiple"; + mode.onchange(); + const choices = box.querySelectorAll("[data-model-choice]"); + requireOk(choices.length === 2, "discovered API models should be selectable"); + choices[0].checked = true; + choices[0].onchange(); + const apiBefore = box.html; + const cliTile = box.querySelectorAll(".lz-kind").find((tile) => tile.dataset.k === "claude"); + cliTile.onclick(); + requireOk(box.html.includes("Opus") && box.html.includes("Multiple models"), "CLI provider must show known models and the shared multiple mode"); + const cliSelect = box.querySelector('[data-f="model"]'); + requireOk(cliSelect.options.includes("opus") && cliSelect.options.includes("sonnet"), "single dropdown must contain known CLI models"); + cliSelect.value = "__multiple"; + cliSelect.onchange(); + const cliChoices = box.querySelectorAll("[data-model-choice]"); + cliChoices[0].checked = true; + cliChoices[0].onchange(); + const apiTile = box.querySelectorAll(".lz-kind").find((tile) => tile.dataset.k === "api"); + apiTile.onclick(); + requireOk(box.html === apiBefore || box.html.includes("checked"), "switching providers should retain API model selection"); + const apiStart = box.querySelector('[data-act="start"]'); + await apiStart.onclick(); + const apiCall = calls.find((call) => call.url === "/api/agents/start" && call.body.kind === "api"); + requireOk(apiCall && apiCall.body.options.multiple_models && apiCall.body.options.models[0].endpoint === "e1" && + apiCall.body.options.models[0].model === "m-one", "API launch must send only selected endpoint/model pairs"); + + const apiModeAgain = box.querySelector('[data-f="api-model"]'); + apiModeAgain.value = "__other"; + apiModeAgain.onchange(); + const apiCustomSelect = box.querySelector('[data-f="api-model"]'); + requireOk(apiCustomSelect.value === "__other", "API custom mode must remain visibly selected"); + const apiCustom = box.querySelector('[data-f="api-custom"]'); + apiCustom.value = "manual-api-model"; + apiCustom.oninput(); + await box.querySelector('[data-act="start"]').onclick(); + const apiSingleCall = calls.filter((call) => call.url === "/api/agents/start" && call.body.kind === "api").at(-1); + requireOk(apiSingleCall.body.options.model === "manual-api-model" && apiSingleCall.body.options.endpoints[0] === "e1", + "API single-model fallback must send the typed model on its saved endpoint"); + + box.querySelectorAll("[data-ep]")[0].checked = false; + box.querySelectorAll("[data-ep]")[0].onchange(); + await box.querySelector('[data-act="start"]').onclick(); + requireOk(calls.filter((call) => call.url === "/api/agents/start" && call.body.kind === "api").length === 2, + "deselecting the selected API endpoint must prevent starting against it"); + + const manualTile = box.querySelectorAll(".lz-kind").find((tile) => tile.dataset.k === "manual"); + manualTile.onclick(); + requireOk(box.querySelector('[data-f="model"]') && box.html.includes("Another model"), "provider without a model list keeps single-model fallback"); + const manualMode = box.querySelector('[data-f="model"]'); + manualMode.value = "__other"; + manualMode.onchange(); + requireOk(box.querySelector('[data-f="model"]').value === "__other", "CLI custom mode must remain visibly selected"); + const cliCustom = box.querySelector('[data-f="custom"]'); + cliCustom.value = "manual-cli-model"; + cliCustom.oninput(); + await box.querySelector('[data-act="start"]').onclick(); + const cliSingleCall = calls.find((call) => call.url === "/api/agents/start" && call.body.kind === "manual"); + requireOk(cliSingleCall.body.options.model === "manual-cli-model", "CLI single-model fallback must start the typed name"); + + const manualTileAgain = box.querySelectorAll(".lz-kind").find((tile) => tile.dataset.k === "manual"); + manualTileAgain.onclick(); + const manualMultiSelect = box.querySelector('[data-f="model"]'); + manualMultiSelect.value = "__multiple"; + manualMultiSelect.onchange(); + requireOk(box.querySelector('[data-act="start"]').disabled, "empty Multiple models selection must disable start"); + for (const name of ["first-custom", "second-custom"]) { + const input = box.querySelector('[data-f="manual-model"]'); + input.value = name; + box.querySelector('[data-act="add-cli-model"]').onclick(); + } + requireOk(box.querySelectorAll("[data-model-choice]:checked").length === 2, "manual model names must remain visible and removable"); + await box.querySelector('[data-act="start"]').onclick(); + const manualCall = calls.filter((call) => call.url === "/api/agents/start" && call.body.kind === "manual").at(-1); + requireOk(JSON.stringify(manualCall.body.options.models) === JSON.stringify(["first-custom", "second-custom"]), + `no-list Multiple mode must send only typed model names: ${JSON.stringify(manualCall.body.options.models)}`); + + const selections = { + api: [{ endpoint: "e1", model: "m-one" }, { endpoint: "e1", model: "m-two" }, { endpoint: "e1", model: "m-three" }], + claude: ["opus", "sonnet", "haiku"], + manual: ["first-custom", "second-custom", "third-custom"], + }; + for (const kind of Object.keys(selections)) for (const count of [1, 2, 4]) { + vm.runInContext(`LZ.kind=${JSON.stringify(kind)}; LZ.eps=["e1"]; LZ.count=${count}; LZ.model=""; LZ.multiple=true; LZ.models=${JSON.stringify(selections[kind])};`, ctx); + ctx.renderLauncher(box); + requireOk(!box.querySelector('[data-act="start"]').disabled, + `${kind} should allow ${selections[kind].length} selected models with ${count} sessions each`); + requireOk(box.html.includes(`Each selected model gets ${count} session`), "multiple mode should explain per-model session count"); + requireOk(!box.html.includes("maximum 8 helpers total"), "multiple mode must not imply an aggregate helper limit"); + } + vm.runInContext(`LZ.kind="api"; LZ.eps=["e1"]; LZ.count=1; LZ.multiple=true; LZ.models=${JSON.stringify(selections.api)};`, ctx); + ctx.renderLauncher(box); + box.querySelectorAll("[data-step]").find((button) => button.dataset.step === "1").onclick(); + requireOk(box.querySelector("[data-model-limit]").textContent.includes("2 sessions"), + "API per-model count guidance must update with the How many control"); + console.log("launcher model selections passed"); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/test_app_server.py b/tests/test_app_server.py index c3374d379..4758cfd8e 100644 --- a/tests/test_app_server.py +++ b/tests/test_app_server.py @@ -220,6 +220,154 @@ def test_helpers_started_to_polish_matched_code_get_that_task(tmp_path, monkeypa assert started == [{"model": "opus", "task": "readable", "per_session": agents.PER_SESSION}] +def test_selected_models_start_only_the_chosen_models_within_the_helper_limit(tmp_path, monkeypatch): + """Starting multiple models uses only the explicit selection, with `count` sessions for each model.""" + from .test_loading import tiny_game + + (tmp_path / "settings.json").write_text(json.dumps({ + "launcher": {"kind": "claude", "model": "opus", "count": 1}, + })) + net = RomNetwork(tmp_path / "decomp.db", tmp_path) + tiny_game(net) + mcp, manager, security, _ = build(net, tmp_path, PORT, SECRET) + monkeypatch.setattr(manager, "detected", lambda: [{"id": "claude", "models": [ + {"id": "sonnet"}, {"id": "opus"}, {"id": "haiku"}, + ]}]) + started = [] + + def start(kind, slug, count=1, options=None): + started.append((kind, slug, count, options)) + return [f"{options['model']}-{i}" for i in range(count)] + + monkeypatch.setattr(manager, "start", start) + client = TestClient(mcp.streamable_http_app(transport_security=security, host="127.0.0.1"), + base_url=f"http://127.0.0.1:{PORT}", headers=WINDOW) + many_models = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json={ + "kind": "claude", "slug": "game", "count": 4, + "options": {"models": ["sonnet", "opus", "haiku"], "effort": "high"}, + }) + assert many_models.status_code == 200, many_models.text + assert many_models.json()["started"] == [f"{model}#high-{i}" for model in ("sonnet", "opus", "haiku") for i in range(4)] + assert started == [ + ("claude", "game", 4, {"model": f"{model}#high", "effort": "high", "per_session": agents.PER_SESSION}) + for model in ("sonnet", "opus", "haiku") + ] + started.clear() + + unavailable = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json={ + "kind": "claude", "slug": "game", "options": {"models": ["retired-model"]}, + }) + assert unavailable.status_code == 400 + assert started == [] + + response = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json={ + "kind": "claude", "slug": "game", "count": 2, + "options": {"models": ["sonnet", "haiku"], "effort": "high"}, + }) + + assert response.status_code == 200, response.text + assert response.json()["started"] == [f"{model}#high-{i}" for model in ("sonnet", "haiku") for i in range(2)] + assert started == [ + ("claude", "game", 2, {"model": f"{model}#high", "effort": "high", "per_session": agents.PER_SESSION}) + for model in ("sonnet", "haiku") + ] + saved = json.loads((tmp_path / "settings.json").read_text())["launcher"] + assert saved["model"] == "" and saved["models"] == ["sonnet", "haiku"] + + +def test_api_models_are_discovered_and_started_only_on_their_saved_endpoint(tmp_path, monkeypatch): + """Each API model uses the saved endpoint that listed it, and its key never reaches another endpoint.""" + from decompnet.app import support + from decompnet.app.routes import setup + + from .test_loading import tiny_game + + settings = support.Settings(tmp_path / "settings.json") + first = settings.save_endpoint({"provider": "custom", "name": "first", "base_url": "http://one.test/v1", + "model": "fallback-one", "api_key": "secret-one", "max_sessions": 2}) + second = settings.save_endpoint({"provider": "custom", "name": "second", "base_url": "http://two.test/v1", + "model": "fallback-two", "api_key": "secret-two", "max_sessions": 1}) + settings.save() + net = RomNetwork(tmp_path / "decomp.db", tmp_path) + tiny_game(net) + mcp, manager, security, _ = build(net, tmp_path, PORT, SECRET) + requests = [] + + class Response: + def raise_for_status(self): + pass + + def json(self): + return {"data": [{"id": "model-a", "name": "Model A"}, {"id": "model-b"}]} + + def get(url, **kwargs): + requests.append((url, kwargs["headers"])) + return Response() + + monkeypatch.setattr(setup.httpx, "get", get) + client = TestClient(mcp.streamable_http_app(transport_security=security, host="127.0.0.1"), + base_url=f"http://127.0.0.1:{PORT}", headers=WINDOW) + listed = client.get(f"/api/endpoints/models?id={first['id']}") + assert listed.json() == {"models": [{"id": "model-a", "name": "Model A"}, {"id": "model-b", "name": "model-b"}]} + assert requests == [("http://one.test/v1/models", {"Authorization": "Bearer secret-one"})] + assert "secret-one" not in listed.text + + started = [] + + def start(kind, slug, count=1, options=None): + started.append((count, options)) + return [f"{options['endpoint']}:{options['model']}:{i}" for i in range(count)] + + monkeypatch.setattr(manager, "start", start) + common = {"kind": "api", "slug": "game", "options": {"multiple_models": True, "models": [ + {"endpoint": first["id"], "model": "model-a"}, {"endpoint": first["id"], "model": "model-b"}, + ]}} + malformed = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json={ + "kind": "api", "slug": "game", "options": {"models": [{"endpoint": first["id"], "model": "x", "extra": []}]}, + }) + assert malformed.status_code == 400 and started == [] + client.post("/api/settings", headers={"X-Decomp": "1"}, json={ + "endpoint": {"id": first["id"], "max_sessions": 4}, + }) + over_endpoint_limit = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json=common | {"count": 3}) + assert over_endpoint_limit.status_code == 400 and started == [] + assert "allows 4 sessions" in over_endpoint_limit.json()["error"] + + client.post("/api/settings", headers={"X-Decomp": "1"}, json={ + "endpoint": {"id": first["id"], "max_sessions": 10}, + }) + response = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json=common | {"count": 5}) + assert response.status_code == 200, response.text + assert response.json()["started"] == [f"{first['id']}:model-a:{i}" for i in range(5)] + [ + f"{first['id']}:model-b:{i}" for i in range(5)] + assert [(n, options["endpoint"], options["model"], options["api_key"]) for n, options in started] == [ + (5, first["id"], "model-a", "secret-one"), (5, first["id"], "model-b", "secret-one")] + started.clear() + common["options"]["models"] = [{"endpoint": first["id"], "model": "model-a"}, + {"endpoint": second["id"], "model": "model-b"}] + response = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json=common | {"count": 1}) + assert response.status_code == 200, response.text + assert [(n, options["endpoint"], options["model"], options["api_key"]) for n, options in started] == [ + (1, first["id"], "model-a", "secret-one"), (1, second["id"], "model-b", "secret-two")] + saved = json.loads((tmp_path / "settings.json").read_text())["launcher"] + assert saved["multiple"] and saved["models"] == common["options"]["models"] + assert "secret-one" not in json.dumps(saved) and "secret-two" not in json.dumps(saved) + + +def test_multiple_model_mode_requires_a_selection(tmp_path): + from .test_loading import tiny_game + + net = RomNetwork(tmp_path / "decomp.db", tmp_path) + tiny_game(net) + mcp, _, security, _ = build(net, tmp_path, PORT, SECRET) + client = TestClient(mcp.streamable_http_app(transport_security=security, host="127.0.0.1"), + base_url=f"http://127.0.0.1:{PORT}", headers=WINDOW) + response = client.post("/api/agents/start", headers={"X-Decomp": "1"}, json={ + "kind": "claude", "slug": "game", "options": {"multiple_models": True}, + }) + assert response.status_code == 400 and response.json()["error"] == "choose at least one model" + + def test_settings_never_echo_the_api_key(app): client, _, _ = app out = client.post("/api/settings", json={"api": {"provider": "deepseek", "api_key": "sk-abcdef123456"}}, diff --git a/tests/test_gui.py b/tests/test_gui.py index 7dcb73bdf..5e3a3303c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -25,6 +25,13 @@ def test_saved_project_dialog_preserves_state_and_cancels_pending_requests(): capture_output=True, text=True, timeout=GUI_TIMEOUT_SECONDS) +def test_launcher_keeps_single_and_multiple_model_modes_for_every_provider(): + if NODE is None: + pytest.skip("needs node to run the GUI's scripts") + subprocess.run([NODE, str(Path(__file__).with_name("gui_launcher.js"))], check=True, + capture_output=True, text=True, timeout=GUI_TIMEOUT_SECONDS) + + @pytest.fixture(scope="module") def gui(): if NODE is None: