studio: agent window, a chat with a model that edits the scene
Deploy web Studio / deploy (push) Successful in 3m3s
Deploy web Studio / deploy (push) Successful in 3m3s
✦ Agent in the Studio's top bar opens a floating chat window. The model (any OpenAI-compatible endpoint with tool calling; default B4L's inference server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page tools (select_object, set_view) and the Studio's state (selection, level, rotation) in its system prompt. Answers and reasoning stream in; tool calls show as rows with arguments and results; object ids link to the object; Stop ends a turn; Undo takes the agent's edits back. The window moves by its title bar and resizes at its top-left corner. - studio/agent.js: shared by both front ends (waits for studio.js's isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio - isokit studio: /api/agent/{config,models,chat} relay the calls on their own threads (ureq, chunked and flushed per read), so the key stays on the machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL / _KEY / _MODEL or agent.json in the user's config folder - web Studio: calls the endpoint from the page with a key kept in the browser; build_web.py --agent-url names a same-site relay as default; the deploy builds with /llm/v1, which deploy/Caddyfile forwards to the inference server (its nginx refuses the CORS preflight) - tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both check_studio.py and check_web.py
This commit is contained in:
1 parent
c647f39059
commit
1a1b62d475
21 files changed
+1690
-25
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""Agent window checks, shared by check_studio.py (isokit studio) and
|
||||
check_web.py (web Studio). The page's agent must use the mock model
|
||||
(tools/mock_llm.py, key test-key); `start_mock()` runs one."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MOCK_PORT = 7932
|
||||
MOCK = f"http://127.0.0.1:{MOCK_PORT}"
|
||||
|
||||
|
||||
def start_mock():
|
||||
p = subprocess.Popen([sys.executable, str(Path(__file__).with_name("mock_llm.py")), str(MOCK_PORT)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
p.stdout.readline() # the banner: listening
|
||||
return p
|
||||
|
||||
|
||||
def seen():
|
||||
return json.loads(urllib.request.urlopen(MOCK + "/_requests").read())
|
||||
|
||||
|
||||
def chats():
|
||||
return [r for r in seen() if r.get("path") == "/v1/chat/completions"]
|
||||
|
||||
|
||||
def run(pg, objects, shot, check, can_set_key):
|
||||
"""objects(): the scene's objects now. can_set_key: the key is set in the
|
||||
window (web), not by the Studio server's environment."""
|
||||
pg.locator("#agent").click()
|
||||
check(pg.locator("#agwin").is_visible(), "✦ Agent opens the agent window")
|
||||
pg.wait_for_function("() => /mock-1/.test(document.getElementById('agwho').textContent)", timeout=8000)
|
||||
check(pg.locator("#agwho .ag-on").count() == 1, "the window connects to the model: " + pg.locator("#agwho").inner_text())
|
||||
check(pg.locator(".ag-sug").count() >= 3, "an empty chat offers suggestions")
|
||||
shot("agent-empty.jpg")
|
||||
|
||||
# a turn with two tool calls, then an answer
|
||||
n0 = len(objects())
|
||||
pg.locator("#agtext").fill("Plant a tree at 6, 4 please")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_selector(".ag-bot:not(.ag-live) .ag-text code.ag-obj", timeout=15000)
|
||||
objs = objects()
|
||||
new = [o for o in objs if o.get("name") == "Agent tree"]
|
||||
check(len(objs) == n0 + 1 and len(new) == 1 and new[0]["x"] == 6 and new[0]["y"] == 4,
|
||||
"the agent's add_object lands in the scene at (6, 4)")
|
||||
tid = new[0]["id"] if new else "?"
|
||||
check(pg.locator(".ag-tool.ag-ok").count() == 2, "both tool calls show as done")
|
||||
check(pg.locator(f'.ag-tool code.ag-obj[data-obj="{tid}"]').count() == 1, "the add_object row links the new object")
|
||||
check(pg.locator(f'#objects li[data-id="{tid}"]').count() == 1, "the object list shows the agent's tree at once")
|
||||
check(pg.locator(f'.iso-svg g[data-obj="{tid}"]').count() >= 1, "the map draws the agent's tree")
|
||||
check(pg.locator(".ag-think summary").first.inner_text().startswith("Thought for"), "the reasoning folds away: "
|
||||
+ pg.locator(".ag-think summary").first.inner_text())
|
||||
txt = pg.locator(".ag-bot .ag-text").last.inner_text()
|
||||
check("Planted" in txt and tid in txt, "the answer streams in: " + txt.replace("\n", " ")[:70])
|
||||
check(pg.locator(".ag-bot .ag-text li").count() == 2 and pg.locator(".ag-bot .ag-text b").count() == 1,
|
||||
"markdown list and bold render")
|
||||
shot("agent-chat.jpg")
|
||||
|
||||
c = chats()
|
||||
first, second = c[-2]["body"], c[-1]["body"]
|
||||
sysmsg = first["messages"][0]
|
||||
names = [t["function"]["name"] for t in first.get("tools", [])]
|
||||
check(all(r["auth"] == "Bearer test-key" for r in c[-2:]), "the key reaches the model")
|
||||
check(sysmsg["role"] == "system" and "isokit Studio" in sysmsg["content"] and "Nothing is selected" in sysmsg["content"]
|
||||
and "project_info" in sysmsg["content"], "the system prompt has the MCP instructions and the Studio state")
|
||||
check(len(names) == 17 and {"add_object", "apply_ops", "select_object", "set_view"} <= set(names),
|
||||
f"the model gets 15 MCP tools + 2 page tools ({len(names)})")
|
||||
check(first.get("stream") is True and first.get("model") == "mock-1", "streamed, first offered model by default")
|
||||
tool_ids = [m.get("tool_call_id") for m in second["messages"] if m["role"] == "tool"]
|
||||
check(tool_ids == ["call_a", "call_b"], f"tool results go back with their call ids {tool_ids}")
|
||||
|
||||
# the id in the answer selects the object
|
||||
pg.locator(f'.ag-bot .ag-text code.ag-obj[data-obj="{tid}"]').click()
|
||||
pg.wait_for_timeout(500)
|
||||
check(("id " + tid) in pg.locator("#right .st-sub").inner_text(), "clicking the id selects the agent's tree")
|
||||
# the selection reaches the model in the next turn; a plain answer
|
||||
pg.locator("#agtext").fill("hello")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
|
||||
check(('"id":"%s"' % tid) in chats()[-1]["body"]["messages"][0]["content"], "the system prompt names the selection")
|
||||
|
||||
# Undo takes the agent's edit back
|
||||
pg.locator("#undo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
check(not any(o.get("name") == "Agent tree" for o in objects()), "Undo removes the agent's tree")
|
||||
|
||||
# Stop a long answer
|
||||
pg.locator("#agtext").fill("tell me a slow story")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_timeout(900)
|
||||
check(pg.locator("#agsend").inner_text() == "Stop", "while the agent works, Send turns into Stop")
|
||||
pg.locator("#agsend").click()
|
||||
pg.wait_for_selector(".ag-note:has-text('Stopped')", timeout=5000)
|
||||
check(pg.locator("#agsend").inner_text() == "Send" and "word1" in pg.locator(".ag-bot .ag-text").last.inner_text(),
|
||||
"Stop ends the turn and keeps what came")
|
||||
|
||||
# settings: pick another model
|
||||
def settings():
|
||||
pg.wait_for_timeout(900) # a good save folds the form away after a moment
|
||||
if pg.locator("#agsetf").is_hidden():
|
||||
pg.locator("#agset").click()
|
||||
|
||||
def save():
|
||||
pg.locator('#agsetf button[type="submit"]').click()
|
||||
pg.wait_for_timeout(700)
|
||||
|
||||
settings()
|
||||
check(pg.locator("#agsetf").is_visible(), "⚙ shows the settings")
|
||||
opts = pg.locator("#agmodel option").all_inner_texts()
|
||||
check("mock-2" in opts, f"the model list comes from the endpoint {opts}")
|
||||
pg.locator("#agmodel").select_option("mock-2")
|
||||
save()
|
||||
pg.wait_for_function("() => /mock-2/.test(document.getElementById('agwho').textContent)", timeout=5000)
|
||||
pg.locator("#agtext").fill("hello again")
|
||||
pg.locator("#agtext").press("Enter")
|
||||
pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000)
|
||||
check(chats()[-1]["body"]["model"] == "mock-2", "the chosen model is used")
|
||||
if can_set_key:
|
||||
settings()
|
||||
pg.locator("#agkey").fill("wrong-key")
|
||||
save()
|
||||
check("401" in pg.locator("#agsetmsg").inner_text(), "a wrong key shows the endpoint's refusal: " + pg.locator("#agsetmsg").inner_text())
|
||||
check(pg.locator("#agwho .ag-off").count() == 1, "the status dot turns red")
|
||||
shot("agent-badkey.jpg")
|
||||
pg.locator("#agkey").fill("test-key")
|
||||
save()
|
||||
check(pg.locator("#agwho .ag-on").count() == 1, "the right key connects again")
|
||||
else:
|
||||
settings()
|
||||
check(pg.locator("#agurl").is_disabled() and "ISOKIT_LLM_KEY" in pg.locator("#agkey").get_attribute("placeholder"),
|
||||
"endpoint and key set by the environment are shown as such")
|
||||
shot("agent-settings.jpg")
|
||||
settings()
|
||||
pg.locator("#agmodel").select_option("")
|
||||
save()
|
||||
check("mock-1" in pg.locator("#agwho").inner_text(), "back to the first offered model")
|
||||
|
||||
# move and resize the window
|
||||
box = pg.locator("#agwin").bounding_box()
|
||||
hb = pg.locator(".ag-logo").bounding_box()
|
||||
pg.mouse.move(hb["x"] + 5, hb["y"] + 5)
|
||||
pg.mouse.down()
|
||||
pg.mouse.move(hb["x"] - 95, hb["y"] - 35, steps=5)
|
||||
pg.mouse.up()
|
||||
box2 = pg.locator("#agwin").bounding_box()
|
||||
check(abs(box2["x"] - box["x"] + 100) < 3 and abs(box2["y"] - box["y"] + 40) < 3, "the window moves by its title bar")
|
||||
gb = pg.locator("#aggrip").bounding_box()
|
||||
pg.mouse.move(gb["x"] + 4, gb["y"] + 4)
|
||||
pg.mouse.down()
|
||||
pg.mouse.move(gb["x"] - 56, gb["y"] - 26, steps=5)
|
||||
pg.mouse.up()
|
||||
box3 = pg.locator("#agwin").bounding_box()
|
||||
check(abs(box3["width"] - box2["width"] - 60) < 3 and abs(box3["height"] - box2["height"] - 30) < 3, "the top-left grip resizes it")
|
||||
|
||||
pg.locator("#agnew").click()
|
||||
check(pg.locator(".ag-empty").count() == 1 and pg.locator(".ag-tool").count() == 0, "New chat clears the conversation")
|
||||
pg.locator("#agclose").click()
|
||||
check(pg.locator("#agwin").is_hidden() and pg.locator("#agent").get_attribute("aria-pressed") == "false", "✕ closes the window")
|
||||
+11
-2
@@ -99,6 +99,10 @@ def build_info():
|
||||
return ""
|
||||
|
||||
|
||||
def html_attr(s):
|
||||
return s.replace("&", "&").replace('"', """).replace("<", "<")
|
||||
|
||||
|
||||
def replace_once(s, old, new):
|
||||
n = s.count(old)
|
||||
assert n == 1, f"studio/index.html: expected {old!r} once, found {n}"
|
||||
@@ -109,6 +113,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default=str(ROOT / "dist" / "web"))
|
||||
ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)")
|
||||
ap.add_argument("--agent-url", help="default endpoint of the agent window, e.g. a relay on the same site (/llm/v1)")
|
||||
a = ap.parse_args()
|
||||
out = Path(a.out).resolve()
|
||||
web = out / "assets" / "web"
|
||||
@@ -134,7 +139,8 @@ def main():
|
||||
print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)")
|
||||
|
||||
for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"),
|
||||
("studio/studio.js", "studio"), ("studio/studio.css", "studio"), ("studio/favicon.svg", "studio"),
|
||||
("studio/studio.js", "studio"), ("studio/agent.js", "studio"), ("studio/studio.css", "studio"),
|
||||
("studio/favicon.svg", "studio"),
|
||||
("web/web.js", "web"), ("web/web.css", "web")]:
|
||||
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
|
||||
@@ -150,7 +156,8 @@ def main():
|
||||
build = build_info()
|
||||
html = replace_once(html, '<title>isokit Studio</title>',
|
||||
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
|
||||
+ (f'\n<meta name="isokit-build" content="{build}">' if build else ""))
|
||||
+ (f'\n<meta name="isokit-build" content="{build}">' if build else "")
|
||||
+ (f'\n<meta name="isokit-agent" content="{html_attr(a.agent_url)}">' if a.agent_url else ""))
|
||||
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
|
||||
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
|
||||
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
|
||||
@@ -158,6 +165,8 @@ def main():
|
||||
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
|
||||
html = replace_once(html, 'href="/view"', 'href="#"')
|
||||
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
|
||||
# agent.js waits for studio.js, which web.js starts once the engine is loaded
|
||||
html = replace_once(html, '<script src="/assets/studio/agent.js"></script>', f'<script src="assets/studio/agent.js?{v}"></script>')
|
||||
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
|
||||
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
|
||||
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
|
||||
|
||||
+19
-1
@@ -1,10 +1,18 @@
|
||||
"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge."""
|
||||
"""Functional test of the isokit Studio (GUI), its MCP endpoint and the agent
|
||||
window, headless Edge. The agent window runs on a mock model this script starts:
|
||||
|
||||
ISOKIT_LLM_URL=http://127.0.0.1:7932/v1 ISOKIT_LLM_KEY=test-key ISOKIT_CONFIG_DIR=<scratch> \
|
||||
isokit studio <copy of examples/demo> --port 7890 --no-open
|
||||
uv run --with playwright python tools/check_studio.py <screenshot dir> http://127.0.0.1:7890
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
import agent_checks
|
||||
|
||||
BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878"
|
||||
OUT = Path(sys.argv[1])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
@@ -36,6 +44,7 @@ def obj(id_):
|
||||
return next((o for o in doc()["objects"] if o["id"] == id_), None)
|
||||
|
||||
|
||||
mock = agent_checks.start_mock()
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(channel="msedge", headless=True)
|
||||
pg = b.new_page(viewport={"width": 1500, "height": 950})
|
||||
@@ -140,8 +149,17 @@ with sync_playwright() as p:
|
||||
pg.locator('[data-a="rotr"]').click()
|
||||
pg.wait_for_timeout(600)
|
||||
pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80)
|
||||
# the agent window, on the mock model (the Studio must run with ISOKIT_LLM_URL=<mock>/v1, see the docstring)
|
||||
cfg = get("/api/agent/config")
|
||||
if agent_checks.MOCK not in cfg["url"]:
|
||||
check(False, "agent window checks need the Studio started with ISOKIT_LLM_URL=" + agent_checks.MOCK + "/v1 (found " + cfg["url"] + ")")
|
||||
else:
|
||||
check(not cfg.get("key") and cfg["has_key"] and cfg["key_from"] == "env", "/api/agent/config says a key is set, without the key")
|
||||
pg.locator('.iso-seg button[data-level="site"]').click()
|
||||
agent_checks.run(pg, lambda: doc()["objects"], lambda n: pg.screenshot(path=str(OUT / n), type="jpeg", quality=80), check, can_set_key=False)
|
||||
check(not errs, f"no script errors {errs}")
|
||||
b.close()
|
||||
|
||||
mock.terminate()
|
||||
print("FAILED:" if fails else "ALL PASSED", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
+15
-2
@@ -7,8 +7,9 @@ Serves the build on a local port and checks: the demo loads from the bundled
|
||||
files, placing / dragging / props / undo, persistence across reloads, the code
|
||||
editor (script error and repair), new / export / import (.zip, stored and
|
||||
deflated), page build and preview, MCP in the page and WebMCP, two tabs on one
|
||||
project, and a folder project (the origin-private file system stands in for a
|
||||
folder picked with showDirectoryPicker: same handle API) following outside edits.
|
||||
project, a folder project (the origin-private file system stands in for a
|
||||
folder picked with showDirectoryPicker: same handle API) following outside
|
||||
edits, and the agent window on a mock model (tools/mock_llm.py, started here).
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
@@ -20,6 +21,8 @@ from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
import agent_checks
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "dist" / "web-shots"
|
||||
DIST = Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "dist" / "web"
|
||||
@@ -60,6 +63,7 @@ WEBMCP_STUB = "navigator.modelContext = { provideContext: function (c) { window.
|
||||
|
||||
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
mock = agent_checks.start_mock()
|
||||
time.sleep(1.0)
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
@@ -186,6 +190,12 @@ try:
|
||||
return t.execute({id: "agent-lamp", tag: "via WebMCP"}); }""")
|
||||
check(r["isError"] is False and obj(pg, "agent-lamp")["tag"] == "via WebMCP", "a WebMCP tool call edits the scene")
|
||||
|
||||
# ---------------------------------------------------------------- agent window (mock model, key kept in the browser)
|
||||
pg.evaluate("(u) => localStorage.setItem('isokit-agent', JSON.stringify({url: u, key: 'test-key'}))", agent_checks.MOCK + "/v1")
|
||||
agent_checks.run(pg, lambda: doc(pg)["objects"], lambda n: shot(pg, "web-" + n), check, can_set_key=True)
|
||||
stored = pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent'))")
|
||||
check(stored.get("key") == "test-key" and not stored.get("model"), f"settings stay in this browser {stored}")
|
||||
|
||||
# ---------------------------------------------------------------- two tabs on one project
|
||||
pg2 = ctx.new_page()
|
||||
watch(pg2)
|
||||
@@ -309,10 +319,13 @@ try:
|
||||
pg.wait_for_timeout(400)
|
||||
shot(pg, "web-narrow.jpg")
|
||||
|
||||
# the browser logs the 401 of the agent window's wrong-key check
|
||||
errs = [e for e in errs if "status of 401" not in e]
|
||||
check(not errs, f"no page errors {errs[:5]}")
|
||||
b.close()
|
||||
finally:
|
||||
srv.terminate()
|
||||
mock.terminate()
|
||||
|
||||
print("\n%d failure(s)" % len(fails))
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""A stand-in for an OpenAI-compatible model server, for the agent window tests.
|
||||
|
||||
python tools/mock_llm.py [port] (default 7932; base URL http://127.0.0.1:<port>/v1)
|
||||
|
||||
Wants the key `test-key`. Answers like a model that uses tools:
|
||||
- a user message "plant a tree at X, Y" -> reasoning, then two tool calls:
|
||||
list_objects {type: tree} and add_object {type: tree, x: X, y: Y, name: "Agent tree"}
|
||||
- tool results -> a short answer naming the new object's id in backticks
|
||||
- a user message containing "slow" -> a long, slow answer (to test Stop)
|
||||
- anything else -> a plain answer
|
||||
Streams (SSE) when asked to. Every page origin may call it (CORS, also the
|
||||
preflight). GET /_requests lists the requests seen (headers and bodies).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
KEY = "test-key"
|
||||
SEEN = []
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "authorization, content-type")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
|
||||
def reply(self, code, obj):
|
||||
b = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.cors()
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def authorized(self):
|
||||
if self.headers.get("Authorization") == "Bearer " + KEY:
|
||||
return True
|
||||
self.reply(401, {"error": {"message": "invalid api key"}})
|
||||
return False
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.cors()
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/_requests":
|
||||
return self.reply(200, SEEN)
|
||||
if self.path == "/v1/models":
|
||||
if self.authorized():
|
||||
self.reply(200, {"object": "list", "data": [{"id": "mock-1", "object": "model"}, {"id": "mock-2", "object": "model"}]})
|
||||
return
|
||||
self.reply(404, {"error": {"message": "not found"}})
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
SEEN.append({"path": self.path, "auth": self.headers.get("Authorization"), "body": body})
|
||||
if self.path != "/v1/chat/completions":
|
||||
return self.reply(404, {"error": {"message": "not found"}})
|
||||
if not self.authorized():
|
||||
return
|
||||
msgs = body.get("messages", [])
|
||||
last = msgs[-1] if msgs else {}
|
||||
deltas, finish = [], "stop"
|
||||
if last.get("role") == "user" and "slow" in last.get("content", ""):
|
||||
deltas = [{"content": "word%d " % i} for i in range(80)]
|
||||
return self.stream(body, deltas, finish, pause=0.1)
|
||||
m = re.search(r"tree at (-?[\d.]+),\s*(-?[\d.]+)", last.get("content", "")) if last.get("role") == "user" else None
|
||||
if m:
|
||||
x, y = float(m.group(1)), float(m.group(2))
|
||||
deltas = [{"reasoning": "The user wants a tree. "}, {"reasoning": "I look at the trees first, then add one."},
|
||||
{"tool_calls": [{"index": 0, "id": "call_a", "type": "function", "function": {"name": "list_objects", "arguments": ""}}]},
|
||||
{"tool_calls": [{"index": 0, "function": {"arguments": '{"type": "tree"}'}}]},
|
||||
{"tool_calls": [{"index": 1, "id": "call_b", "type": "function", "function": {"name": "add_object", "arguments": '{"type": "tree", '}}]},
|
||||
{"tool_calls": [{"index": 1, "function": {"arguments": '"x": %s, "y": %s, "name": "Agent tree"}' % (x, y)}}]}]
|
||||
finish = "tool_calls"
|
||||
elif last.get("role") == "tool":
|
||||
new_id = "?"
|
||||
for mm in reversed(msgs):
|
||||
if mm.get("role") == "tool":
|
||||
try:
|
||||
new_id = json.loads(mm["content"]).get("id") or new_id
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if new_id != "?":
|
||||
break
|
||||
deltas = [{"content": "Planted "}, {"content": "`%s` for you." % new_id}, {"content": "\n\n- one tree\n- **no** overlaps"}]
|
||||
else:
|
||||
deltas = [{"content": "Hello from the mock model."}]
|
||||
self.stream(body, deltas, finish)
|
||||
|
||||
def stream(self, body, deltas, finish, pause=0.02):
|
||||
if not body.get("stream"):
|
||||
msg = {"role": "assistant", "content": "".join(d.get("content", "") for d in deltas)}
|
||||
return self.reply(200, {"choices": [{"index": 0, "message": msg, "finish_reason": finish}]})
|
||||
self.send_response(200)
|
||||
self.cors()
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
for d in [{"role": "assistant", "content": ""}] + deltas:
|
||||
ch = {"object": "chat.completion.chunk", "model": body.get("model") or "mock-1",
|
||||
"choices": [{"index": 0, "delta": d, "finish_reason": None}]}
|
||||
self.wfile.write(("data: %s\n\n" % json.dumps(ch)).encode())
|
||||
self.wfile.flush()
|
||||
time.sleep(pause)
|
||||
end = {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}
|
||||
self.wfile.write(("data: %s\n\ndata: [DONE]\n\n" % json.dumps(end)).encode())
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
SEEN.append({"aborted": True})
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 7932
|
||||
print(f"mock model on http://127.0.0.1:{port}/v1", flush=True)
|
||||
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||
Reference in new issue
Block a user