mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-14 12:48:03 +00:00
Compare commits
63 Commits
9d7a3d66c0
...
f7e2d0c0b7
| Author | SHA1 | Date | |
|---|---|---|---|
| f7e2d0c0b7 | |||
| b5a7d5ccda | |||
| f7a5047228 | |||
| 4ccb7c4890 | |||
| 1aa5ffb57c | |||
| 0939983ddf | |||
| ffc0f1dccc | |||
| 57646300a4 | |||
| f23e2e6ffb | |||
| 955455b797 | |||
| 674457384a | |||
| 2cf8bd14ae | |||
| a172522d87 | |||
| cd41de8043 | |||
| fb9e023381 | |||
| 65c7321ace | |||
| 2966ad6ef6 | |||
| d6a3c9a0fe | |||
| 7ebbc15377 | |||
| 23837f4571 | |||
| b28aa1f2c4 | |||
| 33c26bab88 | |||
| e52d078ea1 | |||
| 7ae6133d7f | |||
| 589fcd314a | |||
| 5e0cdb6cbb | |||
| 039431f5ea | |||
| aac589ee49 | |||
| 8cff1f87ee | |||
| ec4f91afdd | |||
| 268bc1d1a6 | |||
| 7f571c8f7e | |||
| 056d1fb960 | |||
| faf27c4a90 | |||
| ebbcdc15af | |||
| 4b0a977988 | |||
| 29180c4731 | |||
| 54690997ec | |||
| be046dd29a | |||
| 4d070ef4cb | |||
| 59af91cb22 | |||
| e39c9fbbd5 | |||
| ece6cebc03 | |||
| 4c41834dc7 | |||
| 96052c5e8a | |||
| afc81bdd7b | |||
| 71ccd59b54 | |||
| b20cea347a | |||
| a07fe35936 | |||
| a7766d0b7f | |||
| 6824fbb729 | |||
| f14ea6d67d | |||
| 59efa8a44b | |||
| dbd1e6572f | |||
| 2857723e47 | |||
| 011e6b07a5 | |||
| 4e0b65491e | |||
| a633611823 | |||
| 6d756215a2 | |||
| 7dedc51d9f | |||
| 9fd85f67e8 | |||
| 21ff44e9e8 | |||
| 2e99825a29 |
@@ -10,6 +10,12 @@ dist/
|
||||
build/
|
||||
.env
|
||||
.env.bak.*
|
||||
# Secrets: keep plaintext and every transient secrets.env variant out of
|
||||
# the build context. If an encrypted secrets.env is used, it is mounted
|
||||
# at runtime — never baked into the image. Mirrored in .gitignore.
|
||||
secrets.env
|
||||
secrets.env.*
|
||||
!secrets.env.example
|
||||
/data/
|
||||
/logs/
|
||||
.git/
|
||||
|
||||
+7
-6
@@ -1,8 +1,9 @@
|
||||
# Code owners.
|
||||
#
|
||||
# Every file is owned by the maintainer, so that when branch protection has
|
||||
# "Require review from Code Owners" turned on, no pull request can be merged
|
||||
# without the maintainer's review. This is the human gate that backs up the
|
||||
# automated security checks. See docs/security-ci.md for how to turn it on.
|
||||
|
||||
* @pewdiepie-archdaemon
|
||||
# Intentionally empty for now. The catch-all rule that mapped every path to a
|
||||
# single owner froze all merges the moment "Require review from Code Owners"
|
||||
# was enabled, because no other maintainer's approval could satisfy the gate.
|
||||
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
|
||||
# multiple named owners per line) is being worked out in issue #593; once
|
||||
# agreed it replaces this file. Until then, required reviews and the security
|
||||
# CI gate (docs/security-ci.md) remain in force via branch protection.
|
||||
|
||||
+12
@@ -14,6 +14,15 @@ venv/
|
||||
.env
|
||||
.env.bak.*
|
||||
!.env.example
|
||||
# Local uv lockfile (optional, per-platform — see "Faster installs with uv" in README)
|
||||
requirements.lock
|
||||
|
||||
# SOPS workflow — encrypted `secrets.env` is intentionally committable,
|
||||
# but every variant (plaintext, manual decrypt copy, editor backup)
|
||||
# must stay out of git. Mirrored in .dockerignore so the same artifacts
|
||||
# also cannot enter image build layers.
|
||||
secrets.env.*
|
||||
!secrets.env.example
|
||||
|
||||
# Data — all user data stays local
|
||||
data/
|
||||
@@ -61,6 +70,9 @@ output.txt.txt
|
||||
*.tiff
|
||||
*.pdf
|
||||
|
||||
# …except shipped static assets
|
||||
!static/icons/*.png
|
||||
|
||||
# …except shipped demo assets in docs/ that the README links to.
|
||||
!docs/*.jpg
|
||||
!docs/*.jpeg
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
|
||||
|
||||
[](https://repology.org/project/odysseus-ai/versions)
|
||||
|
||||
## Features
|
||||
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
|
||||
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
|
||||
@@ -73,6 +75,10 @@ binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||
only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
||||
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
||||
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
||||
|
||||
### Native Linux / macOS
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
@@ -333,6 +339,25 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||
|
||||
### Faster, reproducible installs with uv (optional)
|
||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||
|
||||
```bash
|
||||
uv venv venv --python 3.13
|
||||
uv pip install -r requirements.txt
|
||||
# then continue as usual: python setup.py, uvicorn, ...
|
||||
```
|
||||
|
||||
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
||||
|
||||
```bash
|
||||
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
||||
uv pip sync requirements.lock # reproduce it exactly later
|
||||
```
|
||||
|
||||
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
||||
|
||||
### Outlook / Office 365 email
|
||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||
@@ -364,6 +389,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
@@ -397,6 +423,7 @@ Key settings:
|
||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||
|
||||
@@ -1602,6 +1602,7 @@ class CalendarCal(TimestampMixin, Base):
|
||||
# NULL for local calendars and for CalDAV calendars created before
|
||||
# multi-account support was added (treated as "use any configured account").
|
||||
account_id = Column(String, nullable=True, index=True)
|
||||
caldav_base_url = Column(String, nullable=True)
|
||||
|
||||
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
@@ -1632,10 +1633,27 @@ class CalendarEvent(TimestampMixin, Base):
|
||||
# vanishes upstream). NULL/local = created locally (agent, email triage, or
|
||||
# a UI event whose write-back failed) and must NOT be pruned by the sync.
|
||||
origin = Column(String, nullable=True, index=True)
|
||||
remote_href = Column(String, nullable=True) # CalDAV object URL for updates/deletes
|
||||
remote_etag = Column(String, nullable=True) # Last seen CalDAV ETag, when available
|
||||
caldav_sync_pending = Column(String, nullable=True) # create | update | delete retry marker
|
||||
|
||||
calendar = relationship("CalendarCal", back_populates="events")
|
||||
|
||||
|
||||
class CalendarDeletedEvent(TimestampMixin, Base):
|
||||
"""Hidden CalDAV delete tombstone retained until remote delete succeeds."""
|
||||
__tablename__ = "caldav_deleted_events"
|
||||
|
||||
uid = Column(String, primary_key=True, index=True)
|
||||
owner = Column(String, nullable=True, index=True)
|
||||
calendar_id = Column(String, nullable=True, index=True)
|
||||
remote_href = Column(String, nullable=True)
|
||||
remote_etag = Column(String, nullable=True)
|
||||
caldav_base_url = Column(String, nullable=True)
|
||||
summary = Column(String, nullable=True)
|
||||
last_error = Column(Text, nullable=True)
|
||||
|
||||
|
||||
class Integration(TimestampMixin, Base):
|
||||
"""An external service connection (email, RSS, webhook, etc.)."""
|
||||
__tablename__ = "integrations"
|
||||
@@ -1767,6 +1785,7 @@ def init_db():
|
||||
_migrate_add_calendar_is_utc()
|
||||
_migrate_add_calendar_origin()
|
||||
_migrate_add_calendar_account_id()
|
||||
_migrate_add_caldav_sync_columns()
|
||||
_migrate_chat_messages_fts()
|
||||
_migrate_encrypt_email_passwords()
|
||||
_migrate_encrypt_signatures()
|
||||
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_add_caldav_sync_columns():
|
||||
"""Add remote CalDAV metadata used for bidirectional sync."""
|
||||
import sqlite3
|
||||
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||
if not os.path.exists(db_path):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
ev_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
|
||||
if ev_columns and "remote_href" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_href TEXT")
|
||||
if ev_columns and "remote_etag" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT")
|
||||
if ev_columns and "caldav_sync_pending" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT")
|
||||
|
||||
cal_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendars)").fetchall()]
|
||||
if cal_columns and "caldav_base_url" not in cal_columns:
|
||||
conn.execute("ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"CalDAV sync metadata migration failed: {e}")
|
||||
|
||||
|
||||
def _migrate_add_calendar_metadata():
|
||||
"""Add importance/event_type/last_pinged columns to calendar_events table."""
|
||||
import sqlite3
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Agent migration manifests
|
||||
|
||||
Odysseus should be able to learn from another agent without blindly trusting
|
||||
that agent's whole state. The safe migration path is:
|
||||
|
||||
```text
|
||||
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
|
||||
```
|
||||
|
||||
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
|
||||
Markdown notes, or any other agent can have its own adapter, but Odysseus only
|
||||
needs to understand the normalized manifest.
|
||||
|
||||
## Why not import everything as memory?
|
||||
|
||||
Durable memory should stay compact and useful. Long notes, logs, session
|
||||
transcripts, and project archives are useful context, but they are not all
|
||||
memories. A good migration keeps two layers separate:
|
||||
|
||||
- **Archive documents** preserve source material for search, reading, and later
|
||||
extraction.
|
||||
- **Memory candidates** are short facts or preferences that can be reviewed
|
||||
before being saved into Odysseus memory.
|
||||
|
||||
This keeps Odysseus' existing memory-review flow intact while giving it better
|
||||
source material to review.
|
||||
|
||||
## Manifest shape
|
||||
|
||||
`agent-migration.v1` is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "agent-migration.v1",
|
||||
"generated_at": "2026-06-06T00:00:00Z",
|
||||
"source": {
|
||||
"name": "example-agent",
|
||||
"kind": "generic"
|
||||
},
|
||||
"summary": {
|
||||
"item_count": 3,
|
||||
"counts_by_kind": {
|
||||
"memory": 1,
|
||||
"skill": 1,
|
||||
"conversation_thread": 1,
|
||||
"archive_document": 1
|
||||
},
|
||||
"warning_count": 0
|
||||
},
|
||||
"items": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
|
||||
future importer to preview it before applying.
|
||||
|
||||
Supported item kinds in the first pass:
|
||||
|
||||
- `memory` — a candidate memory with `text`, `category`, `source`, and
|
||||
provenance metadata.
|
||||
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
|
||||
- `conversation_thread` — a normalized transcript thread from an exported chat
|
||||
history. Message content is optional; adapters can preserve only thread
|
||||
metadata, message counts, timestamps, and hashes when a manifest should stay
|
||||
small or avoid embedding private transcript text.
|
||||
- `archive_document` — long-form source material. Content is optional; adapters
|
||||
can preserve only path/hash/size metadata when a manifest should stay small.
|
||||
|
||||
## Build a manifest
|
||||
|
||||
Use the read-only helper:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name old-agent \
|
||||
--source-kind generic \
|
||||
--memory-json /path/to/memories.json \
|
||||
--skills-dir /path/to/skills \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--archive /path/to/notes \
|
||||
--output /tmp/agent-migration.json
|
||||
```
|
||||
|
||||
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
|
||||
modify the source. It only writes JSON.
|
||||
|
||||
Memory JSON may be:
|
||||
|
||||
```json
|
||||
[
|
||||
"A plain memory string",
|
||||
{
|
||||
"text": "A categorized memory",
|
||||
"category": "preference",
|
||||
"source": "old-agent"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
or an object containing a list under `memories`, `memory`, `items`, or `data`.
|
||||
|
||||
Skills are scanned recursively for `SKILL.md`:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name hermes \
|
||||
--source-kind hermes \
|
||||
--skills-dir ~/.hermes/skills \
|
||||
--output /tmp/hermes-skills-manifest.json
|
||||
```
|
||||
|
||||
Archive documents are metadata-only by default. To embed text content:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name notes-export \
|
||||
--archive /path/to/markdown-notes \
|
||||
--include-archive-content \
|
||||
--output /tmp/notes-manifest.json
|
||||
```
|
||||
|
||||
Conversation exports are also metadata-only by default:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--output /tmp/chatgpt-conversations-manifest.json
|
||||
```
|
||||
|
||||
The first pass supports generic conversation JSON such as:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "thread-1",
|
||||
"title": "Project plan",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Can we design this?"},
|
||||
{"role": "assistant", "content": "Yes, start with a narrow slice."}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
|
||||
To embed normalized messages:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--include-conversation-content \
|
||||
--max-conversation-messages 2000 \
|
||||
--output /tmp/chatgpt-conversations-with-content.json
|
||||
```
|
||||
|
||||
Content embedding is explicit because exported chat histories can be huge and
|
||||
private. A future source-specific adapter can add ZIP traversal, attachment
|
||||
metadata, and provider-specific project/workspace fields while still emitting
|
||||
the same `conversation_thread` manifest item.
|
||||
|
||||
## Recommended apply behavior
|
||||
|
||||
A future Odysseus importer should treat the manifest as untrusted user-provided
|
||||
data and apply it in stages:
|
||||
|
||||
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
|
||||
2. Back up current `data/` state before writing anything.
|
||||
3. Import archive documents as documents or another searchable source, not as
|
||||
memory.
|
||||
4. Import conversation threads as searchable archived context first, with
|
||||
citations back to the source thread. Do not turn whole transcripts into
|
||||
memory.
|
||||
5. Show memory candidates for review before saving through the normal memory
|
||||
path.
|
||||
6. Import skills only after name/category conflict checks.
|
||||
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
|
||||
|
||||
## What belongs in source adapters?
|
||||
|
||||
Adapters can be source-specific. The core manifest should not be.
|
||||
|
||||
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
|
||||
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
|
||||
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
|
||||
and image attachment directories. A Claude adapter may know about Claude's
|
||||
export shape and project boundaries. A generic adapter may only know about
|
||||
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
|
||||
|
||||
Nonstandard folders should be adapter details, not required Odysseus concepts.
|
||||
+65
-29
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import or_, and_
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||
from src.auth_helpers import require_user
|
||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||
|
||||
@@ -126,6 +126,54 @@ def _resolve_base_uid(uid: str) -> str:
|
||||
raise ValueError("malformed compound UID: missing base before ::")
|
||||
return base
|
||||
|
||||
|
||||
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
|
||||
"""Best-effort CalDAV write-through. Local writes stay authoritative if
|
||||
the remote server is unreachable; pending flags let /sync retry later."""
|
||||
try:
|
||||
result = {"ok": True}
|
||||
if action == "create":
|
||||
from src.caldav_sync import push_event_create
|
||||
result = await push_event_create(owner, uid)
|
||||
elif action == "update":
|
||||
from src.caldav_sync import push_event_update
|
||||
result = await push_event_update(owner, uid)
|
||||
elif action == "delete":
|
||||
from src.caldav_sync import push_event_delete
|
||||
result = await push_event_delete(owner, uid)
|
||||
if result and not result.get("ok") and not result.get("skipped"):
|
||||
raise RuntimeError(result.get("error") or result)
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
|
||||
if action in {"create", "update"}:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = _get_or_404_event(db, uid, owner)
|
||||
ev.caldav_sync_pending = action
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
|
||||
if not (ev.calendar and ev.calendar.source == "caldav"):
|
||||
return
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == ev.uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if not tombstone:
|
||||
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
|
||||
db.add(tombstone)
|
||||
tombstone.calendar_id = ev.calendar_id
|
||||
tombstone.remote_href = ev.remote_href
|
||||
tombstone.remote_etag = ev.remote_etag
|
||||
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
|
||||
tombstone.summary = ev.summary or ""
|
||||
tombstone.last_error = None
|
||||
|
||||
# ── Pydantic models ──
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_caldav_endpoint(request: Request):
|
||||
"""Pull events from the configured CalDAV server into local DB.
|
||||
async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
|
||||
"""Sync events with the configured CalDAV server.
|
||||
Returns counts + any per-calendar errors. Called by the frontend
|
||||
on calendar open and by the periodic scheduler loop."""
|
||||
owner = _require_user(request)
|
||||
from src.caldav_sync import sync_caldav
|
||||
return await sync_caldav(owner)
|
||||
from src.caldav_sync import sync_caldav_direction
|
||||
return await sync_caldav_direction(owner, direction)
|
||||
|
||||
|
||||
@router.delete("/calendars/{cal_id}")
|
||||
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
is_utc=_is_utc and not data.all_day,
|
||||
rrule=data.rrule or "",
|
||||
color=data.color or None,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
# Push the new event to the remote so it appears on the user's
|
||||
# other devices — the sync is otherwise pull-only (#800).
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, cal.source, cal.id, {
|
||||
"uid": uid, "summary": data.summary, "description": data.description,
|
||||
"location": data.location, "dtstart": dtstart, "dtend": dtend,
|
||||
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
|
||||
"rrule": data.rrule or "",
|
||||
})
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
return {"ok": True, "uid": uid}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
ev.rrule = data.rrule
|
||||
if data.color is not None:
|
||||
ev.color = data.color if data.color else None
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||
if cal and cal.source == "caldav":
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, cal.source, cal.id, {
|
||||
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
|
||||
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
|
||||
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
|
||||
})
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = _get_or_404_event(db, base_uid, owner)
|
||||
# Capture what the remote push needs BEFORE the row is gone.
|
||||
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||
_is_caldav = bool(_cal and _cal.source == "caldav")
|
||||
_cal_id, _ev_uid = ev.calendar_id, ev.uid
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if _is_caldav:
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
+11
-3
@@ -159,9 +159,17 @@ async def auto_name_session(session_manager, sess):
|
||||
return
|
||||
|
||||
owner = getattr(sess, "owner", None)
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
||||
)
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(owner=owner)
|
||||
if not t_model:
|
||||
# If no task/utility model is configured at all, fall back to
|
||||
# the session's own model so auto-naming still works even on
|
||||
# minimal setups.
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
_fallback = resolve_endpoint("default", owner=owner)
|
||||
if _fallback and _fallback[1]:
|
||||
t_url, t_model, t_headers = _fallback
|
||||
else:
|
||||
t_url, t_model, t_headers = sess.endpoint_url, sess.model, sess.headers
|
||||
if not t_model:
|
||||
logger.debug("[auto-name] No model provided, skipping")
|
||||
return
|
||||
|
||||
@@ -696,7 +696,12 @@ def setup_chat_routes(
|
||||
# by default without having to send allow_bash in every request.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
if allow_web_search is not None and str(allow_web_search).lower() != "true":
|
||||
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
||||
if (
|
||||
allow_web_search is not None
|
||||
and str(allow_web_search).lower() != "true"
|
||||
and not _explicit_web_intent
|
||||
):
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
|
||||
|
||||
+18
-6
@@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse
|
||||
from src.auth_helpers import require_authenticated_request, require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
from src.constants import COOKBOOK_STATE_FILE
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
|
||||
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
||||
@@ -36,6 +37,21 @@ DOCS_WRITE_SCOPES = {"documents:write"}
|
||||
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||
|
||||
|
||||
def _ssh_prefix_for_task(task: dict) -> tuple[str, str]:
|
||||
"""Resolve a cookbook task's stored SSH target into ``(host, port_flag)``.
|
||||
|
||||
``host`` is ``""`` for a local task. ``remoteHost`` / ``sshPort`` come from
|
||||
cookbook_state.json and get interpolated into an ``ssh`` command string, so
|
||||
validate them the same way the cookbook routes do. A tampered entry with
|
||||
shell metacharacters in ``remoteHost`` is rejected with 400 rather than
|
||||
injected.
|
||||
"""
|
||||
host = validate_remote_host((task.get("remoteHost") or "").strip() or None) or ""
|
||||
ssh_port = validate_ssh_port((task.get("sshPort") or "").strip() or None) or ""
|
||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
return host, port_flag
|
||||
|
||||
|
||||
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
"""Run an existing route handler with request.state.current_user temporarily
|
||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||
@@ -486,8 +502,7 @@ def setup_codex_routes(
|
||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||
if task is None:
|
||||
raise HTTPException(404, "task not found")
|
||||
host = (task.get("remoteHost") or "").strip()
|
||||
ssh_port = (task.get("sshPort") or "").strip()
|
||||
host, port_flag = _ssh_prefix_for_task(task)
|
||||
# Prefer the persisted log file over the tmux pane. The pane gets
|
||||
# overwritten by the post-crash neofetch banner + bash prompt the
|
||||
# moment vllm exits; the log file is the raw stdout/stderr and
|
||||
@@ -499,7 +514,6 @@ def setup_codex_routes(
|
||||
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
|
||||
)
|
||||
if host:
|
||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
import shlex
|
||||
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
|
||||
else:
|
||||
@@ -561,10 +575,8 @@ def setup_codex_routes(
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||
host = ((task or {}).get("remoteHost") or "").strip()
|
||||
ssh_port = ((task or {}).get("sshPort") or "").strip()
|
||||
host, port_flag = _ssh_prefix_for_task(task or {})
|
||||
if host:
|
||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
|
||||
else:
|
||||
cmd = f"tmux kill-session -t {session_id}"
|
||||
|
||||
@@ -45,10 +45,14 @@ def _save_settings(settings):
|
||||
def _get_carddav_config():
|
||||
import os
|
||||
settings = _load_settings()
|
||||
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||
if password and "carddav_password" in settings:
|
||||
from src.secret_storage import decrypt
|
||||
password = decrypt(password)
|
||||
return {
|
||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
@@ -785,7 +789,11 @@ def setup_contacts_routes():
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
else:
|
||||
settings[key] = data[key]
|
||||
value = data[key]
|
||||
if key == "carddav_password" and value:
|
||||
from src.secret_storage import encrypt
|
||||
value = encrypt(value)
|
||||
settings[key] = value
|
||||
_save_settings(settings)
|
||||
# Force re-fetch
|
||||
_contact_cache["fetched_at"] = None
|
||||
|
||||
@@ -573,6 +573,36 @@ _GGUF_PRELUDE_RE = re.compile(
|
||||
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
||||
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
||||
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
||||
_LLAMA_CPP_PYTHON_GGML_TYPES = {
|
||||
"f32": "0",
|
||||
"f16": "1",
|
||||
"q4_0": "2",
|
||||
"q4_1": "3",
|
||||
"q5_0": "6",
|
||||
"q5_1": "7",
|
||||
"q8_0": "8",
|
||||
"q8_1": "9",
|
||||
"q2_k": "10",
|
||||
"q3_k": "11",
|
||||
"q4_k": "12",
|
||||
"q5_k": "13",
|
||||
"q6_k": "14",
|
||||
"q8_k": "15",
|
||||
"iq2_xxs": "16",
|
||||
"iq2_xs": "17",
|
||||
"iq3_xxs": "18",
|
||||
"iq1_s": "19",
|
||||
"iq4_nl": "20",
|
||||
"iq3_s": "21",
|
||||
"iq2_s": "22",
|
||||
"iq4_xs": "23",
|
||||
"mxfp4": "39",
|
||||
"nvfp4": "40",
|
||||
"q1_0": "41",
|
||||
}
|
||||
_LLAMA_CPP_PYTHON_TYPE_FLAG_RE = re.compile(
|
||||
r"(?P<flag>--type_[kv])(?P<sep>\s+|=)(?P<quote>['\"]?)(?P<value>[A-Za-z0-9_]+)(?P=quote)"
|
||||
)
|
||||
|
||||
|
||||
def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]:
|
||||
@@ -604,6 +634,22 @@ def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -
|
||||
return f"[{host}]" if bracketed_host else host, port
|
||||
|
||||
|
||||
def _normalize_llama_cpp_python_cache_types(cmd: str | None) -> str | None:
|
||||
"""Map llama.cpp KV cache type names to llama-cpp-python's integer enum."""
|
||||
if not cmd or "llama_cpp.server" not in cmd:
|
||||
return cmd
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
value = match.group("value")
|
||||
mapped = _LLAMA_CPP_PYTHON_GGML_TYPES.get(value.lower())
|
||||
if not mapped:
|
||||
return match.group(0)
|
||||
quote = match.group("quote")
|
||||
return f"{match.group('flag')}{match.group('sep')}{quote}{mapped}{quote}"
|
||||
|
||||
return _LLAMA_CPP_PYTHON_TYPE_FLAG_RE.sub(repl, cmd)
|
||||
|
||||
|
||||
def _check_serve_binary(seg: str) -> None:
|
||||
"""Validate that a single command segment starts with an allowlisted binary
|
||||
(after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`)."""
|
||||
@@ -742,6 +788,7 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
||||
runner_lines.append(' done')
|
||||
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
|
||||
# or HIP attempt) doesn't cause the next configure to reuse stale settings.
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build')
|
||||
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
|
||||
runner_lines.append(' if command -v hipconfig &>/dev/null; then')
|
||||
@@ -1046,6 +1093,16 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native dependencies are missing on this server.",
|
||||
[
|
||||
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
|
||||
@@ -4,6 +4,62 @@ Kept dependency-free (no FastAPI / SQLAlchemy imports) so the behavior can be
|
||||
unit-tested without standing up the whole app.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
|
||||
# Probe scripts for the dead-session download check, run as
|
||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||
# It has to be passed explicitly: the download runner exports
|
||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||
# the probe process's own environment knows nothing about it.
|
||||
HF_CACHE_COMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"snap=os.path.join(d,'snapshots');"
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
|
||||
HF_CACHE_INCOMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
|
||||
|
||||
def classify_dead_download(full_snapshot: str):
|
||||
"""Resolve a dead download session's status from its runner markers.
|
||||
|
||||
The runner prints DOWNLOAD_OK only after exiting 0 (and DOWNLOAD_FAILED
|
||||
otherwise), so the markers stay trustworthy after the tmux pane is gone.
|
||||
Returns (status, zero_files), or None when the snapshot carries no marker
|
||||
and the caller has to fall back to the cache probe. Same precedence as
|
||||
the live-session branch: DOWNLOAD_OK wins, except a "Fetching 0 files"
|
||||
run is an error (nothing matched the include/quant pattern).
|
||||
"""
|
||||
if not full_snapshot:
|
||||
return None
|
||||
if "DOWNLOAD_OK" in full_snapshot:
|
||||
if _FETCHING_ZERO_FILES_RE.search(full_snapshot):
|
||||
return ("error", True)
|
||||
return ("completed", False)
|
||||
if "DOWNLOAD_FAILED" in full_snapshot:
|
||||
return ("error", False)
|
||||
return None
|
||||
|
||||
|
||||
def error_aware_output_tail(full_snapshot: str, status: str) -> str:
|
||||
"""Return the trailing slice of a task log for the status response.
|
||||
|
||||
+41
-33
@@ -30,7 +30,10 @@ from core.platform_compat import (
|
||||
which_tool,
|
||||
)
|
||||
from routes.shell_routes import TMUX_LOG_DIR
|
||||
from routes.cookbook_output import error_aware_output_tail
|
||||
from routes.cookbook_output import (
|
||||
error_aware_output_tail, classify_dead_download,
|
||||
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +49,7 @@ from routes.cookbook_helpers import (
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -54,7 +58,7 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
'echo "[odysseus] HF token: applied"; '
|
||||
'else '
|
||||
'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. '
|
||||
'Add one in Odysseus Settings -> Cookbook -> HuggingFace Token."; '
|
||||
'Add one in Odysseus Cookbook -> Settings -> HuggingFace Token."; '
|
||||
'fi'
|
||||
)
|
||||
|
||||
@@ -170,6 +174,16 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native dependencies are missing on this server.",
|
||||
[
|
||||
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
@@ -353,7 +367,11 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# all output to the log the poller reads. Paths handed to bash use
|
||||
# POSIX form + shell-quoting so drive paths / spaces survive.
|
||||
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||
inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8")
|
||||
pp = shlex.quote(pid_path.as_posix())
|
||||
inner.write_text(
|
||||
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
lp = shlex.quote(log_path.as_posix())
|
||||
ip = shlex.quote(inner.as_posix())
|
||||
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
|
||||
@@ -1211,6 +1229,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# many downstream `"engine" in req.cmd` membership checks can't hit
|
||||
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400).
|
||||
req.cmd = _validate_serve_cmd(req.cmd) or ""
|
||||
req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or ""
|
||||
req.cmd = _venv_safe_local_pip_install_cmd(
|
||||
req.cmd,
|
||||
local=not bool(req.remote_host),
|
||||
@@ -2620,30 +2639,20 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
def _cookbook_tasks_status_sync():
|
||||
import subprocess
|
||||
|
||||
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
|
||||
"""Best-effort check for a completed HF cache entry.
|
||||
|
||||
tmux output can stop at a stale progress line if the pane/session
|
||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||
In that case, trust the cache shape: a snapshot directory with files
|
||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||
model.
|
||||
model. cache_root is the task's custom download dir — the runner
|
||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||
not wherever this probe's environment says.
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
return False
|
||||
py = (
|
||||
"import os,sys;"
|
||||
"repo=sys.argv[1];"
|
||||
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"snap=os.path.join(d,'snapshots');"
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
cmd = ["python3", "-c", py, repo_id]
|
||||
cmd = ["python3", "-c", HF_CACHE_COMPLETE_PROBE, repo_id, cache_root or ""]
|
||||
try:
|
||||
if remote_host:
|
||||
ssh_base = ["ssh"]
|
||||
@@ -2657,7 +2666,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
|
||||
"""Best-effort check for resumable HF partial blobs.
|
||||
|
||||
A lost SSH/tmux session can leave a real download still incomplete.
|
||||
@@ -2666,16 +2675,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
return False
|
||||
py = (
|
||||
"import os,sys;"
|
||||
"repo=sys.argv[1];"
|
||||
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
cmd = ["python3", "-c", py, repo_id]
|
||||
cmd = ["python3", "-c", HF_CACHE_INCOMPLETE_PROBE, repo_id, cache_root or ""]
|
||||
try:
|
||||
if remote_host:
|
||||
ssh_base = ["ssh"]
|
||||
@@ -2880,7 +2880,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
and (
|
||||
".incomplete" in full_snapshot
|
||||
or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot))
|
||||
or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or ""))
|
||||
or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
|
||||
)
|
||||
)
|
||||
if is_alive or (local_win_task and full_snapshot):
|
||||
@@ -2921,11 +2921,19 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
else:
|
||||
status = "running"
|
||||
else:
|
||||
# Session is dead — check if it completed or crashed
|
||||
if (
|
||||
# Session is dead — check if it completed or crashed. The
|
||||
# runner markers in the retained output are conclusive
|
||||
# (DOWNLOAD_OK only prints after exit 0), so check them before
|
||||
# the cache probe, which can't see ollama pulls at all.
|
||||
marker = classify_dead_download(full_snapshot) if task_type == "download" else None
|
||||
if marker is not None:
|
||||
status, download_zero_files = marker
|
||||
if status == "completed" and not progress_text:
|
||||
progress_text = "Download complete"
|
||||
elif (
|
||||
task_type == "download"
|
||||
and not download_has_incomplete_evidence
|
||||
and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or ""))
|
||||
and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
|
||||
):
|
||||
status = "completed"
|
||||
if not progress_text:
|
||||
|
||||
@@ -1087,7 +1087,10 @@ def setup_email_routes():
|
||||
return {"contacts": [], "error": "Mail operation failed"}
|
||||
|
||||
@router.get("/search")
|
||||
async def search_emails(
|
||||
# Sync def: the body is blocking IMAP I/O with no awaits. As `async def` it ran
|
||||
# directly on the event loop and stalled the whole app during a search; as a sync
|
||||
# def FastAPI runs it in a threadpool, keeping the loop responsive.
|
||||
def search_emails(
|
||||
q: str = Query(""),
|
||||
folder: str = Query("INBOX"),
|
||||
limit: int = Query(50),
|
||||
@@ -1736,7 +1739,9 @@ def setup_email_routes():
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/archive/{uid}")
|
||||
async def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
# Sync def: blocking IMAP I/O with no awaits — see search_emails above. Runs in a
|
||||
# threadpool instead of blocking the event loop.
|
||||
def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
"""Move email to Archive folder."""
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.upload_limits import (
|
||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
|
||||
)
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
from src.optional_deps import patch_realesrgan_torchvision_compat
|
||||
|
||||
from routes.gallery_helpers import (
|
||||
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
||||
@@ -108,6 +109,32 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
|
||||
return fallback
|
||||
|
||||
|
||||
async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
||||
"""Fetch an image URL returned in an upstream response body, base64-encoded
|
||||
(or None on a non-200).
|
||||
|
||||
The URL comes from the diffusion/OpenAI server's response, not from our own
|
||||
config, so a malicious or compromised endpoint could otherwise steer this
|
||||
fetch at an internal or cloud-metadata address. Validate it the same way the
|
||||
client-supplied endpoint is validated before the first request.
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
ok, reason = check_outbound_url(
|
||||
url,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||
async with httpx.AsyncClient(timeout=60) as c2:
|
||||
ir = await c2.get(url)
|
||||
if ir.status_code == 200:
|
||||
return base64.b64encode(ir.content).decode()
|
||||
return None
|
||||
|
||||
|
||||
def setup_gallery_routes() -> APIRouter:
|
||||
router = APIRouter(tags=["gallery"])
|
||||
|
||||
@@ -1142,10 +1169,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if item.get("b64_json"):
|
||||
raw_b64 = item["b64_json"]
|
||||
elif item.get("url"):
|
||||
async with httpx.AsyncClient(timeout=60) as c2:
|
||||
img_r = await c2.get(item["url"])
|
||||
if img_r.status_code == 200:
|
||||
raw_b64 = base64.b64encode(img_r.content).decode()
|
||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||
if not raw_b64:
|
||||
raise HTTPException(502, "OpenAI returned no image")
|
||||
|
||||
@@ -1206,7 +1230,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
original and regenerates `strength` fraction. With strength ~0.4
|
||||
you get edge blending + lighting unification while keeping the
|
||||
composition recognisable."""
|
||||
import httpx, base64 as _b64
|
||||
import httpx
|
||||
user = require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
|
||||
@@ -1382,10 +1406,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if item.get("b64_json"):
|
||||
return {"image": item["b64_json"]}
|
||||
if item.get("url"):
|
||||
async with httpx.AsyncClient(timeout=60) as c2:
|
||||
ir = await c2.get(item["url"])
|
||||
if ir.status_code == 200:
|
||||
return {"image": _b64.b64encode(ir.content).decode()}
|
||||
img_b64 = await _fetch_result_image_b64(item["url"])
|
||||
if img_b64:
|
||||
return {"image": img_b64}
|
||||
last_err = f"{path}: server returned no image"
|
||||
except httpx.ConnectError as e:
|
||||
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
|
||||
@@ -1445,6 +1468,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from realesrgan import RealESRGANer
|
||||
except ImportError:
|
||||
return {"error": "realesrgan not installed. Install it from Cookbook → Dependencies (search 'realesrgan')."}
|
||||
@@ -1494,6 +1518,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
img_bytes = base64.b64decode(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from realesrgan import RealESRGANer
|
||||
except ImportError:
|
||||
|
||||
+18
-6
@@ -108,6 +108,12 @@ def _load_disabled_map():
|
||||
db.close()
|
||||
|
||||
|
||||
def _mcp_oauth_redirect_uri() -> str:
|
||||
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
|
||||
from src.mcp_oauth import REDIRECT_URI
|
||||
return REDIRECT_URI
|
||||
|
||||
|
||||
def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"""Setup MCP routes with the provided manager."""
|
||||
|
||||
@@ -445,9 +451,9 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
client_id = keys["client_id"]
|
||||
scopes = oauth_cfg.get("scopes", [])
|
||||
|
||||
# For Desktop App creds, redirect to localhost — the user will
|
||||
# For Desktop App creds, default to localhost — the user will
|
||||
# paste the resulting URL back if they're on a different device.
|
||||
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback"
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
@@ -469,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
return RedirectResponse(auth_url)
|
||||
else:
|
||||
# Remote device — show paste-back page
|
||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host))
|
||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -536,7 +542,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
client_id = keys["client_id"]
|
||||
client_secret = keys["client_secret"]
|
||||
|
||||
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback"
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
@@ -603,13 +609,19 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
return router
|
||||
|
||||
|
||||
def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:
|
||||
def _oauth_authorize_page(
|
||||
auth_url: str,
|
||||
server_id: str,
|
||||
host: str,
|
||||
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
||||
) -> str:
|
||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
||||
# Escape values interpolated into the page: `host` comes from the request
|
||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
||||
auth_url = html.escape(auth_url, quote=True)
|
||||
server_id = html.escape(server_id, quote=True)
|
||||
host = html.escape(host, quote=True)
|
||||
redirect_uri = html.escape(redirect_uri, quote=True)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
|
||||
@@ -654,7 +666,7 @@ def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:
|
||||
<div class="divider"></div>
|
||||
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
||||
<p>Paste the URL from your browser after signing in:</p>
|
||||
<input type="text" name="callback_url" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." required>
|
||||
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
||||
<br><button type="submit">Connect</button>
|
||||
</form>
|
||||
</div></body></html>"""
|
||||
|
||||
+57
-43
@@ -29,6 +29,7 @@ from src.llm_core import llm_call_async
|
||||
from services.memory.memory_extractor import audit_memories
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -240,14 +241,18 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
}
|
||||
messages = [system_msg] + sess.get_context_messages()
|
||||
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||
)
|
||||
|
||||
try:
|
||||
suggestion_text = await llm_call_async(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
t_url,
|
||||
t_model,
|
||||
messages,
|
||||
temperature=0.2,
|
||||
max_tokens=500,
|
||||
headers=sess.headers,
|
||||
headers=t_headers,
|
||||
)
|
||||
try:
|
||||
suggestions = json.loads(suggestion_text)
|
||||
@@ -278,42 +283,50 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
endpoint_url = model = None
|
||||
headers = {}
|
||||
|
||||
# Try default model from settings first
|
||||
settings = _load_settings()
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
default_model = settings.get("default_model", "")
|
||||
if ep_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == ep_id, ModelEndpoint.is_enabled == True
|
||||
).first()
|
||||
if ep:
|
||||
base = _normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base)
|
||||
model = default_model
|
||||
if not model and ep.models:
|
||||
try:
|
||||
models = _json.loads(ep.models) if isinstance(ep.models, str) else ep.models
|
||||
if models:
|
||||
model = models[0]
|
||||
except Exception:
|
||||
pass
|
||||
if ep.api_key:
|
||||
headers = {"Authorization": f"Bearer {ep.api_key}"}
|
||||
finally:
|
||||
db.close()
|
||||
# Try utility model from settings first — memory audit is a background
|
||||
# task and should prefer the lighter utility model over the main chat model.
|
||||
from src.task_endpoint import resolve_task_endpoint
|
||||
user = _owner(request)
|
||||
t_url, t_model, t_headers = resolve_task_endpoint(owner=user)
|
||||
if t_url and t_model:
|
||||
endpoint_url, model, headers = t_url, t_model, t_headers
|
||||
else:
|
||||
# Fall back to default model if no task/utility model configured
|
||||
settings = _load_settings()
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
default_model = settings.get("default_model", "")
|
||||
if ep_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == ep_id, ModelEndpoint.is_enabled == True
|
||||
).first()
|
||||
if ep:
|
||||
base = _normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base)
|
||||
model = default_model
|
||||
if not model and ep.models:
|
||||
try:
|
||||
models = _json.loads(ep.models) if isinstance(ep.models, str) else ep.models
|
||||
if models:
|
||||
model = models[0]
|
||||
except Exception:
|
||||
pass
|
||||
if ep.api_key:
|
||||
headers = {"Authorization": f"Bearer {ep.api_key}"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Fall back to session model if no default configured
|
||||
if not endpoint_url and session:
|
||||
try:
|
||||
sess = session_manager.get_session(session)
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
endpoint_url = sess.endpoint_url
|
||||
model = sess.model
|
||||
headers = sess.headers
|
||||
except KeyError:
|
||||
pass
|
||||
# Fall back to session model if no default configured
|
||||
if not endpoint_url and session:
|
||||
try:
|
||||
sess = session_manager.get_session(session)
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
endpoint_url = sess.endpoint_url
|
||||
model = sess.model
|
||||
headers = sess.headers
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if not endpoint_url or not model:
|
||||
raise HTTPException(400, "No default model configured — set one in Settings")
|
||||
@@ -360,13 +373,14 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
try:
|
||||
sess = session_manager.get_session(session)
|
||||
_assert_session_owner(sess, _owner(request))
|
||||
endpoint_url = sess.endpoint_url
|
||||
model = sess.model
|
||||
headers = sess.headers
|
||||
endpoint_url, model, headers = resolve_task_endpoint(
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found — needed for LLM config")
|
||||
logger.warning("Session %s not found, falling back to utility endpoint", session)
|
||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
||||
else:
|
||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
||||
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
|
||||
|
||||
if not endpoint_url or not model:
|
||||
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
||||
|
||||
+52
-4
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
|
||||
"zai-coding": [
|
||||
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
||||
],
|
||||
"kimi-code": [
|
||||
"kimi-for-coding",
|
||||
],
|
||||
"deepseek": [
|
||||
"deepseek-chat", "deepseek-reasoner",
|
||||
],
|
||||
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
|
||||
parsed = urlparse(base_url)
|
||||
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
||||
return "zai-coding"
|
||||
if _host_match(base_url, "kimi.com") and "/coding" in (parsed.path or ""):
|
||||
return "kimi-code"
|
||||
for domain, key in _HOST_TO_CURATED:
|
||||
if _host_match(base_url, domain):
|
||||
return key
|
||||
@@ -703,6 +708,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
from src.llm_core import httpx_get_kimi_aware
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
provider = _safe_detect_provider(base)
|
||||
if provider == "chatgpt-subscription":
|
||||
@@ -738,7 +744,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
url = _safe_build_models_url(base)
|
||||
headers = _safe_build_headers(api_key, base)
|
||||
try:
|
||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
r = httpx_get_kimi_aware(url, headers, timeout=timeout, verify=llm_verify())
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||
@@ -754,6 +760,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||
models.append(_e)
|
||||
if _host_match(base, "kimi.com") and "/coding" in (urlparse(base).path or ""):
|
||||
_ck = _match_provider_curated(base, None)
|
||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||
models.append(_e)
|
||||
return [m for m in models if _is_chat_model(m)]
|
||||
except httpx.HTTPStatusError as e:
|
||||
if api_key:
|
||||
@@ -870,15 +881,52 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
|
||||
|
||||
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
||||
"""Return a provider-aware error message for failed endpoint probes."""
|
||||
"""Return a provider-aware error message for failed endpoint probes.
|
||||
|
||||
Surfaces the URL we actually probed and, when the endpoint looks like
|
||||
LM Studio (port 1234 or hostname match), adds a hint about loading a
|
||||
model and confirming the Developer Server is running. The user previously
|
||||
saw a generic "No models found for that provider/key" with no way to
|
||||
tell whether the URL was wrong, the server was down, or the server was
|
||||
reachable but had no model loaded (issue #25).
|
||||
"""
|
||||
ping = ping or {}
|
||||
error = ping.get("error")
|
||||
from src.endpoint_resolver import build_models_url
|
||||
try:
|
||||
probed = build_models_url(base_url) or base_url
|
||||
except Exception:
|
||||
probed = base_url
|
||||
parsed = urlparse(base_url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
|
||||
is_lmstudio = (
|
||||
parsed.port == 1234
|
||||
or "lmstudio" in host
|
||||
or "lm-studio" in host
|
||||
or "lm_studio" in host
|
||||
)
|
||||
|
||||
if is_lmstudio:
|
||||
parts = [
|
||||
"LM Studio is reachable, but no models were reported.",
|
||||
f"Probed {probed}.",
|
||||
]
|
||||
if error:
|
||||
parts.append(f"Last probe error: {error}.")
|
||||
parts.append(
|
||||
"Open LM Studio, load at least one model, and confirm the "
|
||||
"Developer Server is running on port 1234."
|
||||
)
|
||||
parts.append(
|
||||
"Base URL should be http://localhost:1234/v1 (native) or "
|
||||
"http://host.docker.internal:1234/v1 (Docker)."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
if is_ollama:
|
||||
parts = ["No Ollama models found for that endpoint."]
|
||||
parts.append(f"Probed {probed}.")
|
||||
if error:
|
||||
parts.append(f"Last probe error: {error}.")
|
||||
parts.append("Check that Ollama is running and that the base URL is correct.")
|
||||
@@ -888,9 +936,9 @@ def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) ->
|
||||
return " ".join(parts)
|
||||
|
||||
if error:
|
||||
return f"No models found for that provider/key. Last probe error: {error}."
|
||||
return f"No models found for that provider/key. Probed {probed}. Last probe error: {error}."
|
||||
|
||||
return "No models found for that provider/key."
|
||||
return f"No models found for that provider/key. Probed {probed}."
|
||||
|
||||
|
||||
def _normalize_model_ids(value):
|
||||
|
||||
@@ -160,8 +160,11 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
JSON response confirming removal
|
||||
"""
|
||||
try:
|
||||
if not directory:
|
||||
raise HTTPException(400, "Directory path is required")
|
||||
# Confine to PERSONAL_DIR — parity with add_directory_to_rag (which
|
||||
# resolves the path the same way). Without this, an arbitrary or
|
||||
# `..`-escaping path is passed straight to
|
||||
# personal_docs_manager.remove_directory / rag.remove_directory.
|
||||
directory = _resolve_allowed_personal_dir(directory)
|
||||
|
||||
logger.info(f"Removing directory from RAG: {directory}")
|
||||
|
||||
|
||||
+16
-2
@@ -1,6 +1,7 @@
|
||||
"""Shell routes — user-facing command execution endpoint."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -14,6 +15,7 @@ from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||
from src.optional_deps import prepare_optional_dependency_import
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
# on Windows, so importing them unconditionally crashed app startup there
|
||||
@@ -149,6 +151,11 @@ def _pip_dist_name(pkg: dict) -> str:
|
||||
return (pkg.get("name") or "").replace("_", "-")
|
||||
|
||||
|
||||
def _import_optional_dependency_for_status(name: str):
|
||||
prepare_optional_dependency_import(name)
|
||||
return importlib.import_module(name)
|
||||
|
||||
|
||||
def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||
"""Return whether an optional dependency is usable by Cookbook.
|
||||
|
||||
@@ -970,7 +977,6 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""
|
||||
_require_admin(request)
|
||||
_reject_cross_site(request)
|
||||
import importlib
|
||||
import importlib.metadata as importlib_metadata
|
||||
import shlex
|
||||
import json as _json
|
||||
@@ -1057,6 +1063,13 @@ def setup_shell_routes() -> APIRouter:
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "transformers",
|
||||
"pip": "transformers",
|
||||
"desc": "Hugging Face model components used by SD/Flux pipelines and image tools",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "rembg",
|
||||
"pip": "rembg[gpu]",
|
||||
@@ -1202,7 +1215,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
pkg["status_note"] = _package_status_note("vllm", probe)
|
||||
else:
|
||||
try:
|
||||
importlib.import_module(pkg["name"])
|
||||
_import_optional_dependency_for_status(pkg["name"])
|
||||
importlib_metadata.version(_pip_dist_name(pkg))
|
||||
pkg["installed"] = True
|
||||
except ImportError:
|
||||
@@ -1251,6 +1264,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"sglang[all]",
|
||||
"diffusers",
|
||||
"diffusers[torch]",
|
||||
"transformers",
|
||||
"TTS",
|
||||
"bark",
|
||||
"faster-whisper",
|
||||
|
||||
@@ -198,6 +198,8 @@ def setup_webhook_routes(
|
||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
"kimicode": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
# Model prefix → provider mapping for auto-detection
|
||||
@@ -210,6 +212,8 @@ def setup_webhook_routes(
|
||||
"mistral": "mistral",
|
||||
"llama": "groq",
|
||||
"mixtral": "groq",
|
||||
"kimi-for-coding": "kimi-code",
|
||||
"kimi": "kimi-code",
|
||||
}
|
||||
|
||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||
|
||||
Executable
+635
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a neutral agent migration manifest.
|
||||
|
||||
This helper is intentionally read-only. It does not import the Odysseus
|
||||
application package, write to data/, call an LLM, or apply anything. It turns
|
||||
common agent export shapes into a portable JSON manifest that Odysseus can
|
||||
preview or import later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCHEMA_VERSION = "agent-migration.v1"
|
||||
TEXT_EXTENSIONS = {
|
||||
".cfg",
|
||||
".conf",
|
||||
".csv",
|
||||
".json",
|
||||
".log",
|
||||
".md",
|
||||
".markdown",
|
||||
".py",
|
||||
".rst",
|
||||
".toml",
|
||||
".txt",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputWarning:
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def sha256_text(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def sha256_path(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def stable_id(kind: str, source_name: str, *parts: Any) -> str:
|
||||
raw = "\x1f".join([kind, source_name, *[str(part) for part in parts]])
|
||||
return f"{kind}:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def normalize_category(value: Any) -> str:
|
||||
category = str(value or "fact").strip().lower()
|
||||
return category or "fact"
|
||||
|
||||
|
||||
def normalize_memory_text(item: Any) -> str:
|
||||
if isinstance(item, str):
|
||||
return item.strip()
|
||||
if isinstance(item, dict):
|
||||
for key in ("text", "content", "memory", "value"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def memory_metadata(item: Any, source_path: Path, index: int) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {
|
||||
"source_path": str(source_path),
|
||||
"source_index": index,
|
||||
}
|
||||
if isinstance(item, dict):
|
||||
for key in ("id", "timestamp", "created_at", "updated_at", "source", "tags", "pinned"):
|
||||
if key in item:
|
||||
metadata[f"source_{key}"] = item.get(key)
|
||||
return metadata
|
||||
|
||||
|
||||
def payload_items(payload: Any, keys: tuple[str, ...]) -> Any:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
if isinstance(payload.get(key), list):
|
||||
return payload[key]
|
||||
return payload
|
||||
|
||||
|
||||
def collect_memory_json(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
try:
|
||||
payload = read_json(path)
|
||||
except Exception as exc:
|
||||
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||
|
||||
payload = payload_items(payload, ("memories", "memory", "items", "data"))
|
||||
|
||||
if not isinstance(payload, list):
|
||||
return [], [InputWarning(str(path), "expected a JSON list or an object containing a memory list")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(payload):
|
||||
text = normalize_memory_text(item)
|
||||
if not text:
|
||||
warnings.append(InputWarning(str(path), f"skipped memory at index {index}: missing text"))
|
||||
continue
|
||||
digest = sha256_text(text.strip().lower())
|
||||
if digest in seen:
|
||||
warnings.append(InputWarning(str(path), f"skipped duplicate memory at index {index}"))
|
||||
continue
|
||||
seen.add(digest)
|
||||
category = normalize_category(item.get("category") if isinstance(item, dict) else "fact")
|
||||
source = str(item.get("source") or source_name) if isinstance(item, dict) else source_name
|
||||
items.append(
|
||||
{
|
||||
"id": stable_id("memory", source_name, path, index, digest),
|
||||
"kind": "memory",
|
||||
"text": text,
|
||||
"category": category,
|
||||
"source": source,
|
||||
"metadata": memory_metadata(item, path, index),
|
||||
}
|
||||
)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def normalize_timestamp(value: Any) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return (
|
||||
datetime.fromtimestamp(float(value), timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_role(value: Any) -> str:
|
||||
role = str(value or "unknown").strip().lower()
|
||||
if role in {"human", "user"}:
|
||||
return "user"
|
||||
if role in {"assistant", "ai", "bot", "model"}:
|
||||
return "assistant"
|
||||
if role in {"system", "tool"}:
|
||||
return role
|
||||
return role or "unknown"
|
||||
|
||||
|
||||
def content_part_text(part: Any) -> str:
|
||||
if isinstance(part, str):
|
||||
return part
|
||||
if isinstance(part, dict):
|
||||
for key in ("text", "content", "value"):
|
||||
value = part.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
return part["text"]
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_message_text(message: dict[str, Any]) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(text for text in (content_part_text(part).strip() for part in content) if text)
|
||||
if isinstance(content, dict):
|
||||
parts = content.get("parts")
|
||||
if isinstance(parts, list):
|
||||
return "\n".join(text for text in (content_part_text(part).strip() for part in parts) if text)
|
||||
for key in ("text", "content", "value"):
|
||||
value = content.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
for key in ("text", "body", "message"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_message(message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
author = message.get("author") if isinstance(message.get("author"), dict) else {}
|
||||
role = (
|
||||
message.get("role")
|
||||
or message.get("sender")
|
||||
or message.get("speaker")
|
||||
or author.get("role")
|
||||
or author.get("name")
|
||||
)
|
||||
text = normalize_message_text(message).strip()
|
||||
if not text:
|
||||
return None
|
||||
normalized: dict[str, Any] = {
|
||||
"role": normalize_role(role),
|
||||
"text": text,
|
||||
}
|
||||
timestamp = normalize_timestamp(message.get("created_at") or message.get("create_time") or message.get("timestamp"))
|
||||
if timestamp:
|
||||
normalized["created_at"] = timestamp
|
||||
message_id = message.get("id")
|
||||
if message_id is not None:
|
||||
normalized["source_id"] = str(message_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def chatgpt_mapping_messages(conversation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
mapping = conversation.get("mapping")
|
||||
if not isinstance(mapping, dict):
|
||||
return []
|
||||
rows: list[tuple[float, int, dict[str, Any]]] = []
|
||||
for index, node in enumerate(mapping.values()):
|
||||
if not isinstance(node, dict) or not isinstance(node.get("message"), dict):
|
||||
continue
|
||||
message = node["message"]
|
||||
sort_value = message.get("create_time")
|
||||
try:
|
||||
sort_key = float(sort_value)
|
||||
except (TypeError, ValueError):
|
||||
sort_key = float(index)
|
||||
normalized = normalize_message(message)
|
||||
if normalized:
|
||||
rows.append((sort_key, index, normalized))
|
||||
return [row[2] for row in sorted(rows, key=lambda row: (row[0], row[1]))]
|
||||
|
||||
|
||||
def conversation_messages(conversation: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
|
||||
mapped = chatgpt_mapping_messages(conversation)
|
||||
if mapped:
|
||||
return mapped, "chatgpt_mapping"
|
||||
for key in ("messages", "chat_messages", "turns"):
|
||||
raw_messages = conversation.get(key)
|
||||
if isinstance(raw_messages, list):
|
||||
messages = [
|
||||
normalized
|
||||
for raw in raw_messages
|
||||
if isinstance(raw, dict)
|
||||
for normalized in [normalize_message(raw)]
|
||||
if normalized
|
||||
]
|
||||
return messages, key
|
||||
return [], "unknown"
|
||||
|
||||
|
||||
def conversation_title(conversation: dict[str, Any], index: int) -> str:
|
||||
for key in ("title", "name", "summary"):
|
||||
value = conversation.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return f"Conversation {index + 1}"
|
||||
|
||||
|
||||
def collect_conversation_json(
|
||||
path: Path,
|
||||
source_name: str,
|
||||
*,
|
||||
include_content: bool = False,
|
||||
max_messages: int = 2000,
|
||||
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
try:
|
||||
payload = read_json(path)
|
||||
except Exception as exc:
|
||||
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||
|
||||
payload = payload_items(payload, ("conversations", "conversation", "items", "data"))
|
||||
if isinstance(payload, dict):
|
||||
payload = [payload]
|
||||
if not isinstance(payload, list):
|
||||
return [], [InputWarning(str(path), "expected a JSON list or an object containing a conversation list")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for index, conversation in enumerate(payload):
|
||||
if not isinstance(conversation, dict):
|
||||
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: expected object"))
|
||||
continue
|
||||
messages, format_hint = conversation_messages(conversation)
|
||||
if not messages:
|
||||
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: no text messages found"))
|
||||
continue
|
||||
title = conversation_title(conversation, index)
|
||||
source_id = conversation.get("id") or conversation.get("uuid") or conversation.get("conversation_id")
|
||||
text_digest = sha256_text("\n".join(f"{msg['role']}:{msg['text']}" for msg in messages))
|
||||
metadata: dict[str, Any] = {
|
||||
"source_path": str(path),
|
||||
"source_index": index,
|
||||
"source_format": format_hint,
|
||||
"message_count": len(messages),
|
||||
"text_sha256": text_digest,
|
||||
"content_included": False,
|
||||
}
|
||||
if source_id is not None:
|
||||
metadata["source_id"] = str(source_id)
|
||||
for key in ("create_time", "created_at", "update_time", "updated_at"):
|
||||
timestamp = normalize_timestamp(conversation.get(key))
|
||||
if timestamp:
|
||||
metadata[f"source_{key}"] = timestamp
|
||||
item: dict[str, Any] = {
|
||||
"id": stable_id("conversation", source_name, path, source_id or index, text_digest),
|
||||
"kind": "conversation_thread",
|
||||
"title": title,
|
||||
"source": source_name,
|
||||
"metadata": metadata,
|
||||
}
|
||||
if include_content:
|
||||
if len(messages) > max_messages:
|
||||
warnings.append(
|
||||
InputWarning(
|
||||
str(path),
|
||||
f"skipped conversation content at index {index}: over {max_messages} messages",
|
||||
)
|
||||
)
|
||||
else:
|
||||
item["messages"] = messages
|
||||
item["metadata"]["content_included"] = True
|
||||
items.append(item)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def parse_skill_frontmatter(text: str) -> dict[str, Any]:
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return {}
|
||||
frontmatter: dict[str, Any] = {}
|
||||
for line in text[3:end].strip().splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key:
|
||||
frontmatter[key] = value
|
||||
return frontmatter
|
||||
|
||||
|
||||
def collect_skill_dir(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
if path.is_symlink():
|
||||
return [], [InputWarning(str(path), "skills path is a symlink; skipped")]
|
||||
if not path.exists():
|
||||
return [], [InputWarning(str(path), "skills directory does not exist")]
|
||||
if not path.is_dir():
|
||||
return [], [InputWarning(str(path), "skills path is not a directory")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for skill_path in sorted(path.rglob("SKILL.md")):
|
||||
if skill_path.is_symlink():
|
||||
warnings.append(InputWarning(str(skill_path), "skipped symlinked skill file"))
|
||||
continue
|
||||
try:
|
||||
text = skill_path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(skill_path), f"could not read skill: {exc}"))
|
||||
continue
|
||||
frontmatter = parse_skill_frontmatter(text)
|
||||
name = str(frontmatter.get("name") or skill_path.parent.name).strip() or skill_path.parent.name
|
||||
items.append(
|
||||
{
|
||||
"id": stable_id("skill", source_name, skill_path, sha256_text(text)),
|
||||
"kind": "skill",
|
||||
"name": name,
|
||||
"category": str(frontmatter.get("category") or "general"),
|
||||
"source": source_name,
|
||||
"format": "SKILL.md",
|
||||
"content": text,
|
||||
"metadata": {
|
||||
"source_path": str(skill_path),
|
||||
"sha256": sha256_text(text),
|
||||
"frontmatter": frontmatter,
|
||||
},
|
||||
}
|
||||
)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def looks_textual(path: Path) -> bool:
|
||||
if path.suffix.lower() in TEXT_EXTENSIONS:
|
||||
return True
|
||||
guessed, _ = mimetypes.guess_type(str(path))
|
||||
return bool(guessed and (guessed.startswith("text/") or guessed in {"application/json"}))
|
||||
|
||||
|
||||
def iter_archive_dir(path: Path) -> Iterable[Path | InputWarning]:
|
||||
try:
|
||||
children = sorted(path.iterdir())
|
||||
except Exception as exc:
|
||||
yield InputWarning(str(path), f"could not scan archive directory: {exc}")
|
||||
return
|
||||
for child in children:
|
||||
if child.is_symlink():
|
||||
yield InputWarning(str(child), "skipped symlinked archive path")
|
||||
continue
|
||||
if child.is_file():
|
||||
yield child
|
||||
elif child.is_dir():
|
||||
yield from iter_archive_dir(child)
|
||||
|
||||
|
||||
def iter_archive_files(paths: Iterable[Path]) -> Iterable[Path | InputWarning]:
|
||||
for path in paths:
|
||||
if path.is_symlink():
|
||||
yield InputWarning(str(path), "skipped symlinked archive path")
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
elif path.is_dir():
|
||||
yield from iter_archive_dir(path)
|
||||
|
||||
|
||||
def collect_archive_paths(
|
||||
paths: list[Path],
|
||||
source_name: str,
|
||||
*,
|
||||
include_content: bool = False,
|
||||
max_bytes: int = 256_000,
|
||||
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
existing_paths: list[Path] = []
|
||||
for path in paths:
|
||||
if path.is_symlink():
|
||||
warnings.append(InputWarning(str(path), "archive path is a symlink; skipped"))
|
||||
continue
|
||||
if not path.exists():
|
||||
warnings.append(InputWarning(str(path), "archive path does not exist"))
|
||||
continue
|
||||
if not path.is_file() and not path.is_dir():
|
||||
warnings.append(InputWarning(str(path), "archive path is not a file or directory"))
|
||||
continue
|
||||
existing_paths.append(path)
|
||||
|
||||
for entry in iter_archive_files(existing_paths):
|
||||
if isinstance(entry, InputWarning):
|
||||
warnings.append(entry)
|
||||
continue
|
||||
path = entry
|
||||
if not looks_textual(path):
|
||||
warnings.append(InputWarning(str(path), "skipped non-text archive file"))
|
||||
continue
|
||||
try:
|
||||
st = path.stat()
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(path), f"could not stat archive file: {exc}"))
|
||||
continue
|
||||
size = st.st_size
|
||||
try:
|
||||
file_hash = sha256_path(path)
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(path), f"could not hash archive file: {exc}"))
|
||||
continue
|
||||
if include_content and size > max_bytes:
|
||||
warnings.append(InputWarning(str(path), f"skipped archive content over {max_bytes} bytes"))
|
||||
archive_item: dict[str, Any] = {
|
||||
"id": stable_id("archive", source_name, path, file_hash),
|
||||
"kind": "archive_document",
|
||||
"title": path.name,
|
||||
"source": source_name,
|
||||
"metadata": {
|
||||
"source_path": str(path),
|
||||
"size_bytes": size,
|
||||
"sha256": file_hash,
|
||||
},
|
||||
}
|
||||
if include_content and size <= max_bytes:
|
||||
try:
|
||||
archive_item["content"] = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
archive_item["content"] = path.read_text(encoding="utf-8", errors="replace")
|
||||
archive_item["metadata"]["decoded_with_replacement"] = True
|
||||
items.append(archive_item)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def build_manifest(args) -> dict[str, Any]:
|
||||
warnings: list[InputWarning] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
for path in args.memory_json:
|
||||
collected, got_warnings = collect_memory_json(path, args.source_name)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
for path in args.skills_dir:
|
||||
collected, got_warnings = collect_skill_dir(path, args.source_name)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
for path in args.conversation_json:
|
||||
collected, got_warnings = collect_conversation_json(
|
||||
path,
|
||||
args.source_name,
|
||||
include_content=args.include_conversation_content,
|
||||
max_messages=args.max_conversation_messages,
|
||||
)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
if args.archive:
|
||||
collected, got_warnings = collect_archive_paths(
|
||||
args.archive,
|
||||
args.source_name,
|
||||
include_content=args.include_archive_content,
|
||||
max_bytes=args.max_archive_bytes,
|
||||
)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for item in items:
|
||||
counts[item["kind"]] = counts.get(item["kind"], 0) + 1
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": utc_now_iso(),
|
||||
"source": {
|
||||
"name": args.source_name,
|
||||
"kind": args.source_kind,
|
||||
},
|
||||
"summary": {
|
||||
"item_count": len(items),
|
||||
"counts_by_kind": counts,
|
||||
"warning_count": len(warnings),
|
||||
},
|
||||
"items": items,
|
||||
"warnings": [{"path": warning.path, "message": warning.message} for warning in warnings],
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(description="Build a neutral Odysseus agent migration manifest.")
|
||||
parser.add_argument("--source-name", default="agent-export", help="Human-readable source name.")
|
||||
parser.add_argument("--source-kind", default="generic", help="Source adapter kind, e.g. generic, openclaw, hermes.")
|
||||
parser.add_argument(
|
||||
"--memory-json",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="JSON memory export. May be a list, or an object containing memories/items/data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-dir",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Directory containing SKILL.md files. Scanned recursively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Text/Markdown/JSON file or directory to preserve as archive documents.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conversation-json",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Conversation export JSON. Supports generic message lists and ChatGPT-style conversations.json.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-archive-content",
|
||||
action="store_true",
|
||||
help="Embed archive document content in the manifest. By default only metadata is included.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-archive-bytes",
|
||||
type=int,
|
||||
default=256_000,
|
||||
help="Maximum bytes to embed per archive file when --include-archive-content is used.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-conversation-content",
|
||||
action="store_true",
|
||||
help="Embed normalized conversation messages. By default only thread metadata is included.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-conversation-messages",
|
||||
type=int,
|
||||
default=2000,
|
||||
help="Maximum messages to embed per conversation when --include-conversation-content is used.",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, help="Write manifest JSON to this path instead of stdout.")
|
||||
parser.add_argument("--compact", action="store_true", help="Write compact JSON without indentation.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
manifest = build_manifest(args)
|
||||
text = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) if args.compact else (
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||
)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -611,6 +611,93 @@ def _cache_key(host: str, ssh_port: str, platform_name: str):
|
||||
)
|
||||
|
||||
|
||||
def _is_containerized():
|
||||
"""Best-effort check for whether the local Odysseus process is running in a container."""
|
||||
if _remote_host:
|
||||
return False
|
||||
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open("/proc/1/cgroup", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read().lower()
|
||||
return any(marker in text for marker in ("docker", "containerd", "kubepods"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _hardware_visibility_warning(result):
|
||||
"""Return a non-blocking UX warning when detected hardware may only be container-visible."""
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
if result.get("manual_hardware"):
|
||||
return None
|
||||
|
||||
if not result.get("containerized"):
|
||||
return None
|
||||
|
||||
if result.get("gpu_error"):
|
||||
return None
|
||||
|
||||
if not result.get("has_gpu"):
|
||||
return {
|
||||
"code": "container_no_gpu_visible",
|
||||
"severity": "warning",
|
||||
"title": "No GPU visible inside Docker",
|
||||
"message": (
|
||||
"Cookbook is scanning hardware from inside the Odysseus container. "
|
||||
"If your host has a GPU, Docker may not be exposing it to the container, "
|
||||
"so model recommendations may be CPU-only or too conservative."
|
||||
),
|
||||
"actions": [
|
||||
"manual_hardware",
|
||||
"rescan",
|
||||
"copy_diagnostics",
|
||||
],
|
||||
}
|
||||
|
||||
total_ram = result.get("total_ram_gb") or 0
|
||||
if total_ram and total_ram <= 8:
|
||||
return {
|
||||
"code": "container_low_ram_visible",
|
||||
"severity": "info",
|
||||
"title": "Container-visible RAM may be lower than host RAM",
|
||||
"message": (
|
||||
"Cookbook is seeing the RAM available inside the container. "
|
||||
"If your host has more memory, validate host RAM separately or use Manual Hardware."
|
||||
),
|
||||
"actions": [
|
||||
"manual_hardware",
|
||||
"rescan",
|
||||
"copy_diagnostics",
|
||||
],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _attach_probe_context(result, host=""):
|
||||
"""Attach probe-scope metadata and optional hardware visibility warning."""
|
||||
if not isinstance(result, dict) or result.get("error"):
|
||||
return result
|
||||
|
||||
is_remote = bool(host)
|
||||
containerized = False if is_remote else _is_containerized()
|
||||
|
||||
result["probe_scope"] = "remote" if is_remote else ("container" if containerized else "native")
|
||||
result["containerized"] = containerized
|
||||
|
||||
warning = _hardware_visibility_warning(result)
|
||||
if warning:
|
||||
result["hardware_visibility_warning"] = warning
|
||||
else:
|
||||
result.pop("hardware_visibility_warning", None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
|
||||
changes, and probing a remote host over SSH is slow). Pass fresh=True to
|
||||
@@ -635,6 +722,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if _remote_platform == "windows" and _remote_host:
|
||||
result = _detect_windows()
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
@@ -653,6 +741,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if not _remote_host and os.name == "nt":
|
||||
result = _detect_windows()
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# PowerShell probe failed entirely — fall through to the generic path
|
||||
@@ -714,6 +803,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_error": _last_gpu_error,
|
||||
}
|
||||
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
|
||||
@@ -188,12 +188,18 @@ def compute_serve_profiles(system, model, serve_weights_gb=None, serve_quant=Non
|
||||
# Shrink context if even the chosen KV won't fit alongside weights.
|
||||
# Start from the smaller of the profile's target and the model's limit.
|
||||
cur_ctx = min(ctx, model_ctx_max)
|
||||
while cur_ctx >= 8192:
|
||||
# Floor the context-shrink loop at 8192, but never above the model's own
|
||||
# trained limit. A model with a sub-8192 context (e.g. a 2048-token
|
||||
# SmolLM) starts below 8192, so a hard-coded 8192 guard skipped the loop
|
||||
# entirely and produced NO profile — the serve UI then fell back to
|
||||
# manual flags even though the model fits the GPU trivially.
|
||||
ctx_floor = min(8192, model_ctx_max)
|
||||
while cur_ctx >= ctx_floor:
|
||||
kv = _kv_gb(model, cur_ctx, kv_type)
|
||||
n_cpu_moe, fits = _cpu_moe_for_budget(model, quant, kv, budget, fixed_gb=serve_weights_gb)
|
||||
est = _weights_gb(model, quant, serve_weights_gb) + kv + 0.6
|
||||
# If a non-MoE model can't fit even fully offloaded, try less context.
|
||||
if model.get("is_moe") or fits or cur_ctx <= 8192:
|
||||
if model.get("is_moe") or fits or cur_ctx <= ctx_floor:
|
||||
profiles.append({
|
||||
"key": key,
|
||||
"label": label,
|
||||
|
||||
@@ -64,20 +64,40 @@ def is_youtube_url(url: str) -> bool:
|
||||
return "youtube.com" in url or "youtu.be" in url
|
||||
|
||||
|
||||
# youtube.com-shaped hosts. music.youtube.com serves the same /watch and
|
||||
# /shorts paths, so links shared from YouTube Music must resolve too.
|
||||
_YT_HOSTS = ("www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com")
|
||||
# Path prefixes whose first following segment is the video id. Covers the
|
||||
# /embed/ player, Shorts (/shorts/), live streams (/live/), and the legacy
|
||||
# /v/ embed — all of which `is_youtube_url` already treats as YouTube, so
|
||||
# they must be extractable or the link is silently dropped (neither web-fetched
|
||||
# nor transcript-fetched) by the chat pipeline.
|
||||
_YT_PATH_PREFIXES = ("/embed/", "/shorts/", "/live/", "/v/")
|
||||
|
||||
|
||||
def extract_youtube_id(url: str) -> Optional[str]:
|
||||
"""Extract YouTube video ID from various URL formats."""
|
||||
"""Extract a YouTube video ID from the common URL shapes:
|
||||
watch?v=, youtu.be/<id>, /embed/<id>, /shorts/<id>, /live/<id>, /v/<id>,
|
||||
across youtube.com / m.youtube.com / music.youtube.com / youtu.be."""
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
|
||||
host = (parsed.hostname or "").lower()
|
||||
if host in _YT_HOSTS:
|
||||
if parsed.path == "/watch":
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
if "v" in params:
|
||||
if params.get("v"):
|
||||
return params["v"][0]
|
||||
elif parsed.path.startswith("/embed/"):
|
||||
return parsed.path.split("/")[-1]
|
||||
elif parsed.hostname == "youtu.be":
|
||||
return parsed.path[1:]
|
||||
else:
|
||||
for prefix in _YT_PATH_PREFIXES:
|
||||
if parsed.path.startswith(prefix):
|
||||
vid = parsed.path[len(prefix):].split("/")[0]
|
||||
if vid:
|
||||
return vid
|
||||
elif host == "youtu.be":
|
||||
vid = parsed.path.lstrip("/").split("/")[0]
|
||||
if vid:
|
||||
return vid
|
||||
return None
|
||||
|
||||
|
||||
@@ -170,6 +190,8 @@ def format_transcript_for_context(
|
||||
if segments:
|
||||
ctx += "Timestamped Transcript:\n"
|
||||
for seg in segments:
|
||||
if not isinstance(seg, dict):
|
||||
continue
|
||||
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
|
||||
# Check length — fall back to plain text if too long
|
||||
if len(ctx) > 12000:
|
||||
@@ -202,15 +224,24 @@ async def fetch_youtube_comments(
|
||||
f"https://www.youtube.com/watch?v={video_id}",
|
||||
]
|
||||
|
||||
proc = await asyncio.wait_for(
|
||||
asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
),
|
||||
timeout=timeout,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
# Bound the wait on the process actually finishing, not on spawning it.
|
||||
# create_subprocess_exec returns as soon as the child starts, so wrapping
|
||||
# it in wait_for never enforces the timeout — proc.communicate() is the
|
||||
# blocking step. Kill and reap the child if it overruns so it does not
|
||||
# linger after we return.
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise
|
||||
|
||||
if proc.returncode != 0:
|
||||
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
|
||||
|
||||
@@ -91,6 +91,9 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
|
||||
|
||||
# Deep research jobs, not quick conceptual mentions of research.
|
||||
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
|
||||
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
|
||||
+43
-17
@@ -262,6 +262,11 @@ _DOMAIN_RULES = {
|
||||
- Use `manage_settings` for preferences and tool enable/disable.
|
||||
- Use named tools over `app_api` when a named wrapper exists.
|
||||
- `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""",
|
||||
"contacts": """\
|
||||
## Contacts rules
|
||||
- Use `resolve_contact` to look up a contact's email or phone number by name. Searches the CardDAV address book and sent email history.
|
||||
- Use `manage_contact` to list, add, update, or delete contacts in the address book.
|
||||
- Do NOT use `manage_memory` for contact lookups — contact details live in the address book, not memory.""",
|
||||
}
|
||||
|
||||
_DOMAIN_TOOL_MAP = {
|
||||
@@ -274,6 +279,7 @@ _DOMAIN_TOOL_MAP = {
|
||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||
"contacts": {"resolve_contact", "manage_contact"},
|
||||
}
|
||||
|
||||
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
||||
@@ -600,7 +606,7 @@ _API_HOSTS = frozenset([
|
||||
"api.deepseek.com", "deepseek.com",
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"ollama.com", "api.venice.ai",
|
||||
"ollama.com", "api.venice.ai", "api.kimi.com",
|
||||
"api.githubcopilot.com",
|
||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||
@@ -787,6 +793,12 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
domains.add("documents")
|
||||
if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"):
|
||||
domains.add("web")
|
||||
if has(
|
||||
r"\b(wyszukaj|wyszukać|wyszukac)\b.*\b(internet|internecie|online|web)\b",
|
||||
r"\b(sprawd[zź]|znajd[zź])\b.*\b(internet|internecie|online|web)\b",
|
||||
r"\b(aktualn\w*|bieżąc\w*|biezac\w*|dzisiaj|teraz)\b.*\b(pogod\w*|temperatur\w*)\b",
|
||||
):
|
||||
domains.add("web")
|
||||
if has(r"\b(research|deep dive|investigate|look into)\b"):
|
||||
domains.add("web")
|
||||
if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"):
|
||||
@@ -797,6 +809,8 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
domains.add("files")
|
||||
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
||||
domains.add("settings")
|
||||
if has(r"\b(contact|contacts|phone|phone number|address book|vcard)\b"):
|
||||
domains.add("contacts")
|
||||
|
||||
low_signal = not continuation and not domains
|
||||
return {
|
||||
@@ -1801,18 +1815,21 @@ async def stream_agent_loop(
|
||||
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
|
||||
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||
if workspace:
|
||||
# An active workspace IS the file-work signal: a vague "look at the
|
||||
# project" means explore this folder. Surface only the READ-ONLY file
|
||||
# tools (intersection with the plan-mode read-only allowlist) so the
|
||||
# agent can investigate; write/shell tools stay out until the request
|
||||
# actually calls for them (RAG retrieval adds those on a real ask).
|
||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||
from src.tool_security import PLAN_MODE_READONLY_TOOLS
|
||||
_relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS)
|
||||
logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools")
|
||||
else:
|
||||
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only")
|
||||
# Don't short-circuit: fall through to RAG retrieval below.
|
||||
# Non-English queries are flagged low_signal by the English-only
|
||||
# intent classifier, but fastembed retrieval works across languages.
|
||||
logger.info("[tool-rag] Low-signal query; will run RAG retrieval")
|
||||
if not guide_only and not _relevant_tools:
|
||||
try:
|
||||
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
|
||||
@@ -1937,6 +1954,10 @@ async def stream_agent_loop(
|
||||
# and can override this list for users who know their setup.
|
||||
_model_no_tools = any(kw in _model_lc for kw in (
|
||||
"deepseek-r1",
|
||||
# Open-weight GPT-OSS models are commonly served through llama.cpp /
|
||||
# llama-cpp-python. Their names contain "gpt-o", but they do not use
|
||||
# OpenAI's native tool-call channel unless the endpoint opts in.
|
||||
"gpt-oss",
|
||||
))
|
||||
# Native Ollama endpoints (/api/chat) handle tool schemas differently from
|
||||
# the OpenAI-compat path. Models like gemma4, qwen3.5, ministral respond to
|
||||
@@ -1998,30 +2019,34 @@ async def stream_agent_loop(
|
||||
_t3 = time.time()
|
||||
try:
|
||||
from src.context_compactor import trim_for_context
|
||||
from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX
|
||||
from src.settings import is_setting_overridden
|
||||
from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX, DEFAULT_BUDGET, budget_is_explicit as _budget_is_explicit
|
||||
from src.model_context import budget_context_for_model
|
||||
|
||||
soft_budget = int(get_setting("agent_input_token_budget", 6000) or 0)
|
||||
soft_budget = int(get_setting("agent_input_token_budget", DEFAULT_BUDGET) or 0)
|
||||
if soft_budget > 0:
|
||||
before_trim_tokens = estimate_tokens(messages)
|
||||
reserve_tokens = min(max(max_tokens or 1024, 512), 2048)
|
||||
# Honour the configurable ceiling for the auto-derived budget path.
|
||||
# No-op when the user has an explicit `agent_input_token_budget`
|
||||
# (that branch ignores hard_max). Falls back to DEFAULT_HARD_MAX
|
||||
# on missing/malformed values so misconfig can't zero the budget.
|
||||
# Ceiling for the auto-derived budget (no effect on an explicit budget;
|
||||
# see #1230). Falls back to DEFAULT_HARD_MAX on missing/malformed values
|
||||
# so misconfig can't zero the budget.
|
||||
try:
|
||||
hard_max = int(get_setting("agent_input_token_hard_max", DEFAULT_HARD_MAX) or DEFAULT_HARD_MAX)
|
||||
except (TypeError, ValueError):
|
||||
hard_max = DEFAULT_HARD_MAX
|
||||
if hard_max <= 0:
|
||||
hard_max = DEFAULT_HARD_MAX
|
||||
# Scale the default budget to the model's context window so long-context
|
||||
# models aren't silently capped at 6000; an explicit user setting is
|
||||
# still honoured (clamped to the window). (#1170)
|
||||
# Default value = auto sentinel (scale to the window); any other value =
|
||||
# explicit cap. Value-based, not presence-based, because the save path
|
||||
# materializes defaults so a persisted default must still read as auto (#4121).
|
||||
budget_is_explicit = _budget_is_explicit(soft_budget)
|
||||
# Scale only off a window we actually discovered, bound to the value it
|
||||
# proves (else 0) — not the passed-in context_length, which can be stale
|
||||
# or unset for some callers (#4122 review).
|
||||
ctx_for_budget = budget_context_for_model(endpoint_url, model, fallback=context_length)
|
||||
effective_budget = compute_input_token_budget(
|
||||
soft_budget,
|
||||
context_length,
|
||||
is_setting_overridden("agent_input_token_budget"),
|
||||
ctx_for_budget,
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
trimmed_messages = trim_for_context(
|
||||
@@ -2096,11 +2121,12 @@ async def stream_agent_loop(
|
||||
# tool, so we don't nudge on harmless transitional text like "let me
|
||||
# know what you think".
|
||||
_INTENT_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:let me|i'?ll|i will|going to|let's)\s+"
|
||||
r"(?:^|\n)\s*(?:let me|i'?ll|i will|i need to|we need to|need to|"
|
||||
r"i should|we should|i must|we must|going to|let's)\s+"
|
||||
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
|
||||
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
|
||||
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
|
||||
r"register|adopt|list|search|find|query|hit|ping|test)"
|
||||
r"register|adopt|list|search|find|query|hit|ping|test|use|perform|do)"
|
||||
r"\b[^.\n]{0,140}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from typing import Dict
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from core.platform_compat import safe_chmod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APIKeyManager:
|
||||
@@ -15,12 +17,20 @@ class APIKeyManager:
|
||||
def get_or_create_key(self) -> bytes:
|
||||
"""Get or create encryption key for API keys"""
|
||||
if os.path.exists(self.key_file):
|
||||
# Older versions wrote .key with the process umask (often 0o644,
|
||||
# i.e. group/world-readable). Re-restrict on read so existing
|
||||
# installs heal without needing the key to be regenerated.
|
||||
safe_chmod(self.key_file, 0o600)
|
||||
with open(self.key_file, 'rb') as f:
|
||||
return f.read()
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
with open(self.key_file, 'wb') as f:
|
||||
f.write(key)
|
||||
# This key decrypts every stored provider credential, so restrict it
|
||||
# to the owner (0o600) — it must not be group/world-readable. No-op
|
||||
# on Windows (files there are ACL-restricted to the user already).
|
||||
safe_chmod(self.key_file, 0o600)
|
||||
return key
|
||||
|
||||
def encrypt_api_key(self, api_key: str) -> str:
|
||||
|
||||
@@ -55,6 +55,8 @@ async def _drain_agent(sess, messages):
|
||||
if "delta" in d:
|
||||
delta = d.get("delta")
|
||||
if isinstance(delta, str):
|
||||
if d.get("thinking"):
|
||||
continue
|
||||
full += delta
|
||||
elif d.get("type") == "agent_step":
|
||||
round_num = d.get("round", round_num)
|
||||
|
||||
+73
-6
@@ -5,12 +5,13 @@ Auto-registration of built-in MCP servers on startup.
|
||||
Each server runs as a stdio subprocess managed by McpManager.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
from core.platform_compat import IS_WINDOWS, which_tool
|
||||
|
||||
@@ -197,12 +198,13 @@ def _npx_package_from_args(args):
|
||||
async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
|
||||
"""Probe whether an npx package is already in the local cache.
|
||||
|
||||
Runs `npx --no-install <pkg> --version`. --no-install tells npx to
|
||||
fail instead of downloading, so a cache miss returns fast. We treat
|
||||
"exited 0 with non-empty stdout" as proof of a working cached copy.
|
||||
Anything else (non-zero exit, empty stdout, timeout, missing npx,
|
||||
network error) means we should skip the server.
|
||||
First checks the local `_npx` cache for an installed package. If the
|
||||
package is not found there, falls back to `npx --no-install <pkg>
|
||||
--version` so older npm layouts still work without downloading.
|
||||
"""
|
||||
if _is_package_in_npx_cache(package_spec):
|
||||
return True
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
npx_path, "--no-install", package_spec, "--version",
|
||||
@@ -231,3 +233,68 @@ async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
|
||||
pass
|
||||
return False
|
||||
return proc.returncode == 0 and bool(stdout.strip())
|
||||
|
||||
|
||||
def _is_package_in_npx_cache(package_spec):
|
||||
"""Return True when npm's `_npx` cache already contains package_spec."""
|
||||
package_name = _npx_package_name(package_spec)
|
||||
if not package_name:
|
||||
return False
|
||||
|
||||
for cache_root in _npm_cache_roots():
|
||||
npx_root = os.path.join(cache_root, "_npx")
|
||||
if _npx_cache_contains_package(npx_root, package_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _npx_package_name(package_spec):
|
||||
"""Strip a version/range suffix from an npm package spec."""
|
||||
if not package_spec:
|
||||
return ""
|
||||
if package_spec.startswith("@"):
|
||||
parts = package_spec.split("@", 2)
|
||||
if len(parts) >= 3:
|
||||
return f"@{parts[1]}"
|
||||
return package_spec
|
||||
return package_spec.split("@", 1)[0]
|
||||
|
||||
|
||||
def _npm_cache_roots():
|
||||
roots = []
|
||||
configured = os.environ.get("npm_config_cache")
|
||||
if configured:
|
||||
roots.append(os.path.expanduser(configured))
|
||||
roots.append(os.path.join(os.path.expanduser("~"), ".npm"))
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
roots.append(os.path.join(local_app_data, "npm-cache"))
|
||||
return list(dict.fromkeys(roots))
|
||||
|
||||
|
||||
def _npx_cache_contains_package(npx_root, package_name):
|
||||
if not os.path.isdir(npx_root):
|
||||
return False
|
||||
package_path = os.path.join("node_modules", *package_name.split("/"), "package.json")
|
||||
try:
|
||||
entries = list(os.scandir(npx_root))
|
||||
except OSError:
|
||||
return False
|
||||
for entry in entries:
|
||||
try:
|
||||
is_dir = entry.is_dir()
|
||||
except OSError:
|
||||
continue
|
||||
cached_name = _cached_package_name(os.path.join(entry.path, package_path))
|
||||
if is_dir and cached_name == package_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _cached_package_name(package_json_path):
|
||||
try:
|
||||
with open(package_json_path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return str(data.get("name", "")).strip()
|
||||
|
||||
+178
-1
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
|
||||
return urlunparse(parsed._replace(fragment="")).rstrip("/")
|
||||
|
||||
|
||||
def _event_etag(obj) -> str:
|
||||
"""Best-effort ETag extraction from python-caldav resources."""
|
||||
try:
|
||||
etag = getattr(obj, "etag", None)
|
||||
if callable(etag):
|
||||
etag = etag()
|
||||
return str(etag or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
||||
"""Deterministic local id for a remote CalDAV calendar, scoped to owner
|
||||
and account so two users — or one user with two accounts — pointing at
|
||||
@@ -316,11 +327,12 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
color="#5b8abf",
|
||||
source="caldav",
|
||||
account_id=account_id or None,
|
||||
caldav_base_url=remote_url,
|
||||
)
|
||||
db.add(local_cal)
|
||||
db.commit()
|
||||
else:
|
||||
# Refresh display name and stamp account_id if missing.
|
||||
# Refresh display name and stamp CalDAV metadata if missing.
|
||||
changed = False
|
||||
if local_cal.name != display_name:
|
||||
local_cal.name = display_name
|
||||
@@ -328,6 +340,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
if account_id and not local_cal.account_id:
|
||||
local_cal.account_id = account_id
|
||||
changed = True
|
||||
if local_cal.caldav_base_url != remote_url:
|
||||
local_cal.caldav_base_url = remote_url
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
result["calendars"] += 1
|
||||
@@ -395,6 +410,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
|
||||
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
||||
if existing:
|
||||
if existing.caldav_sync_pending in {"create", "update"}:
|
||||
result["events"] += 1
|
||||
continue
|
||||
existing.calendar_id = local_cal.id
|
||||
existing.summary = summary
|
||||
existing.description = description
|
||||
@@ -405,6 +423,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
existing.is_utc = row_is_utc
|
||||
existing.rrule = rrule
|
||||
existing.origin = "caldav"
|
||||
existing.remote_href = str(getattr(obj, "url", "") or "") or None
|
||||
existing.remote_etag = _event_etag(obj) or None
|
||||
existing.caldav_sync_pending = None
|
||||
else:
|
||||
new_ev = CalendarEvent(
|
||||
uid=uid_val,
|
||||
@@ -418,6 +439,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
is_utc=row_is_utc,
|
||||
rrule=rrule,
|
||||
origin="caldav",
|
||||
remote_href=str(getattr(obj, "url", "") or "") or None,
|
||||
remote_etag=_event_etag(obj) or None,
|
||||
)
|
||||
db.add(new_ev)
|
||||
pending[uid_val] = new_ev
|
||||
@@ -442,6 +465,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
CalendarEvent.origin == "caldav",
|
||||
CalendarEvent.dtstart >= start,
|
||||
CalendarEvent.dtstart <= end,
|
||||
CalendarEvent.remote_href.isnot(None),
|
||||
CalendarEvent.caldav_sync_pending.is_(None),
|
||||
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
||||
).all()
|
||||
for ev in stale:
|
||||
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
return result
|
||||
|
||||
|
||||
def _event_payload(ev) -> dict:
|
||||
return {
|
||||
"uid": ev.uid,
|
||||
"summary": ev.summary,
|
||||
"description": ev.description,
|
||||
"location": ev.location,
|
||||
"dtstart": ev.dtstart,
|
||||
"dtend": ev.dtend,
|
||||
"all_day": ev.all_day,
|
||||
"is_utc": ev.is_utc,
|
||||
"rrule": ev.rrule or "",
|
||||
}
|
||||
|
||||
|
||||
def _load_event_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||
from core.database import CalendarCal, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||
return None
|
||||
return ev.calendar.source, ev.calendar.id, _event_payload(ev)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _load_delete_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if tombstone:
|
||||
return "caldav", tombstone.calendar_id, {"uid": uid}
|
||||
|
||||
ev = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||
return None
|
||||
return ev.calendar.source, ev.calendar.id, {"uid": uid}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _pending_writeback_uids(owner: str) -> tuple[list[str], list[str]]:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = (
|
||||
db.query(CalendarEvent.uid)
|
||||
.join(CalendarCal)
|
||||
.filter(
|
||||
CalendarCal.owner == owner,
|
||||
CalendarCal.source == "caldav",
|
||||
CalendarEvent.status != "cancelled",
|
||||
(
|
||||
(CalendarEvent.caldav_sync_pending.isnot(None))
|
||||
| (CalendarEvent.remote_href.is_(None))
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
delete_rows = (
|
||||
db.query(CalendarDeletedEvent.uid)
|
||||
.filter(CalendarDeletedEvent.owner == owner)
|
||||
.all()
|
||||
)
|
||||
return [row[0] for row in rows], [row[0] for row in delete_rows]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _load_caldav_accounts(owner: str) -> list:
|
||||
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
|
||||
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
|
||||
@@ -533,3 +644,69 @@ async def sync_caldav(owner: str) -> dict:
|
||||
for err in result.get("errors", []):
|
||||
totals["errors"].append(f"{label}: {err}")
|
||||
return totals
|
||||
|
||||
|
||||
async def push_event_create(owner: str, uid: str) -> dict:
|
||||
loaded = _load_event_for_writeback(owner, uid)
|
||||
if not loaded:
|
||||
return {"ok": True, "skipped": True}
|
||||
source, calendar_id, payload = loaded
|
||||
from src.caldav_writeback import writeback_event
|
||||
return await writeback_event(owner, source, calendar_id, payload)
|
||||
|
||||
|
||||
async def push_event_update(owner: str, uid: str) -> dict:
|
||||
return await push_event_create(owner, uid)
|
||||
|
||||
|
||||
async def push_event_delete(owner: str, uid: str) -> dict:
|
||||
loaded = _load_delete_for_writeback(owner, uid)
|
||||
if not loaded:
|
||||
return {"ok": True, "skipped": True}
|
||||
source, calendar_id, payload = loaded
|
||||
from src.caldav_writeback import writeback_event
|
||||
return await writeback_event(owner, source, calendar_id, payload, delete=True)
|
||||
|
||||
|
||||
async def push_pending_events(owner: str) -> dict:
|
||||
result = {"events": 0, "errors": []}
|
||||
uids, delete_uids = _pending_writeback_uids(owner)
|
||||
for event_uid in uids:
|
||||
try:
|
||||
out = await push_event_update(owner, event_uid)
|
||||
if out.get("ok"):
|
||||
result["events"] += 1
|
||||
elif not out.get("skipped"):
|
||||
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV pending push failed for uid=%s: %s", event_uid, e)
|
||||
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||
for event_uid in delete_uids:
|
||||
try:
|
||||
out = await push_event_delete(owner, event_uid)
|
||||
if out.get("ok"):
|
||||
result["events"] += 1
|
||||
elif not out.get("skipped"):
|
||||
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV pending delete failed for uid=%s: %s", event_uid, e)
|
||||
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||
return result
|
||||
|
||||
|
||||
async def sync_caldav_direction(owner: str, direction: str = "pull") -> dict:
|
||||
direction = (direction or "pull").strip().lower()
|
||||
if direction == "pull":
|
||||
return await sync_caldav(owner)
|
||||
if direction == "push":
|
||||
return await push_pending_events(owner)
|
||||
if direction == "both":
|
||||
pushed = await push_pending_events(owner)
|
||||
pulled = await sync_caldav(owner)
|
||||
return {"push": pushed, "pull": pulled}
|
||||
return {
|
||||
"calendars": 0,
|
||||
"events": 0,
|
||||
"deleted": 0,
|
||||
"errors": [f"Unsupported CalDAV sync direction: {direction}"],
|
||||
}
|
||||
|
||||
+92
-6
@@ -89,6 +89,23 @@ def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_
|
||||
return None
|
||||
|
||||
|
||||
def _resource_href(obj) -> str:
|
||||
try:
|
||||
return str(getattr(obj, "url", "") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _resource_etag(obj) -> str:
|
||||
try:
|
||||
etag = getattr(obj, "etag", None)
|
||||
if callable(etag):
|
||||
etag = etag()
|
||||
return str(etag or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
owner: str = "", account_id: str = "") -> dict:
|
||||
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
||||
@@ -105,6 +122,7 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
||||
if remote is None:
|
||||
return {"ok": False, "error": "remote calendar not found"}
|
||||
remote_url = str(getattr(remote, "url", "") or "")
|
||||
|
||||
try:
|
||||
existing = remote.event_by_uid(uid)
|
||||
@@ -113,17 +131,34 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
|
||||
if delete:
|
||||
if existing is None:
|
||||
return {"ok": True, "note": "already absent on remote"}
|
||||
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
|
||||
existing.delete()
|
||||
return {"ok": True}
|
||||
return {
|
||||
"ok": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(existing),
|
||||
"remote_etag": _resource_etag(existing),
|
||||
}
|
||||
|
||||
ical = build_event_ical(ev)
|
||||
if existing is not None:
|
||||
existing.data = ical
|
||||
existing.save()
|
||||
return {"ok": True, "updated": True}
|
||||
remote.save_event(ical)
|
||||
return {"ok": True, "created": True}
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(existing),
|
||||
"remote_etag": _resource_etag(existing),
|
||||
}
|
||||
created = remote.save_event(ical)
|
||||
return {
|
||||
"ok": True,
|
||||
"created": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(created),
|
||||
"remote_etag": _resource_etag(created),
|
||||
}
|
||||
|
||||
|
||||
def _discover_calendars(client):
|
||||
@@ -154,6 +189,54 @@ def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
||||
owner=owner, account_id=account_id)
|
||||
|
||||
|
||||
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
if not uid or not isinstance(result, dict):
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
calendar = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == calendar_id,
|
||||
CalendarCal.owner == owner,
|
||||
).first()
|
||||
if calendar and result.get("calendar_url"):
|
||||
calendar.caldav_base_url = result.get("calendar_url")
|
||||
|
||||
if delete:
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if result.get("ok"):
|
||||
if tombstone:
|
||||
db.delete(tombstone)
|
||||
elif tombstone:
|
||||
tombstone.last_error = str(result.get("error") or result)[:500]
|
||||
db.commit()
|
||||
return
|
||||
|
||||
event = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if event and result.get("ok"):
|
||||
if result.get("remote_href"):
|
||||
event.remote_href = result.get("remote_href")
|
||||
if result.get("remote_etag"):
|
||||
event.remote_etag = result.get("remote_etag")
|
||||
event.caldav_sync_pending = None
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("CalDAV write-back metadata persistence failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
ev: dict, *, delete: bool = False) -> dict:
|
||||
"""Best-effort push of a local change to the remote CalDAV server.
|
||||
@@ -204,9 +287,12 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
result = await asyncio.to_thread(
|
||||
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
||||
)
|
||||
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||
if not result.get("ok"):
|
||||
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("CalDAV write-back raised")
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
result = {"ok": False, "error": str(e)[:200]}
|
||||
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||
return result
|
||||
|
||||
+27
-7
@@ -31,16 +31,22 @@ def compute_input_token_budget(
|
||||
|
||||
Args:
|
||||
configured: the value read from settings (may be the default).
|
||||
context_length: the model's discovered context window (0/unknown if none).
|
||||
explicit: True if the user explicitly set ``agent_input_token_budget``.
|
||||
context_length: the model's discovered context window. Pass 0 when the
|
||||
window is unknown / only a bare fallback — auto-scaling then stays
|
||||
conservative instead of trusting an unproven window (review on #4122).
|
||||
explicit: True if the user set a NON-default budget. The default value is
|
||||
the "auto" sentinel (scale to the window); any other value is an
|
||||
explicit cap. (A deliberately-chosen default can't be distinguished
|
||||
from a materialized default by value, so the default reads as auto.)
|
||||
|
||||
Rules:
|
||||
- Explicit user budget is honoured exactly, only clamped to the model's
|
||||
window when that window is known (never send more than the model holds).
|
||||
- Otherwise (default), scale to ``headroom`` of the context window, capped
|
||||
at ``hard_max`` — so long-context models use their capacity.
|
||||
- When the window is unknown, fall back to the configured/default value
|
||||
(preserving the previous behaviour).
|
||||
window when that window is known (the user's deliberate choice wins;
|
||||
``hard_max`` is an auto-budget ceiling only — see #1230).
|
||||
- Otherwise (auto), scale to ``headroom`` of the context window, capped at
|
||||
``hard_max`` — so long-context models use their capacity.
|
||||
- When the window is unknown (context_length <= 0), use the conservative
|
||||
``default`` budget and do NOT scale off the fallback.
|
||||
"""
|
||||
configured = int(configured or 0)
|
||||
context_length = int(context_length or 0)
|
||||
@@ -53,3 +59,17 @@ def compute_input_token_budget(
|
||||
return max(1, min(scaled, hard_max))
|
||||
|
||||
return configured if configured > 0 else default
|
||||
|
||||
|
||||
def budget_is_explicit(configured: int, *, default: int = DEFAULT_BUDGET) -> bool:
|
||||
"""Whether a configured agent_input_token_budget is a deliberate explicit cap.
|
||||
|
||||
The default value is the "auto" sentinel (scale to the model's window), so only
|
||||
a NON-default positive value counts as explicit. This keys off the VALUE, not
|
||||
settings *presence* — the settings-save path materializes every default into
|
||||
settings.json, so a persisted default must still read as auto (the regression
|
||||
#4121 / #1230 are about). Centralised here so the materialized-default contract
|
||||
is unit-testable and can't silently regress to a presence check.
|
||||
"""
|
||||
configured = int(configured or 0)
|
||||
return configured > 0 and configured != default
|
||||
|
||||
@@ -136,7 +136,8 @@ async def _tick() -> None:
|
||||
return
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("cookbook_serve_lifecycle: state file unreadable (%s), skipping tick", e)
|
||||
return
|
||||
tasks = state.get("tasks") or []
|
||||
now_ms = int(time.time() * 1000)
|
||||
@@ -178,8 +179,26 @@ async def _tick() -> None:
|
||||
if stopped_any:
|
||||
try:
|
||||
from core.atomic_io import atomic_write_json
|
||||
state["tasks"] = tasks
|
||||
atomic_write_json(state_path, state)
|
||||
# Re-read the state file so concurrent UI writes (task adds,
|
||||
# status flips, config edits) are not silently overwritten.
|
||||
# Apply only our stop mutations to the fresh snapshot.
|
||||
try:
|
||||
fresh = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
fresh_tasks = fresh.get("tasks") or []
|
||||
except Exception:
|
||||
fresh = state
|
||||
fresh_tasks = tasks
|
||||
stopped_sids = {sid for sid, _, _ in to_stop}
|
||||
for ft in fresh_tasks:
|
||||
if not isinstance(ft, dict):
|
||||
continue
|
||||
ft_sid = ft.get("sessionId") or ft.get("id")
|
||||
if ft_sid in stopped_sids:
|
||||
ft["status"] = "stopped"
|
||||
ft["_scheduledStopAtMs"] = None
|
||||
ft["_lastStatusFlipAt"] = now_ms
|
||||
fresh["tasks"] = fresh_tasks
|
||||
atomic_write_json(state_path, fresh)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
|
||||
|
||||
|
||||
+27
-14
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.llm_core import _detect_provider, _host_match, _ollama_api_root
|
||||
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -183,7 +183,16 @@ def build_chat_url(base: str) -> str:
|
||||
|
||||
|
||||
def build_models_url(base: str) -> Optional[str]:
|
||||
"""Return the provider-specific model-list endpoint URL for a base."""
|
||||
"""Return the provider-specific model-list endpoint URL for a base.
|
||||
|
||||
For OpenAI-compatible servers (LM Studio, llama.cpp, vLLM,
|
||||
text-generation-webui, etc.) the model list is exposed at ``/v1/models``.
|
||||
When the user-supplied base has no path — e.g. ``http://localhost:1234`` —
|
||||
we still need to land on ``/v1/models`` (issue #25); insert the ``/v1``
|
||||
segment only when the path is empty, leaving any explicit non-empty path
|
||||
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
|
||||
their semantics).
|
||||
"""
|
||||
base = normalize_base(resolve_url(base))
|
||||
provider = _detect_provider(base)
|
||||
if provider == "anthropic":
|
||||
@@ -192,6 +201,12 @@ def build_models_url(base: str) -> Optional[str]:
|
||||
return _ollama_api_root(base) + "/tags"
|
||||
if provider == "chatgpt-subscription":
|
||||
return None
|
||||
# Generic OpenAI-compatible fallback: ensure the path lands on /v1/models
|
||||
# when the user omitted a path entirely. If a non-empty path is already
|
||||
# present (e.g. /openai, /api/openai/v1, /v1), trust the caller — the
|
||||
# /models suffix is appended as-is and the caller's prefix is preserved.
|
||||
if not urlparse(base).path:
|
||||
base = base + "/v1"
|
||||
return base + "/models"
|
||||
|
||||
|
||||
@@ -215,6 +230,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||
if provider == "openrouter":
|
||||
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||
if _is_kimi_code_url(base):
|
||||
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||
return headers
|
||||
|
||||
|
||||
@@ -250,27 +267,23 @@ def resolve_endpoint(
|
||||
ep_id = _stg(f"{setting_prefix}_endpoint_id")
|
||||
model = _stg(f"{setting_prefix}_model")
|
||||
|
||||
# If the specific endpoint is not configured, but the caller provided a
|
||||
# Fall back to utility model for task/research/auto-naming if not specifically configured.
|
||||
if not ep_id and setting_prefix not in ("utility", "default"):
|
||||
ep_id = _stg("utility_endpoint_id")
|
||||
model = _stg("utility_model")
|
||||
|
||||
# If the endpoint is STILL not configured, but the caller provided a
|
||||
# valid fallback (e.g. the active session model), use that immediately.
|
||||
# This prevents background tasks from jumping to the global default_model
|
||||
# when the user is mid-conversation with a different model.
|
||||
if not ep_id and fallback_url and fallback_model:
|
||||
return fallback_url, fallback_model, fallback_headers
|
||||
|
||||
# Unset Utility means "same as Default Chat Model".
|
||||
if setting_prefix == "utility" and not ep_id:
|
||||
# Unset Utility (or anything else that didn't have a fallback) means "same as Default Chat Model".
|
||||
if not ep_id:
|
||||
ep_id = _stg("default_endpoint_id")
|
||||
model = _stg("default_model")
|
||||
|
||||
# Fall back to utility model for task/research/auto-naming if not specifically configured.
|
||||
# If Utility itself is unset, the block above makes that resolve to Default Chat.
|
||||
if not ep_id and setting_prefix != "utility":
|
||||
ep_id = _stg("utility_endpoint_id")
|
||||
model = _stg("utility_model")
|
||||
if not ep_id:
|
||||
ep_id = _stg("default_endpoint_id")
|
||||
model = _stg("default_model")
|
||||
|
||||
if not ep_id:
|
||||
return fallback_url, fallback_model, fallback_headers
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from core.atomic_io import atomic_write_json
|
||||
from core.platform_compat import safe_chmod
|
||||
@@ -258,6 +259,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
integration.setdefault("name", "")
|
||||
integration.setdefault("base_url", "")
|
||||
|
||||
if not isinstance(integration.get("name"), str) or not integration["name"].strip():
|
||||
raise HTTPException(400, "Integration name is required")
|
||||
if not isinstance(integration.get("base_url"), str) or not integration["base_url"].strip():
|
||||
raise HTTPException(400, "Integration base URL is required")
|
||||
|
||||
integrations = load_integrations()
|
||||
integrations.append(integration)
|
||||
save_integrations(integrations)
|
||||
@@ -266,6 +272,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update fields on an existing integration. Returns updated integration or None."""
|
||||
if "name" in data and (not isinstance(data["name"], str) or not data["name"].strip()):
|
||||
raise HTTPException(400, "Integration name is required")
|
||||
if "base_url" in data and (not isinstance(data["base_url"], str) or not data["base_url"].strip()):
|
||||
raise HTTPException(400, "Integration base URL is required")
|
||||
|
||||
integrations = load_integrations()
|
||||
for item in integrations:
|
||||
if item.get("id") == integration_id:
|
||||
|
||||
+177
-8
@@ -7,6 +7,7 @@ import logging
|
||||
import hashlib
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
||||
@@ -22,6 +23,24 @@ class LLMConfig:
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 0.5
|
||||
STREAM_TIMEOUT = 300
|
||||
# TCP+TLS connect budget for a SINGLE attempt. The old hard-coded 3.0s
|
||||
# assumed LAN/Tailscale peers ('SYN in <100ms'); it is too tight for public
|
||||
# cloud endpoints (offshore APIs take ~0.5-1.5s cold, with jitter), so a
|
||||
# brief blip on the first connect of an idle chat surfaced as a 503 on the
|
||||
# streaming path (which, unlike llm_call, does not retry the connect). A
|
||||
# genuinely dead upstream stays bounded by the dead-host cooldown. Override
|
||||
# with env LLM_CONNECT_TIMEOUT (seconds).
|
||||
CONNECT_TIMEOUT = float(os.getenv('LLM_CONNECT_TIMEOUT', '10') or '10')
|
||||
|
||||
|
||||
def _call_timeout(read_timeout) -> httpx.Timeout:
|
||||
"""Per-request timeout for non-streaming LLM calls (connect from config)."""
|
||||
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=10.0, pool=5.0)
|
||||
|
||||
|
||||
def _stream_timeout(read_timeout) -> httpx.Timeout:
|
||||
"""Per-request timeout for streaming LLM calls (connect from config)."""
|
||||
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=30.0, pool=5.0)
|
||||
|
||||
|
||||
# Cache for LLM responses
|
||||
@@ -423,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
|
||||
return any(host == d or host.endswith("." + d) for d in domains)
|
||||
|
||||
|
||||
# Kimi Code subscription keys (api.kimi.com/coding/v1) require a whitelisted
|
||||
# coding-agent User-Agent; otherwise the API returns 403 access_terminated_error.
|
||||
# Tried in order; first success is cached per base URL for later requests.
|
||||
KIMI_CODE_USER_AGENTS: tuple[str, ...] = (
|
||||
"claude-code/0.1.0",
|
||||
"claude-code/1.0.0",
|
||||
"KimiCLI/1.0",
|
||||
"Kilo-Code/1.0",
|
||||
"Roo-Code/1.0",
|
||||
"Cursor/1.0",
|
||||
)
|
||||
KIMI_CODE_USER_AGENT = KIMI_CODE_USER_AGENTS[0]
|
||||
_kimi_code_ua_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _is_kimi_code_url(url: str) -> bool:
|
||||
if not url or not _host_match(url, "kimi.com"):
|
||||
return False
|
||||
try:
|
||||
return "/coding" in (urlparse(url).path or "")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _kimi_code_base_key(url: str) -> str:
|
||||
"""Normalize a Kimi Code chat/models URL to its OpenAI base (.../coding/v1)."""
|
||||
parsed = urlparse(url)
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
for suffix in ("/chat/completions", "/models", "/completions"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)]
|
||||
path = path.rstrip("/") or "/coding/v1"
|
||||
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
||||
|
||||
|
||||
def _is_kimi_code_access_denied(status: int, body: bytes | str) -> bool:
|
||||
if status != 403:
|
||||
return False
|
||||
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else (body or "")
|
||||
lower = text.lower()
|
||||
return (
|
||||
"access_terminated_error" in lower
|
||||
or "coding agents" in lower
|
||||
or "only available for coding" in lower
|
||||
)
|
||||
|
||||
|
||||
def _kimi_code_ua_candidates(url: str) -> list[str]:
|
||||
if not _is_kimi_code_url(url):
|
||||
return []
|
||||
base_key = _kimi_code_base_key(url)
|
||||
cached = _kimi_code_ua_cache.get(base_key)
|
||||
if cached:
|
||||
return [cached] + [ua for ua in KIMI_CODE_USER_AGENTS if ua != cached]
|
||||
return list(KIMI_CODE_USER_AGENTS)
|
||||
|
||||
|
||||
def _remember_kimi_code_user_agent(url: str, user_agent: str) -> None:
|
||||
_kimi_code_ua_cache[_kimi_code_base_key(url)] = user_agent
|
||||
|
||||
|
||||
def apply_kimi_code_headers(headers: Optional[Dict], url: str) -> Dict[str, str]:
|
||||
"""Pick a Kimi Code User-Agent (cached probe when possible)."""
|
||||
h = dict(headers or {})
|
||||
if not _is_kimi_code_url(url):
|
||||
return h
|
||||
base_key = _kimi_code_base_key(url)
|
||||
cached = _kimi_code_ua_cache.get(base_key)
|
||||
if cached:
|
||||
h["User-Agent"] = cached
|
||||
return h
|
||||
models_url = base_key.rstrip("/") + "/models"
|
||||
from src.tls_overrides import llm_verify
|
||||
for ua in KIMI_CODE_USER_AGENTS:
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
try:
|
||||
r = httpx.get(models_url, headers=trial, timeout=8, verify=llm_verify())
|
||||
except Exception:
|
||||
continue
|
||||
if _is_kimi_code_access_denied(r.status_code, r.content):
|
||||
logger.debug("Kimi Code rejected User-Agent %s (403), trying next", ua)
|
||||
continue
|
||||
if r.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
h["User-Agent"] = ua
|
||||
return h
|
||||
break
|
||||
h.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||
return h
|
||||
|
||||
|
||||
def httpx_get_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return httpx.get(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = httpx.get(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def httpx_post_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return httpx.post(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = httpx.post(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
async def httpx_post_kimi_aware_async(client, url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return await client.post(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = await client.post(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def _detect_provider(url: str) -> str:
|
||||
"""Detect the API provider from a configured endpoint URL.
|
||||
|
||||
@@ -542,6 +701,12 @@ def _provider_label(url: str) -> str:
|
||||
if _host_match(url, "googleapis.com"): return "Google"
|
||||
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
||||
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
||||
if _host_match(url, "kimi.com"):
|
||||
try:
|
||||
if "/coding" in (urlparse(url).path or ""):
|
||||
return "Kimi Code"
|
||||
except Exception:
|
||||
pass
|
||||
if _is_ollama_native_url(url): return "Ollama"
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
@@ -682,7 +847,7 @@ def _uses_max_completion_tokens(model: str) -> bool:
|
||||
# perfectly good model as failing. For these models we omit the field and let
|
||||
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
||||
# not a reasoning model and accepts temperature normally.)
|
||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5")
|
||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5", "kimi-for-coding")
|
||||
|
||||
def _restricts_temperature(model: str) -> bool:
|
||||
"""Check if a model rejects any non-default temperature."""
|
||||
@@ -1138,7 +1303,7 @@ def list_model_ids(
|
||||
from src.endpoint_resolver import build_models_url
|
||||
|
||||
models_url = build_models_url(base_chat_url)
|
||||
r = httpx.get(models_url, headers=h, timeout=timeout)
|
||||
r = httpx_get_kimi_aware(models_url, h, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
@@ -1246,7 +1411,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
payload[tok_key] = max_tokens
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
||||
if not r.is_success:
|
||||
@@ -1446,7 +1611,7 @@ async def llm_call_async(
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
|
||||
call_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=10.0, pool=5.0)
|
||||
call_timeout = _call_timeout(timeout)
|
||||
attempt = 0
|
||||
while attempt < max_retries:
|
||||
attempt += 1
|
||||
@@ -1454,7 +1619,7 @@ async def llm_call_async(
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await client.post(target_url, headers=h, json=payload, timeout=call_timeout)
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
duration = time.time() - start
|
||||
if not r.is_success:
|
||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||
@@ -1570,9 +1735,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
|
||||
# Short connect timeout: a reachable peer answers SYN in <100ms even on
|
||||
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
||||
stream_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=30.0, pool=5.0)
|
||||
# Connect budget from LLMConfig.CONNECT_TIMEOUT (env LLM_CONNECT_TIMEOUT).
|
||||
# The dead-host cooldown still bounds a genuinely unreachable upstream, so a
|
||||
# wider connect budget only affects first contact and stops a brief cold
|
||||
# connect blip (offshore/public endpoints) surfacing as a 503 on this stream
|
||||
# path, which -- unlike llm_call -- does not retry the connect.
|
||||
stream_timeout = _stream_timeout(timeout)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||
@@ -1848,6 +2016,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
events.append(_stream_delta_event(part))
|
||||
return events
|
||||
|
||||
h = apply_kimi_code_headers(h, target_url)
|
||||
try:
|
||||
client = _get_http_client()
|
||||
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||
|
||||
+56
-22
@@ -222,16 +222,12 @@ KNOWN_CONTEXT_WINDOWS = {
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
_context_cache: Dict[Tuple[str, str], int] = {}
|
||||
_context_cache: Dict[Tuple[str, str], Tuple[int, bool]] = {}
|
||||
|
||||
|
||||
def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
"""Get the context window size for a model.
|
||||
|
||||
Queries /v1/models on the endpoint and looks for context_length
|
||||
or context_window fields. Caches result per (endpoint, model).
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
def _get_context_length_cached(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||
"""Return (context_length, known). ``known`` is False only when the value is a
|
||||
bare DEFAULT_CONTEXT fallback (no endpoint report and not in the known table)."""
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
is_local = is_local_endpoint(endpoint_url)
|
||||
# Key on (endpoint_url, model): the same model id can be served by two
|
||||
@@ -242,14 +238,50 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
if not is_local and cache_key in _context_cache:
|
||||
return _context_cache[cache_key]
|
||||
|
||||
ctx = _query_context_length(endpoint_url, model)
|
||||
ctx, known = _query_context_length(endpoint_url, model)
|
||||
# Only cache non-default values to allow retry on next request.
|
||||
# Local endpoints can restart with a different --max-model-len while keeping
|
||||
# the same model id, so always re-query them instead of serving stale cache.
|
||||
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
|
||||
_context_cache[cache_key] = ctx
|
||||
_context_cache[cache_key] = (ctx, known)
|
||||
logger.info(f"Context length for {model}: {ctx}")
|
||||
return ctx
|
||||
return ctx, known
|
||||
|
||||
|
||||
def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
"""Get the context window size for a model.
|
||||
|
||||
Queries /v1/models on the endpoint and looks for context_length
|
||||
or context_window fields. Caches result per (endpoint, model).
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
return _get_context_length_cached(endpoint_url, model)[0]
|
||||
|
||||
|
||||
def get_context_length_known(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||
"""Like ``get_context_length`` but also returns whether the window was actually
|
||||
discovered (endpoint-reported or in the known-models table) rather than the bare
|
||||
DEFAULT_CONTEXT fallback. Callers that *scale* a budget off the window must not
|
||||
trust an unknown value — a fallback 128K isn't proof the model holds 128K
|
||||
(review on #4122)."""
|
||||
return _get_context_length_cached(endpoint_url, model)
|
||||
|
||||
|
||||
def budget_context_for_model(endpoint_url: str, model: str, *, fallback: int = 0) -> int:
|
||||
"""Context window to scale the agent input budget against.
|
||||
|
||||
Returns the *freshly discovered* window when it was actually proven
|
||||
(endpoint-reported / known table), else 0 so auto-scaling stays conservative.
|
||||
Crucially this binds the ``known`` flag to the value it proves — callers must
|
||||
not pair this flag with a context length from a *different* lookup (a stale
|
||||
local re-query, or a caller that didn't pass one), which would budget off an
|
||||
unproven number (review on #4122). On probe error, returns ``fallback`` (the
|
||||
caller's best-known value) to preserve prior behaviour."""
|
||||
try:
|
||||
ctx, known = get_context_length_known(endpoint_url, model)
|
||||
return ctx if known else 0
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _lookup_known(model: str) -> Optional[int]:
|
||||
@@ -271,8 +303,9 @@ def _lookup_known(model: str) -> Optional[int]:
|
||||
return best_ctx
|
||||
|
||||
|
||||
def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
"""Query the model API for context length."""
|
||||
def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||
"""Query the model API for context length. Returns (context_length, known) where
|
||||
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
|
||||
known = _lookup_known(model)
|
||||
api_ctx = None
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
@@ -283,8 +316,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
if configured_kind in ("api", "proxy"):
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known
|
||||
return DEFAULT_CONTEXT
|
||||
return known, True
|
||||
return DEFAULT_CONTEXT, False
|
||||
|
||||
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||
if is_local_endpoint(endpoint_url):
|
||||
@@ -297,7 +330,7 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
n_ctx = slots[0].get("n_ctx")
|
||||
if n_ctx and isinstance(n_ctx, int) and n_ctx > 0:
|
||||
logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}")
|
||||
return n_ctx
|
||||
return n_ctx, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -309,7 +342,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
if is_copilot_base(endpoint_url):
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known or DEFAULT_CONTEXT
|
||||
return known, True
|
||||
return DEFAULT_CONTEXT, False
|
||||
|
||||
from src.endpoint_resolver import build_models_url
|
||||
|
||||
@@ -354,18 +388,18 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
_is_local = is_local_endpoint(endpoint_url)
|
||||
if _is_local and api_ctx < known:
|
||||
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
|
||||
return api_ctx
|
||||
return api_ctx, True
|
||||
result = max(api_ctx, known)
|
||||
if api_ctx < known:
|
||||
logger.info(f"API reported {api_ctx} for {model}, using known {known} instead")
|
||||
return result
|
||||
return result, True
|
||||
if api_ctx:
|
||||
return api_ctx
|
||||
return api_ctx, True
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known
|
||||
return known, True
|
||||
|
||||
return DEFAULT_CONTEXT
|
||||
return DEFAULT_CONTEXT, False
|
||||
|
||||
|
||||
def estimate_tokens(messages: List[Dict]) -> int:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Compatibility helpers for optional third-party dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def patch_realesrgan_torchvision_compat() -> None:
|
||||
"""Restore the torchvision import path expected by BasicSR/Real-ESRGAN."""
|
||||
module_name = "torchvision.transforms.functional_tensor"
|
||||
if module_name in sys.modules:
|
||||
return
|
||||
try:
|
||||
from torchvision.transforms import functional
|
||||
except Exception:
|
||||
return
|
||||
|
||||
rgb_to_grayscale = getattr(functional, "rgb_to_grayscale", None)
|
||||
if rgb_to_grayscale is None:
|
||||
return
|
||||
|
||||
shim = types.ModuleType(module_name)
|
||||
shim.rgb_to_grayscale = rgb_to_grayscale
|
||||
shim.__getattr__ = lambda name: getattr(functional, name)
|
||||
sys.modules[module_name] = shim
|
||||
|
||||
|
||||
def prepare_optional_dependency_import(name: str) -> None:
|
||||
"""Apply known import-time compatibility shims before probing a package."""
|
||||
if name == "realesrgan":
|
||||
patch_realesrgan_torchvision_compat()
|
||||
+18
-8
@@ -101,14 +101,22 @@ DEFAULT_SETTINGS = {
|
||||
"research_run_timeout_seconds": 1800,
|
||||
"agent_max_tool_calls": 0,
|
||||
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
||||
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
|
||||
# "auto" sentinel: it means "scale the budget to the model's context window"
|
||||
# (#1230) — so long-context models aren't capped at 6000. Set ANY OTHER value
|
||||
# to enforce an explicit cap (clamped to the window only — hard_max does not
|
||||
# apply to explicit budgets, #1230); set 0 to disable soft-trimming. The
|
||||
# default is treated as auto because the settings-save path materializes
|
||||
# defaults, so a persisted 6000 can't be told apart from a deliberate 6000 —
|
||||
# to pin a budget near the default, use a nearby value (e.g. 5999).
|
||||
"agent_input_token_budget": 6000,
|
||||
# Ceiling on the *auto-derived* input budget that #1230 introduced. Has
|
||||
# no effect when `agent_input_token_budget` is explicitly set (the user's
|
||||
# value is honoured regardless). Default matches
|
||||
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for cost-paranoid
|
||||
# setups, raise it on premium APIs with very large windows that you
|
||||
# Ceiling on the *auto-derived* input budget; a configurable setting since #1273
|
||||
# (the merged #1230 left it a module constant). No effect on an explicit budget
|
||||
# — a deliberate value is honoured (#1230). Default matches
|
||||
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for
|
||||
# cost-paranoid setups, raise it on premium APIs with very large windows you
|
||||
# want to actually use (e.g. 900_000 to fill a 1M-context model). See
|
||||
# `compute_input_token_budget` in src/context_budget.py.
|
||||
# `compute_input_token_budget`.
|
||||
"agent_input_token_hard_max": 200_000,
|
||||
"agent_stream_timeout_seconds": 300,
|
||||
# Extra directory roots that read_file / write_file may access, in
|
||||
@@ -223,8 +231,10 @@ def is_setting_overridden(key: str) -> bool:
|
||||
|
||||
``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value
|
||||
equal to its default is indistinguishable from "never set" via get_setting.
|
||||
Callers that need to treat an explicit user choice differently from the
|
||||
default (e.g. adaptive budgets) use this to read the raw saved file.
|
||||
Callers that must distinguish an explicit user choice from a default read
|
||||
the raw saved file via this. (Note: a materialized default is also "present",
|
||||
so value-sensitive callers should compare against the default — see
|
||||
``context_budget.budget_is_explicit``.)
|
||||
"""
|
||||
try:
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
|
||||
@@ -1649,6 +1649,8 @@ class TaskScheduler:
|
||||
data = json.loads(event_str[6:])
|
||||
# Capture text from all event types, not just delta
|
||||
if "delta" in data:
|
||||
if data.get("thinking"):
|
||||
continue
|
||||
full_text += data["delta"]
|
||||
elif data.get("type") == "tool_output":
|
||||
# Tool results — capture summary so we have SOMETHING even
|
||||
|
||||
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"generativelanguage.googleapis.com", "api.groq.com",
|
||||
"openrouter.ai", "ollama.com", "api.venice.ai",
|
||||
"openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
|
||||
})
|
||||
|
||||
|
||||
@@ -594,6 +594,8 @@ async def run_teacher_inline(
|
||||
"exit_code": payload.get("exit_code"),
|
||||
})
|
||||
if "delta" in payload and isinstance(payload["delta"], str):
|
||||
if payload.get("thinking"):
|
||||
continue
|
||||
captured_text_parts.append(payload["delta"])
|
||||
yield 'data: ' + json.dumps(payload) + '\n\n'
|
||||
continue
|
||||
|
||||
@@ -1445,7 +1445,15 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from datetime import datetime, timedelta
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
from routes.calendar_routes import _ensure_default_calendar, _parse_dt, _parse_dt_pair, parse_due_for_user, _resolve_base_uid
|
||||
from routes.calendar_routes import (
|
||||
_ensure_default_calendar,
|
||||
_parse_dt,
|
||||
_parse_dt_pair,
|
||||
parse_due_for_user,
|
||||
_resolve_base_uid,
|
||||
_push_caldav_event_after_commit,
|
||||
_record_caldav_delete_tombstone,
|
||||
)
|
||||
import uuid as _uuid
|
||||
|
||||
try:
|
||||
@@ -1643,6 +1651,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except ValueError as e:
|
||||
return {"error": f"Invalid date format: {e}", "exit_code": 1}
|
||||
|
||||
if end_dt <= start_dt:
|
||||
end_dt = start_dt + timedelta(days=1)
|
||||
|
||||
q = _event_query().filter(
|
||||
CalendarEvent.dtstart < end_dt,
|
||||
CalendarEvent.dtend > start_dt,
|
||||
@@ -1822,6 +1833,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
rrule=args.get("rrule", "") or "",
|
||||
event_type=event_type,
|
||||
importance=importance,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
reminder_note_id = None
|
||||
@@ -1836,6 +1848,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
dtstart_is_utc and not all_day,
|
||||
)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
tag_blurb = f" [{event_type}]" if event_type else ""
|
||||
if minutes_before is None:
|
||||
reminder_blurb = ""
|
||||
@@ -1893,7 +1907,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"response": f"Updated event {uid}", "exit_code": 0}
|
||||
|
||||
elif action == "delete_event":
|
||||
@@ -1907,8 +1926,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
|
||||
@@ -384,6 +384,10 @@ class ToolIndex:
|
||||
"delegate to", "have model"}):
|
||||
{"chat_with_model", "ask_teacher", "list_models"},
|
||||
# Deep research intent (incl. common typo "reserach")
|
||||
frozenset({"web search", "search the web", "search online", "look up",
|
||||
"google", "latest", "current", "news", "weather",
|
||||
"forecast", "stock price", "price of"}):
|
||||
{"web_search", "web_fetch"},
|
||||
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
||||
"deep dive", "deep research", "find out about", "study up on",
|
||||
"report on", "do research", "look up everything"}):
|
||||
|
||||
@@ -188,6 +188,12 @@ _MISFENCED_WEB_TOOL_NAMES = {
|
||||
"fetch_url": "web_fetch",
|
||||
}
|
||||
|
||||
_RAW_WEB_JSON_TOOL_RE = re.compile(
|
||||
r"\b(?:web_search|websearch|google_search|google_search_retrieval|google_search_grounding)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "max_pages"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing functions
|
||||
@@ -279,6 +285,73 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
|
||||
return None
|
||||
return ToolBlock("web_fetch", url)
|
||||
|
||||
|
||||
def _coerce_raw_web_query(value) -> Optional[str]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _raw_web_json_to_tool_block(payload) -> Optional[ToolBlock]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if set(payload) - _RAW_WEB_JSON_ALLOWED_KEYS:
|
||||
return None
|
||||
|
||||
query = _coerce_raw_web_query(payload.get("query"))
|
||||
if not query:
|
||||
query = _coerce_raw_web_query(payload.get("queries"))
|
||||
if not query:
|
||||
return None
|
||||
|
||||
content = {"query": query}
|
||||
for key in ("time_filter", "freshness"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip().lower() in ("day", "week", "month", "year"):
|
||||
content[key] = value.strip().lower()
|
||||
|
||||
max_pages = payload.get("max_pages")
|
||||
if isinstance(max_pages, int) and 1 <= max_pages <= 10:
|
||||
content["max_pages"] = max_pages
|
||||
|
||||
if len(content) == 1:
|
||||
return ToolBlock("web_search", query)
|
||||
return ToolBlock("web_search", json.dumps(content))
|
||||
|
||||
|
||||
def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int, int]]]:
|
||||
"""Recover local text-model web_search calls emitted as prose + bare JSON.
|
||||
|
||||
Some non-native tool models leak the intended call as:
|
||||
|
||||
Need to do web_search for ...
|
||||
{"query": "...", "time_filter": "week"}
|
||||
|
||||
Keep this narrower than fenced/tool markup: it only runs when a known web
|
||||
tool name appears shortly before a JSON object shaped like web_search args.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
|
||||
decoder = json.JSONDecoder()
|
||||
for mention in _RAW_WEB_JSON_TOOL_RE.finditer(text):
|
||||
search_start = mention.end()
|
||||
search_end = min(len(text), search_start + 1200)
|
||||
for brace in re.finditer(r"\{", text[search_start:search_end]):
|
||||
start = search_start + brace.start()
|
||||
try:
|
||||
parsed, end = decoder.raw_decode(text[start:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
block = _raw_web_json_to_tool_block(parsed)
|
||||
if block:
|
||||
return block, (start, start + end)
|
||||
return None
|
||||
|
||||
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
||||
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
||||
|
||||
@@ -436,6 +509,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
3. XML-style <tool_call>/<invoke> blocks
|
||||
4. <tool_code> blocks (MiniMax-M2.5 style)
|
||||
5. DeepSeek DSML markup (normalized to <invoke> first)
|
||||
6. Non-native local model fallback: prose mentioning web_search followed by
|
||||
bare JSON args, e.g. {"query":"...", "time_filter":"week"}
|
||||
|
||||
`skip_fenced`: when True, Pattern 1 (fenced ```bash/```python/```json code
|
||||
blocks) is not matched at all. Native function-calling models (GPT/Claude/
|
||||
@@ -509,6 +584,12 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
|
||||
if not blocks and not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(text)
|
||||
if raw_web_json:
|
||||
blocks.append(raw_web_json[0])
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
@@ -532,6 +613,11 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
||||
cleaned = _TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _XML_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _TOOL_CODE_RE.sub('', cleaned)
|
||||
if not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
||||
if raw_web_json:
|
||||
_, (start, end) = raw_web_json
|
||||
cleaned = cleaned[:start] + cleaned[end:]
|
||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||
cleaned = re.sub(r'<invoke\s+name=["\'].*?</invoke>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||
|
||||
@@ -177,13 +177,16 @@ def owner_is_admin_or_single_user(owner: Optional[str]) -> bool:
|
||||
defense-in-depth for callers that bypass it (e.g. trusted loopback).
|
||||
"""
|
||||
try:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
|
||||
if _auth_disabled():
|
||||
return True
|
||||
|
||||
from core.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
if not auth.is_configured:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
|
||||
return _auth_disabled()
|
||||
return False
|
||||
return bool(owner and auth.is_admin(owner))
|
||||
except Exception as exc:
|
||||
logger.warning("Unable to evaluate owner admin status: %s", exc)
|
||||
|
||||
+18
-273
@@ -1,278 +1,23 @@
|
||||
"""
|
||||
YouTube handling — transcript extraction, comment fetching (yt-dlp),
|
||||
and context formatting for LLM injection. Used by chat_handler.py.
|
||||
"""Compatibility wrapper for the canonical services.youtube.youtube_handler module.
|
||||
|
||||
Odysseus historically carried two independent copies of the YouTube handler —
|
||||
one here under ``src`` and one under ``services.youtube``. They drifted: the
|
||||
comment-fetch timeout fix landed only in the ``src`` copy, while ``app.py``
|
||||
calls ``services.youtube.init_youtube()`` at startup. Because the chat flow
|
||||
imported ``extract_transcript_async`` from ``src.youtube_handler`` (a different
|
||||
module object), the ``YOUTUBE_AVAILABLE`` / ``YouTubeTranscriptApi`` globals set
|
||||
by ``init_youtube`` never reached it and transcript extraction always reported
|
||||
"YouTube transcript API not available".
|
||||
|
||||
Keep the old ``src.youtube_handler`` import path working, but make it resolve to
|
||||
the single source of truth so module state and behavior can't diverge again.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import importlib
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Import the canonical module directly (services.youtube.youtube_handler)
|
||||
# without triggering the heavy services/__init__.py top-level imports.
|
||||
_youtube_handler = importlib.import_module("services.youtube.youtube_handler")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
YOUTUBE_INSTRUCTION_PROMPT = """When the user shares a YouTube video, respond with a structured breakdown:
|
||||
|
||||
1. **Summary** — Concise overview of the video's content and main thesis (2-4 sentences)
|
||||
2. **Key Points** — Bullet list of the most important topics, arguments, or moments
|
||||
3. **Notable Timestamps** — If timestamps are available from the transcript, highlight 3-5 interesting moments with their approximate timestamps (e.g. "03:45 — discusses X")
|
||||
4. **Audience Reception** — If comments are available, summarize what viewers think: general sentiment, top reactions, any debate or controversy
|
||||
|
||||
Keep it conversational and concise. Do NOT web search for this video — use only the transcript and comments provided."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init / helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Will be set at startup by init_youtube()
|
||||
YouTubeTranscriptApi = None
|
||||
YOUTUBE_AVAILABLE = False
|
||||
|
||||
|
||||
def _find_ytdlp() -> str:
|
||||
"""Find the yt-dlp binary: venv bin first, then system PATH."""
|
||||
venv_bin = Path(sys.executable).parent / "yt-dlp"
|
||||
if venv_bin.exists():
|
||||
return str(venv_bin)
|
||||
found = shutil.which("yt-dlp")
|
||||
return found or "yt-dlp"
|
||||
|
||||
|
||||
def init_youtube():
|
||||
"""Import and cache the YouTube transcript API."""
|
||||
global YouTubeTranscriptApi, YOUTUBE_AVAILABLE
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi as _Api
|
||||
YouTubeTranscriptApi = _Api
|
||||
YOUTUBE_AVAILABLE = True
|
||||
logger.info("YouTube transcript API available")
|
||||
except ImportError as e:
|
||||
logger.warning(f"youtube-transcript-api not installed: {e}")
|
||||
YOUTUBE_AVAILABLE = False
|
||||
|
||||
|
||||
def is_youtube_url(url: str) -> bool:
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
return "youtube.com" in url or "youtu.be" in url
|
||||
|
||||
|
||||
def extract_youtube_id(url: str) -> Optional[str]:
|
||||
"""Extract YouTube video ID from various URL formats."""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
|
||||
if parsed.path == "/watch":
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
if "v" in params:
|
||||
return params["v"][0]
|
||||
elif parsed.path.startswith("/embed/"):
|
||||
return parsed.path.split("/")[-1]
|
||||
elif parsed.hostname == "youtu.be":
|
||||
return parsed.path[1:]
|
||||
return None
|
||||
|
||||
|
||||
async def extract_transcript_async(
|
||||
url: str, video_id: str, max_retries: int = 3
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async YouTube transcript extraction with retries.
|
||||
|
||||
Args:
|
||||
url: Full YouTube URL
|
||||
video_id: Extracted video ID
|
||||
max_retries: Number of attempts
|
||||
|
||||
Returns:
|
||||
Dict with success/error/transcript keys
|
||||
"""
|
||||
if not YOUTUBE_AVAILABLE or YouTubeTranscriptApi is None:
|
||||
return {"success": False, "error": "YouTube transcript API not available", "transcript": None}
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
api = YouTubeTranscriptApi()
|
||||
transcript = api.fetch(video_id)
|
||||
transcript_list = list(transcript)
|
||||
|
||||
formatted = []
|
||||
for snippet in transcript_list:
|
||||
text = snippet.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
start = snippet.start
|
||||
formatted.append({
|
||||
"text": text,
|
||||
"start": start,
|
||||
"duration": snippet.duration,
|
||||
"timestamp": f"{int(start // 60):02d}:{int(start % 60):02d}",
|
||||
})
|
||||
|
||||
full_text = " ".join(e["text"] for e in formatted)
|
||||
max_len = 8000
|
||||
if len(full_text) > max_len:
|
||||
full_text = full_text[:max_len] + "... [transcript truncated]"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transcript": full_text,
|
||||
"video_id": video_id,
|
||||
"language": "en",
|
||||
"is_generated": False,
|
||||
"segments": formatted,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Transcript attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
|
||||
return {"success": False, "error": f"Failed after {max_retries} attempts", "transcript": None}
|
||||
|
||||
|
||||
def format_transcript_for_context(
|
||||
transcript_data: Dict[str, Any], url: str,
|
||||
title: str = "", channel: str = ""
|
||||
) -> str:
|
||||
"""Format transcript data for inclusion in LLM context."""
|
||||
if not transcript_data.get("success"):
|
||||
header = ""
|
||||
if title:
|
||||
header = f" \"{title}\""
|
||||
if channel:
|
||||
header += f" by {channel}"
|
||||
return f"\n[YouTube Video{header}: Transcript unavailable ({transcript_data.get('error', 'Unknown error')}). Use the comments below if available, do NOT web search for this video.]"
|
||||
|
||||
transcript = transcript_data.get("transcript", "")
|
||||
video_id = transcript_data.get("video_id", "")
|
||||
language = transcript_data.get("language", "unknown")
|
||||
is_generated = transcript_data.get("is_generated", False)
|
||||
segments = transcript_data.get("segments", [])
|
||||
|
||||
ctx = "\n[YOUTUBE VIDEO TRANSCRIPT]\n"
|
||||
if title:
|
||||
ctx += f"Title: {title}\n"
|
||||
if channel:
|
||||
ctx += f"Channel: {channel}\n"
|
||||
ctx += f"Video ID: {video_id}\n"
|
||||
ctx += f"Language: {language}\n"
|
||||
ctx += f"Source: {'Auto-generated' if is_generated else 'Manual'}\n"
|
||||
ctx += f"URL: {url}\n\n"
|
||||
# Include timestamped segments for the LLM to reference
|
||||
if segments:
|
||||
ctx += "Timestamped Transcript:\n"
|
||||
for seg in segments:
|
||||
if not isinstance(seg, dict):
|
||||
continue
|
||||
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
|
||||
# Check length — fall back to plain text if too long
|
||||
if len(ctx) > 12000:
|
||||
ctx = ctx[:ctx.index("Timestamped Transcript:\n")]
|
||||
ctx += "Transcript:\n"
|
||||
ctx += transcript
|
||||
else:
|
||||
ctx += "Transcript:\n"
|
||||
ctx += transcript
|
||||
ctx += "\n[END TRANSCRIPT]\n"
|
||||
return ctx
|
||||
|
||||
|
||||
async def fetch_youtube_comments(
|
||||
video_id: str, max_comments: int = 25, timeout: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch top comments for a YouTube video using yt-dlp.
|
||||
|
||||
Returns dict with 'success', 'comments' list, 'error'.
|
||||
"""
|
||||
try:
|
||||
cmd = [
|
||||
_find_ytdlp(),
|
||||
"--skip-download",
|
||||
"--write-comments",
|
||||
"--extractor-args", f"youtube:max_comments={max_comments},all,100,0",
|
||||
"--dump-json",
|
||||
"--js-runtimes", "node",
|
||||
"--remote-components", "ejs:github",
|
||||
f"https://www.youtube.com/watch?v={video_id}",
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# Bound the wait on the process actually finishing, not on spawning it.
|
||||
# create_subprocess_exec returns as soon as the child starts, so wrapping
|
||||
# it in wait_for never enforces the timeout — proc.communicate() is the
|
||||
# blocking step. Kill and reap the child if it overruns so it does not
|
||||
# linger after we return.
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise
|
||||
|
||||
if proc.returncode != 0:
|
||||
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
|
||||
|
||||
data = json.loads(stdout.decode())
|
||||
title = data.get("title", "")
|
||||
channel = data.get("channel", "") or data.get("uploader", "")
|
||||
raw_comments = data.get("comments", [])
|
||||
|
||||
comments = []
|
||||
for c in raw_comments[:max_comments]:
|
||||
text = (c.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
comments.append({
|
||||
"author": c.get("author", "Unknown"),
|
||||
"text": text,
|
||||
"likes": c.get("like_count", 0),
|
||||
})
|
||||
|
||||
# Sort by likes descending — most popular comments first
|
||||
comments.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
||||
|
||||
return {"success": True, "comments": comments, "count": len(comments),
|
||||
"title": title, "channel": channel}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Comment fetch timed out for {video_id}")
|
||||
return {"success": False, "error": "Comment fetch timed out", "comments": []}
|
||||
except FileNotFoundError:
|
||||
logger.warning("yt-dlp not installed — cannot fetch comments")
|
||||
return {"success": False, "error": "yt-dlp not installed", "comments": []}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch comments for {video_id}: {e}")
|
||||
return {"success": False, "error": str(e), "comments": []}
|
||||
|
||||
|
||||
def format_comments_for_context(comments_data: Dict[str, Any], url: str) -> str:
|
||||
"""Format YouTube comments for inclusion in LLM context."""
|
||||
if not comments_data.get("success") or not comments_data.get("comments"):
|
||||
return ""
|
||||
|
||||
comments = comments_data["comments"]
|
||||
ctx = f"\n[YOUTUBE VIDEO COMMENTS — Top {len(comments)} by popularity]\n"
|
||||
ctx += f"URL: {url}\n\n"
|
||||
|
||||
for i, c in enumerate(comments, 1):
|
||||
likes = c.get("likes", 0)
|
||||
likes_str = f" [{likes} likes]" if likes else ""
|
||||
ctx += f"{i}. @{c['author']}{likes_str}: {c['text']}\n\n"
|
||||
|
||||
if len(ctx) > 4000:
|
||||
ctx = ctx[:4000] + "\n[Comments truncated]\n"
|
||||
|
||||
ctx += "[END COMMENTS]\n"
|
||||
return ctx
|
||||
sys.modules[__name__] = _youtube_handler
|
||||
|
||||
+3
-2
@@ -130,11 +130,12 @@ fi
|
||||
# 3. Python environment + dependencies (kept inside the repo, in venv/).
|
||||
# Named `venv` to match the manual steps and build-macos-app.sh, so the
|
||||
# clickable .app reuses this same environment.
|
||||
if [ ! -d venv ]; then
|
||||
VENV_PY="./venv/bin/python3"
|
||||
if [ ! -x "$VENV_PY" ] || ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
|
||||
[ -d venv ] && { echo "▶ Existing venv is incomplete (no working pip) — rebuilding…"; rm -rf venv; }
|
||||
echo "▶ Creating Python environment…"
|
||||
"$PY" -m venv venv
|
||||
fi
|
||||
VENV_PY="./venv/bin/python3"
|
||||
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
||||
REQ_HASH_FILE="venv/.requirements_hash"
|
||||
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
|
||||
|
||||
+6
-2
@@ -3135,7 +3135,9 @@ function initializeEventListeners() {
|
||||
setTimeout(() => uiModule.autoResize(textarea), 1);
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
const isMobile = window.innerWidth <= 768
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||
// If ghost autocomplete is active, accept the suggestion instead of submitting
|
||||
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
||||
e.preventDefault();
|
||||
@@ -3708,7 +3710,9 @@ function startOdysseusApp() {
|
||||
// Enter to send (shift+enter for newline), or new chat when empty
|
||||
if (messageInput) {
|
||||
messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
const isMobile = window.innerWidth <= 768
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||
e.preventDefault();
|
||||
// Flush the debounced icon update so dataset.mode reflects the current
|
||||
// text state. Without this, a fast type-and-Enter would still see the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
+1
-1
@@ -12,7 +12,7 @@
|
||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||
browser-default styling that didn't match the Odysseus theme. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
(function(){
|
||||
|
||||
+153
-44
@@ -9,7 +9,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { attachColorPicker } from './colorPicker.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
import {
|
||||
WEEKDAYS, MONTHS, MON_SHORT,
|
||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
_trashIcon, _moreIcon, _bellIcon,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||
@@ -64,6 +64,8 @@ let _hiddenTypes = new Set(); // event_type values to hide
|
||||
let _onlyImportant = false;
|
||||
|
||||
let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1';
|
||||
// Week-start preference: 'mon' (default, Mon=first col) or 'sun' (Sun=first col).
|
||||
let _weekStartSun = localStorage.getItem('cal-week-start') === 'sun';
|
||||
let _selectedDay = null;
|
||||
let _view = 'month';
|
||||
let _searchQuery = '';
|
||||
@@ -360,14 +362,14 @@ function _today() { return _ds(new Date()); }
|
||||
function _monthRange(d) {
|
||||
const y = d.getFullYear(), m = d.getMonth();
|
||||
const first = new Date(y, m, 1);
|
||||
const dow = (first.getDay() + 6) % 7;
|
||||
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||
const gs = new Date(y, m, 1 - dow);
|
||||
const ge = new Date(gs); ge.setDate(gs.getDate() + 42);
|
||||
return [_ds(gs), _ds(ge)];
|
||||
}
|
||||
|
||||
function _weekRange(d) {
|
||||
const dow = (d.getDay() + 6) % 7;
|
||||
const dow = _weekStartSun ? d.getDay() : (d.getDay() + 6) % 7;
|
||||
const s = new Date(d); s.setDate(d.getDate() - dow);
|
||||
const e = new Date(s); e.setDate(s.getDate() + 7);
|
||||
return [_ds(s), _ds(e)];
|
||||
@@ -928,11 +930,11 @@ async function _renderMonth() {
|
||||
_slideDir = 0;
|
||||
let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`;
|
||||
h += '<div class="cal-week-headers">';
|
||||
for (const wd of WEEKDAYS) h += `<div class="cal-weekday">${wd}</div>`;
|
||||
for (const wd of (_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)) h += `<div class="cal-weekday">${wd}</div>`;
|
||||
h += '</div>';
|
||||
|
||||
const first = new Date(y, m, 1);
|
||||
const dow = (first.getDay() + 6) % 7;
|
||||
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||
const gs = new Date(y, m, 1 - dow);
|
||||
|
||||
const multiDay = _events.filter(e => {
|
||||
@@ -1141,13 +1143,13 @@ function _wkEventTopHeight(ev, dayStr) {
|
||||
// Date math if the string isn't shaped as expected.
|
||||
const _toMin = (iso, fallbackDate) => {
|
||||
if (!iso) return null;
|
||||
const m = iso.match(/T(\d{2}):(\d{2})/);
|
||||
if (m) {
|
||||
const mins = _timeToMin(iso);
|
||||
if (mins !== null && iso.includes('T')) {
|
||||
// If the event spans into a previous/next day, clamp to today's bounds.
|
||||
const evDate = iso.slice(0, 10);
|
||||
const evDate = _localDateOf(iso);
|
||||
if (evDate < fallbackDate) return 0; // event started before today
|
||||
if (evDate > fallbackDate) return 24 * 60; // event ends after today
|
||||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
||||
return mins;
|
||||
}
|
||||
// All-day or date-only — treat as start of day.
|
||||
return 0;
|
||||
@@ -1204,8 +1206,8 @@ async function _renderWeek() {
|
||||
const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day);
|
||||
|
||||
const isSun = d.getDay() === 0;
|
||||
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
|
||||
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${WEEKDAYS[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
|
||||
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun && !_weekStartSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
|
||||
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${(_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
|
||||
// All-day strip
|
||||
colsHtml += `<div class="cal-wk-allday">`;
|
||||
for (const ev of allDayEvents) {
|
||||
@@ -1286,12 +1288,17 @@ async function _renderWeek() {
|
||||
if (!ev) return;
|
||||
const cols = Array.from(body.querySelectorAll('.cal-wk-grid'));
|
||||
if (!cols.length) return;
|
||||
// Original timing
|
||||
const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
|
||||
const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/);
|
||||
const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0;
|
||||
const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60;
|
||||
const durationMin = Math.max(15, endMin0 - startMin0);
|
||||
// Local/display timing
|
||||
const startMin0 = _timeToMin(ev.dtstart) ?? 0;
|
||||
const endMin0 = _timeToMin(ev.dtend) ?? startMin0 + 60;
|
||||
|
||||
let durationMin = endMin0 - startMin0;
|
||||
const startDs = _localDateOf(ev.dtstart);
|
||||
const endDs = ev.dtend ? _localDateOf(ev.dtend) : startDs;
|
||||
if (endDs > startDs && endMin0 <= startMin0) {
|
||||
durationMin += 24 * 60;
|
||||
}
|
||||
durationMin = Math.max(15, durationMin);
|
||||
|
||||
// Where did the cursor grab the block? (offset from block-top in px)
|
||||
const blockRect = block.getBoundingClientRect();
|
||||
@@ -1365,7 +1372,7 @@ async function _renderWeek() {
|
||||
// a plain click (no movement) must still open the event.
|
||||
if (moved) block.dataset.justResized = '1';
|
||||
// Decide whether anything actually moved.
|
||||
const oldDs = (ev.dtstart || '').slice(0, 10);
|
||||
const oldDs = _localDateOf(ev.dtstart);
|
||||
if (!nextDs) return;
|
||||
if (nextDs === oldDs && nextStartMin === startMin0) return;
|
||||
// Snapshot the original times so we can offer an Undo.
|
||||
@@ -1374,11 +1381,10 @@ async function _renderWeek() {
|
||||
const newEndMin = nextStartMin + durationMin;
|
||||
const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0');
|
||||
const mm = String(nextStartMin % 60).padStart(2, '0');
|
||||
const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0');
|
||||
const mm2 = String((newEndMin) % 60).padStart(2, '0');
|
||||
const _tz = _tzOffset();
|
||||
const newDtstartDate = new Date(`${nextDs}T${hh}:${mm}:00`);
|
||||
const _tz = _tzOffsetForDate(newDtstartDate);
|
||||
const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`;
|
||||
const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`;
|
||||
const newDtend = _addMinutesToLocalIso(newDtstart, durationMin);
|
||||
try {
|
||||
await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend });
|
||||
_render();
|
||||
@@ -1410,10 +1416,7 @@ async function _renderWeek() {
|
||||
const uid = block.dataset.uid;
|
||||
const ev = _events.find(x => x.uid === uid);
|
||||
if (!ev || !grid || !ds) return;
|
||||
const startMin = (() => {
|
||||
const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
|
||||
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0;
|
||||
})();
|
||||
const startMin = _timeToMin(ev.dtstart) ?? 0;
|
||||
const initialTop = parseFloat(block.style.top || '0');
|
||||
const gridRect = grid.getBoundingClientRect();
|
||||
let newEndMin = startMin;
|
||||
@@ -1438,9 +1441,8 @@ async function _renderWeek() {
|
||||
if (resized) block.dataset.justResized = '1';
|
||||
if (newEndMin === startMin) return;
|
||||
const prevDtend = ev.dtend;
|
||||
const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0');
|
||||
const mm = String(newEndMin % 60).padStart(2, '0');
|
||||
const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`;
|
||||
const durationMin = newEndMin - startMin;
|
||||
const newDtend = _addMinutesToLocalIso(ev.dtstart, durationMin);
|
||||
try {
|
||||
await _updateEvent(uid, { dtend: newDtend });
|
||||
_render();
|
||||
@@ -1724,9 +1726,9 @@ async function _renderYear() {
|
||||
for (let m = 0; m < 12; m++) {
|
||||
h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`;
|
||||
h += '<div class="cal-year-grid">';
|
||||
for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `<div class="cal-year-wd">${wd}</div>`;
|
||||
for (const wd of (_weekStartSun ? ['S','M','T','W','T','F','S'] : ['M','T','W','T','F','S','S'])) h += `<div class="cal-year-wd">${wd}</div>`;
|
||||
const first = new Date(y, m, 1);
|
||||
const dow = (first.getDay() + 6) % 7;
|
||||
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>';
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
@@ -1966,10 +1968,10 @@ function _wireAll(body) {
|
||||
const ad = document.getElementById('cal-f-allday');
|
||||
if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); }
|
||||
} else {
|
||||
const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/);
|
||||
const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/);
|
||||
if (t1) set('cal-f-start', t1[1]);
|
||||
if (t2) set('cal-f-end', t2[1]);
|
||||
const t1 = _fmtTime(ev.dtstart);
|
||||
const t2 = _fmtTime(ev.dtend);
|
||||
if (t1) set('cal-f-start', t1);
|
||||
if (t2) set('cal-f-end', t2);
|
||||
document.getElementById('cal-f-start')?.dispatchEvent(new Event('input'));
|
||||
}
|
||||
// Make sure the details panel is open so the user can verify time.
|
||||
@@ -2474,6 +2476,13 @@ async function _showCalSettings() {
|
||||
</div>
|
||||
<div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border);padding-top:12px;">
|
||||
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Week starts on</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="cal-wstart-mon" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${!_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Monday</button>
|
||||
<button id="cal-wstart-sun" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Sunday</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border);padding-top:12px;">
|
||||
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
@@ -2494,6 +2503,28 @@ async function _showCalSettings() {
|
||||
overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
|
||||
|
||||
// Week-start toggle: save to localStorage, update module state, re-render.
|
||||
const _monBtn = overlay.querySelector('#cal-wstart-mon');
|
||||
const _sunBtn = overlay.querySelector('#cal-wstart-sun');
|
||||
const _activeStyle = 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))';
|
||||
const _inactiveStyle = 'var(--panel)';
|
||||
const _applyWeekStartActive = () => {
|
||||
if (_monBtn) _monBtn.style.background = _weekStartSun ? _inactiveStyle : _activeStyle;
|
||||
if (_sunBtn) _sunBtn.style.background = _weekStartSun ? _activeStyle : _inactiveStyle;
|
||||
};
|
||||
_monBtn?.addEventListener('click', () => {
|
||||
_weekStartSun = false;
|
||||
localStorage.setItem('cal-week-start', 'mon');
|
||||
_applyWeekStartActive();
|
||||
if (_open) _render();
|
||||
});
|
||||
_sunBtn?.addEventListener('click', () => {
|
||||
_weekStartSun = true;
|
||||
localStorage.setItem('cal-week-start', 'sun');
|
||||
_applyWeekStartActive();
|
||||
if (_open) _render();
|
||||
});
|
||||
|
||||
// Create a new (local) calendar. Defaults the name + next palette color, then
|
||||
// reopens the panel so the user can rename it inline and pick a color.
|
||||
overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => {
|
||||
@@ -2918,35 +2949,68 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
const startEl = document.getElementById('cal-f-start');
|
||||
const endEl = document.getElementById('cal-f-end');
|
||||
if (!startEl || !endEl) return;
|
||||
|
||||
const _toMin = (v) => {
|
||||
if (!v || !/^\d{2}:\d{2}$/.test(v)) return null;
|
||||
const [h, m] = v.split(':').map(n => parseInt(n, 10));
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
const _toHHMM = (mins) => {
|
||||
let m = ((mins % 1440) + 1440) % 1440;
|
||||
const hh = String(Math.floor(m / 60)).padStart(2, '0');
|
||||
const mm = String(m % 60).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
};
|
||||
|
||||
const _autoAdvanceEndDate = () => {
|
||||
const isAD = document.getElementById('cal-f-allday')?.checked;
|
||||
if (isAD) return;
|
||||
|
||||
const dv = document.getElementById('cal-f-date')?.value;
|
||||
const dvEndEl = document.getElementById('cal-f-date-end');
|
||||
if (!dv || !dvEndEl || dvEndEl.value !== dv) return;
|
||||
|
||||
const sVal = startEl.value;
|
||||
const eVal = endEl.value;
|
||||
|
||||
if (sVal && eVal && eVal <= sVal) {
|
||||
const d = new Date(`${dv}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
|
||||
dvEndEl.value = _ds(d);
|
||||
}
|
||||
};
|
||||
|
||||
let prevStartMin = _toMin(startEl.value);
|
||||
endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; });
|
||||
|
||||
endEl.addEventListener('input', () => {
|
||||
endEl.dataset.userEdited = '1';
|
||||
});
|
||||
|
||||
endEl.addEventListener('change', _autoAdvanceEndDate);
|
||||
|
||||
startEl.addEventListener('change', () => {
|
||||
const newStartMin = _toMin(startEl.value);
|
||||
const endMin = _toMin(endEl.value);
|
||||
if (newStartMin == null) { prevStartMin = newStartMin; return; }
|
||||
// Compute the duration before the change. Use the user's existing
|
||||
// start→end gap, fallback to 1 hour.
|
||||
let durationMin = 60;
|
||||
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
|
||||
durationMin = endMin - prevStartMin;
|
||||
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
|
||||
// User already set a custom end before changing start — leave it.
|
||||
|
||||
if (newStartMin == null) {
|
||||
prevStartMin = newStartMin;
|
||||
return;
|
||||
}
|
||||
|
||||
let durationMin = 60;
|
||||
|
||||
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
|
||||
durationMin = endMin - prevStartMin;
|
||||
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
|
||||
prevStartMin = newStartMin;
|
||||
return;
|
||||
}
|
||||
|
||||
endEl.value = _toHHMM(newStartMin + durationMin);
|
||||
prevStartMin = newStartMin;
|
||||
_autoAdvanceEndDate();
|
||||
});
|
||||
})();
|
||||
// Custom reminder picker
|
||||
@@ -3007,6 +3071,20 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets
|
||||
// re-interpreted as local elsewhere — the timezone-misfire bug.
|
||||
const _tz = _tzOffset();
|
||||
|
||||
if (!isAD) {
|
||||
const startVal = document.getElementById('cal-f-start').value;
|
||||
const endVal = document.getElementById('cal-f-end').value;
|
||||
|
||||
const startDt = new Date(`${dv}T${startVal}:00`);
|
||||
const endDt = new Date(`${dvEnd}T${endVal}:00`);
|
||||
|
||||
if (endDt <= startDt) {
|
||||
uiModule.showToast('End time must be after start time');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
summary,
|
||||
dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`,
|
||||
@@ -3215,6 +3293,37 @@ function _fmtTime(s) {
|
||||
}
|
||||
return s.slice(11, 16);
|
||||
}
|
||||
|
||||
function _timeToMin(iso) {
|
||||
const hm = _fmtTime(iso);
|
||||
if (!hm) return null;
|
||||
const m = hm.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10);
|
||||
const min = parseInt(m[2], 10);
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
return h * 60 + min;
|
||||
}
|
||||
|
||||
function _tzOffsetForDate(d) {
|
||||
const off = -d.getTimezoneOffset();
|
||||
const sign = off >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(off);
|
||||
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
|
||||
const mm = String(abs % 60).padStart(2, '0');
|
||||
return `${sign}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function _addMinutesToLocalIso(baseIso, addMinutes) {
|
||||
const d = new Date(new Date(baseIso).getTime() + addMinutes * 60000);
|
||||
const y = d.getFullYear();
|
||||
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const da = String(d.getDate()).padStart(2, '0');
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${y}-${mo}-${da}T${h}:${m}:00${_tzOffsetForDate(d)}`;
|
||||
}
|
||||
|
||||
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// Pure constants + zero-state helpers for the calendar UI.
|
||||
// No DOM, no fetch, no global mutable state — safe to import anywhere.
|
||||
|
||||
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
export const WEEKDAYS_SUN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
|
||||
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
+40
-21
@@ -1564,9 +1564,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
.replace(/<channel\|>/gi, '');
|
||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||
// Keep thinking box scrolled to bottom
|
||||
// Keep thinking box scrolled to bottom, but let user scroll up
|
||||
var thinkBox = _liveThinkInner.closest('.thinking-content');
|
||||
if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight;
|
||||
if (thinkBox) {
|
||||
var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
|
||||
if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
|
||||
}
|
||||
}
|
||||
uiModule.scrollHistory();
|
||||
continue;
|
||||
@@ -3865,7 +3868,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// Also submit on Enter (without shift)
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
const isMobile = window.innerWidth <= 768
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||
e.preventDefault();
|
||||
saveBtn.click();
|
||||
}
|
||||
@@ -3873,9 +3878,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a user message — truncates history to that point and resubmits.
|
||||
* Resend a user message. Normal resend appends a fresh copy at the end of
|
||||
* the current thread; regenerate flows can opt into replacing from here.
|
||||
*/
|
||||
export async function resendUserMessage(userMsgElement) {
|
||||
export async function resendUserMessage(userMsgElement, opts = {}) {
|
||||
const replaceFromHere = Boolean(opts && opts.replaceFromHere);
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
const msgIndex = allMsgs.indexOf(userMsgElement);
|
||||
@@ -3921,25 +3928,28 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
// Truncate backend to keep everything before this user message
|
||||
const keepCount = msgIndex;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
});
|
||||
if (replaceFromHere) {
|
||||
// Regenerate flows intentionally trim history to this point before
|
||||
// resubmitting. The plain "Resend message" action must not do this.
|
||||
const keepCount = msgIndex;
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
});
|
||||
|
||||
// Drop the AI replies after the user message but KEEP the user bubble
|
||||
// itself (so its photo stays visible). Then suppress the new user
|
||||
// bubble that send would otherwise add — same pattern as regenerate.
|
||||
let sibling = userMsgElement.nextSibling;
|
||||
while (sibling) {
|
||||
const next = sibling.nextSibling;
|
||||
sibling.remove();
|
||||
sibling = next;
|
||||
// Drop the AI replies after the user message but KEEP the user bubble
|
||||
// itself (so its photo stays visible). Then suppress the new user
|
||||
// bubble that send would otherwise add — same pattern as regenerate.
|
||||
let sibling = userMsgElement.nextSibling;
|
||||
while (sibling) {
|
||||
const next = sibling.nextSibling;
|
||||
sibling.remove();
|
||||
sibling = next;
|
||||
}
|
||||
_hideUserBubble = true;
|
||||
}
|
||||
_hideUserBubble = true;
|
||||
_pendingRegenAttachments = _ids;
|
||||
|
||||
// Resubmit
|
||||
@@ -4473,6 +4483,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
* Delete an AI message and its preceding user message from the conversation.
|
||||
*/
|
||||
export async function deleteMessage(msgElement) {
|
||||
if (uiModule && uiModule.styledConfirm) {
|
||||
const ok = await uiModule.styledConfirm('Delete this message?', {
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
const clickedIndex = allMsgs.indexOf(msgElement);
|
||||
|
||||
@@ -362,7 +362,7 @@ function _openVisionEditor(att, userMsgEl) {
|
||||
await _saveVisionText();
|
||||
_closeVisionEditor();
|
||||
if (userMsgEl && window.chatModule?.resendUserMessage) {
|
||||
window.chatModule.resendUserMessage(userMsgEl);
|
||||
window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });
|
||||
} else if (uiModule?.showToast) {
|
||||
uiModule.showToast('Saved');
|
||||
}
|
||||
|
||||
@@ -320,6 +320,15 @@ export const ERROR_PATTERNS = [
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
|
||||
message: 'SGLang native dependencies are missing on this server.',
|
||||
fixes: [
|
||||
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
|
||||
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
|
||||
message: 'SGLang is not installed or not in PATH.',
|
||||
|
||||
@@ -750,6 +750,80 @@ export async function _hwfitFetch(fresh = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Renders a non-blocking hardware visibility warning when Cookbook is using
|
||||
// container-visible hardware that may not match the user's actual host machine.
|
||||
function _renderHwVisibilityWarning(sys) {
|
||||
const row = document.getElementById('hwfit-hw-row');
|
||||
if (!row) return;
|
||||
|
||||
let box = document.getElementById('hwfit-hw-visibility-warning');
|
||||
|
||||
// Manual hardware is an explicit user override, so avoid showing stale
|
||||
// container-detection warnings once the user has chosen a simulated profile.
|
||||
const warning = sys?.manual_hardware ? null : sys?.hardware_visibility_warning;
|
||||
|
||||
if (!warning) {
|
||||
if (box) box.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'hwfit-hw-visibility-warning';
|
||||
box.className = 'hwfit-loading hwfit-hw-visibility-warning';
|
||||
row.insertAdjacentElement('afterend', box);
|
||||
}
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="hwfit-hw-visibility-warning-title">${esc(warning.title || 'Hardware visibility note')}</div>
|
||||
<div class="hwfit-hw-visibility-warning-body">${esc(warning.message || '')}</div>
|
||||
<div class="hwfit-hw-visibility-warning-actions">
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="manual">Edit manual hardware</button>
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="rescan">Rescan</button>
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="copy">Copy diagnostics</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => {
|
||||
const panel = document.getElementById('hwfit-manual-panel');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
box.querySelector('[data-hw-action="rescan"]')?.addEventListener('click', () => {
|
||||
_resetGpuToggleState();
|
||||
_hwfitCache = null;
|
||||
_hwfitFetch(true);
|
||||
});
|
||||
|
||||
box.querySelector('[data-hw-action="copy"]')?.addEventListener('click', () => {
|
||||
// Keep diagnostics copy/paste friendly for GitHub issues and Docker support.
|
||||
const text = [
|
||||
'Odysseus Cookbook hardware diagnostics',
|
||||
`probe_scope=${sys?.probe_scope || ''}`,
|
||||
`containerized=${sys?.containerized === true}`,
|
||||
`backend=${sys?.backend || ''}`,
|
||||
`has_gpu=${sys?.has_gpu === true}`,
|
||||
`gpu_name=${sys?.gpu_name || ''}`,
|
||||
`gpu_count=${sys?.gpu_count || 0}`,
|
||||
`gpu_vram_gb=${sys?.gpu_vram_gb || ''}`,
|
||||
`ram=${sys?.available_ram_gb || '?'} / ${sys?.total_ram_gb || '?'} GB`,
|
||||
`cpu_cores=${sys?.cpu_cores || ''}`,
|
||||
`cpu_name=${sys?.cpu_name || ''}`,
|
||||
'',
|
||||
'Useful checks:',
|
||||
'docker compose exec odysseus nvidia-smi -L',
|
||||
'docker compose exec odysseus cat /proc/meminfo | head',
|
||||
'docker compose exec odysseus python -c "from services.hwfit.hardware import detect_system; import json; print(json.dumps(detect_system(fresh=True), indent=2))"',
|
||||
].join('\n');
|
||||
|
||||
_copyText(text);
|
||||
});
|
||||
}
|
||||
|
||||
export function _hwfitRenderHw(el, sys) {
|
||||
if (!el || !sys) return;
|
||||
// Cache system info globally so other modules can read VRAM without refetching
|
||||
@@ -838,6 +912,7 @@ export function _hwfitRenderHw(el, sys) {
|
||||
+ chip('cores', cores)
|
||||
+ chip('backend', esc(sys.backend || ''))
|
||||
+ manualChip;
|
||||
_renderHwVisibilityWarning(sys);
|
||||
// Body click → toggle "off" (dimmed, still visible). Membership of
|
||||
// _dismissedHwChips is what the ranker reads, so both add+remove
|
||||
// here also flips the model list. The manual chip is excluded —
|
||||
|
||||
@@ -597,7 +597,8 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
} else if (backend === 'diffusers') {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
|
||||
cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
||||
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
|
||||
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
||||
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
|
||||
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
|
||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||
@@ -718,7 +719,7 @@ async function _fetchDependencies() {
|
||||
const data = await resp.json();
|
||||
const pkgs = data.packages || [];
|
||||
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
||||
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
|
||||
@@ -793,9 +793,10 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (tmuxArgs.includes('kill-session')) {
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||
@@ -3532,12 +3533,22 @@ async function _pollBackgroundStatus() {
|
||||
// dead-session check inspects). Recover "done" from the retained output's
|
||||
// exit-0 sentinel so a clean install isn't downgraded to crashed.
|
||||
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
|
||||
// A finished model download whose tmux pane is gone is also reported
|
||||
// "stopped" (the dead-session check can miss the landed snapshot).
|
||||
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
|
||||
// only after the runner exits 0 — so a completed download isn't
|
||||
// downgraded to crashed. This background poll runs blind (no live
|
||||
// stream to debounce against), so unlike the reconnect loop it keys
|
||||
// off the conclusive exit sentinel only, never the `/snapshots/` path,
|
||||
// which can be printed mid-stream for multi-file downloads.
|
||||
const downloadDone = task.type === 'download'
|
||||
&& String(task.output || '').includes('DOWNLOAD_OK');
|
||||
const nextStatus = live.status === 'completed'
|
||||
? 'done'
|
||||
: (live.status === 'error'
|
||||
? 'error'
|
||||
: (live.status === 'stopped'
|
||||
? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
||||
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
||||
: null));
|
||||
if (nextStatus && task.status !== nextStatus) {
|
||||
updates.status = nextStatus;
|
||||
|
||||
@@ -530,7 +530,7 @@ function _rerenderCachedModels() {
|
||||
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
const _allowedBackends = new Set(_isWindows()
|
||||
? ['llamacpp']
|
||||
? ['llamacpp', 'diffusers']
|
||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
||||
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
@@ -590,7 +590,7 @@ function _rerenderCachedModels() {
|
||||
// Row 1: Backend + Server + Env
|
||||
panelHtml += `<div class="hwfit-serve-row">`;
|
||||
const _backendChoices = _isWindows()
|
||||
? [['llamacpp','llama.cpp']]
|
||||
? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
|
||||
: _isMetal()
|
||||
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
|
||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
|
||||
@@ -994,7 +994,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
||||
stripe.style.bottom = '0';
|
||||
stripe.style.width = '10px';
|
||||
stripe.style.cursor = 'col-resize';
|
||||
stripe.style.zIndex = '9999';
|
||||
stripe.style.zIndex = '261';
|
||||
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
|
||||
stripe.style.pointerEvents = 'auto';
|
||||
stripe.style.touchAction = 'none';
|
||||
|
||||
@@ -354,7 +354,7 @@ function _buildPanelHTML() {
|
||||
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
||||
</p>
|
||||
<div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div>
|
||||
<textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him" rows="4"></textarea>
|
||||
<textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and what each one cost him." rows="4"></textarea>
|
||||
<div class="research-category-row" id="research-category-row">
|
||||
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
|
||||
<button class="research-cat" data-cat="product">Product</button>
|
||||
|
||||
@@ -3644,7 +3644,11 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-api-save').addEventListener('click', async () => {
|
||||
const presetKey = preset.value || undefined;
|
||||
const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey };
|
||||
const nameValue = name.value.trim();
|
||||
const urlValue = url.value.trim();
|
||||
if (!nameValue) { el('uf-api-msg').textContent = 'Name required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
|
||||
if (!urlValue) { el('uf-api-msg').textContent = 'Base URL required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
|
||||
const body = { name: nameValue, base_url: urlValue, auth_type: auth.value, auth_header: header.value, preset: presetKey };
|
||||
if (key.value) body.api_key = key.value;
|
||||
try {
|
||||
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
|
||||
|
||||
@@ -339,10 +339,13 @@ function _submitComposedMessage(text) {
|
||||
const msgInput = document.getElementById('message');
|
||||
const form = document.getElementById('chat-form');
|
||||
if (!msgInput || !form) return false;
|
||||
msgInput.value = text;
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
||||
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
// The slash handler and app-level form debounce must both release before
|
||||
// sending the pinned prompt, otherwise the follow-up submit is dropped.
|
||||
setTimeout(() => {
|
||||
msgInput.value = text;
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}, 350);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const fsClass = options.fsClass || null;
|
||||
const onEnterFullscreen = options.onEnterFullscreen || null;
|
||||
const onExitFullscreen = options.onExitFullscreen || null;
|
||||
const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen;
|
||||
const enableFullscreen = false;
|
||||
const onDragEnd = options.onDragEnd || null;
|
||||
const onDragStart = options.onDragStart || null;
|
||||
const skipSelector = options.skipSelector || 'button, input, select';
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
||||
<title>Odysseus — Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"background_color": "#282c34",
|
||||
"theme_color": "#282c34",
|
||||
"icons": [
|
||||
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
||||
+30
-3
@@ -4649,7 +4649,7 @@ body.bg-pattern-sparkles {
|
||||
#email-lib-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
|
||||
.email-reader-tab-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
|
||||
.email-window-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn {
|
||||
width: 44px !important;
|
||||
width: auto !important;
|
||||
height: 44px !important;
|
||||
flex: 0 0 auto !important;
|
||||
display: inline-flex !important;
|
||||
@@ -15244,6 +15244,10 @@ body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane {
|
||||
overflow-y: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list .doclib-card.doclib-card-expanded {
|
||||
flex: 0 0 auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
/* Drag-and-drop visual hint for the email compose pane. Subtle accent
|
||||
outline + tinted overlay so it's obvious files will attach if dropped. */
|
||||
.doc-editor-pane.email-dragover {
|
||||
@@ -15475,6 +15479,9 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
#cookbook-modal .hwfit-cached-list {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-toolbar {
|
||||
transition: opacity 0.12s ease, max-height 0.2s ease;
|
||||
max-height: 120px;
|
||||
@@ -21242,6 +21249,26 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--fg-muted); padding: 16px 0; font-size: 12px;
|
||||
}
|
||||
.hwfit-hw-visibility-warning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.hwfit-hw-visibility-warning-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.hwfit-hw-visibility-warning-body {
|
||||
opacity: 0.78;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hwfit-hw-visibility-warning-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hwfit-row {
|
||||
display: flex; align-items: center; gap: 6px; padding: 5px 8px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 11px;
|
||||
@@ -29147,9 +29174,9 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
|
||||
/* Email reader icon buttons — vertical icon + label stack. */
|
||||
.memory-toolbar-btn.reader-icon-btn {
|
||||
width: 48px;
|
||||
width: auto;
|
||||
height: 44px;
|
||||
padding: 4px 2px;
|
||||
/* padding: 4px 2px; */
|
||||
position: relative;
|
||||
top: 1px;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -83,6 +83,60 @@ python3 -m pytest tests/test_auth_config_lock_concurrency.py
|
||||
python3 -m pytest -m slow
|
||||
```
|
||||
|
||||
## Order-sensitivity reporting (report-only)
|
||||
|
||||
`tests/run_order_report.py` runs pytest with the collected test items shuffled
|
||||
by a seeded RNG, to surface order-sensitive tests (hidden coupling through
|
||||
shared import state, module caches, databases, etc.). It is report-only: it is
|
||||
not wired into CI, adds no gate, and changes no normal pytest collection or
|
||||
ordering - the shuffle exists only inside this runner. The seed is always
|
||||
printed, and pytest targets/options go after a literal `--`:
|
||||
|
||||
```bash
|
||||
python3 tests/run_order_report.py --seed 123 -- tests/cli/ -q
|
||||
python3 tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
|
||||
```
|
||||
|
||||
The same seed reproduces the same order when the reported working directory,
|
||||
pytest target arguments, and test environment are also the same. The runner
|
||||
prints all command arguments with shell-safe POSIX quoting and uses the
|
||||
invoking Python interpreter.
|
||||
|
||||
A generated-seed run starts with output like:
|
||||
|
||||
```text
|
||||
[order-report] working directory: /path/to/odysseus
|
||||
[order-report] shuffling test order with seed 284734921
|
||||
[order-report] reproduce from this working directory with the same test environment:
|
||||
[order-report] reproduce with: /path/to/odysseus/.venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
||||
```
|
||||
|
||||
Run the printed command from the reported working directory to reproduce the
|
||||
same fixed-seed order:
|
||||
|
||||
```text
|
||||
[order-report] working directory: /path/to/odysseus
|
||||
[order-report] shuffling test order with seed 284734921
|
||||
[order-report] reproduce from this working directory with the same test environment:
|
||||
[order-report] reproduce with: /path/to/odysseus/.venv/bin/python /path/to/odysseus/tests/run_order_report.py --seed 284734921 -- tests/cli/ -q
|
||||
```
|
||||
|
||||
Pytest output remains visible between the report header and footer. A failing
|
||||
run ends with pytest's normal failure report followed by:
|
||||
|
||||
```text
|
||||
FAILED tests/example_test.py::test_example - AssertionError
|
||||
[order-report] seed 284734921: pytest exit code 1 (report-only; fix order-sensitive failures in separate scoped PRs)
|
||||
```
|
||||
|
||||
Failures discovered this way are real isolation bugs: fix them in separate
|
||||
scoped PRs - do not silence them with `skip`/`xfail`, and do not "fix" them by
|
||||
depending on a particular order.
|
||||
|
||||
The runner propagates pytest's exit code, so it composes with normal local
|
||||
workflows; "report-only" means it is not a CI gate, not that failures are
|
||||
swallowed.
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report-only randomized test-order runner (issue #3973).
|
||||
|
||||
Runs pytest with the collected test items shuffled by a seeded RNG so
|
||||
order-sensitive tests (hidden coupling through shared import state, module
|
||||
caches, databases, etc.) surface locally. The seed is always printed, so any
|
||||
failing order is reproducible with ``--seed``.
|
||||
|
||||
This runner is report-only: it is not wired into CI, adds no gate, and does
|
||||
not change normal pytest collection or ordering. Failures it discovers should
|
||||
be fixed in separate scoped PRs, not silenced here.
|
||||
|
||||
Examples:
|
||||
python3 tests/run_order_report.py --seed 123 -- tests/cli/ -q
|
||||
python3 tests/run_order_report.py -- tests/cli/ -q # generates and prints a seed
|
||||
|
||||
The shuffle is applied through a local ``pytest_collection_modifyitems`` hook
|
||||
passed to ``pytest.main`` as an in-process plugin; no conftest or global
|
||||
plugin is involved. Reproduction requires the reported working directory,
|
||||
seed, pytest arguments, and test environment. The exit code is pytest's own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
# Seeds are kept in the non-negative 32-bit range so they stay short enough to
|
||||
# copy from a report line into a reproduction command.
|
||||
SEED_MAX = 2**32 - 1
|
||||
|
||||
|
||||
def shuffle_items(items: list, seed: int) -> None:
|
||||
"""Deterministically shuffle ``items`` in place using ``seed``."""
|
||||
random.Random(seed).shuffle(items)
|
||||
|
||||
|
||||
class OrderShuffle:
|
||||
"""Local pytest plugin that shuffles collected items with a fixed seed."""
|
||||
|
||||
def __init__(self, seed: int):
|
||||
self.seed = seed
|
||||
|
||||
def pytest_collection_modifyitems(self, items: list) -> None:
|
||||
shuffle_items(items, self.seed)
|
||||
|
||||
|
||||
def generate_seed() -> int:
|
||||
"""Generate a fresh seed for a run that did not pass ``--seed``."""
|
||||
return random.SystemRandom().randint(0, SEED_MAX)
|
||||
|
||||
|
||||
def seed_type(value: str) -> int:
|
||||
"""argparse type: a seed in ``[0, SEED_MAX]``."""
|
||||
number = int(value)
|
||||
if not 0 <= number <= SEED_MAX:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"seed must be between 0 and {SEED_MAX}, got {value!r}"
|
||||
)
|
||||
return number
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for the order-sensitivity runner."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="run_order_report.py",
|
||||
description=(
|
||||
"Run pytest with randomized test order to surface order-sensitive "
|
||||
"tests. Report-only: prints the seed used and propagates pytest's "
|
||||
"exit code; it changes no normal pytest behavior."
|
||||
),
|
||||
epilog=(
|
||||
"Pass pytest targets and options after a literal -- separator, "
|
||||
"e.g.: run_order_report.py --seed 123 -- tests/cli/ -q"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=seed_type,
|
||||
help="shuffle seed; omitted: a seed is generated and printed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"pytest_args",
|
||||
nargs="*",
|
||||
metavar="-- PYTEST_ARGS",
|
||||
help="pytest targets/options forwarded after a literal --",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def runner_path() -> str:
|
||||
"""Return an absolute path for copy-pasteable reproduction commands."""
|
||||
return str(Path(__file__).resolve())
|
||||
|
||||
|
||||
def print_report_header(seed: int, pytest_args: Sequence[str]) -> None:
|
||||
"""Print the seed and an exact reproduction command before running."""
|
||||
repro = [
|
||||
sys.executable,
|
||||
runner_path(),
|
||||
"--seed",
|
||||
str(seed),
|
||||
"--",
|
||||
*pytest_args,
|
||||
]
|
||||
print(f"[order-report] working directory: {Path.cwd()}")
|
||||
print(f"[order-report] shuffling test order with seed {seed}")
|
||||
print(
|
||||
"[order-report] reproduce from this working directory with the same "
|
||||
"test environment:"
|
||||
)
|
||||
print(f"[order-report] reproduce with: {shlex.join(repro)}")
|
||||
|
||||
|
||||
def print_report_footer(seed: int, exit_code: int) -> None:
|
||||
"""Print the outcome with the seed again, after possibly long pytest output."""
|
||||
outcome = "no failures" if exit_code == 0 else f"pytest exit code {exit_code}"
|
||||
print(
|
||||
f"[order-report] seed {seed}: {outcome} "
|
||||
"(report-only; fix order-sensitive failures in separate scoped PRs)"
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
argv: Sequence[str] | None = None,
|
||||
pytest_main: Callable[..., int] | None = None,
|
||||
) -> int:
|
||||
"""Parse ``argv``, run pytest with shuffled item order, and report the seed.
|
||||
|
||||
``pytest_main`` is injected so tests can assert on the forwarded arguments
|
||||
and plugin without running a nested pytest. It must match ``pytest.main``:
|
||||
accept ``(args, plugins=...)`` and return an exit code.
|
||||
"""
|
||||
namespace = build_parser().parse_args(argv)
|
||||
seed = namespace.seed if namespace.seed is not None else generate_seed()
|
||||
pytest_args = list(namespace.pytest_args)
|
||||
print_report_header(seed, pytest_args)
|
||||
if pytest_main is None:
|
||||
import pytest
|
||||
|
||||
pytest_main = pytest.main
|
||||
exit_code = int(pytest_main(pytest_args, plugins=[OrderShuffle(seed)]))
|
||||
print_report_footer(seed, exit_code)
|
||||
return exit_code
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Console entry point."""
|
||||
return run(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -49,6 +49,13 @@ def test_research_action_promotes_to_agent():
|
||||
assert message_needs_tools("can you look into GPU hosting options")
|
||||
|
||||
|
||||
def test_explicit_web_search_promotes_to_agent():
|
||||
assert message_needs_tools("use web search and find a recipe for chocolate chip cookies")
|
||||
assert message_needs_tools("do a web search for the best chocolate chip cookies")
|
||||
assert message_needs_tools("search the web for current RTX 3090 prices")
|
||||
assert classify_tool_intent("use web search and find a recipe").category == "web"
|
||||
|
||||
|
||||
def test_explanatory_calendar_questions_stay_plain_chat():
|
||||
assert not message_needs_tools("How do I add an entry to my calendar?")
|
||||
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
||||
|
||||
@@ -36,6 +36,7 @@ _IMPORTED_AGENT_LOOP = None
|
||||
try:
|
||||
from src.agent_loop import (
|
||||
_detect_admin_intent,
|
||||
_classify_agent_request,
|
||||
_compute_final_metrics,
|
||||
_append_tool_results,
|
||||
_MCP_KEYWORDS,
|
||||
@@ -62,6 +63,16 @@ def test_mcp_keyword_gate_matches_literal_mcp_requests():
|
||||
assert "mcp" in _MCP_KEYWORDS
|
||||
|
||||
|
||||
def test_polish_internet_search_request_classifies_as_web():
|
||||
intent = _classify_agent_request(
|
||||
[],
|
||||
"Wyszukaj w internecie i podaj temperaturę w Lubartowie dzisiaj",
|
||||
)
|
||||
|
||||
assert intent["low_signal"] is False
|
||||
assert "web" in intent["domains"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_admin_intent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATH = ROOT / "scripts" / "agent_migration_manifest.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("agent_migration_manifest", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_collect_memory_json_accepts_strings_and_objects(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "memories.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
"Pacey prefers GLM for routine coding.",
|
||||
{"text": "Odysseus runs on a self-hosted machine.", "category": "project", "source": "manual"},
|
||||
{"content": "Duplicate source keys still work.", "category": "fact"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_memory_json(path, "example-agent")
|
||||
|
||||
assert [item["kind"] for item in items] == ["memory", "memory", "memory"]
|
||||
assert items[0]["category"] == "fact"
|
||||
assert items[1]["category"] == "project"
|
||||
assert items[1]["source"] == "manual"
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_collect_memory_json_deduplicates_exact_text(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "memories.json"
|
||||
path.write_text(json.dumps(["Same memory", {"text": "Same memory"}]), encoding="utf-8")
|
||||
|
||||
items, warnings = migration.collect_memory_json(path, "example-agent")
|
||||
|
||||
assert len(items) == 1
|
||||
assert warnings[0].message == "skipped duplicate memory at index 1"
|
||||
|
||||
|
||||
def test_collect_skill_dir_scans_skill_markdown(tmp_path):
|
||||
migration = load_module()
|
||||
skill_path = tmp_path / "skills" / "dev" / "git-helper" / "SKILL.md"
|
||||
skill_path.parent.mkdir(parents=True)
|
||||
skill_path.write_text(
|
||||
"""---
|
||||
name: git-helper
|
||||
category: dev
|
||||
---
|
||||
|
||||
## When to Use
|
||||
Use for focused git checks.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_skill_dir(tmp_path / "skills", "example-agent")
|
||||
|
||||
assert len(items) == 1
|
||||
assert warnings == []
|
||||
assert items[0]["kind"] == "skill"
|
||||
assert items[0]["name"] == "git-helper"
|
||||
assert items[0]["category"] == "dev"
|
||||
assert items[0]["format"] == "SKILL.md"
|
||||
assert "## When to Use" in items[0]["content"]
|
||||
|
||||
|
||||
def test_collect_skill_dir_skips_symlinked_skill_markdown(tmp_path):
|
||||
migration = load_module()
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("private skill content", encoding="utf-8")
|
||||
skill_path = tmp_path / "skills" / "bad" / "SKILL.md"
|
||||
skill_path.parent.mkdir(parents=True)
|
||||
skill_path.symlink_to(outside)
|
||||
|
||||
items, warnings = migration.collect_skill_dir(tmp_path / "skills", "example-agent")
|
||||
|
||||
assert items == []
|
||||
assert warnings[0].message == "skipped symlinked skill file"
|
||||
|
||||
|
||||
def test_collect_skill_dir_skips_symlinked_root(tmp_path):
|
||||
migration = load_module()
|
||||
real_skills = tmp_path / "real-skills"
|
||||
real_skills.mkdir()
|
||||
linked_skills = tmp_path / "skills"
|
||||
linked_skills.symlink_to(real_skills, target_is_directory=True)
|
||||
|
||||
items, warnings = migration.collect_skill_dir(linked_skills, "example-agent")
|
||||
|
||||
assert items == []
|
||||
assert warnings[0].message == "skills path is a symlink; skipped"
|
||||
|
||||
|
||||
def test_archive_content_is_optional(tmp_path):
|
||||
migration = load_module()
|
||||
archive = tmp_path / "notes.md"
|
||||
archive.write_text("# Notes\n\nUseful context.", encoding="utf-8")
|
||||
|
||||
metadata_only, _ = migration.collect_archive_paths([archive], "example-agent")
|
||||
with_content, _ = migration.collect_archive_paths([archive], "example-agent", include_content=True)
|
||||
|
||||
assert metadata_only[0]["kind"] == "archive_document"
|
||||
assert "content" not in metadata_only[0]
|
||||
assert with_content[0]["content"].startswith("# Notes")
|
||||
|
||||
|
||||
def test_archive_skips_symlinked_file(tmp_path):
|
||||
migration = load_module()
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("private archive content", encoding="utf-8")
|
||||
archive_dir = tmp_path / "archive"
|
||||
archive_dir.mkdir()
|
||||
linked_file = archive_dir / "leak.md"
|
||||
linked_file.symlink_to(outside)
|
||||
|
||||
items, warnings = migration.collect_archive_paths([archive_dir], "example-agent", include_content=True)
|
||||
|
||||
assert items == []
|
||||
assert warnings[0].message == "skipped symlinked archive path"
|
||||
|
||||
|
||||
def test_archive_skips_symlinked_root(tmp_path):
|
||||
migration = load_module()
|
||||
archive = tmp_path / "notes.md"
|
||||
archive.write_text("# Notes\n\nUseful context.", encoding="utf-8")
|
||||
linked_archive = tmp_path / "linked-notes.md"
|
||||
linked_archive.symlink_to(archive)
|
||||
|
||||
items, warnings = migration.collect_archive_paths([linked_archive], "example-agent", include_content=True)
|
||||
|
||||
assert items == []
|
||||
assert warnings[0].message == "archive path is a symlink; skipped"
|
||||
|
||||
|
||||
def test_conversation_json_imports_generic_threads_metadata_only(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "conversations.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"id": "thread-1",
|
||||
"title": "Project plan",
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Can we design this?"},
|
||||
{"role": "assistant", "content": "Yes, start with a narrow slice."},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_conversation_json(path, "example-agent")
|
||||
|
||||
assert warnings == []
|
||||
assert len(items) == 1
|
||||
assert items[0]["kind"] == "conversation_thread"
|
||||
assert items[0]["title"] == "Project plan"
|
||||
assert items[0]["metadata"]["source_id"] == "thread-1"
|
||||
assert items[0]["metadata"]["message_count"] == 2
|
||||
assert items[0]["metadata"]["content_included"] is False
|
||||
assert "messages" not in items[0]
|
||||
|
||||
|
||||
def test_conversation_json_can_embed_generic_thread_content(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "conversations.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title": "Preference",
|
||||
"messages": [
|
||||
{"sender": "human", "content": [{"type": "text", "text": "Use terse replies."}]},
|
||||
{"sender": "ai", "text": "Noted."},
|
||||
],
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_conversation_json(path, "example-agent", include_content=True)
|
||||
|
||||
assert warnings == []
|
||||
assert items[0]["metadata"]["content_included"] is True
|
||||
assert items[0]["messages"] == [
|
||||
{"role": "user", "text": "Use terse replies."},
|
||||
{"role": "assistant", "text": "Noted."},
|
||||
]
|
||||
|
||||
|
||||
def test_conversation_json_imports_chatgpt_mapping_ordered_by_time(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "conversations.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "chatgpt-thread",
|
||||
"title": "ChatGPT export",
|
||||
"mapping": {
|
||||
"b": {
|
||||
"message": {
|
||||
"id": "m2",
|
||||
"create_time": 20,
|
||||
"author": {"role": "assistant"},
|
||||
"content": {"content_type": "text", "parts": ["Second"]},
|
||||
}
|
||||
},
|
||||
"a": {
|
||||
"message": {
|
||||
"id": "m1",
|
||||
"create_time": 10,
|
||||
"author": {"role": "user"},
|
||||
"content": {"content_type": "text", "parts": ["First"]},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_conversation_json(path, "chatgpt", include_content=True)
|
||||
|
||||
assert warnings == []
|
||||
assert items[0]["metadata"]["source_format"] == "chatgpt_mapping"
|
||||
assert items[0]["messages"] == [
|
||||
{"role": "user", "text": "First", "created_at": "1970-01-01T00:00:10Z", "source_id": "m1"},
|
||||
{"role": "assistant", "text": "Second", "created_at": "1970-01-01T00:00:20Z", "source_id": "m2"},
|
||||
]
|
||||
|
||||
|
||||
def test_conversation_content_respects_message_limit(tmp_path):
|
||||
migration = load_module()
|
||||
path = tmp_path / "conversations.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title": "Long thread",
|
||||
"messages": [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
],
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
items, warnings = migration.collect_conversation_json(
|
||||
path,
|
||||
"example-agent",
|
||||
include_content=True,
|
||||
max_messages=1,
|
||||
)
|
||||
|
||||
assert "messages" not in items[0]
|
||||
assert items[0]["metadata"]["content_included"] is False
|
||||
assert warnings[0].message == "skipped conversation content at index 0: over 1 messages"
|
||||
|
||||
|
||||
def test_archive_missing_path_warns(tmp_path):
|
||||
migration = load_module()
|
||||
missing = tmp_path / "missing"
|
||||
|
||||
items, warnings = migration.collect_archive_paths([missing], "example-agent")
|
||||
|
||||
assert items == []
|
||||
assert warnings[0].message == "archive path does not exist"
|
||||
|
||||
|
||||
def test_main_writes_manifest_with_conversation_thread(tmp_path):
|
||||
migration = load_module()
|
||||
conversation_path = tmp_path / "conversations.json"
|
||||
output_path = tmp_path / "manifest.json"
|
||||
conversation_path.write_text(
|
||||
json.dumps([{"title": "A thread", "messages": [{"role": "user", "content": "hello"}]}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
exit_code = migration.main(
|
||||
[
|
||||
"--source-name",
|
||||
"example-agent",
|
||||
"--conversation-json",
|
||||
str(conversation_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
manifest = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert exit_code == 0
|
||||
assert manifest["summary"]["counts_by_kind"] == {"conversation_thread": 1}
|
||||
assert manifest["items"][0]["title"] == "A thread"
|
||||
|
||||
|
||||
def test_main_writes_manifest(tmp_path):
|
||||
migration = load_module()
|
||||
memory_path = tmp_path / "memories.json"
|
||||
output_path = tmp_path / "manifest.json"
|
||||
memory_path.write_text(json.dumps([{"text": "A useful fact", "category": "fact"}]), encoding="utf-8")
|
||||
|
||||
exit_code = migration.main(
|
||||
[
|
||||
"--source-name",
|
||||
"example-agent",
|
||||
"--memory-json",
|
||||
str(memory_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
manifest = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert exit_code == 0
|
||||
assert manifest["schema_version"] == "agent-migration.v1"
|
||||
assert manifest["summary"]["counts_by_kind"] == {"memory": 1}
|
||||
assert manifest["items"][0]["text"] == "A useful fact"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regression: the API-key encryption key file (data/.key) must be owner-only
|
||||
(0o600).
|
||||
|
||||
``APIKeyManager.get_or_create_key`` writes the Fernet key that decrypts *every*
|
||||
stored provider credential. Older versions created it with the process umask
|
||||
(commonly 0o644 — group/world-readable). It must be locked to the owner, both
|
||||
when freshly created and when an older, too-permissive key is read back.
|
||||
|
||||
POSIX-only: ``core.platform_compat.safe_chmod`` is a documented no-op on Windows
|
||||
(files under the user profile are ACL-restricted), so the mode assertions are
|
||||
skipped there.
|
||||
"""
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api_key_manager import APIKeyManager
|
||||
|
||||
_WINDOWS = sys.platform.startswith("win")
|
||||
|
||||
|
||||
def _mode(path: str) -> int:
|
||||
return stat.S_IMODE(os.stat(path).st_mode)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_WINDOWS, reason="POSIX permission bits only")
|
||||
def test_new_key_file_is_owner_only(tmp_path):
|
||||
mgr = APIKeyManager(str(tmp_path))
|
||||
mgr.get_or_create_key()
|
||||
assert _mode(mgr.key_file) == 0o600, f"expected 0o600, got {oct(_mode(mgr.key_file))}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(_WINDOWS, reason="POSIX permission bits only")
|
||||
def test_existing_world_readable_key_is_relocked(tmp_path):
|
||||
mgr = APIKeyManager(str(tmp_path))
|
||||
# Simulate a key written by an older version with a permissive umask.
|
||||
with open(mgr.key_file, "wb") as f:
|
||||
f.write(b"x" * 44)
|
||||
os.chmod(mgr.key_file, 0o644)
|
||||
mgr.get_or_create_key() # existing-file branch should re-lock it
|
||||
assert _mode(mgr.key_file) == 0o600, f"expected re-lock to 0o600, got {oct(_mode(mgr.key_file))}"
|
||||
|
||||
|
||||
def test_encrypt_decrypt_roundtrip_still_works(tmp_path):
|
||||
# The permission hardening must not change functional behaviour.
|
||||
mgr = APIKeyManager(str(tmp_path))
|
||||
enc = mgr.encrypt_api_key("sk-secret")
|
||||
assert enc and enc != "sk-secret"
|
||||
assert mgr.decrypt_api_key(enc) == "sk-secret"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Agent input-token budget contract (review on #4122).
|
||||
|
||||
- The DEFAULT value is the AUTO sentinel: it scales to the model's context window.
|
||||
Any non-default value is an explicit cap. A materialized default 6000 can't be
|
||||
told apart from a deliberate 6000 (the settings-save path persists defaults), so
|
||||
the default reads as auto — pin a cap with a nearby value (e.g. 5999).
|
||||
- Auto-scaling only trusts a DISCOVERED context window; a bare DEFAULT_CONTEXT
|
||||
fallback stays conservative instead of scaling off an unproven window.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import src.settings as settings
|
||||
import src.model_context as mc
|
||||
from src.context_budget import compute_input_token_budget, DEFAULT_BUDGET, budget_is_explicit
|
||||
|
||||
|
||||
def test_default_value_is_the_auto_sentinel():
|
||||
# The settings default equals DEFAULT_BUDGET, so the agent loop (which compares
|
||||
# the configured value to DEFAULT_BUDGET) treats the default as "auto".
|
||||
assert settings.DEFAULT_SETTINGS["agent_input_token_budget"] == DEFAULT_BUDGET
|
||||
|
||||
|
||||
def test_saving_an_unrelated_setting_does_not_re_cap_the_budget(tmp_path, monkeypatch):
|
||||
"""End-to-end regression (WGlynn, #4121): changing ANY setting makes the
|
||||
settings-save path persist the merged dict, which materializes the budget
|
||||
default into settings.json. The budget must still AUTO-SCALE — it must not be
|
||||
re-read as an explicit 6000 cap. This locks the exact reopening shut.
|
||||
"""
|
||||
settings_file = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings, "SETTINGS_FILE", str(settings_file))
|
||||
settings._settings_cache = None
|
||||
|
||||
# Simulate a real settings save: a handler loads the merged dict (defaults +
|
||||
# saved) and persists it after the user changes one *unrelated* setting.
|
||||
merged = settings.load_settings()
|
||||
merged["search_result_count"] = 9 # unrelated user change
|
||||
settings.save_settings(merged)
|
||||
settings._settings_cache = None
|
||||
|
||||
# The budget default is now physically materialized into the file...
|
||||
raw = json.loads(settings_file.read_text())
|
||||
assert raw["agent_input_token_budget"] == DEFAULT_BUDGET
|
||||
assert raw["search_result_count"] == 9
|
||||
|
||||
# ...yet it must read as AUTO (value == default), not an explicit cap — even
|
||||
# though is_setting_overridden would report True for it now.
|
||||
assert settings.is_setting_overridden("agent_input_token_budget") is True
|
||||
soft = int(settings.get_setting("agent_input_token_budget", DEFAULT_BUDGET) or 0)
|
||||
assert budget_is_explicit(soft) is False
|
||||
# And the effective budget scales to the window rather than capping at 6000.
|
||||
assert compute_input_token_budget(soft, 131072, explicit=budget_is_explicit(soft)) == int(131072 * 0.85)
|
||||
|
||||
|
||||
def test_auto_scales_on_a_known_window():
|
||||
assert compute_input_token_budget(DEFAULT_BUDGET, 131072, explicit=False) == int(131072 * 0.85)
|
||||
|
||||
|
||||
def test_auto_stays_conservative_on_unknown_window():
|
||||
# P2 #2: the budget block passes context_length=0 when the window is only a
|
||||
# fallback, so auto-scaling must NOT inflate to the unproven window.
|
||||
assert compute_input_token_budget(DEFAULT_BUDGET, 0, explicit=False) == DEFAULT_BUDGET
|
||||
|
||||
|
||||
def test_nondefault_value_is_an_explicit_cap():
|
||||
assert compute_input_token_budget(20000, 131072, explicit=True) == 20000 # honoured
|
||||
assert compute_input_token_budget(200000, 32000, explicit=True) == 32000 # clamped to window
|
||||
|
||||
|
||||
def test_get_context_length_known_surfaces_endpoint_proven_vs_fallback():
|
||||
mc._context_cache.clear()
|
||||
with patch.object(mc, "_query_context_length", return_value=(131072, True)):
|
||||
assert mc.get_context_length_known("http://proven/v1", "m1") == (131072, True)
|
||||
mc._context_cache.clear()
|
||||
with patch.object(mc, "_query_context_length", return_value=(mc.DEFAULT_CONTEXT, False)):
|
||||
ctx, known = mc.get_context_length_known("http://unknown/v1", "m2")
|
||||
assert ctx == mc.DEFAULT_CONTEXT and known is False
|
||||
# get_context_length keeps its plain-int contract for existing callers
|
||||
mc._context_cache.clear()
|
||||
with patch.object(mc, "_query_context_length", return_value=(64000, True)):
|
||||
assert mc.get_context_length("http://proven/v1", "m3") == 64000
|
||||
|
||||
|
||||
def test_budget_context_binds_known_flag_to_its_own_value():
|
||||
"""Regression (RaresKeY, #4122): scale the budget off the value the `known`
|
||||
flag actually proves — never a stale/missing context_length from a different
|
||||
lookup. Covers the local-restaleness case (fresh proven value beats a stale
|
||||
fallback) and the no-arg-caller case (discovers a long window despite fallback=0).
|
||||
"""
|
||||
# unknown / bare fallback -> 0 (don't scale off an unproven window)
|
||||
with patch.object(mc, "get_context_length_known", return_value=(128000, False)):
|
||||
assert mc.budget_context_for_model("u", "m", fallback=128000) == 0
|
||||
# known -> the freshly-proven value, NOT the (stale) fallback the caller passed
|
||||
with patch.object(mc, "get_context_length_known", return_value=(4096, True)):
|
||||
assert mc.budget_context_for_model("u", "m", fallback=128000) == 4096
|
||||
# no-arg caller (fallback=0) still gets the discovered long window
|
||||
with patch.object(mc, "get_context_length_known", return_value=(131072, True)):
|
||||
assert mc.budget_context_for_model("u", "m", fallback=0) == 131072
|
||||
# probe error -> caller's fallback (prior behaviour)
|
||||
with patch.object(mc, "get_context_length_known", side_effect=RuntimeError):
|
||||
assert mc.budget_context_for_model("u", "m", fallback=4096) == 4096
|
||||
|
||||
|
||||
def test_no_arg_caller_scales_from_discovered_window_not_6000():
|
||||
"""End-to-end of the fix: a caller that passes no context_length (scheduled
|
||||
tasks, teacher escalation, ...) but whose endpoint reports 131072 now scales to
|
||||
~111k instead of being capped at the conservative 6000."""
|
||||
with patch.object(mc, "get_context_length_known", return_value=(131072, True)):
|
||||
ctx = mc.budget_context_for_model("u", "m", fallback=0)
|
||||
assert compute_input_token_budget(DEFAULT_BUDGET, ctx, explicit=False) == int(131072 * 0.85)
|
||||
@@ -36,7 +36,38 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
|
||||
) == "@playwright/mcp@latest"
|
||||
|
||||
|
||||
def test_npx_cache_check_falls_back_when_async_subprocess_is_unsupported(monkeypatch):
|
||||
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
package_json = (
|
||||
tmp_path
|
||||
/ ".npm"
|
||||
/ "_npx"
|
||||
/ "9833c18b2d85bc59"
|
||||
/ "node_modules"
|
||||
/ "@playwright"
|
||||
/ "mcp"
|
||||
/ "package.json"
|
||||
)
|
||||
package_json.parent.mkdir(parents=True)
|
||||
package_json.write_text('{"name":"@playwright/mcp","version":"0.0.76"}', encoding="utf-8")
|
||||
|
||||
async def unexpected_exec(*args, **kwargs):
|
||||
raise AssertionError("cache hit should not shell out to npx")
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("npm_config_cache", raising=False)
|
||||
monkeypatch.setattr(builtin_mcp.asyncio, "create_subprocess_exec", unexpected_exec)
|
||||
|
||||
assert asyncio.run(
|
||||
builtin_mcp._is_npx_package_cached(
|
||||
"npx",
|
||||
"@playwright/mcp@latest",
|
||||
timeout_s=2,
|
||||
)
|
||||
) is True
|
||||
|
||||
|
||||
def test_npx_cache_check_falls_back_when_async_subprocess_is_unsupported(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
async def unsupported_exec(*args, **kwargs):
|
||||
@@ -51,6 +82,8 @@ def test_npx_cache_check_falls_back_when_async_subprocess_is_unsupported(monkeyp
|
||||
|
||||
monkeypatch.setattr(builtin_mcp.asyncio, "create_subprocess_exec", unsupported_exec)
|
||||
monkeypatch.setattr(builtin_mcp.subprocess, "run", fake_run)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("npm_config_cache", raising=False)
|
||||
|
||||
assert asyncio.run(
|
||||
builtin_mcp._is_npx_package_cached(
|
||||
@@ -69,7 +102,7 @@ def test_npx_cache_check_falls_back_when_async_subprocess_is_unsupported(monkeyp
|
||||
assert captured["kwargs"]["timeout"] == 2
|
||||
|
||||
|
||||
def test_npx_cache_check_fallback_treats_timeout_as_cache_miss(monkeypatch):
|
||||
def test_npx_cache_check_fallback_treats_timeout_as_cache_miss(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
async def unsupported_exec(*args, **kwargs):
|
||||
@@ -80,6 +113,8 @@ def test_npx_cache_check_fallback_treats_timeout_as_cache_miss(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(builtin_mcp.asyncio, "create_subprocess_exec", unsupported_exec)
|
||||
monkeypatch.setattr(builtin_mcp.subprocess, "run", fake_run)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("npm_config_cache", raising=False)
|
||||
|
||||
assert asyncio.run(
|
||||
builtin_mcp._is_npx_package_cached(
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Regression coverage for bidirectional CalDAV sync plumbing.
|
||||
|
||||
These tests avoid a live CalDAV server. They pin the local invariants that keep
|
||||
Odysseus-created CalDAV events from being pruned before they can be pushed.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from src.caldav_writeback import build_event_ical
|
||||
|
||||
|
||||
def test_event_to_ical_serializes_core_fields_and_rrule():
|
||||
ical = build_event_ical({
|
||||
"uid": "evt-123",
|
||||
"summary": "Planning",
|
||||
"description": "Bring notes",
|
||||
"location": "HQ",
|
||||
"dtstart": datetime(2026, 6, 5, 9, 0),
|
||||
"dtend": datetime(2026, 6, 5, 10, 0),
|
||||
"all_day": False,
|
||||
"is_utc": False,
|
||||
"rrule": "FREQ=WEEKLY;COUNT=2",
|
||||
})
|
||||
|
||||
assert "UID:evt-123" in ical
|
||||
assert "SUMMARY:Planning" in ical
|
||||
assert "DESCRIPTION:Bring notes" in ical
|
||||
assert "LOCATION:HQ" in ical
|
||||
assert "RRULE:FREQ=WEEKLY;COUNT=2" in ical
|
||||
|
||||
|
||||
def test_caldav_pull_prune_skips_unsynced_or_pending_local_rows():
|
||||
source = Path("src/caldav_sync.py").read_text()
|
||||
|
||||
assert 'existing.caldav_sync_pending in {"create", "update"}' in source
|
||||
assert "CalendarEvent.remote_href.isnot(None)" in source
|
||||
assert "CalendarEvent.caldav_sync_pending.is_(None)" in source
|
||||
|
||||
|
||||
def test_http_calendar_writes_mark_pending_and_push_after_commit():
|
||||
source = Path("routes/calendar_routes.py").read_text()
|
||||
|
||||
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
|
||||
assert 'ev.caldav_sync_pending = "update"' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
|
||||
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
|
||||
assert 'not result.get("ok")' in source
|
||||
|
||||
|
||||
def test_agent_calendar_writes_share_caldav_push_path():
|
||||
source = Path("src/tool_implementations.py").read_text()
|
||||
|
||||
assert "_push_caldav_event_after_commit" in source
|
||||
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
|
||||
assert 'ev.caldav_sync_pending = "update"' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
|
||||
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
|
||||
|
||||
|
||||
def test_database_declares_and_migrates_caldav_remote_metadata():
|
||||
source = Path("core/database.py").read_text()
|
||||
|
||||
for needle in [
|
||||
"class CalendarDeletedEvent",
|
||||
"remote_href = Column(String, nullable=True)",
|
||||
"remote_etag = Column(String, nullable=True)",
|
||||
"caldav_sync_pending = Column(String, nullable=True)",
|
||||
"caldav_base_url = Column(String, nullable=True)",
|
||||
"ALTER TABLE calendar_events ADD COLUMN remote_href TEXT",
|
||||
"ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT",
|
||||
"ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT",
|
||||
"ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT",
|
||||
"_migrate_add_caldav_sync_columns()",
|
||||
]:
|
||||
assert needle in source
|
||||
|
||||
|
||||
def test_failed_remote_delete_leaves_tombstone_and_later_retry_cleans_up(tmp_path, monkeypatch):
|
||||
import src.caldav_writeback as writeback
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'calendar.db'}")
|
||||
spec = importlib.util.spec_from_file_location("core.database", Path("core/database.py"))
|
||||
dbmod = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "core.database", dbmod)
|
||||
spec.loader.exec_module(dbmod)
|
||||
|
||||
CalendarCal = dbmod.CalendarCal
|
||||
CalendarDeletedEvent = dbmod.CalendarDeletedEvent
|
||||
CalendarEvent = dbmod.CalendarEvent
|
||||
TestingSessionLocal = dbmod.SessionLocal
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
cal = CalendarCal(
|
||||
id="caldav-test",
|
||||
owner="alice",
|
||||
name="Remote",
|
||||
source="caldav",
|
||||
caldav_base_url="https://caldav.example/calendars/alice/main/",
|
||||
)
|
||||
ev = CalendarEvent(
|
||||
uid="evt-delete",
|
||||
calendar_id=cal.id,
|
||||
summary="Delete me",
|
||||
dtstart=datetime(2026, 6, 5, 9, 0),
|
||||
dtend=datetime(2026, 6, 5, 10, 0),
|
||||
remote_href="https://caldav.example/calendars/alice/main/evt-delete.ics",
|
||||
)
|
||||
session.add(cal)
|
||||
session.add(ev)
|
||||
session.commit()
|
||||
|
||||
tombstone = CalendarDeletedEvent(
|
||||
uid=ev.uid,
|
||||
owner="alice",
|
||||
calendar_id=ev.calendar_id,
|
||||
remote_href=ev.remote_href,
|
||||
remote_etag=ev.remote_etag,
|
||||
caldav_base_url=cal.caldav_base_url,
|
||||
summary=ev.summary,
|
||||
)
|
||||
session.add(tombstone)
|
||||
session.delete(ev)
|
||||
session.commit()
|
||||
|
||||
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
|
||||
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
|
||||
assert tombstone is not None
|
||||
assert tombstone.remote_href.endswith("evt-delete.ics")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
writeback._persist_writeback_result(
|
||||
"alice",
|
||||
"caldav-test",
|
||||
"evt-delete",
|
||||
{"ok": False, "error": "temporary remote delete failure"},
|
||||
delete=True,
|
||||
)
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
|
||||
assert tombstone is not None
|
||||
assert "temporary remote delete failure" in tombstone.last_error
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
writeback._persist_writeback_result(
|
||||
"alice",
|
||||
"caldav-test",
|
||||
"evt-delete",
|
||||
{"ok": True},
|
||||
delete=True,
|
||||
)
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
assert session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first() is None
|
||||
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
|
||||
finally:
|
||||
session.close()
|
||||
@@ -22,7 +22,9 @@ CAL_ID = _stable_cal_id(REMOTE_URL)
|
||||
|
||||
|
||||
class FakeEvent:
|
||||
def __init__(self):
|
||||
def __init__(self, url="https://p69-caldav.icloud.com/123/calendars/home/evt-1.ics"):
|
||||
self.url = url
|
||||
self.etag = '"abc123"'
|
||||
self.data = "OLD"
|
||||
self.saved = False
|
||||
self.deleted = False
|
||||
@@ -39,6 +41,7 @@ class FakeCalendar:
|
||||
self.url = url
|
||||
self._existing = existing
|
||||
self.saved_ical = None
|
||||
self.created = FakeEvent(str(url).rstrip("/") + "/created.ics")
|
||||
|
||||
def event_by_uid(self, uid):
|
||||
if self._existing is None:
|
||||
@@ -47,6 +50,7 @@ class FakeCalendar:
|
||||
|
||||
def save_event(self, ical):
|
||||
self.saved_ical = ical
|
||||
return self.created
|
||||
|
||||
|
||||
def _ev(**over):
|
||||
@@ -91,6 +95,8 @@ def test_push_create_calls_save_event():
|
||||
res = push_event([cal], CAL_ID, _ev(), delete=False)
|
||||
assert res["ok"] and res.get("created")
|
||||
assert cal.saved_ical and "UID:evt-1" in cal.saved_ical
|
||||
assert res["calendar_url"] == REMOTE_URL
|
||||
assert res["remote_href"].endswith("/created.ics")
|
||||
|
||||
|
||||
def test_push_update_overwrites_existing():
|
||||
@@ -100,6 +106,8 @@ def test_push_update_overwrites_existing():
|
||||
assert res["ok"] and res.get("updated")
|
||||
assert existing.saved and "SUMMARY:Moved" in existing.data
|
||||
assert cal.saved_ical is None # used update path, not create
|
||||
assert res["remote_href"].endswith("evt-1.ics")
|
||||
assert res["remote_etag"] == '"abc123"'
|
||||
|
||||
|
||||
def test_push_delete_removes_existing():
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
import routes.calendar_routes as croutes
|
||||
import src.caldav_writeback as wb
|
||||
import src.caldav_sync as csync
|
||||
from core.database import CalendarCal
|
||||
from routes.calendar_routes import EventCreate
|
||||
|
||||
@@ -39,11 +39,16 @@ croutes.SessionLocal = _TS
|
||||
def calls(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
async def _fake_writeback(owner, source, cal_id, ev, *, delete=False):
|
||||
recorded.append({"source": source, "cal_id": cal_id, "uid": ev.get("uid"), "delete": delete})
|
||||
async def _fake_create(owner, uid):
|
||||
recorded.append({"uid": uid, "delete": False, "action": "create"})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(wb, "writeback_event", _fake_writeback)
|
||||
async def _fake_delete(owner, uid):
|
||||
recorded.append({"uid": uid, "delete": True, "action": "delete"})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(csync, "push_event_create", _fake_create)
|
||||
monkeypatch.setattr(csync, "push_event_delete", _fake_delete)
|
||||
return recorded
|
||||
|
||||
|
||||
@@ -77,7 +82,6 @@ async def test_create_on_caldav_calendar_pushes_to_remote(calls):
|
||||
summary="Dentist", dtstart="2026-06-10T14:00:00Z", calendar_href=cal_id))
|
||||
assert res["ok"] is True
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["source"] == "caldav" and calls[0]["cal_id"] == cal_id
|
||||
assert calls[0]["delete"] is False
|
||||
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ def _install_calendar_db_stub(monkeypatch):
|
||||
db = types.ModuleType("core.database")
|
||||
db.SessionLocal = MagicMock()
|
||||
db.CalendarCal = _CalendarCal
|
||||
db.CalendarDeletedEvent = MagicMock()
|
||||
db.CalendarEvent = _CalendarEvent
|
||||
for name in [
|
||||
"Base",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _import_contacts(tmp_path, monkeypatch):
|
||||
sys.modules.setdefault("core.database", MagicMock())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"routes.contacts_routes.SETTINGS_FILE",
|
||||
tmp_path / "settings.json",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"routes.contacts_routes.DATA_DIR",
|
||||
tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"routes.contacts_routes.LOCAL_CONTACTS_FILE",
|
||||
tmp_path / "contacts.json",
|
||||
)
|
||||
|
||||
sys.modules.pop("src.secret_storage", None)
|
||||
from src import secret_storage
|
||||
monkeypatch.setattr(secret_storage, "_KEY_PATH", tmp_path / ".app_key")
|
||||
monkeypatch.setattr(secret_storage, "_fernet", None)
|
||||
|
||||
sys.modules.pop("routes.contacts_routes", None)
|
||||
from routes import contacts_routes
|
||||
return contacts_routes
|
||||
|
||||
|
||||
def test_carddav_password_encrypted_at_rest(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
settings = contacts._load_settings()
|
||||
password = "my-carddav-secret"
|
||||
from src.secret_storage import encrypt
|
||||
settings["carddav_password"] = encrypt(password)
|
||||
contacts._save_settings(settings)
|
||||
|
||||
raw_text = (tmp_path / "settings.json").read_text(encoding="utf-8")
|
||||
assert password not in raw_text
|
||||
raw = json.loads(raw_text)
|
||||
assert raw["carddav_password"].startswith("enc:")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == password
|
||||
|
||||
|
||||
def test_get_carddav_config_decrypts_encrypted_value(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
from src.secret_storage import encrypt
|
||||
encrypted = encrypt("super-secret")
|
||||
settings = {
|
||||
"carddav_url": "https://carddav.example",
|
||||
"carddav_username": "u",
|
||||
"carddav_password": encrypted,
|
||||
}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["url"] == "https://carddav.example"
|
||||
assert cfg["username"] == "u"
|
||||
assert cfg["password"] == "super-secret"
|
||||
|
||||
|
||||
def test_get_carddav_config_plaintext_legacy_passthrough(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
settings = {
|
||||
"carddav_url": "https://carddav.example",
|
||||
"carddav_username": "u",
|
||||
"carddav_password": "legacy-plaintext",
|
||||
}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == "legacy-plaintext"
|
||||
|
||||
|
||||
def test_get_carddav_config_env_var_passthrough(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
monkeypatch.setenv("CARDDAV_PASSWORD", "env-pass")
|
||||
|
||||
settings = {
|
||||
"carddav_url": "https://carddav.example",
|
||||
"carddav_username": "u",
|
||||
}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == "env-pass"
|
||||
|
||||
|
||||
def test_get_carddav_config_env_var_not_decrypted(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CARDDAV_PASSWORD", "env:plain-value-not-encrypted")
|
||||
settings = {
|
||||
"carddav_url": "https://carddav.example",
|
||||
"carddav_username": "u",
|
||||
}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == "env:plain-value-not-encrypted"
|
||||
|
||||
|
||||
def test_get_carddav_config_empty_password(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
settings = {
|
||||
"carddav_url": "https://carddav.example",
|
||||
"carddav_username": "u",
|
||||
}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == ""
|
||||
|
||||
|
||||
def test_get_carddav_config_no_settings_file(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == ""
|
||||
assert cfg["url"] == ""
|
||||
|
||||
|
||||
def test_double_save_encrypted_value_not_corrupted(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
from src.secret_storage import encrypt
|
||||
password = "persistent-secret"
|
||||
encrypted = encrypt(password)
|
||||
|
||||
settings = {"carddav_password": encrypted}
|
||||
contacts._save_settings(settings)
|
||||
|
||||
settings2 = contacts._load_settings()
|
||||
contacts._save_settings(settings2)
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == password
|
||||
|
||||
|
||||
def test_double_save_re_encrypts_already_encrypted_is_noop(tmp_path, monkeypatch):
|
||||
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||
|
||||
from src.secret_storage import encrypt
|
||||
password = "another-secret"
|
||||
|
||||
settings = contacts._load_settings()
|
||||
settings["carddav_password"] = encrypt(password)
|
||||
contacts._save_settings(settings)
|
||||
|
||||
settings2 = contacts._load_settings()
|
||||
settings2["carddav_password"] = encrypt(settings2["carddav_password"])
|
||||
contacts._save_settings(settings2)
|
||||
|
||||
raw = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8"))
|
||||
assert raw["carddav_password"].startswith("enc:")
|
||||
|
||||
cfg = contacts._get_carddav_config()
|
||||
assert cfg["password"] == password
|
||||
@@ -89,6 +89,9 @@ def test_disabled_tools_does_not_bash_when_allow_bash_is_none():
|
||||
assert "allow_web_search is not None" in source, (
|
||||
"disabled_tools check must guard against allow_web_search being None"
|
||||
)
|
||||
assert "_explicit_web_intent" in source and "not _explicit_web_intent" in source, (
|
||||
"explicit web-search requests must override an off web toggle for that turn"
|
||||
)
|
||||
|
||||
|
||||
# ── Functional tests of the disabled-tools logic ───────────────
|
||||
@@ -99,6 +102,7 @@ def _build_disabled_tools(
|
||||
allow_web_search=None,
|
||||
can_use_bash=True,
|
||||
can_use_browser=True,
|
||||
explicit_web_intent=False,
|
||||
):
|
||||
"""Replicate the disabled-tools logic from chat_stream for unit testing.
|
||||
|
||||
@@ -109,7 +113,11 @@ def _build_disabled_tools(
|
||||
# Issue #3229 fix: only disable when explicitly set to a falsy value.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
if allow_web_search is not None and str(allow_web_search).lower() != "true":
|
||||
if (
|
||||
allow_web_search is not None
|
||||
and str(allow_web_search).lower() != "true"
|
||||
and not explicit_web_intent
|
||||
):
|
||||
disabled_tools.add("web_search")
|
||||
disabled_tools.add("web_fetch")
|
||||
|
||||
@@ -148,6 +156,17 @@ def test_json_body_allow_web_search_false_disables_web():
|
||||
assert "web_fetch" in disabled
|
||||
|
||||
|
||||
def test_explicit_web_intent_overrides_false_web_toggle_for_turn():
|
||||
"""A stale/off web toggle must not remove web tools when the message
|
||||
explicitly asks to use web search."""
|
||||
disabled = _build_disabled_tools(
|
||||
allow_web_search="false",
|
||||
explicit_web_intent=True,
|
||||
)
|
||||
assert "web_search" not in disabled
|
||||
assert "web_fetch" not in disabled
|
||||
|
||||
|
||||
def test_admin_user_gets_bash_enabled_by_default():
|
||||
"""When allow_bash is not set and user has can_use_bash privilege,
|
||||
bash must NOT be disabled.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The Codex cookbook bridge resolves a task's SSH target (remoteHost / sshPort)
|
||||
from cookbook_state.json and interpolates it into an ``ssh ...`` command string
|
||||
that runs through a shell. The command body is shlex-quoted, but the host and
|
||||
port were not validated, so a tampered task entry carrying shell metacharacters
|
||||
in ``remoteHost`` would be injected into that command.
|
||||
|
||||
These pin validation on the host/port before they reach the ssh string, matching
|
||||
the validators the rest of the cookbook routes already apply.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import routes.codex_routes as codex_routes
|
||||
|
||||
|
||||
def test_rejects_remote_host_with_shell_metacharacters():
|
||||
task = {"remoteHost": "box; rm -rf ~", "sshPort": ""}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
codex_routes._ssh_prefix_for_task(task)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_rejects_non_numeric_ssh_port():
|
||||
task = {"remoteHost": "box", "sshPort": "22; evil"}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
codex_routes._ssh_prefix_for_task(task)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_local_task_has_no_host():
|
||||
host, port_flag = codex_routes._ssh_prefix_for_task({})
|
||||
assert host == ""
|
||||
assert port_flag == ""
|
||||
|
||||
|
||||
def test_valid_remote_builds_port_flag():
|
||||
host, port_flag = codex_routes._ssh_prefix_for_task(
|
||||
{"remoteHost": "user@box", "sshPort": "2222"}
|
||||
)
|
||||
assert host == "user@box"
|
||||
assert port_flag == "-p 2222 "
|
||||
|
||||
|
||||
def test_default_ssh_port_omits_flag():
|
||||
host, port_flag = codex_routes._ssh_prefix_for_task(
|
||||
{"remoteHost": "box", "sshPort": "22"}
|
||||
)
|
||||
assert host == "box"
|
||||
assert port_flag == ""
|
||||
@@ -47,11 +47,11 @@ def test_is_setting_overridden_reads_raw_saved_file(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configurable hard_max — completes the reviewer requirement from #1190 that
|
||||
# was carried over but not implemented in #1230: the ceiling on the auto-
|
||||
# derived path should be a setting, not a hidden constant. Without this,
|
||||
# admins on premium APIs with very large windows (1M+ context) can only
|
||||
# raise the ceiling by editing src/context_budget.py.
|
||||
# Configurable hard_max — the ceiling on the auto-derived path is a setting
|
||||
# (`agent_input_token_hard_max`), not a hidden constant. History: a reviewer
|
||||
# required it on #1190, the merged #1230 shipped without it, and #1273 added it.
|
||||
# This test pins the function-level override (the `hard_max` parameter); without
|
||||
# a raisable ceiling, admins on 1M+ context APIs would be stuck at the 200K default.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_custom_hard_max_overrides_default_in_auto_branch():
|
||||
|
||||
@@ -13,7 +13,7 @@ def _setup(monkeypatch, windows):
|
||||
"""windows: {endpoint_url: context_length}. Force the remote path."""
|
||||
monkeypatch.setattr(mc, "is_local_endpoint", lambda url: False)
|
||||
monkeypatch.setattr(mc, "_configured_endpoint_kind", lambda url: "api")
|
||||
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: windows[url])
|
||||
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: (windows[url], True))
|
||||
mc._context_cache.clear()
|
||||
|
||||
|
||||
@@ -34,6 +34,6 @@ def test_cache_hit_still_works_per_endpoint(monkeypatch):
|
||||
|
||||
# Both endpoints are now cached under their own key; flip the underlying
|
||||
# query to prove subsequent reads come from the per-endpoint cache, not a re-query.
|
||||
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: 999)
|
||||
monkeypatch.setattr(mc, "_query_context_length", lambda url, model: (999, True))
|
||||
assert mc.get_context_length(a, "shared-model") == 8000
|
||||
assert mc.get_context_length(b, "shared-model") == 200000
|
||||
|
||||
@@ -15,6 +15,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "static/js/cookbook.js"
|
||||
SERVE_SRC = Path(__file__).resolve().parent.parent / "static/js/cookbookServe.js"
|
||||
|
||||
|
||||
def test_cpu_only_drops_gpu_only_flags():
|
||||
@@ -28,3 +29,25 @@ def test_cpu_only_drops_gpu_only_flags():
|
||||
# The CUDA unified-memory env must be suppressed for CPU-only too.
|
||||
assert "f.unified_mem && !_cpuOnly" in text, \
|
||||
"GGML_CUDA_ENABLE_UNIFIED_MEMORY must be gated on !_cpuOnly"
|
||||
|
||||
|
||||
def test_diffusers_is_not_blocked_on_windows_dependencies_panel():
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
|
||||
assert "const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);" in text
|
||||
assert "new Set(['diffusers'" not in text
|
||||
|
||||
|
||||
def test_diffusers_is_available_on_windows_serve_panel():
|
||||
text = SERVE_SRC.read_text(encoding="utf-8")
|
||||
|
||||
assert "? ['llamacpp', 'diffusers']" in text
|
||||
assert "? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]" in text
|
||||
|
||||
|
||||
def test_windows_diffusers_uses_python_not_python3():
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
|
||||
assert "const diffusersPy = _isWindows() ? 'python' : _py3Bin;" in text
|
||||
assert "cmd += `${diffusersPy} scripts/diffusion_server.py" in text
|
||||
assert "cmd += `python3 scripts/diffusion_server.py" not in text
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Behavioral guards for dead-session download classification (issue #4017).
|
||||
|
||||
A download whose tmux pane is gone must not be reported as stopped when its
|
||||
retained output carries DOWNLOAD_OK, or when the files landed in a custom
|
||||
download dir. The runner exports HF_HOME=<local_dir>, so the cache lives
|
||||
under <local_dir>/hub — the probe only finds it if the task's dir is passed
|
||||
in explicitly rather than read from the probe process's environment.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from routes.cookbook_output import (
|
||||
classify_dead_download,
|
||||
HF_CACHE_COMPLETE_PROBE,
|
||||
HF_CACHE_INCOMPLETE_PROBE,
|
||||
)
|
||||
|
||||
REPO = "org/some-model-GGUF"
|
||||
|
||||
|
||||
# ── Marker classification ──
|
||||
|
||||
|
||||
def test_download_ok_resolves_completed():
|
||||
snap = "Fetching 4 files: 100%|####| 4/4\nDownload complete\n\nDOWNLOAD_OK\n$"
|
||||
assert classify_dead_download(snap) == ("completed", False)
|
||||
|
||||
|
||||
def test_download_failed_resolves_error():
|
||||
snap = "some progress\n\nDOWNLOAD_FAILED (exit 1 after 3 attempts)"
|
||||
assert classify_dead_download(snap) == ("error", False)
|
||||
|
||||
|
||||
def test_download_ok_with_zero_files_resolves_error():
|
||||
# A DOWNLOAD_OK from a run that matched no files (bad include/quant
|
||||
# pattern) is still a failure — same guard as the live-session branch.
|
||||
snap = "Fetching 0 files: 0it [00:00, ?it/s]\n\nDOWNLOAD_OK"
|
||||
assert classify_dead_download(snap) == ("error", True)
|
||||
|
||||
|
||||
def test_no_marker_returns_none():
|
||||
# Mid-download tail with no terminal marker — caller must fall back to
|
||||
# the cache probe.
|
||||
assert classify_dead_download("Downloading model.gguf: 42%") is None
|
||||
assert classify_dead_download("") is None
|
||||
|
||||
|
||||
def test_ollama_pull_output_resolves_completed():
|
||||
snap = "pulling manifest\npulling 8f39d1c3...: 100%\nsuccess\n\nDOWNLOAD_OK"
|
||||
assert classify_dead_download(snap) == ("completed", False)
|
||||
|
||||
|
||||
# ── Cache probe scripts ──
|
||||
|
||||
|
||||
def _make_cache(root, repo=REPO, incomplete=False, empty_snapshot=False):
|
||||
d = os.path.join(root, "hub", "models--" + repo.replace("/", "--"))
|
||||
snap = os.path.join(d, "snapshots", "abc123")
|
||||
os.makedirs(snap)
|
||||
if not empty_snapshot:
|
||||
with open(os.path.join(snap, "model.gguf"), "w") as f:
|
||||
f.write("x")
|
||||
if incomplete:
|
||||
blobs = os.path.join(d, "blobs")
|
||||
os.makedirs(blobs)
|
||||
with open(os.path.join(blobs, "deadbeef.incomplete"), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
|
||||
def _run_probe(probe, repo, cache_root, env=None):
|
||||
# Strip the HF cache vars so the probe can't accidentally find a real
|
||||
# cache on the machine running the tests.
|
||||
full_env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("HF_HOME", "HUGGINGFACE_HUB_CACHE", "HF_HUB_CACHE")}
|
||||
full_env.update(env or {})
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", probe, repo, cache_root],
|
||||
env=full_env, capture_output=True, timeout=30,
|
||||
).returncode
|
||||
|
||||
|
||||
def test_complete_probe_finds_custom_dir_cache(tmp_path):
|
||||
# Model materialized under <local_dir>/hub — found only via the explicit
|
||||
# cache_root argument (issue #4017).
|
||||
root = str(tmp_path)
|
||||
_make_cache(root)
|
||||
assert _run_probe(HF_CACHE_COMPLETE_PROBE, REPO, root) == 0
|
||||
|
||||
|
||||
def test_complete_probe_misses_without_cache_root(tmp_path):
|
||||
# Same on-disk layout, but without the cache_root argument the probe
|
||||
# falls back to the default cache and misses it.
|
||||
_make_cache(str(tmp_path))
|
||||
assert _run_probe(HF_CACHE_COMPLETE_PROBE, REPO, "") == 1
|
||||
|
||||
|
||||
def test_complete_probe_rejects_incomplete_blobs(tmp_path):
|
||||
root = str(tmp_path)
|
||||
_make_cache(root, incomplete=True)
|
||||
assert _run_probe(HF_CACHE_COMPLETE_PROBE, REPO, root) == 1
|
||||
|
||||
|
||||
def test_complete_probe_rejects_empty_snapshot(tmp_path):
|
||||
root = str(tmp_path)
|
||||
_make_cache(root, empty_snapshot=True)
|
||||
assert _run_probe(HF_CACHE_COMPLETE_PROBE, REPO, root) == 1
|
||||
|
||||
|
||||
def test_complete_probe_env_fallback_still_works(tmp_path):
|
||||
# No custom dir on the task — the probe must keep honoring the standard
|
||||
# HF env vars so default-cache downloads classify as before.
|
||||
root = str(tmp_path)
|
||||
_make_cache(root)
|
||||
hub = os.path.join(root, "hub")
|
||||
assert _run_probe(HF_CACHE_COMPLETE_PROBE, REPO, "", env={"HUGGINGFACE_HUB_CACHE": hub}) == 0
|
||||
|
||||
|
||||
def test_incomplete_probe_sees_custom_dir_partials(tmp_path):
|
||||
root = str(tmp_path)
|
||||
_make_cache(root, incomplete=True)
|
||||
assert _run_probe(HF_CACHE_INCOMPLETE_PROBE, REPO, root) == 0
|
||||
# Clean cache → no resumable partials.
|
||||
assert _run_probe(HF_CACHE_INCOMPLETE_PROBE, "org/other-model", root) == 1
|
||||
@@ -74,7 +74,23 @@ def test_background_poll_recovers_done_for_stopped_dependency_install():
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);" in source
|
||||
assert "depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source
|
||||
assert "(depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source
|
||||
|
||||
|
||||
def test_background_poll_recovers_done_for_completed_download():
|
||||
"""When the backend reports a finished model download as "stopped" (its
|
||||
tmux pane is gone after DOWNLOAD_OK, so the dead-session check can miss the
|
||||
landed snapshot), the reconciler must recover "done" from the terminal
|
||||
DOWNLOAD_OK sentinel instead of downgrading the card to crashed. The
|
||||
background poll keys off DOWNLOAD_OK only (not the "/snapshots/" path, which
|
||||
can appear mid-stream for multi-file downloads)."""
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
normalized = " ".join(source.split())
|
||||
assert (
|
||||
"const downloadDone = task.type === 'download' "
|
||||
"&& String(task.output || '').includes('DOWNLOAD_OK');"
|
||||
) in normalized
|
||||
|
||||
|
||||
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
||||
|
||||
@@ -13,3 +13,24 @@ def test_diagnose_vllm_modelopt_lm_head_error():
|
||||
assert "ModelOpt LM-head" in diagnosis["message"]
|
||||
assert diagnosis["suggestions"][0]["op"] == "manual"
|
||||
assert "provides this CLI" in diagnosis["suggestions"][0]["label"]
|
||||
|
||||
|
||||
def test_diagnose_sglang_native_dependency_errors():
|
||||
output = """
|
||||
/tmp/cuda_utils.c:7:10: fatal error: Python.h: No such file or directory
|
||||
ImportError:
|
||||
[sgl_kernel] CRITICAL: Could not load any common_ops library!
|
||||
Please ensure sgl_kernel is properly installed with:
|
||||
pip install --upgrade sglang-kernel
|
||||
Error details from previous import attempts:
|
||||
- ImportError: libnuma.so.1: cannot open shared object file
|
||||
"""
|
||||
|
||||
diagnosis = _diagnose_serve_output(output)
|
||||
|
||||
assert diagnosis is not None
|
||||
assert "SGLang native dependencies" in diagnosis["message"]
|
||||
labels = [suggestion["label"] for suggestion in diagnosis["suggestions"]]
|
||||
assert any("libnuma-dev" in label for label in labels)
|
||||
assert any("python3.12-dev" in label for label in labels)
|
||||
assert any("sglang-kernel" in label for label in labels)
|
||||
|
||||
@@ -10,3 +10,13 @@ def test_repair_kernels_pip_spec_is_shell_quoted():
|
||||
|
||||
assert '"kernels<0.15"' in source
|
||||
assert " --break-system-packages kernels<0.15" not in source
|
||||
|
||||
|
||||
def test_sglang_native_dependency_diagnosis_is_exposed_to_browser():
|
||||
source = DIAGNOSIS_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert r"Python\.h" in source
|
||||
assert r"libnuma\.so\.1" in source
|
||||
assert "SGLang native dependencies" in source
|
||||
assert "libnuma-dev python3.12-dev build-essential" in source
|
||||
assert "sglang-kernel" in source
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -21,6 +22,7 @@ from routes.cookbook_helpers import (
|
||||
_safe_env_prefix,
|
||||
_user_shell_path_bootstrap,
|
||||
_venv_safe_local_pip_install_cmd,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
_validate_gpus,
|
||||
_validate_local_dir,
|
||||
_validate_repo_id,
|
||||
@@ -549,6 +551,35 @@ def test_validate_serve_cmd_accepts_windows_printf_format():
|
||||
assert _validate_serve_cmd(cmd) == cmd
|
||||
|
||||
|
||||
def test_normalize_llama_cpp_python_cache_types_for_stale_client_cmd():
|
||||
cmd = (
|
||||
"python -m llama_cpp.server --model model.gguf --host 0.0.0.0 --port 8000 "
|
||||
"--type_k q4_0 --type_v q4_0"
|
||||
)
|
||||
|
||||
assert _normalize_llama_cpp_python_cache_types(cmd).endswith("--type_k 2 --type_v 2")
|
||||
|
||||
|
||||
def test_normalize_llama_cpp_python_cache_types_preserves_native_cache_flags():
|
||||
cmd = (
|
||||
"llama-server --model model.gguf --cache-type-k q4_0 --cache-type-v q4_0 "
|
||||
"|| python3 -m llama_cpp.server --model model.gguf --type_k=q8_0 --type_v='f16'"
|
||||
)
|
||||
|
||||
normalized = _normalize_llama_cpp_python_cache_types(cmd)
|
||||
assert "--cache-type-k q4_0 --cache-type-v q4_0" in normalized
|
||||
assert "--type_k=8" in normalized
|
||||
assert "--type_v='1'" in normalized
|
||||
|
||||
|
||||
def test_model_serve_normalizes_llama_cpp_python_cache_types_after_validation():
|
||||
src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "req.cmd = _validate_serve_cmd(req.cmd) or \"\"" in src
|
||||
assert "req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or \"\"" in src
|
||||
assert src.index("_validate_serve_cmd(req.cmd)") < src.index("_normalize_llama_cpp_python_cache_types(req.cmd)")
|
||||
|
||||
|
||||
def test_ollama_serve_defaults_to_loopback_bind():
|
||||
assert _ollama_bind_from_cmd("ollama serve") == ("127.0.0.1", "11434")
|
||||
assert _ollama_bind_from_cmd("ollama run qwen2.5:0.5b") == ("127.0.0.1", "11434")
|
||||
@@ -588,6 +619,8 @@ def test_llama_cpp_linux_bootstrap_prefers_rocm_before_cuda():
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
script = "\n".join(runner_lines)
|
||||
|
||||
assert "mkdir -p ~/bin" in script
|
||||
assert script.index("mkdir -p ~/bin") < script.index("cd ~/llama.cpp && rm -rf build")
|
||||
assert 'command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]' in script
|
||||
assert 'cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_HIP=ON' in script
|
||||
assert 'cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON' in script
|
||||
@@ -673,6 +706,16 @@ def test_llama_cpp_rebuild_cmd_clears_cached_build_paths():
|
||||
assert 'curl' not in cmd and 'wget' not in cmd
|
||||
|
||||
|
||||
def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
|
||||
routes_src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
running_src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbookRunning.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
|
||||
assert "function Stop-Tree([int]$Id)" in running_src
|
||||
assert "ParentProcessId = $Id" in running_src
|
||||
assert "Stop-Tree ([int]$p)" in running_src
|
||||
|
||||
|
||||
def test_llama_cpp_rebuild_cmd_runs_clean_on_a_fresh_home(tmp_path):
|
||||
"""The command should succeed even when neither path exists yet."""
|
||||
import os
|
||||
|
||||
@@ -23,6 +23,7 @@ def test_llama_cpp_maps_to_llama_cpp_python_distribution():
|
||||
|
||||
def test_extras_and_version_markers_are_stripped():
|
||||
assert _pip_dist_name({"name": "diffusers", "pip": "diffusers[torch]"}) == "diffusers"
|
||||
assert _pip_dist_name({"name": "transformers", "pip": "transformers"}) == "transformers"
|
||||
assert _pip_dist_name({"name": "sglang", "pip": "sglang[all]"}) == "sglang"
|
||||
assert _pip_dist_name({"name": "rembg", "pip": "rembg[gpu]"}) == "rembg"
|
||||
assert _pip_dist_name({"name": "x", "pip": "foo>=1.2,<2"}) == "foo"
|
||||
@@ -48,3 +49,11 @@ def test_route_uses_dist_name_helper_not_munged_import_name():
|
||||
src = (Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py").read_text(encoding="utf-8")
|
||||
assert "importlib_metadata.version(_pip_dist_name(pkg))" in src
|
||||
assert 'importlib_metadata.version(pkg["name"].replace("_", "-"))' not in src
|
||||
|
||||
|
||||
def test_transformers_is_listed_as_image_dependency():
|
||||
src = (Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert '"name": "transformers"' in src
|
||||
assert '"pip": "transformers"' in src
|
||||
assert '"transformers",' in src
|
||||
|
||||
@@ -12,8 +12,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text()
|
||||
STYLE_CSS = (ROOT / "static/style.css").read_text()
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_document_textarea_scrollbar_is_visible():
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The gallery image-edit proxies (inpaint, harmonize) accept an upstream
|
||||
diffusion / OpenAI response that may carry an image *URL* instead of inline
|
||||
base64, and then fetch that URL server-side. That URL is controlled by whatever
|
||||
server the request was sent to, so a malicious or compromised endpoint can
|
||||
return e.g. ``http://169.254.169.254/...`` and turn the result fetch into an
|
||||
SSRF primitive (cloud-metadata credential exfil).
|
||||
|
||||
The client-supplied ``_endpoint`` is already validated through
|
||||
``check_outbound_url`` before the first request; this pins the same guard on the
|
||||
*result* URL pulled from the response body, which previously went unchecked.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code: int, content: bytes = b""):
|
||||
self.status_code = status_code
|
||||
self.content = content
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
instances: list["_FakeAsyncClient"] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.gets: list[str] = []
|
||||
_FakeAsyncClient.instances.append(self)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
self.gets.append(url)
|
||||
return _FakeResp(200, b"PNGDATA")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_httpx(monkeypatch):
|
||||
import httpx
|
||||
|
||||
_FakeAsyncClient.instances = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
|
||||
|
||||
async def test_rejects_link_local_result_url():
|
||||
# A compromised upstream returns the cloud-metadata address as the image
|
||||
# URL. The helper must refuse it and never issue the fetch.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await gallery_routes._fetch_result_image_b64(
|
||||
"http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
assert exc.value.status_code == 502
|
||||
assert all(c.gets == [] for c in _FakeAsyncClient.instances), (
|
||||
"the unsafe result URL must not be fetched"
|
||||
)
|
||||
|
||||
|
||||
async def test_fetches_safe_result_url():
|
||||
# A normal loopback/LAN diffusion server result URL is allowed (local-first)
|
||||
# and returned base64-encoded, matching the prior inline behavior.
|
||||
out = await gallery_routes._fetch_result_image_b64("http://127.0.0.1/img.png")
|
||||
assert out == base64.b64encode(b"PNGDATA").decode()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for Cookbook hardware probe context and container visibility warnings."""
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
|
||||
|
||||
@pytest.mark.area_services
|
||||
@pytest.mark.area_unit
|
||||
def test_container_no_gpu_gets_visibility_warning(monkeypatch):
|
||||
"""Warn when a containerized local probe cannot see a GPU."""
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: True)
|
||||
|
||||
result = {
|
||||
"total_ram_gb": 7.7,
|
||||
"available_ram_gb": 6.4,
|
||||
"cpu_cores": 12,
|
||||
"cpu_name": "Test CPU",
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"backend": "cpu_x86",
|
||||
"gpu_error": None,
|
||||
}
|
||||
|
||||
out = hardware._attach_probe_context(result, host="")
|
||||
|
||||
assert out["containerized"] is True
|
||||
assert out["probe_scope"] == "container"
|
||||
assert out["hardware_visibility_warning"]["code"] == "container_no_gpu_visible"
|
||||
assert "manual_hardware" in out["hardware_visibility_warning"]["actions"]
|
||||
|
||||
|
||||
@pytest.mark.area_services
|
||||
@pytest.mark.area_unit
|
||||
def test_native_no_gpu_does_not_get_container_warning(monkeypatch):
|
||||
"""Do not warn for a native local probe that genuinely has no GPU."""
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
|
||||
|
||||
result = {
|
||||
"total_ram_gb": 16,
|
||||
"available_ram_gb": 10,
|
||||
"cpu_cores": 12,
|
||||
"cpu_name": "Test CPU",
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"backend": "cpu_x86",
|
||||
"gpu_error": None,
|
||||
}
|
||||
|
||||
out = hardware._attach_probe_context(result, host="")
|
||||
|
||||
assert out["containerized"] is False
|
||||
assert out["probe_scope"] == "native"
|
||||
assert "hardware_visibility_warning" not in out
|
||||
|
||||
|
||||
@pytest.mark.area_services
|
||||
@pytest.mark.area_unit
|
||||
def test_remote_probe_does_not_get_local_container_warning(monkeypatch):
|
||||
"""Do not apply local container warnings to remote hardware probes."""
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: True)
|
||||
|
||||
result = {
|
||||
"total_ram_gb": 16,
|
||||
"available_ram_gb": 10,
|
||||
"cpu_cores": 12,
|
||||
"cpu_name": "Remote CPU",
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"backend": "cpu_x86",
|
||||
"gpu_error": None,
|
||||
}
|
||||
|
||||
out = hardware._attach_probe_context(result, host="user@example.com")
|
||||
|
||||
assert out["containerized"] is False
|
||||
assert out["probe_scope"] == "remote"
|
||||
assert "hardware_visibility_warning" not in out
|
||||
|
||||
|
||||
@pytest.mark.area_services
|
||||
@pytest.mark.area_unit
|
||||
def test_gpu_driver_error_does_not_show_container_no_gpu_warning(monkeypatch):
|
||||
"""Preserve GPU driver errors instead of replacing them with Docker warnings."""
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: True)
|
||||
|
||||
result = {
|
||||
"total_ram_gb": 16,
|
||||
"available_ram_gb": 10,
|
||||
"cpu_cores": 12,
|
||||
"cpu_name": "Test CPU",
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"backend": "cpu_x86",
|
||||
"gpu_error": "NVIDIA driver/library version mismatch",
|
||||
}
|
||||
|
||||
out = hardware._attach_probe_context(result, host="")
|
||||
|
||||
assert out["containerized"] is True
|
||||
assert out["probe_scope"] == "container"
|
||||
assert "hardware_visibility_warning" not in out
|
||||
@@ -1,4 +1,8 @@
|
||||
import json
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import integrations
|
||||
|
||||
@@ -9,3 +13,117 @@ def test_load_integrations_skips_non_object_rows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(integrations, "DATA_FILE", str(data_file))
|
||||
|
||||
assert integrations.load_integrations() == [{"id": "good", "name": "Good"}]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integrations_routes(tmp_path, monkeypatch):
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from routes import auth_routes
|
||||
|
||||
monkeypatch.setattr(integrations, "DATA_FILE", str(tmp_path / "integrations.json"))
|
||||
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
||||
|
||||
class _AuthManager:
|
||||
def get_username_for_token(self, token):
|
||||
return "admin" if token == "session-token" else None
|
||||
|
||||
def is_admin(self, user):
|
||||
return user == "admin"
|
||||
|
||||
router = auth_routes.setup_auth_routes(_AuthManager())
|
||||
|
||||
def endpoint(path, method):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not registered")
|
||||
|
||||
return endpoint, auth_routes.SESSION_COOKIE, fastapi.HTTPException
|
||||
|
||||
|
||||
class _JsonRequest(SimpleNamespace):
|
||||
def __init__(self, body, session_cookie):
|
||||
super().__init__(
|
||||
cookies={session_cookie: "session-token"},
|
||||
client=SimpleNamespace(host="127.0.0.1"),
|
||||
_body=body,
|
||||
)
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_name", ["", " "])
|
||||
def test_create_integration_rejects_blank_name_without_persisting(integrations_routes, blank_name):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
create_integration = endpoint("/api/auth/integrations", "POST")
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(create_integration(
|
||||
_JsonRequest({"name": blank_name, "base_url": "https://example.test"}, session_cookie)
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Integration name is required"
|
||||
assert integrations.load_integrations() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_base_url", ["", " "])
|
||||
def test_create_integration_rejects_blank_base_url_without_persisting(integrations_routes, blank_base_url):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
create_integration = endpoint("/api/auth/integrations", "POST")
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(create_integration(
|
||||
_JsonRequest({"name": "Example", "base_url": blank_base_url}, session_cookie)
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Integration base URL is required"
|
||||
assert integrations.load_integrations() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_name", ["", " "])
|
||||
def test_update_integration_rejects_blank_name_without_changing_existing(integrations_routes, blank_name):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
update_integration = endpoint("/api/auth/integrations/{integration_id}", "PUT")
|
||||
integrations.save_integrations([
|
||||
{
|
||||
"id": "existing",
|
||||
"name": "Original",
|
||||
"base_url": "https://example.test",
|
||||
}
|
||||
])
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(update_integration(
|
||||
integration_id="existing",
|
||||
request=_JsonRequest({"name": blank_name}, session_cookie),
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Integration name is required"
|
||||
assert integrations.load_integrations()[0]["name"] == "Original"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_base_url", ["", " "])
|
||||
def test_update_integration_rejects_blank_base_url_without_changing_existing(integrations_routes, blank_base_url):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
update_integration = endpoint("/api/auth/integrations/{integration_id}", "PUT")
|
||||
integrations.save_integrations([
|
||||
{
|
||||
"id": "existing",
|
||||
"name": "Original",
|
||||
"base_url": "https://example.test",
|
||||
}
|
||||
])
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(update_integration(
|
||||
integration_id="existing",
|
||||
request=_JsonRequest({"base_url": blank_base_url}, session_cookie),
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Integration base URL is required"
|
||||
assert integrations.load_integrations()[0]["base_url"] == "https://example.test"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Kimi Code host-allowlist behavior (follow-up to provider support).
|
||||
|
||||
Kimi Code (https://api.kimi.com/coding/v1) is a subscription, OpenAI-compatible
|
||||
cloud API with native tool-calling. These tests pin the three host-list integrations:
|
||||
- agent loop sends native tool schemas to Kimi Code (not fenced-block parsing),
|
||||
- teacher escalation treats Kimi Code as SOTA (loop OFF, no added latency).
|
||||
"""
|
||||
from src import agent_loop, teacher_escalation
|
||||
|
||||
|
||||
class TestAgentToolHosts:
|
||||
def test_kimi_code_in_api_hosts(self):
|
||||
assert "api.kimi.com" in agent_loop._API_HOSTS
|
||||
|
||||
def test_kimi_code_url_matches_api_host(self):
|
||||
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||
assert any(h in url for h in agent_loop._API_HOSTS)
|
||||
|
||||
def test_unknown_host_not_matched(self):
|
||||
url = "https://example.invalid/v1/chat/completions"
|
||||
assert not any(h in url for h in agent_loop._API_HOSTS)
|
||||
|
||||
|
||||
class TestTeacherEscalationSota:
|
||||
def test_kimi_code_is_sota_not_self_hosted(self):
|
||||
assert teacher_escalation.is_self_hosted("https://api.kimi.com/coding/v1/chat/completions") is False
|
||||
|
||||
def test_known_cloud_still_sota(self):
|
||||
assert teacher_escalation.is_self_hosted("https://api.openai.com/v1") is False
|
||||
|
||||
def test_local_endpoint_still_self_hosted(self):
|
||||
assert teacher_escalation.is_self_hosted("http://localhost:8000/v1") is True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user