mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 21:22:23 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fe87e1d8 |
Generated
+16
-34
@@ -6,15 +6,15 @@
|
||||
"": {
|
||||
"name": "odysseus",
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@antithesishq/bombadil": "^0.7.10",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
|
||||
"integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
|
||||
"version": "0.7.10",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.10.tgz",
|
||||
"integrity": "sha512-71aLKQBB3GNDBQZXU6MCKbt99dl9I+/pjg0EfGD9eCKXDIqpKPXD2rFH6yCJude37RGtnKq6hZFfZhuTp+Tykg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -22,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -50,44 +50,26 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@antithesishq/bombadil": "^0.7.10",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,10 +696,11 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# A configured effort is the user's explicit choice. Otherwise thinking
|
||||
# stays off, because reasoning would use up this small token budget and
|
||||
# leave no title.
|
||||
title_effort = effort_for_call(t_url, t_model, owner, sess)
|
||||
# max_tokens big enough that reasoning models (Minimax M2,
|
||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||
# plus the actual title — 200 used to clip them mid-reasoning
|
||||
# so strip_think left an empty string and no rename happened.
|
||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
@@ -712,8 +713,7 @@ async def auto_name_session(session_manager, sess):
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=title_effort,
|
||||
thinking_mode=None if title_effort else "off",
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Cookbook route domain package (#4071)."""
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Auto-register Cookbook-served models as model endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_register_image_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
port = int(port_match.group(1)) if port_match else 8100
|
||||
|
||||
# Determine host
|
||||
if remote:
|
||||
# SSH alias — use as hostname (Tailscale resolves it later)
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
# Friendly display name from repo_id
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = f"{short_name} (image)"
|
||||
pinned_models = [req.repo_id] if req.repo_id else []
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check for existing endpoint with same base_url — update it
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
if settings.get("image_gen_enabled") is not True:
|
||||
settings["image_gen_enabled"] = True
|
||||
save_settings(settings)
|
||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="image",
|
||||
endpoint_kind="local",
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
settings["image_gen_enabled"] = True
|
||||
if not settings.get("image_model"):
|
||||
settings["image_model"] = req.repo_id
|
||||
save_settings(settings)
|
||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register image endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _auto_register_llm_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
|
||||
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
||||
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
||||
endpoint at that server's /v1; the picker auto-discovers the model id by
|
||||
probing /v1/models and dims the endpoint until the server is reachable,
|
||||
so registering immediately (before the server finishes loading) is safe.
|
||||
"""
|
||||
logger.info(
|
||||
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
||||
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||
# registered every Ollama endpoint at 11434 — even if the user
|
||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||
# existing systemd Ollama, the registered endpoint pointed at
|
||||
# the OLD port and showed as offline.
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
# Host length is capped at the longest DNS name; unbounded, repeated
|
||||
# assignments make this scan quadratic and stall the event loop.
|
||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in req.cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||
|
||||
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
||||
# the odysseus container — so the right URL for the in-container
|
||||
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
||||
# (the latter points at the docker HOST, which doesn't have a server
|
||||
# on that port). The previous host.docker.internal fallback only made
|
||||
# sense for /setup-added external services like systemd Ollama on the
|
||||
# host — and those go through manual setup, not this auto-register
|
||||
# code path. For remote serves we still use the SSH host alias.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
||||
host = "host.docker.internal"
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = short_name or "Local model"
|
||||
is_mlx_deepseek_v4 = (
|
||||
"mlx_lm.server" in (req.cmd or "")
|
||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||
)
|
||||
mlx_shim_model_id = ""
|
||||
if is_mlx_deepseek_v4 and short_name:
|
||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||
remote_home = home_match.group(1) if home_match else ""
|
||||
if remote_home:
|
||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||
|
||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||
# Pin the model the user launched for every Cookbook-created LLM
|
||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
launched_model = mlx_shim_model_id or req.repo_id
|
||||
pinned_models = [launched_model] if launched_model else []
|
||||
|
||||
# Launch registration must not delete other endpoints.
|
||||
# Matching names, model pins, and failed probes do not prove that
|
||||
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
# A reused endpoint belongs to the newest serve session.
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if launched_model:
|
||||
# Pins represent model selection, not launch history.
|
||||
existing_pins = _normalize_model_ids(
|
||||
existing.pinned_models
|
||||
)
|
||||
if launched_model not in existing_pins:
|
||||
existing_pins.append(launched_model)
|
||||
existing.pinned_models = json.dumps(existing_pins)
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
if supports_tools is not None:
|
||||
existing.supports_tools = supports_tools
|
||||
db.commit()
|
||||
logger.info(f"Updated existing local model endpoint: {base_url}")
|
||||
# Re-probe so cached_models matches what the server actually
|
||||
# serves right now (the URL may have stayed the same but the
|
||||
# model behind it changed across launches).
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="llm",
|
||||
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
||||
model_refresh_mode="auto",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
# cached_models until the next background refresh fires (up
|
||||
# to a minute later) and the picker shows nothing — even
|
||||
# though the endpoint is in the DB and the server is up.
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
+271
-3
@@ -43,9 +43,6 @@ from routes.cookbook_output import (
|
||||
error_aware_output_tail, classify_dead_download,
|
||||
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
||||
)
|
||||
from routes.cookbook.endpoint_registration import (
|
||||
_auto_register_image_endpoint, _auto_register_llm_endpoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1411,6 +1408,90 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
return {"models": models, "host": host or "local"}
|
||||
|
||||
def _auto_register_image_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
port = int(port_match.group(1)) if port_match else 8100
|
||||
|
||||
# Determine host
|
||||
if remote:
|
||||
# SSH alias — use as hostname (Tailscale resolves it later)
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
# Friendly display name from repo_id
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = f"{short_name} (image)"
|
||||
pinned_models = [req.repo_id] if req.repo_id else []
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check for existing endpoint with same base_url — update it
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
if settings.get("image_gen_enabled") is not True:
|
||||
settings["image_gen_enabled"] = True
|
||||
save_settings(settings)
|
||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="image",
|
||||
endpoint_kind="local",
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
settings["image_gen_enabled"] = True
|
||||
if not settings.get("image_model"):
|
||||
settings["image_model"] = req.repo_id
|
||||
save_settings(settings)
|
||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register image endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _pick_free_port_for_ollama(
|
||||
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
|
||||
) -> int | None:
|
||||
@@ -1599,6 +1680,193 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return
|
||||
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
||||
|
||||
def _auto_register_llm_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
|
||||
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
||||
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
||||
endpoint at that server's /v1; the picker auto-discovers the model id by
|
||||
probing /v1/models and dims the endpoint until the server is reachable,
|
||||
so registering immediately (before the server finishes loading) is safe.
|
||||
"""
|
||||
logger.info(
|
||||
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
||||
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||
# registered every Ollama endpoint at 11434 — even if the user
|
||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||
# existing systemd Ollama, the registered endpoint pointed at
|
||||
# the OLD port and showed as offline.
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in req.cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||
|
||||
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
||||
# the odysseus container — so the right URL for the in-container
|
||||
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
||||
# (the latter points at the docker HOST, which doesn't have a server
|
||||
# on that port). The previous host.docker.internal fallback only made
|
||||
# sense for /setup-added external services like systemd Ollama on the
|
||||
# host — and those go through manual setup, not this auto-register
|
||||
# code path. For remote serves we still use the SSH host alias.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
||||
host = "host.docker.internal"
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = short_name or "Local model"
|
||||
is_mlx_deepseek_v4 = (
|
||||
"mlx_lm.server" in (req.cmd or "")
|
||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||
)
|
||||
mlx_shim_model_id = ""
|
||||
if is_mlx_deepseek_v4 and short_name:
|
||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||
remote_home = home_match.group(1) if home_match else ""
|
||||
if remote_home:
|
||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||
|
||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||
# Pin the model the user launched for every Cookbook-created LLM
|
||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
launched_model = mlx_shim_model_id or req.repo_id
|
||||
pinned_models = [launched_model] if launched_model else []
|
||||
|
||||
# Launch registration must not delete other endpoints.
|
||||
# Matching names, model pins, and failed probes do not prove that
|
||||
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
# A reused endpoint belongs to the newest serve session.
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if launched_model:
|
||||
# Pins represent model selection, not launch history.
|
||||
existing_pins = _normalize_model_ids(
|
||||
existing.pinned_models
|
||||
)
|
||||
if launched_model not in existing_pins:
|
||||
existing_pins.append(launched_model)
|
||||
existing.pinned_models = json.dumps(existing_pins)
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
if supports_tools is not None:
|
||||
existing.supports_tools = supports_tools
|
||||
db.commit()
|
||||
logger.info(f"Updated existing local model endpoint: {base_url}")
|
||||
# Re-probe so cached_models matches what the server actually
|
||||
# serves right now (the URL may have stayed the same but the
|
||||
# model behind it changed across launches).
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="llm",
|
||||
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
||||
model_refresh_mode="auto",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
# cached_models until the next background refresh fires (up
|
||||
# to a minute later) and the picker shows nothing — even
|
||||
# though the endpoint is in the DB and the server is up.
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/api/model/serve")
|
||||
async def model_serve(request: Request, req: ServeRequest):
|
||||
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
|
||||
|
||||
@@ -340,7 +340,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
max_tokens=20,
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
thinking_mode="off",
|
||||
)
|
||||
title = result.strip().strip('"\'').strip()
|
||||
return title[:60] if title else prompt[:50].strip()
|
||||
|
||||
+1
-16
@@ -64,21 +64,6 @@ _SHELL_COMMAND = (
|
||||
r"nvcc|docker|systemctl|journalctl|tmux|git)"
|
||||
)
|
||||
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
|
||||
# "on <word>" and "from <word>" alone describe people, companies, and topics
|
||||
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
|
||||
# only counts as a computer next to wording that describes work on a machine.
|
||||
_NAMED_COMPUTER = (
|
||||
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
|
||||
r"[a-z][a-z0-9_.-]{1,31}\b"
|
||||
)
|
||||
NAMED_COMPUTER_TASK = (
|
||||
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
|
||||
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
|
||||
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
|
||||
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
|
||||
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
|
||||
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
|
||||
)
|
||||
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
|
||||
_CODE_ARTIFACT = (
|
||||
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
|
||||
@@ -179,7 +164,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
||||
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
|
||||
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
|
||||
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
|
||||
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
||||
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
|
||||
|
||||
# Shell / remote-host intent.
|
||||
|
||||
+1
-2
@@ -44,7 +44,6 @@ from src.model_profiles import (
|
||||
is_odysseus_merged_tools_model,
|
||||
tool_schema_profile,
|
||||
)
|
||||
from src.action_intents import NAMED_COMPUTER_TASK
|
||||
from src.agent_evidence import (
|
||||
EvidenceLedger,
|
||||
command_has_mutation_effect,
|
||||
@@ -8765,7 +8764,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
|
||||
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
|
||||
rf"|{NAMED_COMPUTER_TASK}",
|
||||
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
|
||||
|
||||
+11
-60
@@ -9,61 +9,9 @@ from src.chat_helpers import extract_urls
|
||||
from src.youtube_handler import is_youtube_url
|
||||
from src.search import comprehensive_web_search, fetch_webpage_content
|
||||
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
|
||||
from src.text_helpers import strip_think
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_QUERY_HISTORY_TURNS = 4
|
||||
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
|
||||
|
||||
|
||||
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
|
||||
"""Build the query extraction prompt, with recent turns to resolve references.
|
||||
|
||||
A follow-up such as "Does he work with Jane?" has no searchable subject on
|
||||
its own. The current turn is already the last history entry when the session
|
||||
persists it, so a trailing user entry is dropped rather than repeated.
|
||||
"""
|
||||
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
|
||||
if turns and turns[-1].role == "user":
|
||||
turns.pop()
|
||||
|
||||
lines = []
|
||||
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
|
||||
content = turn.content
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
str(part.get("text") or "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
|
||||
if text:
|
||||
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
|
||||
|
||||
if not lines:
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
|
||||
},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
conversation = "\n".join(lines)
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Extract a concise search query from the user's latest message. "
|
||||
"Use the earlier conversation only to resolve references such as pronouns, "
|
||||
"and leave out earlier topics the latest message does not refer to. "
|
||||
"Reply ONLY with the query."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clean_search_query(query: str, max_len: int = 200) -> str:
|
||||
"""Strip fenced code blocks from a search query while preserving inline
|
||||
@@ -474,18 +422,21 @@ class ChatProcessor:
|
||||
generated_query = llm_call(
|
||||
t_url,
|
||||
t_model,
|
||||
_search_query_messages(
|
||||
message,
|
||||
[] if incognito else getattr(session, "history", None) or [],
|
||||
),
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Extract a concise search query from the user's message. "
|
||||
"Reply ONLY with the query."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
headers=t_headers,
|
||||
temperature=0.1,
|
||||
max_tokens=50,
|
||||
timeout=15,
|
||||
thinking_mode="off",
|
||||
)
|
||||
# Models without a reasoning parser put thinking in the content.
|
||||
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
|
||||
).strip()
|
||||
|
||||
if generated_query:
|
||||
# LLM successfully generated a non-empty query -> use the generated query
|
||||
|
||||
@@ -625,8 +625,7 @@ class DeepResearcher:
|
||||
# LLM helper
|
||||
# ------------------------------------------------------------------
|
||||
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 4096, timeout: int = 60,
|
||||
thinking_mode: Optional[str] = None) -> str:
|
||||
max_tokens: int = 4096, timeout: int = 60) -> str:
|
||||
"""Call the LLM asynchronously and strip thinking tags."""
|
||||
from src.llm_core import llm_call_async
|
||||
response = await llm_call_async(
|
||||
@@ -637,7 +636,6 @@ class DeepResearcher:
|
||||
max_tokens=max_tokens,
|
||||
headers=self.llm_headers,
|
||||
timeout=timeout,
|
||||
thinking_mode=thinking_mode,
|
||||
)
|
||||
return strip_thinking(response)
|
||||
|
||||
@@ -698,7 +696,7 @@ class DeepResearcher:
|
||||
try:
|
||||
result = await self._llm(
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
|
||||
temperature=0, max_tokens=20, timeout=15,
|
||||
)
|
||||
cat = (result or "").strip().lower()
|
||||
# Clean one-word answer first.
|
||||
|
||||
+23
-25
@@ -2321,25 +2321,6 @@ def _apply_explicit_nonstream_reasoning_effort(
|
||||
payload["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str:
|
||||
"""Return the answer text of an OpenAI-compatible non-streaming message.
|
||||
|
||||
With thinking off the caller wants only the answer, so an empty answer
|
||||
stays empty even when the model spent its whole budget on reasoning.
|
||||
Otherwise reasoning stands in for a missing answer, and Mistral's
|
||||
structured thinking is kept ahead of its text.
|
||||
"""
|
||||
thinking_off = _normalize_thinking_mode(thinking_mode) == "off"
|
||||
reasoning = "" if thinking_off else msg.get("reasoning_content")
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
return content or reasoning or ""
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part and not thinking_off:
|
||||
return thinking_part + "\n\n" + (text_part or "")
|
||||
return text_part or reasoning or ""
|
||||
|
||||
|
||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
@@ -2431,9 +2412,17 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(cache_key, response)
|
||||
return response
|
||||
except Exception:
|
||||
@@ -2871,9 +2860,18 @@ async def llm_call_async(
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
# (same contract as llm_call / stream_llm; see #5435).
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(
|
||||
cache_key,
|
||||
response,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import pytest
|
||||
|
||||
from src.action_intents import classify_tool_intent, message_needs_tools
|
||||
from src.turn_contract import requested_capabilities
|
||||
|
||||
|
||||
def test_calendar_entry_request_promotes_to_agent():
|
||||
@@ -100,44 +97,6 @@ def test_page_references_are_not_mistaken_for_named_computers():
|
||||
assert intent.needs_tools and intent.category == "workspace"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
[
|
||||
"Who is Jensen Huang from NVIDIA?",
|
||||
"Who is Tim Cook from Apple?",
|
||||
"Who is Satya Nadella from Microsoft?",
|
||||
"What happened on Monday?",
|
||||
"What is the best book on Rome?",
|
||||
"Tell me a story from Japan",
|
||||
"What is the best GPU from NVIDIA?",
|
||||
"Who is running from the police?",
|
||||
],
|
||||
)
|
||||
def test_people_and_topics_are_not_mistaken_for_named_computers(prompt):
|
||||
assert classify_tool_intent(prompt).reason != "named computer task request"
|
||||
assert "shell_files" not in requested_capabilities(prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
[
|
||||
"check disk usage on gpu-box",
|
||||
"how much free disk on gpu-box?",
|
||||
"what's the uptime on nas01",
|
||||
"list running processes on gpu-box",
|
||||
"is nginx running on gpu-box",
|
||||
"is ffmpeg installed on workstation",
|
||||
"what is the gpu usage on gpu-box",
|
||||
"copy the logs from gpu-box",
|
||||
"fetch the config file from prod-db",
|
||||
"download the checkpoint from gpu-box",
|
||||
],
|
||||
)
|
||||
def test_computer_tasks_on_a_named_host_keep_shell_tools(prompt):
|
||||
assert classify_tool_intent(prompt).category == "workspace"
|
||||
assert requested_capabilities(prompt) == {"shell_files"}
|
||||
|
||||
|
||||
def test_direct_code_requests_promote_to_workspace_agent():
|
||||
prompts = [
|
||||
"write a Python function that parses CSV",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from unittest.mock import MagicMock
|
||||
import httpx
|
||||
from types import SimpleNamespace
|
||||
from src.chat_processor import ChatProcessor
|
||||
|
||||
@@ -94,124 +93,3 @@ def test_build_context_preface_web_search_query_sanitization(monkeypatch):
|
||||
called_query = mock_web_search.call_args[0][0]
|
||||
assert len(called_query) <= 150
|
||||
assert " " not in called_query
|
||||
|
||||
|
||||
def _run_web_preface(monkeypatch, message):
|
||||
mock_web_search = MagicMock(return_value=("Search Results", []))
|
||||
monkeypatch.setattr("src.chat_processor.comprehensive_web_search", mock_web_search)
|
||||
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
|
||||
session = SimpleNamespace(
|
||||
endpoint_url="http://127.0.0.1:8081/v1/chat/completions", model="gemma4-26b", headers={},
|
||||
)
|
||||
processor.build_context_preface(
|
||||
message=message,
|
||||
session=session,
|
||||
use_web=True,
|
||||
use_rag=False,
|
||||
use_memory=False,
|
||||
use_skills=False,
|
||||
)
|
||||
return mock_web_search
|
||||
|
||||
|
||||
def test_truncated_reasoning_never_becomes_the_web_search_query(monkeypatch):
|
||||
"""A reasoning model that exhausts max_tokens falls back to the user's line."""
|
||||
sent = []
|
||||
|
||||
def fake_post(_url, **kwargs):
|
||||
sent.append(kwargs["json"])
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=httpx.Request("POST", "http://127.0.0.1:8081/v1/chat/completions"),
|
||||
json={"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {
|
||||
"content": "",
|
||||
"reasoning_content": '* User message: "Who is Jensen?" * Goal: Extract a query.',
|
||||
},
|
||||
}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
|
||||
mock_web_search = _run_web_preface(monkeypatch, "Does he work with Lisa Su?\nThanks")
|
||||
|
||||
mock_web_search.assert_called_with(
|
||||
"Does he work with Lisa Su?", time_filter=None, return_sources=True,
|
||||
)
|
||||
assert sent[0]["chat_template_kwargs"] == {"enable_thinking": False}
|
||||
|
||||
|
||||
def test_inline_think_block_never_becomes_the_web_search_query(monkeypatch):
|
||||
"""Without a reasoning parser, thinking arrives as ordinary content."""
|
||||
monkeypatch.setattr(
|
||||
"src.llm_core.llm_call",
|
||||
MagicMock(return_value="<think>The user is asking about"),
|
||||
)
|
||||
mock_web_search = _run_web_preface(monkeypatch, "Who is Jensen Huang?")
|
||||
|
||||
mock_web_search.assert_called_with("Who is Jensen Huang?", time_filter=None, return_sources=True)
|
||||
|
||||
|
||||
def _turn(role, content):
|
||||
return SimpleNamespace(role=role, content=content)
|
||||
|
||||
|
||||
def _extraction_messages(monkeypatch, message, history, **kwargs):
|
||||
mock_llm_call = MagicMock(return_value="query")
|
||||
monkeypatch.setattr("src.llm_core.llm_call", mock_llm_call)
|
||||
monkeypatch.setattr(
|
||||
"src.chat_processor.comprehensive_web_search",
|
||||
MagicMock(return_value=("Search Results", [])),
|
||||
)
|
||||
processor = ChatProcessor(memory_manager=MagicMock(), personal_docs_manager=MagicMock())
|
||||
processor.build_context_preface(
|
||||
message=message,
|
||||
session=SimpleNamespace(endpoint_url="http://local", model="test", headers={}, history=history),
|
||||
use_web=True,
|
||||
use_rag=False,
|
||||
use_memory=False,
|
||||
use_skills=False,
|
||||
**kwargs,
|
||||
)
|
||||
return mock_llm_call.call_args.args[2]
|
||||
|
||||
|
||||
def test_follow_up_query_extraction_sees_recent_turns(monkeypatch):
|
||||
followup = "Does he work with Lisa Su?"
|
||||
messages = _extraction_messages(monkeypatch, followup, [
|
||||
_turn("user", "Who is Jensen Huang from NVIDIA?"),
|
||||
_turn("assistant", "<think>Let me look.</think>Jensen Huang leads NVIDIA."),
|
||||
_turn("user", followup),
|
||||
])
|
||||
|
||||
assert messages[1]["content"] == (
|
||||
"Earlier conversation:\n"
|
||||
"user: Who is Jensen Huang from NVIDIA?\n"
|
||||
"assistant: Jensen Huang leads NVIDIA.\n\n"
|
||||
f"Latest message:\n{followup}"
|
||||
)
|
||||
|
||||
|
||||
def test_first_message_query_extraction_is_only_the_message(monkeypatch):
|
||||
messages = _extraction_messages(monkeypatch, "Who is Jensen?", [_turn("user", "Who is Jensen?")])
|
||||
|
||||
assert messages[1] == {"role": "user", "content": "Who is Jensen?"}
|
||||
|
||||
|
||||
def test_query_extraction_history_is_bounded(monkeypatch):
|
||||
history = [_turn("user", f"old-{i} " + "x" * 2000) for i in range(10)]
|
||||
history.append(_turn("assistant", "reply"))
|
||||
prompt = _extraction_messages(monkeypatch, "next", history)[1]["content"]
|
||||
|
||||
assert "old-6" not in prompt
|
||||
assert "old-7" in prompt and "old-9" in prompt
|
||||
assert len(prompt) < 2200
|
||||
|
||||
|
||||
def test_incognito_query_extraction_ignores_saved_history(monkeypatch):
|
||||
messages = _extraction_messages(
|
||||
monkeypatch, "Does he code?", [_turn("user", "Saved chat"), _turn("assistant", "Reply")],
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert messages[1] == {"role": "user", "content": "Does he code?"}
|
||||
|
||||
@@ -50,10 +50,17 @@ def _launch_llm(
|
||||
port: int,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str:
|
||||
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
endpoint_id = _auto_register_llm_endpoint(
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods
|
||||
)
|
||||
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
|
||||
register = cells["_auto_register_llm_endpoint"].cell_contents
|
||||
endpoint_id = register(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
|
||||
None,
|
||||
serve_session_id=serve_session_id,
|
||||
@@ -69,83 +76,6 @@ def _endpoint(factory, endpoint_id: str):
|
||||
return db.get(database.ModelEndpoint, endpoint_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cmd", "registers", "skips"),
|
||||
[
|
||||
(
|
||||
"python scripts/diffusion_server.py --port 8100",
|
||||
"_auto_register_image_endpoint",
|
||||
"_auto_register_llm_endpoint",
|
||||
),
|
||||
(
|
||||
"llama-server -m /models/model.gguf --port 8085",
|
||||
"_auto_register_llm_endpoint",
|
||||
"_auto_register_image_endpoint",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_registers_endpoint_for_its_model_type(monkeypatch, tmp_path, cmd, registers, skips):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes import cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
launch =MagicMock(returncode=0, wait=AsyncMock(return_value=0))
|
||||
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
|
||||
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=launch))
|
||||
# Returning no endpoint id keeps the crash watchdog from being scheduled.
|
||||
register = MagicMock(return_value=None)
|
||||
skip = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(cookbook_routes, registers, register)
|
||||
monkeypatch.setattr(cookbook_routes, skips, skip)
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods
|
||||
)
|
||||
req = ServeRequest(repo_id="organization-a/Model-A", cmd=cmd, remote_host="gpu-box")
|
||||
|
||||
response = await serve(Request({"type": "http", "headers": []}), req)
|
||||
|
||||
register.assert_called_once_with(req, "gpu-box", serve_session_id=response["session_id"])
|
||||
skip.assert_not_called()
|
||||
|
||||
|
||||
def _register_ollama(cmd: str) -> str:
|
||||
from routes.cookbook.endpoint_registration import _auto_register_llm_endpoint
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
endpoint_id = _auto_register_llm_endpoint(ServeRequest(repo_id="library/model", cmd=cmd), None)
|
||||
assert endpoint_id
|
||||
return endpoint_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["0.0.0.0", "h" * 253])
|
||||
def test_registers_port_from_ollama_host(endpoint_db, host):
|
||||
endpoint_id = _register_ollama(f"OLLAMA_HOST={host}:11435 ollama serve")
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11435/v1"
|
||||
|
||||
|
||||
def test_repeated_ollama_host_assignments_register_quickly(endpoint_db):
|
||||
import time
|
||||
|
||||
start = time.perf_counter()
|
||||
endpoint_id = _register_ollama("OLLAMA_HOST=" * 32000 + " ollama serve")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# The unbounded pattern took about 30s on this input and blocked the event loop.
|
||||
assert elapsed < 4.0, f"registration took {elapsed:.2f}s"
|
||||
assert _endpoint(endpoint_db, endpoint_id).base_url == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reuse", [False, True])
|
||||
def test_launch_keeps_endpoint_of_other_model_with_same_short_name(endpoint_db, reuse):
|
||||
if reuse:
|
||||
@@ -518,13 +448,20 @@ async def test_ordinary_admin_delete_does_not_require_serve_owner(
|
||||
|
||||
|
||||
def _launch_image(repo_id: str, port: int, monkeypatch) -> str:
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
import src.settings as settings
|
||||
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id})
|
||||
monkeypatch.setattr(settings, "save_settings", lambda _settings: None)
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods
|
||||
)
|
||||
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
|
||||
register = cells["_auto_register_image_endpoint"].cell_contents
|
||||
endpoint_id = register(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
|
||||
)
|
||||
assert endpoint_id
|
||||
|
||||
@@ -156,10 +156,28 @@ async def test_scheduled_stop_requires_session_identity(endpoint_db, endpoint_ht
|
||||
|
||||
|
||||
def _launch_image(repo_id, port, serve_session_id):
|
||||
from routes.cookbook.endpoint_registration import _auto_register_image_endpoint
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
|
||||
serve = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/model/serve"
|
||||
and "POST" in route.methods
|
||||
)
|
||||
|
||||
cells = dict(
|
||||
zip(
|
||||
serve.__code__.co_freevars,
|
||||
serve.__closure__ or (),
|
||||
)
|
||||
)
|
||||
|
||||
register = cells["_auto_register_image_endpoint"].cell_contents
|
||||
|
||||
endpoint_id = register(
|
||||
ServeRequest(
|
||||
repo_id=repo_id,
|
||||
cmd=f"python scripts/diffusion_server.py --port {port}",
|
||||
|
||||
@@ -93,22 +93,3 @@ def test_small_model_detection_uses_parameter_size_in_model_title():
|
||||
assert DeepResearcher._looks_like_small_local_model("model-9B")
|
||||
assert DeepResearcher._looks_like_small_local_model("model-10B")
|
||||
assert not DeepResearcher._looks_like_small_local_model("model-11B")
|
||||
|
||||
|
||||
def test_category_classification_turns_thinking_off(monkeypatch):
|
||||
"""Reasoning lists every category name, which would defeat the label scan."""
|
||||
sent = {}
|
||||
|
||||
async def fake_call(**kwargs):
|
||||
sent.update(kwargs)
|
||||
return "product"
|
||||
|
||||
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
|
||||
r = DeepResearcher.__new__(DeepResearcher)
|
||||
r.llm_endpoint = "http://x"
|
||||
r.llm_model = "m"
|
||||
r.llm_headers = {}
|
||||
|
||||
assert asyncio.run(r._classify_category("Best ThinkPad to buy?")) == "product"
|
||||
assert sent["thinking_mode"] == "off"
|
||||
assert sent["max_tokens"] == 20
|
||||
|
||||
@@ -87,58 +87,6 @@ def test_llm_call_content_wins_over_reasoning_content(monkeypatch):
|
||||
assert result == "Normal answer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking off: reasoning never stands in for the answer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_call_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_core.httpx, "post",
|
||||
lambda *a, **kw: _sync_response(_openai_msg("", "* User message: ...")),
|
||||
)
|
||||
result = llm_core.llm_call(
|
||||
"http://test/v1", "qwen3-8b",
|
||||
[{"role": "user", "content": "extract"}],
|
||||
thinking_mode="off",
|
||||
)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_llm_call_async_with_thinking_off_returns_empty_instead_of_reasoning(monkeypatch):
|
||||
class _FakeAsyncClient:
|
||||
async def post(self, *a, **kw):
|
||||
req = httpx.Request("POST", "http://test-async/v1/chat/completions")
|
||||
return httpx.Response(200, request=req,
|
||||
json=_openai_msg("", "* User message: ..."))
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_http_client",
|
||||
lambda: _FakeAsyncClient())
|
||||
|
||||
result = asyncio.run(llm_core.llm_call_async(
|
||||
"http://test-async/v1", "qwen3-8b",
|
||||
[{"role": "user", "content": "extract"}],
|
||||
thinking_mode="off",
|
||||
))
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatch):
|
||||
content = [
|
||||
{"type": "thinking", "thinking": [{"type": "text", "text": "reasoning"}]},
|
||||
{"type": "text", "text": "answer"},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
llm_core.httpx, "post",
|
||||
lambda *a, **kw: _sync_response(_openai_msg(content)),
|
||||
)
|
||||
result = llm_core.llm_call(
|
||||
"http://test/v1", "some-model",
|
||||
[{"role": "user", "content": "extract"}],
|
||||
thinking_mode="off",
|
||||
)
|
||||
assert result == "answer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming agent path tests (4 and 5)
|
||||
# These import and test _empty_response_fallback — the real production helper
|
||||
@@ -146,7 +94,21 @@ def test_llm_call_with_thinking_off_drops_mistral_structured_thinking(monkeypatc
|
||||
# changed, these tests will fail.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from src.agent_loop import _empty_response_fallback
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock heavy DB/tool deps before importing agent_loop
|
||||
for _mod in [
|
||||
"sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext",
|
||||
"sqlalchemy.ext.declarative", "sqlalchemy.ext.hybrid",
|
||||
"sqlalchemy.sql", "sqlalchemy.sql.expression",
|
||||
"src.database", "src.agent_tools",
|
||||
"core.models", "core.database",
|
||||
]:
|
||||
if _mod not in sys.modules:
|
||||
sys.modules[_mod] = MagicMock()
|
||||
|
||||
from src.agent_loop import _empty_response_fallback # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,7 +17,6 @@ clear_fake_database_modules()
|
||||
import core.database as cdb
|
||||
import routes.task_routes as task_routes
|
||||
from core.database import ScheduledTask
|
||||
from routes.task.task_routes import TaskCreate
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
@@ -126,32 +125,3 @@ def test_scheduler_cycle_guard_treats_cross_owner_chain_as_unsafe():
|
||||
assert scheduler._has_chain_cycle(db, "bob-target-cycle", owner="alice") is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_task_name_turns_thinking_off(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
async def fake_call(**kwargs):
|
||||
sent.update(kwargs)
|
||||
return "Daily AI News"
|
||||
|
||||
monkeypatch.setattr("src.llm_core.llm_call_async", fake_call)
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(cdb.Session(
|
||||
id="task-name-session", name="s", owner="alice",
|
||||
endpoint_url="http://127.0.0.1:8081/v1", model="gemma4-26b",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
create_task = _endpoint("POST", "/api/tasks")
|
||||
created = await create_task(
|
||||
SimpleNamespace(state=SimpleNamespace(current_user="alice"), headers={}),
|
||||
TaskCreate(prompt="Summarize AI news", task_type="llm", trigger_type="webhook"),
|
||||
)
|
||||
|
||||
assert sent["thinking_mode"] == "off"
|
||||
assert sent["max_tokens"] == 20
|
||||
assert created["name"] == "Daily AI News"
|
||||
|
||||
@@ -196,13 +196,6 @@ def test_network_prompts_are_classified_as_local_computer_work():
|
||||
assert _looks_like_local_computer_request(prompt), prompt
|
||||
|
||||
|
||||
def test_named_targets_need_computer_task_wording_to_be_local_computer_work():
|
||||
assert _looks_like_local_computer_request("check disk usage on gpu-box")
|
||||
assert _looks_like_local_computer_request("copy the logs from gpu-box")
|
||||
assert not _looks_like_local_computer_request("Who is Jensen Huang from NVIDIA?")
|
||||
assert not _looks_like_local_computer_request("What is the best book on Rome?")
|
||||
|
||||
|
||||
def test_network_lookup_replaces_ping_with_address_evidence():
|
||||
normalized = _tui_normalize_network_host_command(
|
||||
"ping -c 4 ajax.local",
|
||||
|
||||
@@ -393,36 +393,6 @@ def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monke
|
||||
assert sent == [(UTIL[0], UTIL[1], None)]
|
||||
|
||||
|
||||
def _capture_title_thinking(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
sent.append((kw.get("reasoning_effort"), kw.get("thinking_mode")))
|
||||
return "A title"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
return sent
|
||||
|
||||
|
||||
def test_auto_title_turns_thinking_off_without_a_configured_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
sent = _capture_title_thinking(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(None, "off")]
|
||||
|
||||
|
||||
def test_auto_title_keeps_thinking_default_with_a_configured_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
util.settings["alice"] = "medium"
|
||||
sent = _capture_title_thinking(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [("medium", None)]
|
||||
|
||||
|
||||
def test_memory_extraction_passes_chat_effort(util, monkeypatch):
|
||||
from services.memory import memory_extractor
|
||||
|
||||
|
||||
Reference in New Issue
Block a user