mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-04 04:02:21 +01:00
Compare commits
6
Commits
7e5db9a3c6
...
119228a6db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119228a6db | ||
|
|
8f5e36a079 | ||
|
|
30dd789351 | ||
|
|
e8175c9535 | ||
|
|
bd9149f79a | ||
|
|
fef08ed114 |
@@ -44,7 +44,7 @@ from typing import Dict
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -65,7 +65,7 @@ from core.exceptions import (
|
||||
|
||||
import bcrypt as _bcrypt
|
||||
|
||||
from src.app_helpers import abs_join
|
||||
from src.app_helpers import abs_join, serve_html_with_nonce
|
||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
@@ -791,22 +791,14 @@ app.include_router(setup_companion_routes())
|
||||
|
||||
# ========= ROUTES (kept in app.py) =========
|
||||
|
||||
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
nonce = getattr(request.state, "csp_nonce", "")
|
||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index(request: Request):
|
||||
static_path = abs_join(BASE_DIR, "static/index.html")
|
||||
if os.path.exists(static_path):
|
||||
return _serve_html_with_nonce(request, static_path)
|
||||
return serve_html_with_nonce(request, static_path)
|
||||
root_path = abs_join(BASE_DIR, "index.html")
|
||||
if os.path.exists(root_path):
|
||||
return _serve_html_with_nonce(request, root_path)
|
||||
return serve_html_with_nonce(request, root_path)
|
||||
raise HTTPException(404, "index.html not found")
|
||||
|
||||
@app.get("/notes")
|
||||
@@ -848,13 +840,13 @@ async def serve_library(request: Request):
|
||||
@app.get("/backgrounds")
|
||||
async def serve_backgrounds(request: Request):
|
||||
"""Sandbox page for prototyping background effects. No auth required."""
|
||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||
|
||||
@app.get("/login")
|
||||
async def serve_login(request: Request):
|
||||
if not AUTH_ENABLED:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version():
|
||||
|
||||
@@ -14059,6 +14059,138 @@
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.5,
|
||||
"recommended_ram_gb": 11.0,
|
||||
"min_vram_gb": 7.5,
|
||||
"quantization": "Q4_K_M",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal; unsloth/gemma-4-12B-it-GGUF Dynamic variants reduce VRAM from ~7.5 GB to ~5.5 GB",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "unsloth/gemma-4-12B-it-GGUF",
|
||||
"provider": "unsloth"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int4",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.0,
|
||||
"recommended_ram_gb": 9.5,
|
||||
"min_vram_gb": 6.5,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal (QAT quantization-aware training — higher quality than post-train INT4; vLLM native; no GGUF)",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-int8",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 15.0,
|
||||
"recommended_ram_gb": 20.0,
|
||||
"min_vram_gb": 13.5,
|
||||
"quantization": "QAT-INT8",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal (QAT INT8 — highest quality, 2x VRAM of QAT-INT4; vLLM native; no GGUF)",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 8.5,
|
||||
"recommended_ram_gb": 11.0,
|
||||
"min_vram_gb": 7.5,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 262144,
|
||||
"use_case": "General purpose, multimodal (vision + audio); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp/Ollama with CPU offload",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"file": "gemma-4-12b-it-qat-q4_0.gguf"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||
"provider": "Google",
|
||||
"parameter_count": "25.2B",
|
||||
"parameters_raw": 25200000000,
|
||||
"min_ram_gb": 14.4,
|
||||
"recommended_ram_gb": 18.0,
|
||||
"min_vram_gb": 14.4,
|
||||
"quantization": "QAT-INT4",
|
||||
"context_length": 262144,
|
||||
"use_case": "High-throughput, multimodal MoE (3.8B active); official Google QAT int4 GGUF — near-bf16 quality at int4 size, served on llama.cpp with CPU offload",
|
||||
"is_moe": true,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": 3800000000,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [
|
||||
{
|
||||
"repo": "google/gemma-4-26B-A4B-it-qat-q4_0-gguf",
|
||||
"provider": "Google"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-31B-it",
|
||||
"provider": "Google",
|
||||
@@ -19144,4 +19276,4 @@
|
||||
],
|
||||
"_discovered": true
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -9,7 +9,7 @@ from services.hwfit.models import (
|
||||
GPU_BANDWIDTH = {
|
||||
"5090": 1792, "5080": 960, "5070 ti": 896, "5070": 672, "5060 ti": 448, "5060": 256,
|
||||
"4090": 1008, "4080 super": 736, "4080": 717, "4070 ti super": 672, "4070 ti": 504, "4070 super": 504, "4070": 504, "4060 ti": 288, "4060": 272,
|
||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360,
|
||||
"3090 ti": 1008, "3090": 936, "3080 ti": 912, "3080": 760, "3070 ti": 608, "3070": 448, "3060 ti": 448, "3060": 360, "3050 ti": 192, "3050": 224,
|
||||
"2080 ti": 616, "2080 super": 496, "2080": 448, "2070 super": 448, "2070": 448, "2060 super": 448, "2060": 336,
|
||||
"1660 ti": 288, "1660 super": 336, "1660": 192, "1650 super": 192, "1650": 128,
|
||||
"h100 sxm": 3350, "h100": 2039, "h200": 4800, "a100 sxm": 2039, "a100": 1555,
|
||||
|
||||
@@ -12,6 +12,7 @@ QUANT_BPP = {
|
||||
"Q4_K_M": 0.58, "Q4_0": 0.58, "Q3_K_M": 0.48, "Q2_K": 0.37,
|
||||
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
|
||||
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
|
||||
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
|
||||
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
|
||||
@@ -30,6 +31,7 @@ QUANT_SPEED_MULT = {
|
||||
"Q4_K_M": 1.15, "Q4_0": 1.15, "Q3_K_M": 1.25, "Q2_K": 1.35,
|
||||
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
|
||||
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
|
||||
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
|
||||
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
|
||||
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
|
||||
"FP8-Mixed": 0.85,
|
||||
@@ -47,6 +49,10 @@ QUANT_QUALITY_PENALTY = {
|
||||
# penalty so FP8 wins when both fit. AWQ-4bit stays heavier.
|
||||
"AWQ": -1.0, "AWQ-4bit": -4.0, "AWQ-8bit": -1.0,
|
||||
"GPTQ": -1.0, "GPTQ-Int4": -4.0, "GPTQ-Int8": -1.0,
|
||||
# Quantization-aware training recovers most of the int4 quality loss, so a
|
||||
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
|
||||
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
|
||||
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
|
||||
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
|
||||
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
|
||||
# so the realized quality is much closer to FP8 than to pure FP4 —
|
||||
@@ -63,6 +69,7 @@ QUANT_BYTES_PER_PARAM = {
|
||||
"Q4_K_M": 0.5, "Q4_0": 0.5, "Q3_K_M": 0.375, "Q2_K": 0.25,
|
||||
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
|
||||
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
|
||||
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
|
||||
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
|
||||
"FP4-MoE-Mixed": 0.55,
|
||||
"FP8-Mixed": 1.0,
|
||||
@@ -74,6 +81,7 @@ PREQUANTIZED_PREFIXES = (
|
||||
"AWQ-", "GPTQ-", "mlx-", "FP8", "FP4", "NVFP4", "MXFP4", "NF4",
|
||||
"INT4", "INT8", "W4A16", "W8A8", "W8A16",
|
||||
"FP4-MoE-Mixed", "FP8-Mixed",
|
||||
"QAT-",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+30
-1
@@ -1,6 +1,13 @@
|
||||
# src/app_helpers.py
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def read_if_exists(path: str) -> str:
|
||||
"""Read file if it exists, return empty string otherwise."""
|
||||
@@ -20,6 +27,28 @@ def abs_join(base_dir: str, rel: str) -> str:
|
||||
"""Join paths and return absolute path."""
|
||||
return os.path.abspath(os.path.join(base_dir, rel))
|
||||
|
||||
def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||
"""Read an app-bundled HTML page and inject the CSP nonce into inline <script> tags.
|
||||
|
||||
Callers pass fixed, server-owned template paths (index/login/backgrounds),
|
||||
never a client-supplied path. So any read failure here — a missing file
|
||||
(broken deployment) or a permission/IO error — is a server fault, not a
|
||||
client "not found": map all of them to a logged 500 so a missing core
|
||||
template surfaces in 5xx alerting instead of hiding behind a 404. If a
|
||||
future caller serves a client-influenced path where 404 is correct, branch
|
||||
that at the call site rather than defaulting this shared helper to 404.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
except OSError:
|
||||
logger.exception("Failed to read page %s", file_path)
|
||||
raise HTTPException(500, "Internal server error")
|
||||
nonce = getattr(request.state, "csp_nonce", "")
|
||||
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
def inside_base_dir(base_dir: str, path: str) -> bool:
|
||||
"""Check if path is inside base directory."""
|
||||
if not isinstance(base_dir, str) or not isinstance(path, str):
|
||||
|
||||
+155
-28
@@ -345,43 +345,102 @@ def _normalize_ollama_url(url: str) -> str:
|
||||
return base.rstrip("/") + "/chat"
|
||||
|
||||
|
||||
def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
|
||||
def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
|
||||
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
|
||||
|
||||
Odysseus carries assistant tool calls in the OpenAI shape, where
|
||||
`function.arguments` is a JSON *string*. Native Ollama expects it to be a
|
||||
JSON *object*; given the string it fails the whole request with HTTP 400
|
||||
"Value looks like object, but can't find closing '}' symbol", which aborts
|
||||
every follow-up (tool-result) round. Parse the arguments back into an object
|
||||
here, on a shallow copy, leaving non-tool messages untouched. The opaque
|
||||
Gemini `extra_content` (thought_signature) is dropped — it is meaningless to
|
||||
Ollama and only matters when the conversation is replayed to Gemini.
|
||||
Two shape mismatches silently break requests:
|
||||
|
||||
1. Tool calls: Odysseus carries `function.arguments` as a JSON *string*.
|
||||
Native Ollama expects a JSON *object* and rejects the string form with
|
||||
HTTP 400 ("Value looks like object, but can't find closing '}' symbol"),
|
||||
aborting every follow-up (tool-result) round. Parse the arguments back
|
||||
into an object here, on a shallow copy, leaving non-tool messages
|
||||
untouched. The opaque Gemini `extra_content` (thought_signature) is
|
||||
dropped — it is meaningless to Ollama and only matters when the
|
||||
conversation is replayed to Gemini.
|
||||
|
||||
2. Images (issue #4723): Odysseus carries multimodal user content as an
|
||||
OpenAI-style list ``[{type: "text", ...}, {type: "image_url",
|
||||
image_url: {url: "data:image/...;base64,XXX"}}, ...]``. Native Ollama
|
||||
does not accept a list for ``content`` — it wants ``content`` as a
|
||||
string plus a separate ``images`` array of raw base64 strings (no
|
||||
``data:`` prefix). Without this conversion the image blocks pass
|
||||
through untouched, the vision-capable model never sees the picture,
|
||||
and the user gets "I can't see any image" even though the request
|
||||
succeeded.
|
||||
"""
|
||||
out: List[Dict] = []
|
||||
for m in messages or []:
|
||||
tcs = m.get("tool_calls") if isinstance(m, dict) else None
|
||||
if not tcs:
|
||||
if not isinstance(m, dict):
|
||||
out.append(m)
|
||||
continue
|
||||
new_calls = []
|
||||
for tc in tcs:
|
||||
fn = tc.get("function") or {}
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args) if args.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
||||
if tc.get("id"):
|
||||
call["id"] = tc["id"]
|
||||
new_calls.append(call)
|
||||
|
||||
nm = dict(m)
|
||||
nm["tool_calls"] = new_calls
|
||||
|
||||
# 1. Tool-call argument strings -> objects.
|
||||
tcs = nm.get("tool_calls")
|
||||
if tcs:
|
||||
new_calls = []
|
||||
for tc in tcs:
|
||||
fn = tc.get("function") or {}
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args) if args.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
||||
if tc.get("id"):
|
||||
call["id"] = tc["id"]
|
||||
new_calls.append(call)
|
||||
nm["tool_calls"] = new_calls
|
||||
|
||||
# 2. Multimodal content list -> native content string + images array.
|
||||
content = nm.get("content")
|
||||
if isinstance(content, list):
|
||||
text_parts: List[str] = []
|
||||
images: List[str] = list(nm.get("images") or [])
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
t = block.get("text")
|
||||
if t:
|
||||
text_parts.append(str(t))
|
||||
elif btype == "image_url":
|
||||
url = (block.get("image_url") or {}).get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("data:"):
|
||||
# Strip the ``data:[...];base64,`` prefix — native
|
||||
# Ollama wants only the base64 bytes.
|
||||
_, _, b64 = url.partition(",")
|
||||
if b64:
|
||||
images.append(b64)
|
||||
else:
|
||||
# Native Ollama images[] is base64-only; it does
|
||||
# not fetch HTTP URLs. Skip unsupported schemes
|
||||
# rather than sending a non-base64 string that the
|
||||
# model silently ignores.
|
||||
logger.warning(
|
||||
"Skipping non-data image_url (Ollama images[] "
|
||||
"requires base64): %s",
|
||||
url[:80],
|
||||
)
|
||||
nm["content"] = "\n".join(text_parts).strip()
|
||||
if images:
|
||||
nm["images"] = images
|
||||
|
||||
out.append(nm)
|
||||
return out
|
||||
|
||||
|
||||
# Backward-compatible alias for callers/tests that imported the older name
|
||||
# (it only handled tool messages originally — issue #4723 broadened scope).
|
||||
_ollama_normalize_tool_messages = _ollama_normalize_messages
|
||||
|
||||
|
||||
def _build_ollama_payload(
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
@@ -404,7 +463,7 @@ def _build_ollama_payload(
|
||||
"""
|
||||
payload: Dict = {
|
||||
"model": model,
|
||||
"messages": _ollama_normalize_tool_messages(messages),
|
||||
"messages": _ollama_normalize_messages(messages),
|
||||
"stream": stream,
|
||||
}
|
||||
options: Dict = {}
|
||||
@@ -618,6 +677,8 @@ def _detect_provider(url: str) -> str:
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url):
|
||||
return "copilot"
|
||||
if _host_match(url, "mistral.ai"):
|
||||
return "mistral"
|
||||
return "openai"
|
||||
|
||||
|
||||
@@ -906,10 +967,17 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
||||
return False
|
||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||
|
||||
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||
# API accepts "high", "medium", "low", "none" — see
|
||||
# https://docs.mistral.ai/capabilities/reasoning/. Override via env var
|
||||
# ODYSSEUS_MISTRAL_REASONING_EFFORT (e.g. set to "medium" for cheaper chat).
|
||||
_MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high")
|
||||
|
||||
# Models that support structured thinking — may output </think> without opening tag
|
||||
_THINKING_MODEL_PATTERNS = (
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
|
||||
"m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
)
|
||||
|
||||
def _supports_thinking(model: str) -> bool:
|
||||
@@ -919,6 +987,38 @@ def _supports_thinking(model: str) -> bool:
|
||||
m = model.lower()
|
||||
return any(p in m for p in _THINKING_MODEL_PATTERNS)
|
||||
|
||||
def _normalize_mistral_content(content):
|
||||
"""Mistral returns content as a structured array when reasoning is on:
|
||||
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||
{"type": "text", "text": "...final answer..."}]
|
||||
Convert to (text, thinking) tuple of plain strings. Pass through strings
|
||||
unchanged so non-Mistral OpenAI-compat endpoints are unaffected.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content, ""
|
||||
if not isinstance(content, list):
|
||||
return "", ""
|
||||
text_parts = []
|
||||
thinking_parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
t = block.get("text", "")
|
||||
if t:
|
||||
text_parts.append(t)
|
||||
elif btype == "thinking":
|
||||
inner = block.get("thinking", [])
|
||||
if isinstance(inner, list):
|
||||
for tb in inner:
|
||||
if isinstance(tb, dict) and tb.get("text"):
|
||||
thinking_parts.append(tb["text"])
|
||||
elif isinstance(inner, str):
|
||||
thinking_parts.append(inner)
|
||||
return "".join(text_parts), "".join(thinking_parts)
|
||||
|
||||
|
||||
def _convert_openai_content_to_anthropic(content):
|
||||
"""Convert OpenAI multimodal content blocks to Anthropic format.
|
||||
|
||||
@@ -1441,6 +1541,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
@@ -1456,7 +1558,16 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
msg = data["choices"][0]["message"]
|
||||
response = msg.get("content") or msg.get("reasoning_content") or ""
|
||||
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:
|
||||
@@ -1638,6 +1749,8 @@ async def llm_call_async(
|
||||
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
@@ -1756,6 +1869,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
payload[tok_key] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||
# activates thinking mode and returns structured reasoning_content.
|
||||
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||
# (high / medium / low / none); default "high".
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
|
||||
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
|
||||
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
|
||||
@@ -2134,9 +2253,17 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
# Text content
|
||||
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
|
||||
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
|
||||
content = delta.get("content") or ""
|
||||
# Mistral structured content: content is a list of typed blocks
|
||||
# ({"type": "thinking", ...}, {"type": "text", ...}). Split into
|
||||
# reasoning + text so thinking streams into the thinking panel.
|
||||
if isinstance(content, list):
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||
content = text_part
|
||||
if reasoning:
|
||||
yield _stream_delta_event(reasoning, thinking=True)
|
||||
content = delta.get("content") or ""
|
||||
if content:
|
||||
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { attachColorPicker } from './colorPicker.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) {
|
||||
dropdown.className = 'cal-event-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||
|
||||
const _item = (icon, label, onClick, danger) => {
|
||||
const it = document.createElement('div');
|
||||
|
||||
@@ -407,8 +407,44 @@ function _openVisionEditor(att, userMsgEl) {
|
||||
|
||||
// Tool call syntax patterns to strip from displayed text
|
||||
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
||||
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
|
||||
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
|
||||
// Strip fenced tool-call blocks that look like structured invocations, not
|
||||
// regular code examples. The tool tags are NOT hard-coded here — they are the
|
||||
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
|
||||
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
|
||||
// thus the single source: the live-strip list can never drift from the backend
|
||||
// or miss a future tool (#3993). bash/python are carved out on purpose — they
|
||||
// are languages a user may legitimately have asked the model to show, not tool
|
||||
// invocations.
|
||||
//
|
||||
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
|
||||
// stripped — normally a sub-second window before the first stream. If the fetch
|
||||
// fails it stays null for the rest of the session (logged below), so live exec
|
||||
// fences won't be stripped until reload. Either way the backend already strips
|
||||
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
|
||||
// so a reload always renders clean.
|
||||
let EXEC_FENCE_RE = null;
|
||||
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
|
||||
|
||||
async function loadExecFenceRegex() {
|
||||
try {
|
||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const tags = (data.tools || [])
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||
if (tags.length) {
|
||||
EXEC_FENCE_RE = new RegExp(
|
||||
'```(?:' + tags.join('|') + ')\\s*\\n[\\s\\S]*?```', 'gi'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
|
||||
// so this session won't strip live exec fences until reload (persisted path
|
||||
// stays clean regardless).
|
||||
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
|
||||
}
|
||||
}
|
||||
loadExecFenceRegex();
|
||||
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
|
||||
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
||||
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
||||
@@ -853,7 +889,7 @@ export function roleTimestamp(when) {
|
||||
*/
|
||||
export function stripToolBlocks(text) {
|
||||
let cleaned = text.replace(TOOL_CALL_RE, '');
|
||||
cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
||||
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
||||
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './cookbookServe.js';
|
||||
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||
@@ -1529,7 +1530,7 @@ async function _fetchDependencies() {
|
||||
const minW = 150;
|
||||
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
|
||||
left = Math.max(8, left);
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
let _envState;
|
||||
@@ -1019,7 +1020,7 @@ function _rerenderCachedModels() {
|
||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||
dropdown.appendChild(cancelDiv);
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
document.body.appendChild(dropdown);
|
||||
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
||||
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
||||
@@ -2166,7 +2167,7 @@ function _rerenderCachedModels() {
|
||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||
// position after mount (below) using the menu's real measured size, so it
|
||||
// can't run off-screen on a narrow mobile viewport.
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
|
||||
if (!modelSlots.length) {
|
||||
const empty = document.createElement('div');
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Extracted from document.js to reduce file size.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import uiModule from './ui.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.display = 'block';
|
||||
dd.style.zIndex = '100000';
|
||||
dd.style.zIndex = String(topPortalZ());
|
||||
requestAnimationFrame(() => {
|
||||
const mr = dd.getBoundingClientRect();
|
||||
if (mr.bottom > window.innerHeight - 8) {
|
||||
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
document.body.appendChild(dropdown);
|
||||
dropdown.dataset.owner = doc.id;
|
||||
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;`;
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js';
|
||||
import settingsModule from './settings.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import {
|
||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||
@@ -5512,7 +5513,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
dropdown._anchor = anchor;
|
||||
anchor.classList.add('reader-more-active');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
@@ -5749,7 +5750,7 @@ function _showCardMenu(em, anchor) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
||||
@@ -5940,7 +5941,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
@@ -6221,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) {
|
||||
`max-height:${window.innerHeight - 16}px`,
|
||||
'overflow:auto',
|
||||
'box-sizing:border-box',
|
||||
'z-index:10060',
|
||||
`z-index:${topPortalZ()}`,
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Each entry: [char, label, svgPath OR svg]
|
||||
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
|
||||
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
|
||||
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
|
||||
_pickerEl.style.position = 'fixed';
|
||||
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
||||
_pickerEl.style.left = rect.left + 'px';
|
||||
_pickerEl.style.zIndex = '10000';
|
||||
_pickerEl.style.zIndex = String(topPortalZ());
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const pr = _pickerEl.getBoundingClientRect();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -2524,7 +2525,7 @@ export function openGallery() {
|
||||
const left = Math.min(rect.left, window.innerWidth - 200);
|
||||
// Inline the standard dropdown look so it renders correctly even where the
|
||||
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
|
||||
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
|
||||
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -865,7 +866,13 @@ export function renderMemoryList() {
|
||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.zIndex = '10001';
|
||||
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
|
||||
// Tool modals get a monotonically increasing z-index from modalManager's
|
||||
// bring-to-front counter, which climbs unbounded over a long session —
|
||||
// once it passed the old hardcoded 10001 the menu rendered behind the
|
||||
// panel (#4720). topPortalZ() derives the value from the live tool-window
|
||||
// stack so the menu always sits just above, however high it has climbed.
|
||||
dropdown.style.zIndex = String(topPortalZ());
|
||||
dropdown.style.display = 'block';
|
||||
document.body.appendChild(dropdown);
|
||||
// Keep on-screen (mobile): flip above the button if it overflows the
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import { attachColorPicker } from './colorPicker.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
@@ -4335,7 +4335,7 @@ function _openNoteCornerMenu(btn) {
|
||||
const mh = menu.offsetHeight || 96;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||
@@ -4349,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
|
||||
const mh = menu.offsetHeight || 112;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
|
||||
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
|
||||
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||
return top + 1;
|
||||
}
|
||||
|
||||
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
|
||||
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
|
||||
// dropdown has to clear those too, not just the open tool-window stack, so this
|
||||
// floor keeps it above a chip even when no modal is currently raised.
|
||||
const DOCK_OVERLAY_FLOOR = 10030;
|
||||
|
||||
// The z a body-portaled dropdown/menu needs so it always sits just above every
|
||||
// open tool window (and the dock chips) right now. Tool modals get a
|
||||
// monotonically increasing z from the bring-to-front counter (modalManager),
|
||||
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
|
||||
// these dropdowns historically used eventually rendered them BEHIND their own
|
||||
// modal (#4720). Derive the value from the live stack instead, sharing the same
|
||||
// single source of truth as nextToolWindowZ().
|
||||
export function topPortalZ(options = {}) {
|
||||
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, is_prequantized
|
||||
|
||||
|
||||
def _8gb_vram_system():
|
||||
return {
|
||||
"has_gpu": True,
|
||||
"backend": "cuda",
|
||||
"gpu_name": "NVIDIA GeForce RTX 4060",
|
||||
"gpu_vram_gb": 8.0,
|
||||
"gpu_count": 1,
|
||||
"available_ram_gb": 32.0,
|
||||
"total_ram_gb": 32.0,
|
||||
}
|
||||
|
||||
|
||||
def test_gemma4_12b_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||
|
||||
|
||||
def test_gemma4_12b_has_gguf_source():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
entry = catalog["google/gemma-4-12B-it"]
|
||||
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||
|
||||
|
||||
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||
names = [r["name"] for r in results]
|
||||
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Regression test for #3993 — live chat leaves executed tool fences visible.
|
||||
|
||||
The backend strips every fenced tool block (``src/tool_parsing.py`` builds its
|
||||
regex from the full ``TOOL_TAGS`` set), so a reloaded session renders cleanly.
|
||||
The live frontend path uses its own regex, ``EXEC_FENCE_RE`` in
|
||||
``static/js/chatRenderer.js``.
|
||||
|
||||
Originally that regex came from a hand-maintained subset, so any executable tool
|
||||
not in it — and every *future* tool added to ``TOOL_TAGS`` — left its executed
|
||||
fence lingering as a raw code block in the live bubble until reload. The fix
|
||||
makes ``TOOL_TAGS`` the single source: ``chatRenderer.js`` no longer hard-codes a
|
||||
tool list at all. It fetches the backend's authoritative set once from
|
||||
``GET /api/tools`` (which serves ``sorted(TOOL_TAGS)``) and builds
|
||||
``EXEC_FENCE_RE`` from it at load, minus ``bash``/``python`` (legitimate code
|
||||
examples a user may have asked the model to show). There is no second list to
|
||||
drift.
|
||||
|
||||
``chatRenderer.js`` pulls browser globals and can't be imported under node, so
|
||||
the behavioral tests exercise an equivalent Python regex built straight from the
|
||||
backend ``TOOL_TAGS`` — the same source the live regex now derives from — and
|
||||
source-level guards assert the frontend keeps no hard-coded list.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path("static/js/chatRenderer.js")
|
||||
_TOOLS_SRC = Path("src/agent_tools/__init__.py")
|
||||
_ROUTES_SRC = Path("routes/model_routes.py")
|
||||
|
||||
# Deliberately NOT stripped: legitimate code-example languages, not tool
|
||||
# invocations. Must match the carve-out in chatRenderer.js.
|
||||
_NON_STRIPPED = {"bash", "python"}
|
||||
|
||||
|
||||
def _tool_tags() -> set[str]:
|
||||
"""Extract the backend TOOL_TAGS set from src/agent_tools/__init__.py (source-level)."""
|
||||
source = _TOOLS_SRC.read_text(encoding="utf-8")
|
||||
m = re.search(r"TOOL_TAGS\s*=\s*\{(?P<body>.*?)\}", source, re.DOTALL)
|
||||
assert m, "TOOL_TAGS literal not found in src/agent_tools/__init__.py"
|
||||
return set(re.findall(r'"([a-z_]+)"', m.group("body")))
|
||||
|
||||
|
||||
def _exec_fence_regex() -> re.Pattern:
|
||||
"""Rebuild EXEC_FENCE_RE's behavior from the same source the live regex now
|
||||
derives from: the backend TOOL_TAGS (served via /api/tools) minus bash/python."""
|
||||
tags = _tool_tags() - _NON_STRIPPED
|
||||
assert tags, "TOOL_TAGS is empty"
|
||||
return re.compile(r"```(?:" + "|".join(sorted(tags)) + r")\s*\n[\s\S]*?```", re.IGNORECASE)
|
||||
|
||||
|
||||
def test_strips_executed_email_tool_fences():
|
||||
rx = _exec_fence_regex()
|
||||
# The exact shape the reporter observed lingering in the live bubble.
|
||||
text = 'Here are emails\n\n```list_emails\n{"max_results":10}\n```'
|
||||
assert rx.sub("", text).strip() == "Here are emails"
|
||||
|
||||
|
||||
def test_strips_every_named_email_tool_fence():
|
||||
rx = _exec_fence_regex()
|
||||
email_tools = [
|
||||
"list_email_accounts", "send_email", "list_emails", "read_email",
|
||||
"reply_to_email", "bulk_email", "archive_email", "delete_email",
|
||||
"mark_email_read",
|
||||
]
|
||||
for tool in email_tools:
|
||||
fence = f"```{tool}\n{{}}\n```"
|
||||
assert rx.sub("", fence).strip() == "", f"{tool} fence not stripped"
|
||||
|
||||
|
||||
def test_preserves_existing_web_search_stripping():
|
||||
rx = _exec_fence_regex()
|
||||
fence = '```web_search\n{"q":"x"}\n```'
|
||||
assert rx.sub("", fence).strip() == ""
|
||||
|
||||
|
||||
def test_does_not_strip_bash_or_python_code_examples():
|
||||
"""bash/python fences are deliberately excluded — they are legitimate code
|
||||
examples a user may have asked the model to show, not tool invocations."""
|
||||
rx = _exec_fence_regex()
|
||||
for lang in sorted(_NON_STRIPPED):
|
||||
example = f"```{lang}\nls -la\n```"
|
||||
assert rx.sub("", example) == example, f"{lang} example wrongly stripped"
|
||||
|
||||
|
||||
def test_frontend_keeps_no_hardcoded_tool_list():
|
||||
"""Root-cause guard for #3993: chatRenderer.js must NOT reintroduce a
|
||||
hand-maintained tool list. A hard-coded mirror of TOOL_TAGS silently drifts
|
||||
when a new tool is added — leaving its executed fence in the live bubble
|
||||
until reload. The live regex must instead be built from the backend's
|
||||
authoritative set fetched at runtime."""
|
||||
source = _SRC.read_text(encoding="utf-8")
|
||||
assert "EXEC_TOOL_TAGS" not in source, (
|
||||
"chatRenderer.js reintroduced a hard-coded EXEC_TOOL_TAGS list; the "
|
||||
"live-strip tags must come from GET /api/tools so TOOL_TAGS stays the "
|
||||
"single source (#3993)."
|
||||
)
|
||||
assert "/api/tools" in source, (
|
||||
"chatRenderer.js must fetch the tool set from /api/tools to build "
|
||||
"EXEC_FENCE_RE."
|
||||
)
|
||||
# The bash/python carve-out must survive the move to the runtime list.
|
||||
m = re.search(r"EXEC_FENCE_NON_TOOL\s*=\s*new Set\(\[(?P<body>.*?)\]\)", source, re.DOTALL)
|
||||
assert m, "bash/python carve-out (EXEC_FENCE_NON_TOOL) not found in chatRenderer.js"
|
||||
carve_out = set(re.findall(r"['\"]([a-z_]+)['\"]", m.group("body")))
|
||||
assert carve_out == _NON_STRIPPED, (
|
||||
f"EXEC_FENCE_NON_TOOL must carve out exactly {sorted(_NON_STRIPPED)}, "
|
||||
f"got {sorted(carve_out)}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_tools_endpoint_serves_full_tool_tags():
|
||||
"""The frontend's single source is GET /api/tools. Guard that the endpoint
|
||||
serves the complete TOOL_TAGS set (sorted) — if it ever served a subset, the
|
||||
live-strip list would silently shrink with no second list to catch it."""
|
||||
source = _ROUTES_SRC.read_text(encoding="utf-8")
|
||||
assert re.search(r"for\s+tag\s+in\s+sorted\(\s*TOOL_TAGS\s*\)", source), (
|
||||
"GET /api/tools must iterate sorted(TOOL_TAGS) so the frontend's "
|
||||
"EXEC_FENCE_RE covers every executable tool (#3993)."
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for _normalize_mistral_content() — Mistral's structured content parser.
|
||||
|
||||
Mistral's chat completions API returns content as a typed array when reasoning
|
||||
is enabled, instead of the plain string most OpenAI-compat servers use:
|
||||
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||
{"type": "text", "text": "..."}
|
||||
]
|
||||
|
||||
_normalize_mistral_content() splits that into (text, thinking) plain strings.
|
||||
The function is called from three sites:
|
||||
- llm_call (sync, non-streaming response parser)
|
||||
- llm_call_async (async, non-streaming response parser)
|
||||
- stream_llm (streaming delta parser)
|
||||
|
||||
These tests pin the contract: string passthrough, the array shape, and the
|
||||
edge cases (empty, garbage, missing fields) so a refactor doesn't silently
|
||||
drop thinking content or break non-Mistral providers.
|
||||
"""
|
||||
from src.llm_core import _normalize_mistral_content
|
||||
|
||||
|
||||
def test_string_passthrough_returns_text_with_empty_thinking():
|
||||
"""Plain string content (the common case) passes through unchanged."""
|
||||
text, thinking = _normalize_mistral_content("hello world")
|
||||
assert text == "hello world"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_empty_string_passthrough():
|
||||
text, thinking = _normalize_mistral_content("")
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_with_thinking_and_text_blocks():
|
||||
"""Mistral's documented format: thinking block + text block."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "Let me work through this..."}],
|
||||
"closed": True,
|
||||
},
|
||||
{"type": "text", "text": "The answer is 42."},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "The answer is 42."
|
||||
assert thinking == "Let me work through this..."
|
||||
|
||||
|
||||
def test_array_with_only_thinking_block():
|
||||
"""Streaming deltas often contain only a thinking fragment (no text block yet)."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "Okay, let's"}],
|
||||
"closed": True,
|
||||
}
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == "Okay, let's"
|
||||
|
||||
|
||||
def test_array_with_only_text_block():
|
||||
"""Final answer delta — only the text block, no thinking."""
|
||||
content = [{"type": "text", "text": "Final answer."}]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "Final answer."
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_concatenates_multiple_text_blocks():
|
||||
"""Multiple text blocks are concatenated in order."""
|
||||
content = [
|
||||
{"type": "text", "text": "part 1 "},
|
||||
{"type": "text", "text": "part 2"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "part 1 part 2"
|
||||
|
||||
|
||||
def test_array_concatenates_multiple_thinking_fragments():
|
||||
"""Multiple thinking sub-blocks are concatenated in order."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [
|
||||
{"type": "text", "text": "first "},
|
||||
{"type": "text", "text": "second"},
|
||||
],
|
||||
"closed": True,
|
||||
}
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == "first second"
|
||||
|
||||
|
||||
def test_empty_array_returns_empty_strings():
|
||||
text, thinking = _normalize_mistral_content([])
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_with_garbage_entries_skips_them():
|
||||
"""Non-dict entries, missing type, missing text — all silently skipped."""
|
||||
content = [
|
||||
"not a dict",
|
||||
None,
|
||||
{"type": "unknown_type", "text": "should be ignored"},
|
||||
{"type": "text"}, # missing text key
|
||||
{"type": "thinking"}, # missing thinking key
|
||||
{"type": "text", "text": "valid text"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "valid text"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_none_returns_empty_strings():
|
||||
"""Defensive: None content (server bug or schema drift) doesn't crash."""
|
||||
text, thinking = _normalize_mistral_content(None)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_int_returns_empty_strings():
|
||||
"""Defensive: wrong-typed content doesn't crash."""
|
||||
text, thinking = _normalize_mistral_content(42)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_thinking_block_with_string_inner():
|
||||
"""Some Mistral API versions may use a string instead of an array for
|
||||
the inner 'thinking' field. Accept both shapes."""
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "inline string thinking"},
|
||||
{"type": "text", "text": "answer"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "answer"
|
||||
assert thinking == "inline string thinking"
|
||||
|
||||
|
||||
def test_thinking_block_with_empty_text_field():
|
||||
"""Empty text fields don't pollute the output."""
|
||||
content = [
|
||||
{"type": "thinking", "thinking": [{"type": "text", "text": ""}], "closed": True},
|
||||
{"type": "text", "text": ""},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Regression tests for Ollama-native multimodal image routing (issue #4723).
|
||||
|
||||
Odysseus builds user messages in OpenAI style::
|
||||
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "..."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||||
]}
|
||||
|
||||
Native Ollama ``/api/chat`` does **not** accept a list for ``content``. It
|
||||
expects ``content`` to be a string and images carried separately on
|
||||
``images`` (a list of raw base64 strings, no ``data:`` prefix). Without
|
||||
this conversion the image block silently never reaches the vision model —
|
||||
the model reports "I can't see the image" even though it is vision-capable
|
||||
and the request succeeded.
|
||||
"""
|
||||
from src import llm_core
|
||||
|
||||
|
||||
def _multimodal_msg():
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this picture?"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_ollama_payload_converts_openai_image_blocks_to_native_images_array():
|
||||
payload = llm_core._build_ollama_payload(
|
||||
"gemma4:e4b", [_multimodal_msg()], temperature=0.0, max_tokens=0,
|
||||
)
|
||||
msg = payload["messages"][0]
|
||||
# Content must be a string, not a list — native Ollama rejects lists.
|
||||
assert isinstance(msg["content"], str)
|
||||
assert "What is in this picture?" in msg["content"]
|
||||
# Base64 data extracted into the native images array (no data: prefix).
|
||||
assert msg["images"] == ["AAAA", "BBBB"]
|
||||
|
||||
|
||||
def test_ollama_payload_skips_http_image_url():
|
||||
"""Non-data-URI image_url values are skipped with a warning because
|
||||
native Ollama images[] accepts base64 only."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Look"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
|
||||
],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Look"
|
||||
# HTTP URL is NOT added to images — Ollama cannot fetch it.
|
||||
assert "images" not in out
|
||||
|
||||
|
||||
def test_ollama_payload_preserves_native_images_array():
|
||||
"""If the caller already used Ollama's native shape, leave it alone."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "Describe",
|
||||
"images": ["XXXX"],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Describe"
|
||||
assert out["images"] == ["XXXX"]
|
||||
|
||||
|
||||
def test_ollama_payload_merges_native_and_openai_images():
|
||||
"""A message that carries both native ``images`` and OpenAI ``image_url``
|
||||
blocks (e.g. assembled by different code paths) must produce one combined
|
||||
list rather than drop either half."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hi"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,OPENAI"}},
|
||||
],
|
||||
"images": ["NATIVE"],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Hi"
|
||||
assert out["images"] == ["NATIVE", "OPENAI"]
|
||||
|
||||
|
||||
def test_ollama_payload_text_only_message_untouched():
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", msgs, temperature=0.0, max_tokens=0)
|
||||
assert payload["messages"][0] == {"role": "user", "content": "hello"}
|
||||
|
||||
|
||||
def test_ollama_payload_string_content_with_only_image_block():
|
||||
"""A message whose content list has only image_url blocks (no text part)
|
||||
still yields a non-empty content string so native Ollama accepts it."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QQ=="}},
|
||||
],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert isinstance(out["content"], str)
|
||||
assert out["images"] == ["QQ=="]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Node-driven regression coverage for body-portaled dropdown z-order.
|
||||
|
||||
Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the
|
||||
old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually
|
||||
rendered them BEHIND their own modal in a long session (#4720). topPortalZ()
|
||||
replaces every one of those literals with a value derived from the live
|
||||
tool-window stack. These tests pin that it always clears both the modal stack
|
||||
and the dock-chip floor, without importing the browser-heavy UI modules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js"
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _node_eval(source: str):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open():
|
||||
# No tool window raised → topToolWindowZ floors at 250, but a portaled
|
||||
# dropdown must still clear the dock chips pinned up to 10030, so it lands
|
||||
# just above that floor.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const root = {{ querySelectorAll() {{ return []; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 10031}
|
||||
|
||||
|
||||
def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001():
|
||||
# The #4720 scenario: a long session bumped the owning modal's bring-to-front
|
||||
# z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must
|
||||
# land one above the live modal z.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||
const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }};
|
||||
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 100000}
|
||||
|
||||
|
||||
def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it():
|
||||
# A modal raised to 5000 is still below the dock-chip floor, so the floor
|
||||
# (10030) wins and the dropdown lands at 10031 — never below a pinned chip.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||
const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }};
|
||||
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 10031}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Behavior tests for src.app_helpers.serve_html_with_nonce.
|
||||
|
||||
Every caller of this helper serves a fixed, app-bundled template
|
||||
(index/login/backgrounds), never a client-supplied path. So a read failure —
|
||||
a missing file (broken deployment) or a permission/IO error — is a server
|
||||
fault, not a client "not found", and must surface as a logged 500 rather than
|
||||
hiding behind a 404 where 5xx alerting can't see it. These tests lock that
|
||||
intent (raised in the PR #4637 review).
|
||||
"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("starlette.responses")
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.app_helpers import serve_html_with_nonce
|
||||
|
||||
|
||||
def _request_with_nonce(nonce: str = ""):
|
||||
"""Minimal stand-in for a Starlette Request: only request.state.csp_nonce is read."""
|
||||
return types.SimpleNamespace(state=types.SimpleNamespace(csp_nonce=nonce))
|
||||
|
||||
|
||||
def test_missing_fixed_template_returns_500_not_404(tmp_path):
|
||||
missing = tmp_path / "does_not_exist.html"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
serve_html_with_nonce(_request_with_nonce(), str(missing))
|
||||
assert exc_info.value.status_code == 500
|
||||
# Generic detail — no OS error string or absolute path leaked to the client.
|
||||
assert exc_info.value.detail == "Internal server error"
|
||||
|
||||
|
||||
def test_unreadable_template_returns_500(tmp_path):
|
||||
# A directory at the path makes open() raise an OSError subtype
|
||||
# (IsADirectoryError on POSIX, PermissionError on Windows) — same branch.
|
||||
a_dir = tmp_path / "a_dir.html"
|
||||
a_dir.mkdir()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
serve_html_with_nonce(_request_with_nonce(), str(a_dir))
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_readable_template_injects_nonce(tmp_path):
|
||||
page = tmp_path / "page.html"
|
||||
page.write_text('<script nonce="{{CSP_NONCE}}">x</script>', encoding="utf-8")
|
||||
resp = serve_html_with_nonce(_request_with_nonce("nonce-abc"), str(page))
|
||||
assert resp.status_code == 200
|
||||
body = resp.body.decode("utf-8")
|
||||
assert "nonce-abc" in body
|
||||
assert "{{CSP_NONCE}}" not in body
|
||||
Reference in New Issue
Block a user