Compare commits

..
Author SHA1 Message Date
Typosaurus 0650c697eb ticket #76 attempt 1 2026-07-19 18:50:44 +00:00
Typosaurus 3d7b655a45 ticket #76 attempt 1 2026-07-19 18:23:56 +00:00
13 changed files with 63 additions and 77 deletions
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -37,9 +37,10 @@ from devplacepy.cli.backups import (
) )
from devplacepy.cli.containers import ( from devplacepy.cli.containers import (
cmd_containers_list, cmd_containers_list,
cmd_containers_reconcile,
cmd_containers_prune, cmd_containers_prune,
cmd_containers_prune_builds, cmd_containers_prune_builds,
cmd_containers_prune_stopped,
cmd_containers_reconcile,
cmd_containers_gc_workspaces, cmd_containers_gc_workspaces,
) )
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
@@ -83,6 +84,7 @@ __all__ = [
"cmd_containers_reconcile", "cmd_containers_reconcile",
"cmd_containers_prune", "cmd_containers_prune",
"cmd_containers_prune_builds", "cmd_containers_prune_builds",
"cmd_containers_prune_stopped",
"cmd_containers_gc_workspaces", "cmd_containers_gc_workspaces",
"cmd_emoji_sync", "cmd_emoji_sync",
"cmd_migrate_data", "cmd_migrate_data",
+27 -2
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
from devplacepy.cli._shared import _audit_cli from devplacepy.cli._shared import _audit_cli
@@ -18,7 +19,6 @@ def cmd_containers_list(args):
def cmd_containers_reconcile(args): def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once()) asyncio.run(ContainerService().run_once())
@@ -27,7 +27,6 @@ def cmd_containers_reconcile(args):
def cmd_containers_prune(args): def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService from devplacepy.services.containers.service import ContainerService
@@ -66,6 +65,29 @@ def cmd_containers_prune_builds(args):
) )
def cmd_containers_prune_stopped(args):
from devplacepy.services.containers import api, store
from devplacepy.services.containers.service import ContainerService
async def run():
instances = store.all_instances()
marked = 0
for inst in instances:
if inst["status"] == store.ST_STOPPED and inst["desired_state"] == store.DESIRED_STOPPED:
api.mark_for_removal(inst, actor=("system", "prune-stopped"))
marked += 1
if marked:
await ContainerService().run_once()
return marked
marked = asyncio.run(run())
_audit_cli("cli.containers.prune_stopped", f"CLI removed {marked} stale stopped container(s)", metadata={"count": marked})
if marked:
print(f"Prune stopped: marked {marked} container(s) for removal and reconciled.")
else:
print("No stopped containers to prune.")
def cmd_containers_gc_workspaces(args): def cmd_containers_gc_workspaces(args):
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -102,6 +124,9 @@ def register_containers(subparsers):
"prune-builds", "prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables", help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds) ).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"prune-stopped", help="Remove all stopped containers (completed purpose)"
).set_defaults(func=cmd_containers_prune_stopped)
containers_sub.add_parser( containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances" "gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces) ).set_defaults(func=cmd_containers_gc_workspaces)
+1 -4
View File
@@ -227,8 +227,7 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
user = get_current_user(request) user = get_current_user(request)
viewer_is_admin = is_admin(user) viewer_is_admin = is_admin(user)
done = bool(report) or job.get("status") == queue.DONE done = bool(report) or job.get("status") == queue.DONE
cost_usd = report.get("cost_usd", 0.0) if report else 0.0 return {
ctx = {
"uid": uid, "uid": uid,
"status": queue.DONE if done else job.get("status", ""), "status": queue.DONE if done else job.get("status", ""),
"query": report.get("query") or session.get("query"), "query": report.get("query") or session.get("query"),
@@ -248,7 +247,6 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
"export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None, "export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None,
"export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None, "export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None,
"export_pdf_url": f"/tools/deepsearch/{uid}/export.pdf" if done else None, "export_pdf_url": f"/tools/deepsearch/{uid}/export.pdf" if done else None,
"cost_usd": cost_usd if viewer_is_admin else None,
"viewer_is_admin": viewer_is_admin, "viewer_is_admin": viewer_is_admin,
"viewer_owns": _owns(request, session), "viewer_owns": _owns(request, session),
"created_at": session.get("created_at"), "created_at": session.get("created_at"),
@@ -257,7 +255,6 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
"user": user, "user": user,
"meta_robots": "noindex,nofollow", "meta_robots": "noindex,nofollow",
} }
return ctx
@router.get("/{uid}/session") @router.get("/{uid}/session")
async def deepsearch_session(request: Request, uid: str): async def deepsearch_session(request: Request, uid: str):
-1
View File
@@ -136,7 +136,6 @@ class DeepsearchSessionOut(_Out):
export_md_url: Optional[str] = None export_md_url: Optional[str] = None
export_json_url: Optional[str] = None export_json_url: Optional[str] = None
export_pdf_url: Optional[str] = None export_pdf_url: Optional[str] = None
cost_usd: Optional[float] = None
viewer_is_admin: bool = False viewer_is_admin: bool = False
viewer_owns: bool = False viewer_owns: bool = False
created_at: Optional[str] = None created_at: Optional[str] = None
+1 -5
View File
@@ -7,7 +7,6 @@ import time
from devplacepy import stealth from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.services.openai_gateway.usage import parse_usage_headers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,10 +41,7 @@ async def request_completion(
if response.status_code >= 400: if response.status_code >= 400:
raise RuntimeError(f"chat gateway returned {response.status_code}") raise RuntimeError(f"chat gateway returned {response.status_code}")
data: dict = response.json() data: dict = response.json()
cost_info = parse_usage_headers(response.headers) or {} return data, data.get("usage") or {}, elapsed_ms
usage = data.get("usage") or {}
usage["cost_usd"] = cost_info.get("cost_usd", 0.0)
return data, usage, elapsed_ms
async def complete_chat( async def complete_chat(
@@ -5,11 +5,10 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from typing import Callable, Tuple from typing import Callable
from devplacepy import stealth from devplacepy import stealth
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
from devplacepy.services.openai_gateway.usage import parse_usage_headers
from .phases import PHASE_PLANNING from .phases import PHASE_PLANNING
@@ -62,7 +61,7 @@ def _noop(frame: dict) -> None:
async def plan_queries( async def plan_queries(
query: str, api_key: str, emit: Callable[[dict], None] = _noop query: str, api_key: str, emit: Callable[[dict], None] = _noop
) -> Tuple[list[str], float]: ) -> list[str]:
emit( emit(
{ {
"type": "substep", "type": "substep",
@@ -90,8 +89,6 @@ async def plan_queries(
) )
if response.status_code >= 400: if response.status_code >= 400:
raise RuntimeError(f"planner gateway returned {response.status_code}") raise RuntimeError(f"planner gateway returned {response.status_code}")
cost_info = parse_usage_headers(response.headers) or {}
planner_cost = cost_info.get("cost_usd", 0.0)
data = response.json() data = response.json()
content = ( content = (
(data.get("choices") or [{}])[0].get("message", {}).get("content") or "" (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
@@ -105,7 +102,7 @@ async def plan_queries(
"message": f"Planned {len(parsed)} search angles", "message": f"Planned {len(parsed)} search angles",
} }
) )
return parsed, planner_cost return parsed
except Exception as exc: except Exception as exc:
logger.warning("deepsearch query planner failed, using fallback: %s", exc) logger.warning("deepsearch query planner failed, using fallback: %s", exc)
fallback = _fallback(query) fallback = _fallback(query)
@@ -116,4 +113,4 @@ async def plan_queries(
"message": f"Planner unavailable, using {len(fallback)} heuristic angles", "message": f"Planner unavailable, using {len(fallback)} heuristic angles",
} }
) )
return fallback, 0.0 return fallback
@@ -40,7 +40,6 @@ class Orchestration:
source_diversity: float = 0.0 source_diversity: float = 0.0
score: int = 0 score: int = 0
synthesis: str = "agents" synthesis: str = "agents"
total_cost_usd: float = 0.0
def _domain(url: str) -> str: def _domain(url: str) -> str:
@@ -166,7 +165,6 @@ async def _complete(
"tokens_in": int(raw_usage.get("prompt_tokens") or prompt_chars // 4), "tokens_in": int(raw_usage.get("prompt_tokens") or prompt_chars // 4),
"tokens_out": int(raw_usage.get("completion_tokens") or len(text) // 4), "tokens_out": int(raw_usage.get("completion_tokens") or len(text) // 4),
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"cost_usd": float(raw_usage.get("cost_usd", 0.0)),
} }
return text, usage return text, usage
@@ -298,7 +296,6 @@ def _agent_done(emit: Callable[[dict], None], agent: str, usage: dict) -> None:
"elapsed_ms": usage.get("elapsed_ms", 0), "elapsed_ms": usage.get("elapsed_ms", 0),
"tokens_in": usage.get("tokens_in", 0), "tokens_in": usage.get("tokens_in", 0),
"tokens_out": usage.get("tokens_out", 0), "tokens_out": usage.get("tokens_out", 0),
"cost_usd": usage.get("cost_usd", 0.0),
} }
) )
@@ -349,7 +346,6 @@ async def orchestrate(
emit: Callable[[dict], None], emit: Callable[[dict], None],
store=None, store=None,
queries: list[str] | None = None, queries: list[str] | None = None,
planner_cost_usd: float = 0.0,
) -> Orchestration: ) -> Orchestration:
diversity = source_diversity(pages) diversity = source_diversity(pages)
if not pages: if not pages:
@@ -422,12 +418,6 @@ async def orchestrate(
score = int( score = int(
min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX) min(SCORE_MAX, (confidence * 0.5 + diversity * 0.3 + coverage * 0.2) * SCORE_MAX)
) )
total_cost = (
planner_cost_usd
+ float(summary_usage.get("cost_usd", 0.0))
+ float(findings_usage.get("cost_usd", 0.0))
+ float(linker_usage.get("cost_usd", 0.0))
)
return Orchestration( return Orchestration(
summary=summary, summary=summary,
findings=findings, findings=findings,
@@ -435,7 +425,6 @@ async def orchestrate(
source_diversity=diversity, source_diversity=diversity,
score=score, score=score,
synthesis="agents", synthesis="agents",
total_cost_usd=total_cost,
) )
except Exception as exc: except Exception as exc:
logger.warning("deepsearch orchestration failed, using heuristic: %s", exc) logger.warning("deepsearch orchestration failed, using heuristic: %s", exc)
@@ -165,7 +165,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
should_stop = _make_stop(output_dir) should_stop = _make_stop(output_dir)
_stage("planning", "Planning research queries", PHASE_PLANNING) _stage("planning", "Planning research queries", PHASE_PLANNING)
queries, planner_cost = await plan_queries(query, api_key, _emit) queries = await plan_queries(query, api_key, _emit)
_emit({"type": "queries", "queries": queries}) _emit({"type": "queries", "queries": queries})
_stage("searching", "Searching the web", PHASE_SEARCHING) _stage("searching", "Searching the web", PHASE_SEARCHING)
@@ -203,8 +203,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
_stage("analysis", "Running research agents", PHASE_ANALYSIS) _stage("analysis", "Running research agents", PHASE_ANALYSIS)
result = await orchestrate( result = await orchestrate(
query, outcome.pages, api_key, _emit, store=store, queries=queries, query, outcome.pages, api_key, _emit, store=store, queries=queries
planner_cost_usd=planner_cost,
) )
_stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS) _stage("synthesis", "Compiling cited report", PHASE_SYNTHESIS)
@@ -229,7 +228,6 @@ async def _run(payload: dict, output_dir: Path) -> dict:
"chunk_count": chunk_count, "chunk_count": chunk_count,
"embed_backend": embed_backend, "embed_backend": embed_backend,
"collection": collection, "collection": collection,
"cost_usd": result.total_cost_usd,
} }
(output_dir / "report.json").write_text( (output_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False), encoding="utf-8" json.dumps(report, ensure_ascii=False), encoding="utf-8"
@@ -246,7 +244,6 @@ async def _run(payload: dict, output_dir: Path) -> dict:
"synthesis": report["synthesis"], "synthesis": report["synthesis"],
"page_count": report["page_count"], "page_count": report["page_count"],
"chunk_count": report["chunk_count"], "chunk_count": report["chunk_count"],
"cost_usd": report["cost_usd"],
} }
) )
return report return report
@@ -107,7 +107,7 @@ instead of the gateway. Before the reconciler has recorded a gateway, the proxy
The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95 The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95
runtime) are computed on read over the ring buffer. The CLI exposes runtime) are computed on read over the ring buffer. The CLI exposes
`devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a `devplace containers list | reconcile | prune | prune-builds | prune-stopped | gc-workspaces` (`prune-builds` is a
one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's
admin `container_*` tools cover the same instance operations in natural language. admin `container_*` tools cover the same instance operations in natural language.
</div> </div>
@@ -16,9 +16,6 @@
<span class="ds-metric"><strong>{{ source_diversity }}</strong> diversity</span> <span class="ds-metric"><strong>{{ source_diversity }}</strong> diversity</span>
<span class="ds-metric"><strong>{{ page_count }}</strong> sources</span> <span class="ds-metric"><strong>{{ page_count }}</strong> sources</span>
<span class="ds-metric"><strong>{{ chunk_count }}</strong> chunks</span> <span class="ds-metric"><strong>{{ chunk_count }}</strong> chunks</span>
{% if viewer_is_admin and cost_usd is not none %}
<span class="ds-metric"><strong>${{ "%.6f"|format(cost_usd) }}</strong> cost</span>
{% endif %}
</div> </div>
{% if export_md_url %} {% if export_md_url %}
<div class="ds-exports"> <div class="ds-exports">
+24 -8
View File
@@ -1,8 +1,24 @@
2026-07-19T17:31:36 INFO logging initialised at /workspace/repo/dpc.log 2026-07-19T09:32:07 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T17:31:36 DEBUG model=molodetz-pro fps=30 2026-07-19T09:32:07 DEBUG model=molodetz-pro fps=30
2026-07-19T17:31:36 INFO read task from file: /workspace/prompts/research-10.txt 2026-07-19T09:32:07 INFO read task from file: /workspace/prompts/research-1.txt
2026-07-19T17:31:36 INFO settings merged: model=<default> allow=0 deny=0 ask=0 2026-07-19T09:32:07 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T19:05:24 INFO logging initialised at /workspace/repo/dpc.log 2026-07-19T15:54:39 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T19:05:24 DEBUG model=molodetz-pro fps=30 2026-07-19T15:54:39 DEBUG model=molodetz-pro fps=30
2026-07-19T19:05:24 INFO read task from file: /workspace/prompts/execution-1.txt 2026-07-19T15:54:39 INFO read task from file: /workspace/prompts/research-2.txt
2026-07-19T19:05:24 INFO settings merged: model=<default> allow=0 deny=0 ask=0 2026-07-19T15:54:39 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T17:25:31 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T17:25:31 DEBUG model=molodetz-pro fps=30
2026-07-19T17:25:31 INFO read task from file: /workspace/prompts/research-3.txt
2026-07-19T17:25:31 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T17:37:32 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T17:37:32 DEBUG model=molodetz-pro fps=30
2026-07-19T17:37:32 INFO read task from file: /workspace/prompts/research-4.txt
2026-07-19T17:37:32 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T18:22:18 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T18:22:18 DEBUG model=molodetz-pro fps=30
2026-07-19T18:22:18 INFO read task from file: /workspace/prompts/execution-1.txt
2026-07-19T18:22:18 INFO settings merged: model=<default> allow=0 deny=0 ask=0
2026-07-19T18:49:24 INFO logging initialised at /workspace/repo/dpc.log
2026-07-19T18:49:24 DEBUG model=molodetz-pro fps=30
2026-07-19T18:49:24 INFO read task from file: /workspace/prompts/execution-2.txt
2026-07-19T18:49:24 INFO settings merged: model=<default> allow=0 deny=0 ask=0
-28
View File
@@ -39,7 +39,6 @@ def _seed_done_session(owner_id="ds-session-owner"):
"source_diversity": 0.5, "source_diversity": 0.5,
"page_count": 3, "page_count": 3,
"chunk_count": 12, "chunk_count": 12,
"cost_usd": 0.123456,
} }
result = { result = {
"query": "the question", "query": "the question",
@@ -48,7 +47,6 @@ def _seed_done_session(owner_id="ds-session-owner"):
"source_diversity": 0.5, "source_diversity": 0.5,
"page_count": 3, "page_count": 3,
"chunk_count": 12, "chunk_count": 12,
"cost_usd": 0.123456,
"report": report, "report": report,
} }
get_table("jobs").update( get_table("jobs").update(
@@ -99,7 +97,6 @@ def test_session_json_shape(app_server):
assert body["sources"] assert body["sources"]
assert "viewer_is_admin" in body assert "viewer_is_admin" in body
assert "viewer_owns" in body assert "viewer_owns" in body
assert body.get("cost_usd") is None # guest user, not admin
finally: finally:
_clear() _clear()
@@ -111,7 +108,6 @@ def test_session_html_renders_without_jinja_global_collision(app_server):
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert "the question" in r.text assert "the question" in r.text
assert "dp-deepsearch-chat" in r.text assert "dp-deepsearch-chat" in r.text
assert "0.123456" not in r.text # guest user, cost hidden
finally: finally:
_clear() _clear()
@@ -146,7 +142,6 @@ def test_session_reads_disk_report_before_result_commit(app_server):
"synthesis": "agents", "synthesis": "agents",
"page_count": 12, "page_count": 12,
"chunk_count": 61, "chunk_count": 61,
"cost_usd": 0.123456,
} }
session_dir = DEEPSEARCH_DIR / uid session_dir = DEEPSEARCH_DIR / uid
session_dir.mkdir(parents=True, exist_ok=True) session_dir.mkdir(parents=True, exist_ok=True)
@@ -208,26 +203,3 @@ def test_export_json(app_server):
def test_export_unknown_uid_404(app_server): def test_export_unknown_uid_404(app_server):
r = requests.get(f"{BASE_URL}/tools/deepsearch/nope/export.md") r = requests.get(f"{BASE_URL}/tools/deepsearch/nope/export.md")
assert r.status_code == 404 assert r.status_code == 404
def test_session_admin_sees_cost(app_server, seeded_db):
try:
uid = _seed_done_session("alice_test")
s = requests.Session()
creds = seeded_db["alice"]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
r = s.get(f"{BASE_URL}/tools/deepsearch/{uid}/session", headers=_json_headers())
assert r.status_code == 200, r.text
body = r.json()
assert body.get("cost_usd") == 0.123456
html = s.get(f"{BASE_URL}/tools/deepsearch/{uid}/session")
assert html.status_code == 200, html.text
assert "0.123456" in html.text
assert "cost" in html.text
finally:
_clear()