mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 07:12:21 +02:00
Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c88c27c8 | ||
|
|
6edd771cc9 | ||
|
|
938251000b | ||
|
|
b52296471b | ||
|
|
53869d194d | ||
|
|
17ee856d1c | ||
|
|
e7eddbae13 | ||
|
|
93eb10d4f0 | ||
|
|
3f9633c44f | ||
|
|
858c872832 | ||
|
|
1939a6ad2d | ||
|
|
937c883c41 | ||
|
|
e0615cda47 | ||
|
|
1976fe1b60 | ||
|
|
adfe3ab379 | ||
|
|
d87a913729 | ||
|
|
bea48c749c | ||
|
|
5a016e492c | ||
|
|
93653120d6 | ||
|
|
c2b9666def | ||
|
|
1183fe0ff1 | ||
|
|
663d6879b7 | ||
|
|
3bea7a53ee | ||
|
|
22e0af2a58 | ||
|
|
c00ef8f9c2 | ||
|
|
1fef4929cf | ||
|
|
651bf714de | ||
|
|
d449a9d431 | ||
|
|
dbeed4b63f | ||
|
|
96aca52094 | ||
|
|
8f2f483725 | ||
|
|
42da399b4d | ||
|
|
48cf08328f | ||
|
|
e4fa4ae5dd | ||
|
|
378518f6df | ||
|
|
f06a0a30a8 | ||
|
|
99566d28b5 | ||
|
|
f1e96d102e | ||
|
|
36d4098421 | ||
|
|
5ddef23d94 | ||
|
|
c8a012d4d2 | ||
|
|
20e7fc0164 | ||
|
|
9d686180dd | ||
|
|
bb719f217a | ||
|
|
fb8c391a88 | ||
|
|
0de76c4056 | ||
|
|
25c9e735ef | ||
|
|
28c333e647 | ||
|
|
84709a00d9 | ||
|
|
578312200a | ||
|
|
f23221420f | ||
|
|
6a84398e75 | ||
|
|
3250a4ce68 | ||
|
|
cb0f6af002 | ||
|
|
9297bed5b9 | ||
|
|
2e631ad816 | ||
|
|
d183fe545b | ||
|
|
9914651cc9 | ||
|
|
46905ab9b0 | ||
|
|
61c138d9e7 | ||
|
|
25a4d134b1 | ||
|
|
98e4d8451b | ||
|
|
5104a9a967 | ||
|
|
01790c2f08 | ||
|
|
e222e92153 | ||
|
|
b91f48f50a | ||
|
|
d96c7af3df | ||
|
|
d8a2059df8 | ||
|
|
4c9a8ca115 | ||
|
|
d49629fa14 | ||
|
|
65987fc772 | ||
|
|
dcc7e52a86 | ||
|
|
93f97e74cd | ||
|
|
16b04a9792 | ||
|
|
b07c1e3b33 | ||
|
|
fe0748308e | ||
|
|
483c42bb12 | ||
|
|
833cdbd140 | ||
|
|
b7d3f2a28d | ||
|
|
cc4c7f4263 | ||
|
|
88e0ce3037 | ||
|
|
1aad1db9f6 | ||
|
|
d05900cb90 | ||
|
|
b3f8b77317 | ||
|
|
23ac3e3e82 | ||
|
|
5f481e7db1 | ||
|
|
0d47b78f47 | ||
|
|
b9cafd67a1 | ||
|
|
b4e5ad088a | ||
|
|
4239bc850b | ||
|
|
0f259ea5f6 | ||
|
|
27ecd41cde | ||
|
|
3718e61c40 | ||
|
|
a57dd37005 | ||
|
|
45b6771e73 | ||
|
|
98bcb64192 | ||
|
|
0d1d28c7d6 | ||
|
|
c7b3b84f24 | ||
|
|
bff38a4406 | ||
|
|
c2d2075833 | ||
|
|
a02f8d8600 | ||
|
|
d02565ce32 | ||
|
|
524fa9dce2 | ||
|
|
b3432873fb | ||
|
|
1c61c358cb | ||
|
|
e5ef8cf4bf | ||
|
|
410ad9a2fa | ||
|
|
c0b032fa10 | ||
|
|
c161866199 | ||
|
|
6725d1863c | ||
|
|
99f0facc2c | ||
|
|
7a8f47e4ab | ||
|
|
a6c457f74e | ||
|
|
a4e66bb59f | ||
|
|
a6efea5486 | ||
|
|
565c69f40d | ||
|
|
06e038f00e | ||
|
|
0cb8db4de4 | ||
|
|
851bf4d0c8 | ||
|
|
a0a24058bb | ||
|
|
582a51054d | ||
|
|
21c8053505 | ||
|
|
109301be78 | ||
|
|
93c2501a6d | ||
|
|
dadf178ed5 | ||
|
|
30e87e3b82 | ||
|
|
db1170e63a | ||
|
|
d9f6341f01 |
@@ -130,6 +130,27 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Google OAuth2 (Google Workspace / .edu email accounts)
|
||||||
|
# ============================================================
|
||||||
|
# Required to use the "Connect with Google" OAuth flow in email account setup.
|
||||||
|
# Create credentials at: console.cloud.google.com → APIs & Services → Credentials
|
||||||
|
# 1. Enable the Gmail API for your project.
|
||||||
|
# 2. Configure the OAuth consent screen (User Type: Internal for Workspace orgs).
|
||||||
|
# Add scopes: https://mail.google.com/ and email.
|
||||||
|
# 3. Create an OAuth 2.0 Client ID (type: Web application).
|
||||||
|
# Add your redirect URI: http://localhost:7000/api/email/oauth/google/callback
|
||||||
|
# (replace host/port for hosted installs).
|
||||||
|
# 4. Copy the Client ID and Client Secret below.
|
||||||
|
#
|
||||||
|
# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
# GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret
|
||||||
|
#
|
||||||
|
# Set this explicitly for HTTPS, reverse-proxy, or hosted deployments. The
|
||||||
|
# value must exactly match an authorized redirect URI in the Google client.
|
||||||
|
# Local HTTP setups may use the callback URL inferred by the application.
|
||||||
|
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Misc
|
# Misc
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -168,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
||||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
||||||
|
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Host Docker access (explicit opt-in)
|
# Host Docker access (explicit opt-in)
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues)
|
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
||||||
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first.
|
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
||||||
Duplicate reports slow things down.
|
Duplicate reports slow things down.
|
||||||
|
|
||||||
For security vulnerabilities, **do not open a public issue** —
|
For security vulnerabilities, **do not open a public issue** —
|
||||||
use [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new)
|
use [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new)
|
||||||
and read [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md) first.
|
and read [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md) first.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and did not find an existing report of this bug.
|
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/odysseus-dev/odysseus/discussions) and did not find an existing report of this bug.
|
||||||
required: true
|
required: true
|
||||||
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).)
|
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md).)
|
||||||
required: true
|
required: true
|
||||||
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Question / Need Help
|
- name: Question / Need Help
|
||||||
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/q-a
|
url: https://github.com/odysseus-dev/odysseus/discussions/categories/q-a
|
||||||
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
|
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
|
||||||
|
|
||||||
- name: Idea or Suggestion
|
- name: Idea or Suggestion
|
||||||
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas
|
url: https://github.com/odysseus-dev/odysseus/discussions/categories/ideas
|
||||||
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
|
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
|
||||||
|
|
||||||
- name: Security Vulnerability
|
- name: Security Vulnerability
|
||||||
url: https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new
|
url: https://github.com/odysseus-dev/odysseus/security/advisories/new
|
||||||
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
|
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues)
|
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
||||||
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first.
|
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
||||||
Feature requests that duplicate [ROADMAP.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/ROADMAP.md)
|
Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md)
|
||||||
or an existing open issue will be closed as duplicates.
|
or an existing open issue will be closed as duplicates.
|
||||||
|
|
||||||
If your idea needs community input before it becomes a concrete proposal,
|
If your idea needs community input before it becomes a concrete proposal,
|
||||||
start a [discussion](https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas) instead.
|
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
|
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
|
||||||
required: true
|
required: true
|
||||||
- label: I searched [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and this is not already being debated there.
|
- label: I searched [discussions](https://github.com/odysseus-dev/odysseus/discussions) and this is not already being debated there.
|
||||||
required: true
|
required: true
|
||||||
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
|
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Fixes #
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) and [open PRs](https://github.com/pewdiepie-archdaemon/odysseus/pulls) — this is not a duplicate.
|
- [ ] I searched [open issues](https://github.com/odysseus-dev/odysseus/issues) and [open PRs](https://github.com/odysseus-dev/odysseus/pulls) — this is not a duplicate.
|
||||||
- [ ] This PR targets `dev`
|
- [ ] This PR targets `dev`
|
||||||
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
||||||
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
|
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LABEL_BAD = 'needs more info';
|
||||||
|
const LABEL_GOOD = 'ready for review';
|
||||||
|
|
||||||
|
// Closed issues are no longer awaiting review.
|
||||||
|
// This also prevents later edits to closed issues from restoring the label.
|
||||||
|
if (issue.state === 'closed') {
|
||||||
|
await dropLabel(LABEL_GOOD);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Find existing bot comment to update in-place ──────────────────────────
|
// ── Find existing bot comment to update in-place ──────────────────────────
|
||||||
const MARKER = '<!-- issue-description-check -->';
|
const MARKER = '<!-- issue-description-check -->';
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
@@ -160,9 +170,6 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
||||||
|
|
||||||
const LABEL_BAD = 'needs more info';
|
|
||||||
const LABEL_GOOD = 'ready for review';
|
|
||||||
|
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# Least privilege: none of the jobs write to the repo.
|
# Least privilege: none of the jobs write to the repo.
|
||||||
@@ -103,10 +103,7 @@ jobs:
|
|||||||
python-tests:
|
python-tests:
|
||||||
name: Python tests (pytest)
|
name: Python tests (pytest)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Informational for now: the suite has known flaky / environment-dependent
|
# Make Python test validation authoritative for the configured scope.
|
||||||
# failures (test isolation + embedding-model assertions). Tracked under the
|
|
||||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
|
||||||
continue-on-error: true
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Advanced setup so CodeQL also runs on pull requests (including from forks),
|
||||||
|
# surfacing findings before merge instead of only after a change lands on dev.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [actions, javascript-typescript, python]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: none
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -2,7 +2,7 @@ name: ci / issue description check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited, reopened]
|
types: [opened, edited, reopened, closed]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ End-users cloning the repo will land on `dev` by default. To run the curated/sta
|
|||||||
Docker is the recommended path for normal testing:
|
Docker is the recommended path for normal testing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|||||||
+4
-2
@@ -16,7 +16,8 @@ FROM python:3.14-slim
|
|||||||
# downloads, and serves from Docker installs.
|
# downloads, and serves from Docker installs.
|
||||||
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
||||||
# launch inside Docker.
|
# launch inside Docker.
|
||||||
# nodejs/npm provide npx for the optional built-in Browser MCP server.
|
# nodejs/npm provide npx for the built-in Browser MCP server.
|
||||||
|
# chromium provides the actual browser binary used by that MCP server.
|
||||||
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
||||||
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -26,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
|
chromium \
|
||||||
tmux \
|
tmux \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
gosu \
|
gosu \
|
||||||
@@ -54,7 +56,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
||||||
# dockerd but not the client binary on slim, so grab the static client
|
# dockerd but not the client binary on slim, so grab the static client
|
||||||
# tarball from download.docker.com instead.
|
# tarball from download.docker.com instead.
|
||||||
ARG DOCKER_CLI_VERSION=27.5.1
|
ARG DOCKER_CLI_VERSION=29.6.2
|
||||||
RUN ARCH="$(dpkg --print-architecture)" \
|
RUN ARCH="$(dpkg --print-architecture)" \
|
||||||
&& case "$ARCH" in \
|
&& case "$ARCH" in \
|
||||||
amd64) DARCH=x86_64 ;; \
|
amd64) DARCH=x86_64 ;; \
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch.
|
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -63,11 +63,11 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
|
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,6 @@ the codebase, you are probably right to stay away.
|
|||||||
and WSL all need coverage.
|
and WSL all need coverage.
|
||||||
|
|
||||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
||||||
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
|
|
||||||
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
||||||
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
||||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
||||||
@@ -33,6 +32,14 @@ the codebase, you are probably right to stay away.
|
|||||||
before the user request really starts. We need slimmer prompts, better tool
|
before the user request really starts. We need slimmer prompts, better tool
|
||||||
selection, smaller default tool sets, and clearer guidance for models with
|
selection, smaller default tool sets, and clearer guidance for models with
|
||||||
4k/8k/16k context windows.
|
4k/8k/16k context windows.
|
||||||
|
- Local model speculative decoding support. For Odysseus-tuned local models,
|
||||||
|
plan to ship or recommend a small same-tokenizer draft model when the serving
|
||||||
|
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
|
||||||
|
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
|
||||||
|
DSpark conversion performed poorly. Treat this as a supported draft-model lane
|
||||||
|
first; keep MTP-specific packaging as future work only when the architecture
|
||||||
|
and runtime support are real. Judge this by time-to-success, tool correctness,
|
||||||
|
grammar, and unchanged target output, not tokens/sec alone.
|
||||||
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
||||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
fetched pages, and memories should be treated as untrusted data. Keep testing
|
||||||
whether models follow malicious instructions from those surfaces.
|
whether models follow malicious instructions from those surfaces.
|
||||||
|
|||||||
@@ -655,10 +655,15 @@ app.include_router(setup_emoji_routes())
|
|||||||
# Sessions
|
# Sessions
|
||||||
from routes.session_routes import setup_session_routes
|
from routes.session_routes import setup_session_routes
|
||||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||||
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
|
app.include_router(setup_session_routes(
|
||||||
|
session_manager,
|
||||||
|
session_config,
|
||||||
|
webhook_manager=webhook_manager,
|
||||||
|
upload_handler=upload_handler,
|
||||||
|
))
|
||||||
|
|
||||||
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
||||||
from routes.admin_wipe_routes import setup_admin_wipe_routes
|
from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
|
||||||
app.include_router(setup_admin_wipe_routes(session_manager))
|
app.include_router(setup_admin_wipe_routes(session_manager))
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
@@ -684,10 +689,10 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi
|
|||||||
|
|
||||||
# History
|
# History
|
||||||
from routes.history.history_routes import setup_history_routes
|
from routes.history.history_routes import setup_history_routes
|
||||||
app.include_router(setup_history_routes(session_manager))
|
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
from routes.search_routes import setup_search_routes
|
from routes.search.search_routes import setup_search_routes
|
||||||
app.include_router(setup_search_routes(config))
|
app.include_router(setup_search_routes(config))
|
||||||
|
|
||||||
# Presets
|
# Presets
|
||||||
@@ -699,7 +704,7 @@ from routes.diagnostics_routes import setup_diagnostics_routes
|
|||||||
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector))
|
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector))
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
from routes.cleanup_routes import setup_cleanup_routes
|
from routes.cleanup.cleanup_routes import setup_cleanup_routes
|
||||||
app.include_router(setup_cleanup_routes(session_manager))
|
app.include_router(setup_cleanup_routes(session_manager))
|
||||||
|
|
||||||
# Personal docs
|
# Personal docs
|
||||||
@@ -734,7 +739,7 @@ app.include_router(setup_stt_routes(stt_service))
|
|||||||
logger.info("STT service initialized (provider managed via settings)")
|
logger.info("STT service initialized (provider managed via settings)")
|
||||||
|
|
||||||
# Documents (artifacts/canvas)
|
# Documents (artifacts/canvas)
|
||||||
from routes.document_routes import setup_document_routes
|
from routes.document.document_routes import setup_document_routes
|
||||||
document_router = setup_document_routes(session_manager, upload_handler)
|
document_router = setup_document_routes(session_manager, upload_handler)
|
||||||
app.include_router(document_router)
|
app.include_router(document_router)
|
||||||
|
|
||||||
@@ -763,7 +768,7 @@ app.include_router(setup_assistant_routes(task_scheduler))
|
|||||||
|
|
||||||
# Calendar (CalDAV)
|
# Calendar (CalDAV)
|
||||||
from routes.calendar_routes import setup_calendar_routes
|
from routes.calendar_routes import setup_calendar_routes
|
||||||
calendar_router = setup_calendar_routes()
|
calendar_router = setup_calendar_routes(upload_handler=upload_handler)
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
# Shell (user-facing command execution)
|
# Shell (user-facing command execution)
|
||||||
@@ -782,7 +787,7 @@ from routes.hwfit_routes import setup_hwfit_routes
|
|||||||
app.include_router(setup_hwfit_routes())
|
app.include_router(setup_hwfit_routes())
|
||||||
|
|
||||||
# Model A/B Comparison
|
# Model A/B Comparison
|
||||||
from routes.compare_routes import setup_compare_routes
|
from routes.compare.compare_routes import setup_compare_routes
|
||||||
app.include_router(setup_compare_routes(session_manager))
|
app.include_router(setup_compare_routes(session_manager))
|
||||||
|
|
||||||
# User Preferences
|
# User Preferences
|
||||||
@@ -800,7 +805,7 @@ app.include_router(setup_font_routes())
|
|||||||
# MCP (Model Context Protocol)
|
# MCP (Model Context Protocol)
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
from src.agent_tools import set_mcp_manager
|
from src.agent_tools import set_mcp_manager
|
||||||
from routes.mcp_routes import setup_mcp_routes
|
from routes.mcp.mcp_routes import setup_mcp_routes
|
||||||
|
|
||||||
mcp_manager = McpManager()
|
mcp_manager = McpManager()
|
||||||
set_mcp_manager(mcp_manager)
|
set_mcp_manager(mcp_manager)
|
||||||
@@ -815,7 +820,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
|
|||||||
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
from routes.webhook_routes import setup_webhook_routes
|
from routes.webhook.webhook_routes import setup_webhook_routes
|
||||||
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
||||||
|
|
||||||
# API Tokens
|
# API Tokens
|
||||||
@@ -825,8 +830,8 @@ app.include_router(setup_api_token_routes())
|
|||||||
logger.info("Webhook & API token routes initialized")
|
logger.info("Webhook & API token routes initialized")
|
||||||
|
|
||||||
# Notes (Google Keep-style notes/todos)
|
# Notes (Google Keep-style notes/todos)
|
||||||
from routes.note_routes import setup_note_routes
|
from routes.note.note_routes import setup_note_routes
|
||||||
app.include_router(setup_note_routes(task_scheduler))
|
app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
from routes.email_routes import setup_email_routes
|
from routes.email_routes import setup_email_routes
|
||||||
@@ -847,7 +852,7 @@ app.include_router(setup_codex_routes(
|
|||||||
))
|
))
|
||||||
app.include_router(setup_claude_routes())
|
app.include_router(setup_claude_routes())
|
||||||
|
|
||||||
from routes.vault_routes import setup_vault_routes
|
from routes.vault.vault_routes import setup_vault_routes
|
||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
# Contacts (CardDAV)
|
||||||
@@ -1043,7 +1048,7 @@ async def _startup_event():
|
|||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20)
|
await mcp_manager.connect_all_enabled()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("User MCP startup timed out (non-critical)")
|
logger.warning("User MCP startup timed out (non-critical)")
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
|
|||||||
+8
-4
@@ -15,17 +15,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
||||||
"""Atomically persist `data` as JSON at `path`.
|
"""Atomically persist `data` as JSON at `path`.
|
||||||
|
|
||||||
The temp file uses the live PID as a suffix so two processes saving the
|
The temp file uses a random suffix so two concurrent writers saving the
|
||||||
same file (e.g. unit tests) don't collide on the rename target.
|
same file don't collide on the rename target. A PID suffix does not do
|
||||||
|
this: the PID is constant for the life of a process, so two writers on
|
||||||
|
the same path within one process (or one single-process container, where
|
||||||
|
the PID never changes at all) still race for the same temp file.
|
||||||
"""
|
"""
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=indent)
|
json.dump(data, f, indent=indent)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -37,7 +41,7 @@ def atomic_write_text(path: str, text: str) -> None:
|
|||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise TypeError("atomic_write_text expects a string")
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|||||||
+385
-70
@@ -3,13 +3,16 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
from typing import Optional
|
||||||
from sqlalchemy.engine import Engine
|
from urllib.parse import unquote, urlparse
|
||||||
|
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
|
||||||
|
from sqlalchemy.engine import Engine, make_url
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, backref
|
from sqlalchemy.orm import relationship, sessionmaker, backref
|
||||||
|
|
||||||
from src.runtime_paths import get_app_root
|
from src.runtime_paths import get_app_root
|
||||||
|
from core.platform_compat import safe_chmod, IS_WINDOWS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_sqlite_url(url: str) -> str:
|
def _normalize_sqlite_url(url: str) -> str:
|
||||||
if not url.startswith("sqlite:///"):
|
"""Resolve relative ordinary SQLite paths without rewriting URI filenames."""
|
||||||
|
try:
|
||||||
|
parsed = make_url(url)
|
||||||
|
except Exception:
|
||||||
return url
|
return url
|
||||||
db_path = url.replace("sqlite:///", "", 1)
|
|
||||||
if db_path == ":memory:" or os.path.isabs(db_path):
|
if parsed.get_backend_name() != "sqlite":
|
||||||
return url
|
return url
|
||||||
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
|
|
||||||
|
db_path = parsed.database
|
||||||
|
if (
|
||||||
|
not db_path
|
||||||
|
or db_path == ":memory:"
|
||||||
|
or str(db_path).lower().startswith("file:")
|
||||||
|
or os.path.isabs(str(db_path))
|
||||||
|
):
|
||||||
|
return url
|
||||||
|
|
||||||
|
absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
|
||||||
|
return parsed.set(database=absolute_path).render_as_string(
|
||||||
|
hide_password=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Get database URL from environment, default to SQLite in DATA_DIR
|
# Get database URL from environment, default to SQLite in DATA_DIR
|
||||||
@@ -59,6 +78,59 @@ engine = create_engine(
|
|||||||
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Sidecar files SQLite can create next to the main DB. -journal is the default
|
||||||
|
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
|
||||||
|
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
|
||||||
|
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_db_path(url) -> Optional[str]:
|
||||||
|
"""Return the filesystem path for a file-backed SQLite URL.
|
||||||
|
|
||||||
|
SQLite query parameters such as ``mode=memory`` only affect filename
|
||||||
|
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
|
||||||
|
file URLs must therefore remain file-backed even when they contain a query
|
||||||
|
parameter named ``mode``.
|
||||||
|
|
||||||
|
For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
|
||||||
|
local path. Other authorities are retained as UNC-style paths.
|
||||||
|
"""
|
||||||
|
if url.get_backend_name() != "sqlite":
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_path = url.database
|
||||||
|
if not db_path or db_path == ":memory:":
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_path = str(db_path)
|
||||||
|
query = {
|
||||||
|
str(key).lower(): str(value).strip().lower()
|
||||||
|
for key, value in dict(getattr(url, "query", {}) or {}).items()
|
||||||
|
}
|
||||||
|
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
|
||||||
|
is_file_uri = db_path.lower().startswith("file:")
|
||||||
|
|
||||||
|
if not uri_enabled or not is_file_uri:
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
if (
|
||||||
|
db_path.lower().startswith("file::memory:")
|
||||||
|
or query.get("mode") == "memory"
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(db_path)
|
||||||
|
fs_path = parsed.path or ""
|
||||||
|
if not fs_path or fs_path == ":memory:":
|
||||||
|
return None
|
||||||
|
|
||||||
|
authority = parsed.netloc
|
||||||
|
if authority and authority.lower() != "localhost":
|
||||||
|
fs_path = f"//{authority}{fs_path}"
|
||||||
|
|
||||||
|
return unquote(fs_path)
|
||||||
|
|
||||||
# Create session factory
|
# Create session factory
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
@@ -358,6 +430,93 @@ class EmailAccount(TimestampMixin, Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAccountOwnerLock(Base):
|
||||||
|
"""Durable per-owner mutex for email-account default mutations.
|
||||||
|
|
||||||
|
Row-locking databases serialize mutations by locking this row before they
|
||||||
|
inspect or stage EmailAccount changes. SQLite uses ``BEGIN IMMEDIATE``
|
||||||
|
instead, because it ignores ``SELECT ... FOR UPDATE``; keeping the table in
|
||||||
|
the shared metadata still makes the non-SQLite path available without a
|
||||||
|
separate migration. The empty key represents the normalized legacy /
|
||||||
|
unconfigured scope shared by ``owner IS NULL`` and ``owner = ''`` rows.
|
||||||
|
"""
|
||||||
|
__tablename__ = "email_account_owner_locks"
|
||||||
|
|
||||||
|
owner_key = Column(String, primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_ACCOUNT_DEFAULT_INDEX = "ux_email_accounts_one_default_per_owner"
|
||||||
|
_EMAIL_ACCOUNT_DEFAULT_INDEX_DDL = {
|
||||||
|
"sqlite": (
|
||||||
|
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||||
|
"ON email_accounts (COALESCE(owner, '')) WHERE is_default = 1"
|
||||||
|
),
|
||||||
|
"postgresql": (
|
||||||
|
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||||
|
"ON email_accounts ((COALESCE(owner, ''))) WHERE is_default IS TRUE"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# SQLAlchemy cannot express one portable partial, functional index across the
|
||||||
|
# two supported database families. Register dialect-specific DDL so fresh
|
||||||
|
# databases get the invariant as part of create_all(); the startup migration
|
||||||
|
# below installs the same index on existing databases after normalizing legacy
|
||||||
|
# duplicate rows.
|
||||||
|
for _dialect_name, _index_ddl in _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.items():
|
||||||
|
event.listen(
|
||||||
|
EmailAccount.__table__,
|
||||||
|
"after_create",
|
||||||
|
DDL(_index_ddl).execute_if(dialect=_dialect_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_email_account_owner_mutations(db, *owners: str) -> None:
|
||||||
|
"""Lock normalized email-account owner scopes in canonical order.
|
||||||
|
|
||||||
|
``NULL`` and the empty string are one legacy/single-user owner partition,
|
||||||
|
matching the unique default-account index. SQLite has only a database
|
||||||
|
writer reservation, while row-locking databases use durable mutex rows.
|
||||||
|
Sorting all requested owner keys keeps multi-owner operations such as user
|
||||||
|
rename from deadlocking with another mutation that requests the same keys
|
||||||
|
in the opposite order.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
owner_keys = sorted({owner or "" for owner in owners} or {""})
|
||||||
|
if db.get_bind().dialect.name == "sqlite":
|
||||||
|
db.execute(text("BEGIN IMMEDIATE"))
|
||||||
|
return
|
||||||
|
|
||||||
|
for owner_key in owner_keys:
|
||||||
|
lock_row = db.get(
|
||||||
|
EmailAccountOwnerLock,
|
||||||
|
owner_key,
|
||||||
|
with_for_update=True,
|
||||||
|
)
|
||||||
|
if lock_row is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
inserted = False
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(EmailAccountOwnerLock(owner_key=owner_key))
|
||||||
|
db.flush()
|
||||||
|
inserted = True
|
||||||
|
except IntegrityError:
|
||||||
|
# A competing transaction created the mutex row first. Once its
|
||||||
|
# insert commits, lock that durable row before touching accounts.
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not inserted:
|
||||||
|
(
|
||||||
|
db.query(EmailAccountOwnerLock)
|
||||||
|
.filter(EmailAccountOwnerLock.owner_key == owner_key)
|
||||||
|
.with_for_update()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelEndpoint(TimestampMixin, Base):
|
class ModelEndpoint(TimestampMixin, Base):
|
||||||
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
|
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
|
||||||
__tablename__ = "model_endpoints"
|
__tablename__ = "model_endpoints"
|
||||||
@@ -1740,72 +1899,142 @@ class Integration(TimestampMixin, Base):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_seed_email_account():
|
def _migrate_email_account_default_invariant():
|
||||||
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host
|
"""Normalize legacy duplicates and install durable at-most-one enforcement.
|
||||||
keys, create a single default account from them so nothing breaks for users who
|
|
||||||
upgraded. Safe to run repeatedly — it short-circuits once any row exists."""
|
Older databases only had a non-unique ``(owner, is_default)`` lookup index.
|
||||||
|
Keep the oldest default deterministically in each normalized owner scope,
|
||||||
|
then add the same partial functional unique index used for fresh schemas.
|
||||||
|
"""
|
||||||
|
dialect_name = engine.dialect.name
|
||||||
|
index_ddl = _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.get(dialect_name)
|
||||||
|
if index_ddl is None:
|
||||||
|
logger.warning(
|
||||||
|
"Email-account default uniqueness is not available for database "
|
||||||
|
"dialect %s; mutations remain serialized but are not protected by "
|
||||||
|
"a database constraint",
|
||||||
|
dialect_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with engine.connect() as conn:
|
with engine.begin() as conn:
|
||||||
tables = [r[0] for r in conn.execute(text(
|
if not inspect(conn).has_table(EmailAccount.__tablename__):
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='email_accounts'"
|
|
||||||
))]
|
|
||||||
if "email_accounts" not in tables:
|
|
||||||
return
|
|
||||||
existing = conn.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
|
||||||
if existing > 0:
|
|
||||||
return
|
return
|
||||||
|
default_rows = conn.execute(text("""
|
||||||
|
SELECT id, owner
|
||||||
|
FROM email_accounts
|
||||||
|
WHERE is_default IS TRUE
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(owner, ''),
|
||||||
|
CASE WHEN created_at IS NULL THEN 1 ELSE 0 END,
|
||||||
|
created_at,
|
||||||
|
id
|
||||||
|
""")).mappings()
|
||||||
|
seen_owner_keys = set()
|
||||||
|
duplicate_ids = []
|
||||||
|
for row in default_rows:
|
||||||
|
owner_key = row["owner"] or ""
|
||||||
|
if owner_key in seen_owner_keys:
|
||||||
|
duplicate_ids.append(row["id"])
|
||||||
|
else:
|
||||||
|
seen_owner_keys.add(owner_key)
|
||||||
|
|
||||||
import json as _json
|
for account_id in duplicate_ids:
|
||||||
import uuid as _uuid
|
conn.execute(
|
||||||
from pathlib import Path
|
text("UPDATE email_accounts SET is_default = :value WHERE id = :id"),
|
||||||
settings_file = Path(SETTINGS_FILE)
|
{"value": False, "id": account_id},
|
||||||
if not settings_file.exists():
|
)
|
||||||
return
|
conn.execute(text(index_ddl))
|
||||||
try:
|
|
||||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
imap_host = (s.get("imap_host") or "").strip()
|
if duplicate_ids:
|
||||||
smtp_host = (s.get("smtp_host") or "").strip()
|
logger.warning(
|
||||||
if not imap_host and not smtp_host:
|
"Normalized %d duplicate default email account(s) before "
|
||||||
return # nothing to migrate
|
"installing %s",
|
||||||
|
len(duplicate_ids),
|
||||||
|
_EMAIL_ACCOUNT_DEFAULT_INDEX,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Starting without the constraint would silently retain the race this
|
||||||
|
# migration is intended to close. Fail startup so an operator sees and
|
||||||
|
# can repair an incompatible schema instead of accepting unsafe writes.
|
||||||
|
logger.exception("Failed to enforce the email-account default invariant")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_seed_email_account():
|
||||||
|
"""Atomically seed one legacy default account when no account exists.
|
||||||
|
|
||||||
|
Reading settings is intentionally done before taking the owner mutex. The
|
||||||
|
decisive emptiness check and insert share one locked transaction, so two
|
||||||
|
application workers starting together cannot both seed a default row.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
settings_file = Path(SETTINGS_FILE)
|
||||||
|
if not settings_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
imap_host = (s.get("imap_host") or "").strip()
|
||||||
|
smtp_host = (s.get("smtp_host") or "").strip()
|
||||||
|
if not imap_host and not smtp_host:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
if not inspect(engine).has_table(EmailAccount.__tablename__):
|
||||||
|
return
|
||||||
|
db = SessionLocal()
|
||||||
|
lock_email_account_owner_mutations(db, "")
|
||||||
|
existing = db.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
||||||
|
if existing > 0:
|
||||||
|
return
|
||||||
|
|
||||||
now = utcnow_naive()
|
now = utcnow_naive()
|
||||||
with engine.begin() as conn:
|
db.execute(text("""
|
||||||
conn.execute(text("""
|
INSERT INTO email_accounts
|
||||||
INSERT INTO email_accounts
|
(id, owner, name, is_default, enabled,
|
||||||
(id, owner, name, is_default, enabled,
|
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
from_address, created_at, updated_at)
|
||||||
from_address, created_at, updated_at)
|
VALUES
|
||||||
VALUES
|
(:id, :owner, :name, :is_default, :enabled,
|
||||||
(:id, :owner, :name, :is_default, :enabled,
|
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
||||||
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
||||||
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
:from_address, :created_at, :updated_at)
|
||||||
:from_address, :created_at, :updated_at)
|
"""), {
|
||||||
"""), {
|
"id": _uuid.uuid4().hex,
|
||||||
"id": _uuid.uuid4().hex,
|
"owner": None,
|
||||||
"owner": None,
|
"name": "Default",
|
||||||
"name": "Default",
|
"is_default": True,
|
||||||
"is_default": True,
|
"enabled": True,
|
||||||
"enabled": True,
|
"imap_host": imap_host,
|
||||||
"imap_host": imap_host,
|
"imap_port": int(s.get("imap_port") or 993),
|
||||||
"imap_port": int(s.get("imap_port") or 993),
|
"imap_user": s.get("imap_user") or "",
|
||||||
"imap_user": s.get("imap_user") or "",
|
"imap_password": s.get("imap_password") or "",
|
||||||
"imap_password": s.get("imap_password") or "",
|
"imap_starttls": bool(s.get("imap_starttls", True)),
|
||||||
"imap_starttls": bool(s.get("imap_starttls", True)),
|
"smtp_host": smtp_host,
|
||||||
"smtp_host": smtp_host,
|
"smtp_port": int(s.get("smtp_port") or 465),
|
||||||
"smtp_port": int(s.get("smtp_port") or 465),
|
"smtp_user": s.get("smtp_user") or "",
|
||||||
"smtp_user": s.get("smtp_user") or "",
|
"smtp_password": s.get("smtp_password") or "",
|
||||||
"smtp_password": s.get("smtp_password") or "",
|
"from_address": s.get("email_from") or "",
|
||||||
"from_address": s.get("email_from") or "",
|
"created_at": now,
|
||||||
"created_at": now,
|
"updated_at": now,
|
||||||
"updated_at": now,
|
})
|
||||||
})
|
db.commit()
|
||||||
logging.getLogger(__name__).info("Seeded email_accounts 'Default' from settings.json")
|
logger.info("Seeded email_accounts 'Default' from settings.json")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"seed email account migration: {e}")
|
if db is not None:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("seed email account migration: %s", e)
|
||||||
|
finally:
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
|
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
|
||||||
@@ -1819,6 +2048,41 @@ def init_db():
|
|||||||
"""
|
"""
|
||||||
_migrate_model_endpoints()
|
_migrate_model_endpoints()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
|
||||||
|
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
|
||||||
|
# on Windows (ACL-restricted profile dir) and the path helper returns None for
|
||||||
|
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
|
||||||
|
# the umask default, and nothing below resets the mode. The path comes from
|
||||||
|
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
|
||||||
|
# DATABASE_URL still resolves to the real file instead of slipping through.
|
||||||
|
db_path = _sqlite_db_path(engine.url)
|
||||||
|
if db_path is not None:
|
||||||
|
# Fail closed-loud on the main file: this is the only access control on
|
||||||
|
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
|
||||||
|
# operator should hear about it. safe_chmod also returns False as a
|
||||||
|
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
|
||||||
|
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
|
||||||
|
logger.warning(
|
||||||
|
"Could not restrict %s to 0o600; it holds secrets and may be "
|
||||||
|
"world-readable. Check filesystem permissions and ownership.",
|
||||||
|
db_path,
|
||||||
|
)
|
||||||
|
# Re-lock any sidecars present at startup. New ones inherit the main
|
||||||
|
# file's mode (now 0o600, since we set it first), and they're usually
|
||||||
|
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
|
||||||
|
# install could still expose secret pages. Absent sidecars are the
|
||||||
|
# normal case, not an error — only a failed chmod warrants a warning.
|
||||||
|
for suffix in _SQLITE_SIDECARS:
|
||||||
|
sidecar = db_path + suffix
|
||||||
|
if (
|
||||||
|
os.path.exists(sidecar)
|
||||||
|
and not safe_chmod(sidecar, 0o600)
|
||||||
|
and not IS_WINDOWS
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Could not restrict %s to 0o600; it may expose DB pages.",
|
||||||
|
sidecar,
|
||||||
|
)
|
||||||
_migrate_add_hidden_models_column()
|
_migrate_add_hidden_models_column()
|
||||||
_migrate_add_cached_models_column()
|
_migrate_add_cached_models_column()
|
||||||
_migrate_add_pinned_models_column()
|
_migrate_add_pinned_models_column()
|
||||||
@@ -1853,6 +2117,7 @@ def init_db():
|
|||||||
_migrate_add_crew_member_id()
|
_migrate_add_crew_member_id()
|
||||||
_migrate_add_assistant_columns()
|
_migrate_add_assistant_columns()
|
||||||
_migrate_add_email_smtp_security()
|
_migrate_add_email_smtp_security()
|
||||||
|
_migrate_email_account_default_invariant()
|
||||||
_migrate_seed_email_account()
|
_migrate_seed_email_account()
|
||||||
_migrate_add_calendar_metadata()
|
_migrate_add_calendar_metadata()
|
||||||
_migrate_add_calendar_is_utc()
|
_migrate_add_calendar_is_utc()
|
||||||
@@ -1904,6 +2169,20 @@ def _migrate_chat_messages_fts():
|
|||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
fts_content_expr_new = (
|
||||||
|
"CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
|
||||||
|
"OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
|
||||||
|
"OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
|
||||||
|
"THEN '[inline media omitted from search index]' "
|
||||||
|
"ELSE COALESCE(new.content, '') END"
|
||||||
|
)
|
||||||
|
fts_content_expr_cm = (
|
||||||
|
"CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
|
||||||
|
"OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
|
||||||
|
"OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
|
||||||
|
"THEN '[inline media omitted from search index]' "
|
||||||
|
"ELSE COALESCE(cm.content, '') END"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
|
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
|
||||||
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
|
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
|
||||||
@@ -1912,7 +2191,7 @@ def _migrate_chat_messages_fts():
|
|||||||
return
|
return
|
||||||
|
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
"""
|
f"""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
|
||||||
content,
|
content,
|
||||||
message_id UNINDEXED,
|
message_id UNINDEXED,
|
||||||
@@ -1920,10 +2199,14 @@ def _migrate_chat_messages_fts():
|
|||||||
role UNINDEXED
|
role UNINDEXED
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_ai;
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_ad;
|
||||||
|
DROP TRIGGER IF EXISTS chat_messages_fts_au;
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
|
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
|
||||||
AFTER INSERT ON chat_messages BEGIN
|
AFTER INSERT ON chat_messages BEGIN
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
|
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
|
||||||
END;
|
END;
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
|
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
|
||||||
@@ -1935,14 +2218,14 @@ def _migrate_chat_messages_fts():
|
|||||||
AFTER UPDATE ON chat_messages BEGIN
|
AFTER UPDATE ON chat_messages BEGIN
|
||||||
DELETE FROM chat_messages_fts WHERE message_id = old.id;
|
DELETE FROM chat_messages_fts WHERE message_id = old.id;
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role);
|
VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
|
||||||
END;
|
END;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role
|
SELECT {fts_content_expr_cm}, cm.id, cm.session_id, cm.role
|
||||||
FROM chat_messages cm
|
FROM chat_messages cm
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM chat_messages_fts fts
|
SELECT 1 FROM chat_messages_fts fts
|
||||||
@@ -1950,6 +2233,7 @@ def _migrate_chat_messages_fts():
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_scrub_legacy_chat_message_fts_media(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
|
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
|
||||||
@@ -1960,6 +2244,37 @@ def _migrate_chat_messages_fts():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_legacy_chat_message_fts_media(conn) -> None:
|
||||||
|
"""Replace already-indexed inline media rows with searchable text only."""
|
||||||
|
try:
|
||||||
|
from src.attachment_refs import search_index_text
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub skipped: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, session_id, role, content
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE instr(COALESCE(content, ''), ';base64,') > 0
|
||||||
|
OR instr(COALESCE(content, ''), 'data:image/') > 0
|
||||||
|
OR instr(COALESCE(content, ''), 'data:audio/') > 0
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for message_id, session_id, role, content in rows:
|
||||||
|
conn.execute("DELETE FROM chat_messages_fts WHERE message_id = ?", (message_id,))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(search_index_text(content), message_id, session_id, role),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_email_smtp_security():
|
def _migrate_add_email_smtp_security():
|
||||||
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
|
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|||||||
+94
-33
@@ -16,6 +16,8 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
||||||
from .models import Session, ChatMessage
|
from .models import Session, ChatMessage
|
||||||
|
from src.attachment_refs import persistable_message_content
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
|
|
||||||
# Re-export singleton accessors from models for convenience
|
# Re-export singleton accessors from models for convenience
|
||||||
from .models import set_session_manager_instance, get_session_manager_instance
|
from .models import set_session_manager_instance, get_session_manager_instance
|
||||||
@@ -72,6 +74,7 @@ class SessionManager:
|
|||||||
def __init__(self, sessions_file: str = None):
|
def __init__(self, sessions_file: str = None):
|
||||||
# sessions_file kept for backward compat, not used
|
# sessions_file kept for backward compat, not used
|
||||||
self.sessions: Dict[str, Session] = {}
|
self.sessions: Dict[str, Session] = {}
|
||||||
|
self.upload_handler = None
|
||||||
self.load_sessions()
|
self.load_sessions()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -191,7 +194,12 @@ class SessionManager:
|
|||||||
is_important=getattr(db_session, 'is_important', False) or False,
|
is_important=getattr(db_session, 'is_important', False) or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.message_count = getattr(db_session, 'message_count', len(history))
|
# The rows just loaded are the whole transcript, so they — not the
|
||||||
|
# denormalized sessions.message_count column — are the truth for this
|
||||||
|
# cached object. get_session's hydration gate compares against this
|
||||||
|
# number; seeding it from a drifted column would ask for a reload that
|
||||||
|
# can never close the gap.
|
||||||
|
session.message_count = len(history)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -230,17 +238,26 @@ class SessionManager:
|
|||||||
logger.warning("Dropping message for deleted session %s", session_id)
|
logger.warning("Dropping message for deleted session %s", session_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
missing_upload_id = reserve_message_upload_references(
|
||||||
|
getattr(self, "upload_handler", None),
|
||||||
|
getattr(db_session, "owner", None),
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
)
|
||||||
|
if missing_upload_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Referenced upload is no longer available: {missing_upload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
msg_time = datetime.utcnow()
|
msg_time = datetime.utcnow()
|
||||||
if message.metadata is None:
|
if message.metadata is None:
|
||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
||||||
# Multimodal content (image/audio attachments) is a list — serialize
|
# Multimodal content may contain provider data URLs for the live
|
||||||
# to JSON so the Text column can store it. On reload, _db_to_session
|
# model call. Persist only readable text plus attachment references
|
||||||
# detects the JSON-array prefix and parses it back.
|
# so chat_messages/FTS do not duplicate upload bytes.
|
||||||
_content = message.content
|
_content = persistable_message_content(message.content, message.metadata)
|
||||||
if isinstance(_content, list):
|
|
||||||
_content = json.dumps(_content)
|
|
||||||
db_message = DbChatMessage(
|
db_message = DbChatMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -322,6 +339,28 @@ class SessionManager:
|
|||||||
session = self.get_session(session_id)
|
session = self.get_session(session_id)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
|
if db_session is None:
|
||||||
|
logger.warning("Cannot replace history for missing session %s", session_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reserve every incoming attachment before removing any durable
|
||||||
|
# message row. reserve_upload() shares the upload lifecycle lock
|
||||||
|
# with cleanup, so an upload cannot be deleted between this
|
||||||
|
# ownership check/access touch and the replacement transaction.
|
||||||
|
# A failed reservation must leave the existing transcript intact.
|
||||||
|
for message in messages:
|
||||||
|
missing_upload_id = reserve_message_upload_references(
|
||||||
|
getattr(self, "upload_handler", None),
|
||||||
|
getattr(db_session, "owner", None),
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
)
|
||||||
|
if missing_upload_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Referenced upload is no longer available: {missing_upload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for i, message in enumerate(messages):
|
for i, message in enumerate(messages):
|
||||||
@@ -330,15 +369,9 @@ class SessionManager:
|
|||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=message.role,
|
role=message.role,
|
||||||
# Multimodal content (image/audio attachments) is a list;
|
# Mirrors _persist_message: keep raw media bytes out of the
|
||||||
# serialize to JSON so the Text column round-trips via
|
# persisted transcript and search index.
|
||||||
# _parse_msg_content. Storing the raw list let SQLAlchemy
|
content=persistable_message_content(message.content, message.metadata),
|
||||||
# bind its single-quoted repr, which _parse_msg_content
|
|
||||||
# cannot parse (it looks for double-quoted "type"), so the
|
|
||||||
# attachment was destroyed on reload. Mirrors _persist_message.
|
|
||||||
content=(json.dumps(message.content)
|
|
||||||
if isinstance(message.content, list)
|
|
||||||
else message.content),
|
|
||||||
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
||||||
timestamp=now + timedelta(microseconds=i),
|
timestamp=now + timedelta(microseconds=i),
|
||||||
)
|
)
|
||||||
@@ -347,12 +380,10 @@ class SessionManager:
|
|||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata["_db_id"] = msg_id
|
message.metadata["_db_id"] = msg_id
|
||||||
|
|
||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
db_session.message_count = len(messages)
|
||||||
if db_session:
|
db_session.updated_at = now
|
||||||
db_session.message_count = len(messages)
|
db_session.last_accessed = now
|
||||||
db_session.updated_at = now
|
db_session.last_message_at = now
|
||||||
db_session.last_accessed = now
|
|
||||||
db_session.last_message_at = now
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
session.history = list(messages)
|
session.history = list(messages)
|
||||||
@@ -372,30 +403,50 @@ class SessionManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> Session:
|
def get_session(self, session_id: str) -> Session:
|
||||||
"""Get a session by ID, loading from DB if needed.
|
"""Get a session by ID, loading complete DB history when needed.
|
||||||
|
|
||||||
Sessions seeded by `load_sessions` start with empty history. The
|
Sessions seeded by ``load_sessions`` start with empty history, and a
|
||||||
first read here hydrates them with the message rows.
|
cached session can also become partially stale. Refresh metadata first,
|
||||||
|
then hydrate whenever the cached transcript is short of the stored rows.
|
||||||
|
Model-send routes enter through this method before building context,
|
||||||
|
while paginated display history reads SQLite directly.
|
||||||
|
|
||||||
|
The gate compares against ``sync_session_metadata``'s reconciled count
|
||||||
|
(the real ``chat_messages`` total), never the denormalized column, so a
|
||||||
|
hydrate always closes the gap and the next read is a cache hit.
|
||||||
"""
|
"""
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
self._load_session_from_db(session_id)
|
self._load_session_from_db(session_id)
|
||||||
else:
|
|
||||||
cached = self.sessions[session_id]
|
|
||||||
# Lazy hydrate: metadata-only entries get their messages on first read.
|
|
||||||
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
|
||||||
self._load_session_from_db(session_id)
|
|
||||||
|
|
||||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||||
# DB row while a session object is still cached in RAM.
|
# DB row while a session object is still cached in RAM. Refreshing first
|
||||||
|
# also exposes the authoritative message count before completeness is
|
||||||
|
# checked.
|
||||||
self.sync_session_metadata(session_id)
|
self.sync_session_metadata(session_id)
|
||||||
|
|
||||||
|
cached = self.sessions[session_id]
|
||||||
|
cached_count = len(cached.history or [])
|
||||||
|
stored_count = int(getattr(cached, "message_count", 0) or 0)
|
||||||
|
if cached_count < stored_count:
|
||||||
|
self._load_session_from_db(session_id)
|
||||||
|
|
||||||
# Update last_accessed
|
# Update last_accessed
|
||||||
self._touch_session(session_id)
|
self._touch_session(session_id)
|
||||||
|
|
||||||
return self.sessions[session_id]
|
return self.sessions[session_id]
|
||||||
|
|
||||||
def sync_session_metadata(self, session_id: str) -> bool:
|
def sync_session_metadata(self, session_id: str) -> bool:
|
||||||
"""Refresh non-message session fields from the DB into the cached object."""
|
"""Refresh non-message session fields from the DB into the cached object.
|
||||||
|
|
||||||
|
``message_count`` is reconciled against the real ``chat_messages`` rows
|
||||||
|
rather than copied from the denormalized ``sessions.message_count``
|
||||||
|
column. That column drifts in normal operation — ``_persist_message``
|
||||||
|
swallows a failed insert but ``add_message`` has already appended in
|
||||||
|
memory, so the next successful persist writes rows+1, and a persist for
|
||||||
|
an uncached session writes 0. Hydration keys off this number: a
|
||||||
|
drifted-high column would reload the whole transcript on every warm
|
||||||
|
read, and a drifted-low one would leave the model a truncated one.
|
||||||
|
"""
|
||||||
session = self.sessions.get(session_id)
|
session = self.sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
return False
|
return False
|
||||||
@@ -418,7 +469,11 @@ class SessionManager:
|
|||||||
session.archived = db_session.archived
|
session.archived = db_session.archived
|
||||||
session.owner = getattr(db_session, "owner", None)
|
session.owner = getattr(db_session, "owner", None)
|
||||||
session.is_important = getattr(db_session, "is_important", False) or False
|
session.is_important = getattr(db_session, "is_important", False) or False
|
||||||
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
session.message_count = (
|
||||||
|
db.query(DbChatMessage)
|
||||||
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||||
@@ -517,6 +572,12 @@ class SessionManager:
|
|||||||
"""Permanently delete a session and all its messages."""
|
"""Permanently delete a session and all its messages."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
from src.session_image_cleanup import cleanup_session_images
|
||||||
|
cleanup_session_images(session_id, db=db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
|
||||||
|
|
||||||
# Detach documents so they survive as orphans in the library
|
# Detach documents so they survive as orphans in the library
|
||||||
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
|
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
|
||||||
{DbDocument.session_id: None}, synchronize_session=False
|
{DbDocument.session_id: None}, synchronize_session=False
|
||||||
|
|||||||
@@ -67,9 +67,13 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -66,9 +66,13 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -55,9 +55,13 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Attachment References and Upload Storage
|
||||||
|
|
||||||
|
Odysseus stores uploaded bytes once under the configured upload directory and
|
||||||
|
passes stable references through chat history, tools, and future artifact work.
|
||||||
|
The goal is to avoid duplicating large inline media payloads in
|
||||||
|
`chat_messages.content` or the SQLite FTS index.
|
||||||
|
|
||||||
|
## Reference Shape
|
||||||
|
|
||||||
|
Attachment references use this minimum shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "attachment_ref",
|
||||||
|
"attachment_id": "32hex-or-32hex.ext",
|
||||||
|
"name": "original-filename.png",
|
||||||
|
"mime": "image/png",
|
||||||
|
"size": 12345,
|
||||||
|
"checksum_sha256": "hex-digest",
|
||||||
|
"created_at": "2026-07-09T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional fields such as `width`, `height`, `vision`, `vision_model`, and
|
||||||
|
`gallery_id` may be present when the uploader or preprocessing path knows them.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The live model call may still receive provider-specific multimodal blocks for
|
||||||
|
the current turn. Persistence is different:
|
||||||
|
|
||||||
|
- `chat_messages.content` stores readable text plus compact attachment reference
|
||||||
|
lines, never raw `data:*;base64,...` upload bytes.
|
||||||
|
- `chat_messages.metadata.attachments` stores structured attachment reference
|
||||||
|
metadata for UI reloads and future processing.
|
||||||
|
- The SQLite FTS migration recreates chat-message FTS triggers so new rows do
|
||||||
|
not index inline media payloads, and it scrubs legacy rows that were already
|
||||||
|
indexed with data URLs.
|
||||||
|
|
||||||
|
## Tool Access
|
||||||
|
|
||||||
|
Agent/tool context receives upload entries as `attachment_ref` manifests with an
|
||||||
|
`odysseus://attachment/<id>` URI and `read_policy: "owner_checked_upload"`.
|
||||||
|
|
||||||
|
For compatibility with existing built-in tools, a local `path` may be included
|
||||||
|
only after all of these checks pass:
|
||||||
|
|
||||||
|
- the upload ID resolves through `UploadHandler.resolve_upload`;
|
||||||
|
- the requested owner is allowed to read the upload;
|
||||||
|
- the file remains inside the configured upload directory;
|
||||||
|
- the file path is inside the tool-readable roots.
|
||||||
|
|
||||||
|
External MCP/custom tools should treat the URI and attachment ID as the stable
|
||||||
|
contract and request bytes through an owner-checked server path, not by assuming
|
||||||
|
host filesystem layout.
|
||||||
|
|
||||||
|
## Retention and Deletion
|
||||||
|
|
||||||
|
Current retention behavior is conservative:
|
||||||
|
|
||||||
|
- uploads are indexed in `uploads.json` with owner, checksum, MIME type, size,
|
||||||
|
and creation time;
|
||||||
|
- admin cleanup first scans persisted chat metadata/content, document versions,
|
||||||
|
PDF source markers, gallery hashes, notes, and calendar records for live
|
||||||
|
references;
|
||||||
|
- cleanup fails closed if that reference scan cannot complete, and the lower-level
|
||||||
|
cleanup API removes nothing unless it receives a complete reference snapshot;
|
||||||
|
- expired, unreferenced uploads are removed during the completed scan, while
|
||||||
|
attachment-bearing writers must first take an owner-checked reservation that
|
||||||
|
serializes with deletion and refreshes the upload's access timestamp;
|
||||||
|
- deliberate removal atomically drops matching `uploads.json` rows before deleting
|
||||||
|
the bytes and restores those rows if filesystem removal fails;
|
||||||
|
- deleting a chat removes the chat rows but does not immediately delete shared
|
||||||
|
upload bytes, because the same upload may also be referenced by gallery items,
|
||||||
|
documents, duplicate-upload rows, or future artifact records.
|
||||||
|
|
||||||
|
There is no distinct artifact table in the current schema. Artifact-like upload
|
||||||
|
references persisted in chat or document text are covered by the canonical
|
||||||
|
attachment-ID scan; any future artifact store must be added to reference discovery
|
||||||
|
before cleanup is allowed to consider its uploads unreferenced.
|
||||||
|
|
||||||
|
Cleanup and write reservations share the upload-index lock. This closes the
|
||||||
|
scan/write/delete race in the documented single-worker deployment; a future
|
||||||
|
multi-process deployment must add an inter-process lock or move lifecycle state
|
||||||
|
into the database before enabling destructive cleanup in more than one worker.
|
||||||
+4
-4
@@ -409,7 +409,7 @@
|
|||||||
<a href="#testimonials">Testimonials</a>
|
<a href="#testimonials">Testimonials</a>
|
||||||
<a href="#how">How it started</a>
|
<a href="#how">How it started</a>
|
||||||
<a href="#start">Get started</a>
|
<a href="#start">Get started</a>
|
||||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
@@ -437,7 +437,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a class="btn primary" href="#start">Get started</a>
|
<a class="btn primary" href="#start">Get started</a>
|
||||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
|
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">View on GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -678,9 +678,9 @@
|
|||||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
||||||
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
||||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
||||||
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus">Copy</button></div>
|
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus">Copy</button></div>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
<a class="btn primary" href="https://github.com/odysseus-dev/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="pill-row">
|
<div class="pill-row">
|
||||||
<span class="pill">Self-hosted</span>
|
<span class="pill">Self-hosted</span>
|
||||||
|
|||||||
+6
-12
@@ -7,10 +7,9 @@ benefit.
|
|||||||
|
|
||||||
## What runs, and why
|
## What runs, and why
|
||||||
|
|
||||||
Most checks live in files under `.github/workflows/`. CodeQL is configured
|
Most checks live in files under `.github/workflows/`. CodeQL uses the
|
||||||
through GitHub's code scanning default setup, so it appears as a dynamic GitHub
|
checked-in advanced configuration in `.github/workflows/codeql.yml`. They run
|
||||||
workflow instead of a checked-in workflow file. They run automatically; you do
|
automatically; you do not start them.
|
||||||
not start them.
|
|
||||||
|
|
||||||
| Check | What it protects against | Blocks a merge? |
|
| Check | What it protects against | Blocks a merge? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -90,14 +89,9 @@ let the workflows run on one pull request first, then add them here.
|
|||||||
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
||||||
powers Dependency review and Dependabot.
|
powers Dependency review and Dependabot.
|
||||||
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
||||||
4. Under **Code scanning**, use **Set up -> Default** for CodeQL. GitHub then
|
4. Under **Code scanning**, keep **Default setup** disabled. CodeQL is
|
||||||
runs CodeQL as a dynamic workflow without the fork-token limitations that
|
configured by `.github/workflows/codeql.yml`; enabling default setup at the
|
||||||
affect checked-in advanced workflows.
|
same time causes GitHub to reject uploads from the checked-in workflow.
|
||||||
|
|
||||||
Do not also add a checked-in CodeQL workflow while default setup is enabled:
|
|
||||||
GitHub rejects advanced CodeQL uploads when default setup is active. If the
|
|
||||||
project later needs an advanced CodeQL workflow, disable default setup first
|
|
||||||
and keep only one CodeQL publishing path active.
|
|
||||||
|
|
||||||
## Keeping it current
|
## Keeping it current
|
||||||
|
|
||||||
|
|||||||
+105
-6
@@ -4,7 +4,7 @@ This page keeps the detailed install, deployment, troubleshooting, and configura
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
|
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/odysseus-dev/odysseus/tree/main).
|
||||||
|
|
||||||
Defaults work out of the box: clone, run, then configure models/search/email
|
Defaults work out of the box: clone, run, then configure models/search/email
|
||||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||||
@@ -20,7 +20,7 @@ pull request guidelines.
|
|||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
cp .env.example .env # optional, but recommended for explicit defaults
|
cp .env.example .env # optional, but recommended for explicit defaults
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -38,7 +38,7 @@ only when you intentionally want LAN/reverse-proxy access.
|
|||||||
|
|
||||||
### Native Linux / macOS
|
### Native Linux / macOS
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -56,7 +56,7 @@ Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
|||||||
M-series Mac, run Odysseus natively:
|
M-series Mac, run Odysseus natively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
./start-macos.sh
|
./start-macos.sh
|
||||||
```
|
```
|
||||||
@@ -150,6 +150,79 @@ scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
|||||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||||
```
|
```
|
||||||
|
#### Arch Linux NVIDIA Docker notes
|
||||||
|
|
||||||
|
On Arch Linux, verify the host NVIDIA driver and Docker GPU passthrough before enabling the Odysseus NVIDIA overlay.
|
||||||
|
|
||||||
|
Install the required packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -Syu
|
||||||
|
sudo pacman -S docker docker-compose nvidia-container-toolkit nvidia-utils
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Docker to use the NVIDIA container runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the host GPU:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Docker GPU passthrough:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.9.0-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable the Odysseus NVIDIA compose overlay:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild and verify the GPU inside the Odysseus container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec odysseus nvidia-smi -L
|
||||||
|
```
|
||||||
|
|
||||||
|
For first-time local model testing on 8 GB laptop GPUs, start with GGUF/Q4 models on llama.cpp before trying GPTQ/AWQ models on vLLM or SGLang. This keeps the first run simpler while confirming GPU passthrough works.
|
||||||
|
|
||||||
|
**WSL2 + snap Docker.** If the NVIDIA check fails with this error, Docker may be
|
||||||
|
installed via snap:
|
||||||
|
|
||||||
|
```text
|
||||||
|
failed to fulfil mount request: open /usr/lib/wsl/lib/libdxcore.so: no such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
|
Check with `snap list docker` or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker info --format '{{.DockerRootDir}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
A Docker root under `/var/snap/docker/` means snap confinement can prevent
|
||||||
|
Docker from seeing WSL2's `/usr/lib/wsl/lib` GPU libraries even when the files
|
||||||
|
exist on the host. Reinstalling or reconfiguring `nvidia-container-toolkit` will
|
||||||
|
not fix that. Remove snap Docker, install the official apt-based Docker Engine
|
||||||
|
([Docker docs](https://docs.docker.com/engine/install/ubuntu/)), then configure
|
||||||
|
the NVIDIA runtime again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo snap remove docker
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-run `scripts/check-docker-gpu.sh`.
|
||||||
|
|
||||||
Safety notes:
|
Safety notes:
|
||||||
- The app never installs host GPU runtime automatically.
|
- The app never installs host GPU runtime automatically.
|
||||||
@@ -236,6 +309,32 @@ container. Cookbook **Serve** is a separate workflow for serving downloaded
|
|||||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||||
install usually only need to add the endpoint in Settings.
|
install usually only need to add the endpoint in Settings.
|
||||||
|
|
||||||
|
**Tool calls not firing on a manually-added Ollama `/v1` endpoint.** By
|
||||||
|
design, a local Ollama `/v1` endpoint defaults to the conservative
|
||||||
|
text-based (fenced-block) tool-calling path rather than native structured
|
||||||
|
tool calls, since some locally-served models mishandle native schemas (see
|
||||||
|
#1567). This is correct for most local setups, but if you know your specific
|
||||||
|
model reliably supports native tool calling (check `ollama show <model>` for
|
||||||
|
`tools` under Capabilities), you can opt that endpoint in explicitly. There
|
||||||
|
is currently no UI control for this on manually-added endpoints (see #5192);
|
||||||
|
the flag can still be set directly against the existing API, from a browser
|
||||||
|
console on an authenticated admin session:
|
||||||
|
|
||||||
|
```js
|
||||||
|
fetch('/api/model-endpoints/<endpoint-id>', {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({supports_tools: true})
|
||||||
|
}).then(r => r.json()).then(console.log)
|
||||||
|
```
|
||||||
|
|
||||||
|
Find `<endpoint-id>` by inspecting the `/api/model-endpoints` response (or
|
||||||
|
your browser's network tab while Settings loads the endpoint list). Send
|
||||||
|
`supports_tools: false` to disable native structured tool calls and force the
|
||||||
|
conservative fenced/text path, or `supports_tools: null` to return the endpoint
|
||||||
|
to the Auto heuristic.
|
||||||
|
|
||||||
**Useful checks.**
|
**Useful checks.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -257,7 +356,7 @@ do not run on macOS. MLX-only models are not served by Odysseus.
|
|||||||
server; safe to re-run):
|
server; safe to re-run):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
||||||
```
|
```
|
||||||
@@ -265,7 +364,7 @@ powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
|||||||
Or do it by hand:
|
Or do it by hand:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
py -3.11 -m venv venv
|
py -3.11 -m venv venv
|
||||||
venv\Scripts\Activate.ps1
|
venv\Scripts\Activate.ps1
|
||||||
|
|||||||
+369
-6
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import uuid
|
import uuid
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.stdio import stdio_server
|
from mcp.server.stdio import stdio_server
|
||||||
@@ -129,20 +130,36 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
|||||||
return _has_owner_scoped_accounts(rows)
|
return _has_owner_scoped_accounts(rows)
|
||||||
|
|
||||||
|
|
||||||
def _load_email_writing_style() -> str:
|
def _load_email_writing_style(account: str | None = None) -> str:
|
||||||
"""Return the existing Settings > Email > Writing Style value."""
|
"""Return the saved Settings > Email > Writing Style value.
|
||||||
|
|
||||||
|
Prefer the selected account's style when one exists; fall back to the
|
||||||
|
legacy global style so older installs keep behaving as before.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
settings_path = DATA_DIR / "settings.json"
|
settings_path = DATA_DIR / "settings.json"
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
return ""
|
return ""
|
||||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
|
account_id = ""
|
||||||
|
if account:
|
||||||
|
try:
|
||||||
|
cfg = _load_config(account)
|
||||||
|
account_id = str(cfg.get("account_id") or account or "").strip()
|
||||||
|
except Exception:
|
||||||
|
account_id = str(account or "").strip()
|
||||||
|
by_account = settings.get("email_writing_styles_by_account") or {}
|
||||||
|
if account_id and isinstance(by_account, dict):
|
||||||
|
style = by_account.get(account_id)
|
||||||
|
if isinstance(style, str) and style.strip():
|
||||||
|
return style.strip()
|
||||||
return str(settings.get("email_writing_style") or "").strip()
|
return str(settings.get("email_writing_style") or "").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _writing_style_guidance() -> str:
|
def _writing_style_guidance(account: str | None = None) -> str:
|
||||||
style = _load_email_writing_style()
|
style = _load_email_writing_style(account)
|
||||||
if not style:
|
if not style:
|
||||||
return (
|
return (
|
||||||
"No saved writing style is configured in Settings > Email > Writing Style. "
|
"No saved writing style is configured in Settings > Email > Writing Style. "
|
||||||
@@ -509,6 +526,251 @@ def _decode_header(raw):
|
|||||||
return "".join(decoded)
|
return "".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||||
|
m = re.search(rb"UID\s+(\d+)", meta_b or b"")
|
||||||
|
return m.group(1).decode("ascii", errors="ignore") if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
pieces = re.findall(r"<([^>]+)>", raw)
|
||||||
|
if not pieces:
|
||||||
|
pieces = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
out: list[dict] = []
|
||||||
|
seen = set()
|
||||||
|
for piece in pieces:
|
||||||
|
target = piece.strip().strip("<>").strip()
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
parsed = urlparse(target)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
key = target.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if scheme == "mailto":
|
||||||
|
addr = unquote(parsed.path or "").strip()
|
||||||
|
if not addr or "\r" in addr or "\n" in addr:
|
||||||
|
continue
|
||||||
|
query = parse_qs(parsed.query or "", keep_blank_values=True)
|
||||||
|
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
|
||||||
|
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
|
||||||
|
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
|
||||||
|
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
|
||||||
|
out.append({
|
||||||
|
"kind": "mailto",
|
||||||
|
"target": addr,
|
||||||
|
"subject": subject[:200],
|
||||||
|
"body": body[:1000],
|
||||||
|
"executable": True,
|
||||||
|
})
|
||||||
|
elif scheme in {"http", "https"}:
|
||||||
|
out.append({
|
||||||
|
"kind": "url",
|
||||||
|
"target": target,
|
||||||
|
"executable": False,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str) -> dict | None:
|
||||||
|
sender = _decode_header(msg.get("From", ""))
|
||||||
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||||
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||||
|
list_id = _decode_header(msg.get("List-Id", ""))
|
||||||
|
precedence = (msg.get("Precedence") or "").strip().lower()
|
||||||
|
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
|
||||||
|
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
|
||||||
|
if not methods:
|
||||||
|
return None
|
||||||
|
reasons: list[str] = ["has unsubscribe header"]
|
||||||
|
score = 45
|
||||||
|
if list_id:
|
||||||
|
score += 20
|
||||||
|
reasons.append("mailing-list header")
|
||||||
|
if precedence in {"bulk", "junk", "list"}:
|
||||||
|
score += 20
|
||||||
|
reasons.append(f"precedence={precedence}")
|
||||||
|
if auto_submitted and auto_submitted != "no":
|
||||||
|
score += 10
|
||||||
|
reasons.append(f"auto-submitted={auto_submitted}")
|
||||||
|
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", (subject or "").lower()):
|
||||||
|
score += 10
|
||||||
|
reasons.append("promotional subject")
|
||||||
|
executable = [m for m in methods if m.get("executable")]
|
||||||
|
return {
|
||||||
|
"uid": str(uid),
|
||||||
|
"folder": folder,
|
||||||
|
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||||
|
"subject": subject,
|
||||||
|
"from_name": sender_name or sender_addr,
|
||||||
|
"from_address": sender_addr,
|
||||||
|
"list_id": list_id,
|
||||||
|
"score": min(score, 100),
|
||||||
|
"reasons": reasons[:5],
|
||||||
|
"methods": methods,
|
||||||
|
"can_execute": bool(executable),
|
||||||
|
"recommended_method": executable[0] if executable else methods[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
|
||||||
|
list_id = str(candidate.get("list_id") or "").strip().lower()
|
||||||
|
method = candidate.get("recommended_method") or {}
|
||||||
|
method_kind = str(method.get("kind") or "").strip().lower()
|
||||||
|
method_target = str(method.get("target") or "").strip().lower()
|
||||||
|
sender = str(candidate.get("from_address") or "").strip().lower()
|
||||||
|
if list_id:
|
||||||
|
return ("list", list_id, method_target or sender)
|
||||||
|
if method_target:
|
||||||
|
return ("method", method_kind, method_target)
|
||||||
|
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
|
||||||
|
deduped: dict[tuple[str, str, str], dict] = {}
|
||||||
|
for candidate in candidates or []:
|
||||||
|
key = _unsubscribe_candidate_dedupe_key(candidate)
|
||||||
|
existing = deduped.get(key)
|
||||||
|
if not existing:
|
||||||
|
copy = dict(candidate)
|
||||||
|
copy["duplicate_count"] = 1
|
||||||
|
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
|
||||||
|
deduped[key] = copy
|
||||||
|
continue
|
||||||
|
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
|
||||||
|
uid = str(candidate.get("uid") or "")
|
||||||
|
if uid:
|
||||||
|
existing.setdefault("duplicate_uids", []).append(uid)
|
||||||
|
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
|
||||||
|
keep_count = existing.get("duplicate_count")
|
||||||
|
keep_uids = existing.get("duplicate_uids")
|
||||||
|
replacement = dict(candidate)
|
||||||
|
replacement["duplicate_count"] = keep_count
|
||||||
|
replacement["duplicate_uids"] = keep_uids
|
||||||
|
deduped[key] = replacement
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_unsubscribe_candidates(folder="INBOX", account=None, limit=25, max_scan=150) -> dict:
|
||||||
|
limit = max(1, min(int(limit or 25), 100))
|
||||||
|
max_scan = max(limit, min(int(max_scan or 150), 500))
|
||||||
|
folder = folder or "INBOX"
|
||||||
|
candidates: list[dict] = []
|
||||||
|
conn = _imap_connect(account)
|
||||||
|
try:
|
||||||
|
status, _ = conn.select(_q(folder), readonly=True)
|
||||||
|
if status != "OK":
|
||||||
|
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
|
||||||
|
status, data = conn.uid("SEARCH", None, "ALL")
|
||||||
|
if status != "OK" or not data or not data[0]:
|
||||||
|
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||||
|
uids = []
|
||||||
|
for raw_uid in data[0].split():
|
||||||
|
try:
|
||||||
|
uids.append(int(raw_uid))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
uids = sorted(uids, reverse=True)[:max_scan]
|
||||||
|
if not uids:
|
||||||
|
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||||
|
status, msg_data = conn.uid("FETCH", _b(",".join(str(u) for u in uids)), "(UID RFC822.HEADER)")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if status != "OK":
|
||||||
|
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
|
||||||
|
for item in msg_data or []:
|
||||||
|
if not isinstance(item, tuple) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
|
||||||
|
uid = _uid_from_fetch_meta(meta_b)
|
||||||
|
if not uid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(item[1] or b"")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
||||||
|
if candidate:
|
||||||
|
candidates.append(candidate)
|
||||||
|
raw_total = len(candidates)
|
||||||
|
candidates = _dedupe_unsubscribe_candidates(candidates)
|
||||||
|
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), int(c.get("uid") or 0)), reverse=True)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"candidates": candidates[:limit],
|
||||||
|
"total": len(candidates),
|
||||||
|
"raw_total": raw_total,
|
||||||
|
"scanned": len(uids),
|
||||||
|
"folder": folder,
|
||||||
|
"account": account or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unsubscribe_email(uid, folder="INBOX", account=None, method_index=0, allow_web=False) -> dict:
|
||||||
|
uid = str(uid or "").strip()
|
||||||
|
if not uid:
|
||||||
|
return {"success": False, "error": "uid is required"}
|
||||||
|
conn = _imap_connect(account)
|
||||||
|
try:
|
||||||
|
status, _ = conn.select(_q(folder), readonly=True)
|
||||||
|
if status != "OK":
|
||||||
|
return {"success": False, "error": f"Folder not found: {folder}"}
|
||||||
|
status, msg_data = conn.uid("FETCH", _b(uid), "(UID RFC822.HEADER)")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if status != "OK" or not msg_data:
|
||||||
|
return {"success": False, "error": f"Email not found: {uid}"}
|
||||||
|
raw_header = b""
|
||||||
|
for item in msg_data or []:
|
||||||
|
if isinstance(item, tuple) and len(item) >= 2:
|
||||||
|
raw_header = item[1] or b""
|
||||||
|
break
|
||||||
|
msg = email.message_from_bytes(raw_header)
|
||||||
|
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
||||||
|
if not candidate:
|
||||||
|
return {"success": False, "error": "No List-Unsubscribe header found"}
|
||||||
|
methods = candidate.get("methods") or []
|
||||||
|
method_index = int(method_index or 0)
|
||||||
|
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
|
||||||
|
if method.get("kind") == "url":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"requires_browser": True,
|
||||||
|
"url": method.get("target"),
|
||||||
|
"candidate": candidate,
|
||||||
|
"instructions": (
|
||||||
|
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
|
||||||
|
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if method.get("kind") != "mailto" or not method.get("executable"):
|
||||||
|
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
|
||||||
|
result = _send_email(
|
||||||
|
to=method.get("target"),
|
||||||
|
subject=method.get("subject") or "unsubscribe",
|
||||||
|
body=method.get("body") or "unsubscribe",
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
return {"success": False, "error": result["error"], "candidate": candidate}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"method": method,
|
||||||
|
"candidate": candidate,
|
||||||
|
"send_result": result,
|
||||||
|
"pending": bool(result.get("pending")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_text(msg):
|
def _extract_text(msg):
|
||||||
"""Extract plain text body from email message."""
|
"""Extract plain text body from email message."""
|
||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
@@ -1546,8 +1808,7 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"error": f"AI reply helpers unavailable: {exc}"}
|
return {"error": f"AI reply helpers unavailable: {exc}"}
|
||||||
|
|
||||||
settings = _load_settings()
|
style = _load_email_writing_style(account)
|
||||||
style = settings.get("email_writing_style", "")
|
|
||||||
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
||||||
if style:
|
if style:
|
||||||
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
|
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
|
||||||
@@ -1890,6 +2151,45 @@ async def list_tools() -> list[Tool]:
|
|||||||
"required": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Tool(
|
||||||
|
name="scan_email_unsubscribes",
|
||||||
|
description=(
|
||||||
|
"Scan recent email headers for likely spam/newsletter unsubscribe candidates. "
|
||||||
|
"Returns reviewable candidates with UID, sender, subject, score, reasons, and "
|
||||||
|
"List-Unsubscribe methods. This does not unsubscribe anything. For mailto "
|
||||||
|
"methods, use unsubscribe_email after user approval. For web URL methods, use "
|
||||||
|
"browser/web tools after user approval to open the exact URL and complete the page."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
|
||||||
|
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 25},
|
||||||
|
"max_scan": {"type": "integer", "description": "How many newest messages to inspect", "default": 150},
|
||||||
|
**ACCOUNT_PROP,
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="unsubscribe_email",
|
||||||
|
description=(
|
||||||
|
"Execute one approved unsubscribe action for an email UID. Supports safe mailto "
|
||||||
|
"List-Unsubscribe directly. If the selected method is a web URL, this returns "
|
||||||
|
"requires_browser with the exact URL; use browser/web tools only after user approval."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
|
||||||
|
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
|
||||||
|
"method_index": {"type": "integer", "description": "Unsubscribe method index from scan_email_unsubscribes", "default": 0},
|
||||||
|
"allow_web": {"type": "boolean", "description": "Return web unsubscribe URL instructions when the method is URL", "default": False},
|
||||||
|
**ACCOUNT_PROP,
|
||||||
|
},
|
||||||
|
"required": ["uid"],
|
||||||
|
},
|
||||||
|
),
|
||||||
Tool(
|
Tool(
|
||||||
name="download_attachment",
|
name="download_attachment",
|
||||||
description=(
|
description=(
|
||||||
@@ -2264,6 +2564,69 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
lines.append(line)
|
lines.append(line)
|
||||||
return [TextContent(type="text", text="\n\n".join(lines))]
|
return [TextContent(type="text", text="\n\n".join(lines))]
|
||||||
|
|
||||||
|
elif name == "scan_email_unsubscribes":
|
||||||
|
try:
|
||||||
|
result = _scan_unsubscribe_candidates(
|
||||||
|
folder=arguments.get("folder", "INBOX"),
|
||||||
|
account=acct,
|
||||||
|
limit=arguments.get("limit", 25),
|
||||||
|
max_scan=arguments.get("max_scan", 150),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return [TextContent(type="text", text=f"Unsubscribe scan failed: {e}")]
|
||||||
|
if not result.get("success"):
|
||||||
|
return [TextContent(type="text", text=f"Unsubscribe scan failed: {result.get('error', 'unknown error')}")]
|
||||||
|
candidates = result.get("candidates") or []
|
||||||
|
if not candidates:
|
||||||
|
return [TextContent(type="text", text=f"No unsubscribe candidates found in {result.get('scanned', 0)} recent emails.")]
|
||||||
|
lines = [
|
||||||
|
f"Found {len(candidates)} unsubscribe candidate(s) from {result.get('scanned', 0)} recent emails.",
|
||||||
|
"Review these with the user before executing. Mailto methods can use unsubscribe_email; URL methods require browser/web tools after approval.\n",
|
||||||
|
]
|
||||||
|
for i, cand in enumerate(candidates, 1):
|
||||||
|
lines.append(
|
||||||
|
f"{i}. **{cand.get('subject') or '(no subject)'}**\n"
|
||||||
|
f" From: {cand.get('from_name') or cand.get('from_address') or ''} ({cand.get('from_address') or ''})\n"
|
||||||
|
f" UID: {cand.get('uid')} Folder: {cand.get('folder')}\n"
|
||||||
|
f" Score: {cand.get('score')} Matching emails: {cand.get('duplicate_count', 1)} Reasons: {', '.join(cand.get('reasons') or [])}"
|
||||||
|
)
|
||||||
|
for j, method in enumerate(cand.get("methods") or []):
|
||||||
|
if method.get("kind") == "mailto":
|
||||||
|
lines.append(f" Method {j}: mailto {method.get('target')} (executable via unsubscribe_email)")
|
||||||
|
elif method.get("kind") == "url":
|
||||||
|
lines.append(f" Method {j}: web URL {method.get('target')} (use browser/web tools after approval)")
|
||||||
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
|
elif name == "unsubscribe_email":
|
||||||
|
result = _unsubscribe_email(
|
||||||
|
uid=arguments.get("uid"),
|
||||||
|
folder=arguments.get("folder", "INBOX"),
|
||||||
|
account=acct,
|
||||||
|
method_index=arguments.get("method_index", 0),
|
||||||
|
allow_web=bool(arguments.get("allow_web", False)),
|
||||||
|
)
|
||||||
|
if result.get("requires_browser"):
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=(
|
||||||
|
"Web unsubscribe requires browser/web navigation.\n"
|
||||||
|
f"URL: {result.get('url')}\n"
|
||||||
|
f"{result.get('instructions')}"
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
if not result.get("success"):
|
||||||
|
return [TextContent(type="text", text=f"Unsubscribe failed: {result.get('error', 'unknown error')}")]
|
||||||
|
method = result.get("method") or {}
|
||||||
|
if result.get("pending"):
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=(
|
||||||
|
f"Unsubscribe email staged for approval to {method.get('target')}. "
|
||||||
|
"Nothing has been sent until the user approves the pending email."
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}.")]
|
||||||
|
|
||||||
elif name == "download_attachment":
|
elif name == "download_attachment":
|
||||||
uid = arguments.get("uid")
|
uid = arguments.get("uid")
|
||||||
index = arguments.get("index")
|
index = arguments.get("index")
|
||||||
|
|||||||
@@ -81,7 +81,13 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
||||||
|
|
||||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
|
try:
|
||||||
|
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, model_type="image")
|
||||||
|
except ValueError:
|
||||||
|
_lower_model_spec = model_spec.lower()
|
||||||
|
if not any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e")):
|
||||||
|
raise
|
||||||
|
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
|
||||||
|
|
||||||
is_gpt_image = "gpt-image" in model_id.lower()
|
is_gpt_image = "gpt-image" in model_id.lower()
|
||||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from mcp.types import Tool, TextContent
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.memory import MemoryStoreUnreadable
|
||||||
|
|
||||||
server = Server("memory")
|
server = Server("memory")
|
||||||
|
|
||||||
# Late-initialized managers (set during first tool call)
|
# Late-initialized managers (set during first tool call)
|
||||||
@@ -29,6 +31,10 @@ _OWNER_SCOPE_ERROR = (
|
|||||||
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
||||||
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
||||||
)
|
)
|
||||||
|
_UNREADABLE_STORE_ERROR = (
|
||||||
|
"Error: Memory store is temporarily unreadable — nothing was saved. "
|
||||||
|
"Repair or restore memory.json, then retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configured_owner() -> str | None:
|
def _configured_owner() -> str | None:
|
||||||
@@ -51,9 +57,21 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
|
|||||||
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
||||||
|
|
||||||
|
|
||||||
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||||
"""Return configured owner, all entries, visible entries, and optional error."""
|
"""Return configured owner, all entries, visible entries, and optional error.
|
||||||
entries = _memory_manager.load_all()
|
|
||||||
|
``for_update=True`` is for read-modify-write callers. They save the ``all
|
||||||
|
entries`` list back, so an unreadable store must be reported as an error
|
||||||
|
instead of degrading to ``[]`` — otherwise the save writes their one new
|
||||||
|
entry over the whole store (issue #5673).
|
||||||
|
"""
|
||||||
|
if for_update:
|
||||||
|
try:
|
||||||
|
entries = _memory_manager.load_all_for_update()
|
||||||
|
except MemoryStoreUnreadable as e:
|
||||||
|
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
|
||||||
|
else:
|
||||||
|
entries = _memory_manager.load_all()
|
||||||
owner = _configured_owner()
|
owner = _configured_owner()
|
||||||
if owner is None and _owner_scoped_store(entries):
|
if owner is None and _owner_scoped_store(entries):
|
||||||
return None, entries, [], _OWNER_SCOPE_ERROR
|
return None, entries, [], _OWNER_SCOPE_ERROR
|
||||||
@@ -161,7 +179,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
category = arguments.get("category", "fact")
|
category = arguments.get("category", "fact")
|
||||||
if not text:
|
if not text:
|
||||||
return _text_result("Error: Memory text cannot be empty")
|
return _text_result("Error: Memory text cannot be empty")
|
||||||
owner, memories, _visible, scope_error = _scope_entries()
|
owner, memories, _visible, scope_error = _scope_entries(for_update=True)
|
||||||
if scope_error:
|
if scope_error:
|
||||||
return _text_result(scope_error)
|
return _text_result(scope_error)
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
"url": "https://github.com/odysseus-dev/odysseus.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.6.1"
|
"@antithesishq/bombadil": "^0.6.1"
|
||||||
|
|||||||
+4
-1
@@ -38,7 +38,10 @@ python-dateutil
|
|||||||
caldav
|
caldav
|
||||||
cryptography
|
cryptography
|
||||||
bcrypt
|
bcrypt
|
||||||
mcp
|
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
||||||
|
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
||||||
|
# servers are migrated together.
|
||||||
|
mcp<2
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
croniter
|
croniter
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Admin wipe route domain package (slice 2h, #4082/#4071).
|
||||||
|
|
||||||
|
Contains admin_wipe_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/admin_wipe_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Admin Danger Zone — per-category wipes.
|
||||||
|
|
||||||
|
Each endpoint is admin-only and truncates exactly one domain so the
|
||||||
|
user can selectively reset memory / skills / notes / etc. without
|
||||||
|
nuking everything. The catch-all `chats` endpoint mirrors the
|
||||||
|
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
|
||||||
|
|
||||||
|
URL shape: DELETE /api/admin/wipe/{kind}
|
||||||
|
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from core.middleware import require_admin
|
||||||
|
from core.database import (
|
||||||
|
SessionLocal,
|
||||||
|
Session as DbSession,
|
||||||
|
ChatMessage as DbChatMessage,
|
||||||
|
Memory,
|
||||||
|
Note,
|
||||||
|
ScheduledTask,
|
||||||
|
TaskRun,
|
||||||
|
Document,
|
||||||
|
DocumentVersion,
|
||||||
|
GalleryImage,
|
||||||
|
GalleryAlbum,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarCal,
|
||||||
|
)
|
||||||
|
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _wipe_memory_files():
|
||||||
|
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
|
||||||
|
next audit doesn't try to diff against gone memories."""
|
||||||
|
for name in ("memory.json", "memory_tidy_state.json"):
|
||||||
|
p = os.path.join(DATA_DIR, name)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if name == "memory.json":
|
||||||
|
with open(p, "w", encoding="utf-8") as f:
|
||||||
|
json.dump([], f)
|
||||||
|
else:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not reset {name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rmtree_quiet(path: str):
|
||||||
|
"""rmtree that doesn't crash if the path doesn't exist."""
|
||||||
|
if os.path.isdir(path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not remove {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_admin_wipe_routes(session_manager):
|
||||||
|
"""The session_manager is passed in so we can also clear its
|
||||||
|
in-memory cache when wiping chats — without it the DB is empty
|
||||||
|
but the next /api/sessions returns stale entries."""
|
||||||
|
router = APIRouter(prefix="/api/admin")
|
||||||
|
|
||||||
|
@router.delete("/wipe/{kind}")
|
||||||
|
def wipe(kind: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
kind = (kind or "").strip().lower()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if kind == "chats":
|
||||||
|
count = db.query(DbSession).count()
|
||||||
|
db.query(DbChatMessage).delete()
|
||||||
|
db.query(DbSession).delete()
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
session_manager.sessions.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "memory":
|
||||||
|
count = db.query(Memory).count()
|
||||||
|
db.query(Memory).delete()
|
||||||
|
db.commit()
|
||||||
|
_wipe_memory_files()
|
||||||
|
# Drop the vector store too so semantic search doesn't
|
||||||
|
# return ghosts. Lazy import — chromadb may not be
|
||||||
|
# initialised in every deployment.
|
||||||
|
try:
|
||||||
|
from src.memory_vector import get_memory_vector_store
|
||||||
|
mv = get_memory_vector_store()
|
||||||
|
if mv and hasattr(mv, "clear"):
|
||||||
|
mv.clear()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Memory vector clear skipped: {e}")
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "skills":
|
||||||
|
# Skills live as SKILL.md files under data/skills/. Drop
|
||||||
|
# the entire directory; the SkillsManager re-creates the
|
||||||
|
# tree on next write.
|
||||||
|
skills_dir = SKILLS_DIR
|
||||||
|
count = 0
|
||||||
|
if os.path.isdir(skills_dir):
|
||||||
|
# Count SKILL.md files for the response — quick walk.
|
||||||
|
for _, _, files in os.walk(skills_dir):
|
||||||
|
count += sum(1 for f in files if f == "SKILL.md")
|
||||||
|
_rmtree_quiet(skills_dir)
|
||||||
|
# Legacy fallback file
|
||||||
|
legacy = SKILLS_FILE
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
try:
|
||||||
|
os.remove(legacy)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "notes":
|
||||||
|
count = db.query(Note).count()
|
||||||
|
db.query(Note).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "tasks":
|
||||||
|
# TaskRun rows reference tasks via FK — clear them first.
|
||||||
|
db.query(TaskRun).delete()
|
||||||
|
count = db.query(ScheduledTask).count()
|
||||||
|
db.query(ScheduledTask).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "documents":
|
||||||
|
# DocumentVersion FKs Document — clear children first.
|
||||||
|
db.query(DocumentVersion).delete()
|
||||||
|
count = db.query(Document).count()
|
||||||
|
db.query(Document).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "gallery":
|
||||||
|
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
|
||||||
|
db.query(GalleryImage).delete()
|
||||||
|
db.query(GalleryAlbum).delete()
|
||||||
|
db.commit()
|
||||||
|
# Also drop the upload dir so disk doesn't keep orphans.
|
||||||
|
_rmtree_quiet(GALLERY_DIR)
|
||||||
|
_rmtree_quiet(GALLERY_UPLOADS_DIR)
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "calendar":
|
||||||
|
# Events FK calendars — clear children first, then both.
|
||||||
|
db.query(CalendarEvent).delete()
|
||||||
|
count = db.query(CalendarCal).count()
|
||||||
|
db.query(CalendarCal).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception(f"Wipe {kind} failed")
|
||||||
|
raise HTTPException(500, f"Wipe {kind} failed: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return router
|
||||||
+12
-171
@@ -1,176 +1,17 @@
|
|||||||
"""Admin Danger Zone — per-category wipes.
|
"""Backward-compat shim — canonical location is routes/admin_wipe/admin_wipe_routes.py.
|
||||||
|
|
||||||
Each endpoint is admin-only and truncates exactly one domain so the
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
user can selectively reset memory / skills / notes / etc. without
|
that ``import routes.admin_wipe_routes``, ``from routes.admin_wipe_routes
|
||||||
nuking everything. The catch-all `chats` endpoint mirrors the
|
import X``, ``importlib.import_module("routes.admin_wipe_routes")``, and the
|
||||||
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
|
``import ... as admin_wipe_routes`` + ``monkeypatch.setattr(admin_wipe_routes,
|
||||||
|
"SessionLocal", ...)`` / ``"require_admin"`` pattern used by
|
||||||
URL shape: DELETE /api/admin/wipe/{kind}
|
test_admin_wipe_gallery.py all operate on the *same* object the application
|
||||||
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
|
actually uses. Keeps existing import paths working after slice 2h
|
||||||
|
(#4082/#4071).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import sys as _sys
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from routes.admin_wipe import admin_wipe_routes as _canonical # noqa: F401
|
||||||
from core.database import (
|
|
||||||
SessionLocal,
|
|
||||||
Session as DbSession,
|
|
||||||
ChatMessage as DbChatMessage,
|
|
||||||
Memory,
|
|
||||||
Note,
|
|
||||||
ScheduledTask,
|
|
||||||
TaskRun,
|
|
||||||
Document,
|
|
||||||
DocumentVersion,
|
|
||||||
GalleryImage,
|
|
||||||
GalleryAlbum,
|
|
||||||
CalendarEvent,
|
|
||||||
CalendarCal,
|
|
||||||
)
|
|
||||||
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
|
|
||||||
def _wipe_memory_files():
|
|
||||||
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
|
|
||||||
next audit doesn't try to diff against gone memories."""
|
|
||||||
for name in ("memory.json", "memory_tidy_state.json"):
|
|
||||||
p = os.path.join(DATA_DIR, name)
|
|
||||||
if not os.path.exists(p):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if name == "memory.json":
|
|
||||||
with open(p, "w", encoding="utf-8") as f:
|
|
||||||
json.dump([], f)
|
|
||||||
else:
|
|
||||||
os.remove(p)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not reset {name}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _rmtree_quiet(path: str):
|
|
||||||
"""rmtree that doesn't crash if the path doesn't exist."""
|
|
||||||
if os.path.isdir(path):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not remove {path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def setup_admin_wipe_routes(session_manager):
|
|
||||||
"""The session_manager is passed in so we can also clear its
|
|
||||||
in-memory cache when wiping chats — without it the DB is empty
|
|
||||||
but the next /api/sessions returns stale entries."""
|
|
||||||
router = APIRouter(prefix="/api/admin")
|
|
||||||
|
|
||||||
@router.delete("/wipe/{kind}")
|
|
||||||
def wipe(kind: str, request: Request):
|
|
||||||
require_admin(request)
|
|
||||||
kind = (kind or "").strip().lower()
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if kind == "chats":
|
|
||||||
count = db.query(DbSession).count()
|
|
||||||
db.query(DbChatMessage).delete()
|
|
||||||
db.query(DbSession).delete()
|
|
||||||
db.commit()
|
|
||||||
try:
|
|
||||||
session_manager.sessions.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "memory":
|
|
||||||
count = db.query(Memory).count()
|
|
||||||
db.query(Memory).delete()
|
|
||||||
db.commit()
|
|
||||||
_wipe_memory_files()
|
|
||||||
# Drop the vector store too so semantic search doesn't
|
|
||||||
# return ghosts. Lazy import — chromadb may not be
|
|
||||||
# initialised in every deployment.
|
|
||||||
try:
|
|
||||||
from src.memory_vector import get_memory_vector_store
|
|
||||||
mv = get_memory_vector_store()
|
|
||||||
if mv and hasattr(mv, "clear"):
|
|
||||||
mv.clear()
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"Memory vector clear skipped: {e}")
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "skills":
|
|
||||||
# Skills live as SKILL.md files under data/skills/. Drop
|
|
||||||
# the entire directory; the SkillsManager re-creates the
|
|
||||||
# tree on next write.
|
|
||||||
skills_dir = SKILLS_DIR
|
|
||||||
count = 0
|
|
||||||
if os.path.isdir(skills_dir):
|
|
||||||
# Count SKILL.md files for the response — quick walk.
|
|
||||||
for _, _, files in os.walk(skills_dir):
|
|
||||||
count += sum(1 for f in files if f == "SKILL.md")
|
|
||||||
_rmtree_quiet(skills_dir)
|
|
||||||
# Legacy fallback file
|
|
||||||
legacy = SKILLS_FILE
|
|
||||||
if os.path.exists(legacy):
|
|
||||||
try:
|
|
||||||
os.remove(legacy)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "notes":
|
|
||||||
count = db.query(Note).count()
|
|
||||||
db.query(Note).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "tasks":
|
|
||||||
# TaskRun rows reference tasks via FK — clear them first.
|
|
||||||
db.query(TaskRun).delete()
|
|
||||||
count = db.query(ScheduledTask).count()
|
|
||||||
db.query(ScheduledTask).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "documents":
|
|
||||||
# DocumentVersion FKs Document — clear children first.
|
|
||||||
db.query(DocumentVersion).delete()
|
|
||||||
count = db.query(Document).count()
|
|
||||||
db.query(Document).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "gallery":
|
|
||||||
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
|
|
||||||
db.query(GalleryImage).delete()
|
|
||||||
db.query(GalleryAlbum).delete()
|
|
||||||
db.commit()
|
|
||||||
# Also drop the upload dir so disk doesn't keep orphans.
|
|
||||||
_rmtree_quiet(GALLERY_DIR)
|
|
||||||
_rmtree_quiet(GALLERY_UPLOADS_DIR)
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "calendar":
|
|
||||||
# Events FK calendars — clear children first, then both.
|
|
||||||
db.query(CalendarEvent).delete()
|
|
||||||
count = db.query(CalendarCal).count()
|
|
||||||
db.query(CalendarCal).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.exception(f"Wipe {kind} failed")
|
|
||||||
raise HTTPException(500, f"Wipe {kind} failed: {e}")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
+53
-1
@@ -345,9 +345,61 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
# docs, email accounts, tasks, etc.
|
# docs, email accounts, tasks, etc.
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from core.database import Base, SessionLocal
|
from core.database import (
|
||||||
|
Base,
|
||||||
|
EmailAccount,
|
||||||
|
SessionLocal,
|
||||||
|
lock_email_account_owner_mutations,
|
||||||
|
)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
# Email-account defaults are protected by per-owner mutex rows.
|
||||||
|
# A rename crosses two owner partitions, so lock both in the
|
||||||
|
# shared helper's canonical order before inspecting either.
|
||||||
|
lock_email_account_owner_mutations(
|
||||||
|
db, old_username, new_username
|
||||||
|
)
|
||||||
|
|
||||||
|
source_default_ids = [
|
||||||
|
row[0]
|
||||||
|
for row in (
|
||||||
|
db.query(EmailAccount.id)
|
||||||
|
.filter(
|
||||||
|
func.lower(EmailAccount.owner) == old_username,
|
||||||
|
EmailAccount.is_default == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
destination_default_ids = [
|
||||||
|
row[0]
|
||||||
|
for row in (
|
||||||
|
db.query(EmailAccount.id)
|
||||||
|
.filter(
|
||||||
|
func.lower(EmailAccount.owner) == new_username,
|
||||||
|
EmailAccount.is_default == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if destination_default_ids:
|
||||||
|
clear_default_ids = (
|
||||||
|
destination_default_ids[1:] + source_default_ids
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
clear_default_ids = source_default_ids[1:]
|
||||||
|
if clear_default_ids:
|
||||||
|
(
|
||||||
|
db.query(EmailAccount)
|
||||||
|
.filter(EmailAccount.id.in_(clear_default_ids))
|
||||||
|
.update(
|
||||||
|
{EmailAccount.is_default: False},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for mapper in Base.registry.mappers:
|
for mapper in Base.registry.mappers:
|
||||||
model = mapper.class_
|
model = mapper.class_
|
||||||
if not hasattr(model, "owner"):
|
if not hasattr(model, "owner"):
|
||||||
|
|||||||
+10
-1
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
from services.memory import MemoryStoreUnreadable
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.settings import load_settings, save_settings, load_features, save_features
|
from src.settings import load_settings, save_settings, load_features, save_features
|
||||||
|
|
||||||
@@ -76,7 +77,15 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
|
|
||||||
# ── Memories ──
|
# ── Memories ──
|
||||||
if "memories" in body and isinstance(body["memories"], list):
|
if "memories" in body and isinstance(body["memories"], list):
|
||||||
existing = memory_manager.load_all()
|
# Strict load: importing on top of an unreadable store would write
|
||||||
|
# only the incoming rows back and drop everything already saved.
|
||||||
|
try:
|
||||||
|
existing = memory_manager.load_all_for_update()
|
||||||
|
except MemoryStoreUnreadable as e:
|
||||||
|
logger.error("Refusing to import memories: %s", e)
|
||||||
|
raise HTTPException(
|
||||||
|
503, "Memory store is temporarily unreadable — nothing was imported."
|
||||||
|
)
|
||||||
# Dedup against THIS user's own memories only. Using every tenant's
|
# Dedup against THIS user's own memories only. Using every tenant's
|
||||||
# rows (load_all) meant a memory whose text matched any other
|
# rows (load_all) meant a memory whose text matched any other
|
||||||
# user's was silently skipped, so the importing user lost their own
|
# user's was silently skipped, so the importing user lost their own
|
||||||
|
|||||||
+149
-10
@@ -10,11 +10,13 @@ from typing import Optional, List
|
|||||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
|
|
||||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import effective_user, require_user
|
||||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -220,22 +222,125 @@ class EventUpdate(BaseModel):
|
|||||||
|
|
||||||
# ── Helpers ──
|
# ── Helpers ──
|
||||||
|
|
||||||
|
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
|
||||||
|
"""Return one stable primary-key candidate for an owner's lazy default.
|
||||||
|
|
||||||
|
Slot zero preserves the original owner-derived identifier. Later slots
|
||||||
|
let a username be reused after its prior calendar was migrated to another
|
||||||
|
owner during a rename, without making concurrent first use choose random
|
||||||
|
and therefore divergent identifiers.
|
||||||
|
"""
|
||||||
|
if collision_index == 0:
|
||||||
|
candidate_name = owner
|
||||||
|
else:
|
||||||
|
candidate_name = json.dumps(
|
||||||
|
[owner, collision_index],
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _begin_sqlite_default_write(db) -> None:
|
||||||
|
"""Serialize an absent-default check with other SQLite writers.
|
||||||
|
|
||||||
|
SQLite's default deferred transactions allow two workers to both read an
|
||||||
|
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
|
||||||
|
writer reservation before the second, authoritative lookup. We issue it
|
||||||
|
only when the driver has not already opened a write transaction; a caller
|
||||||
|
with a pending write already owns the required reservation.
|
||||||
|
"""
|
||||||
|
connection = db.connection()
|
||||||
|
dbapi_connection = connection.connection
|
||||||
|
driver_connection = getattr(
|
||||||
|
dbapi_connection,
|
||||||
|
"driver_connection",
|
||||||
|
dbapi_connection,
|
||||||
|
)
|
||||||
|
if not getattr(driver_connection, "in_transaction", False):
|
||||||
|
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
||||||
"""Create default calendar if none exist for this owner."""
|
"""Return the owner's calendar, staging a default in the caller's transaction.
|
||||||
|
|
||||||
|
A stable owner-derived primary key makes concurrent first-use inserts
|
||||||
|
converge on one row on every SQL backend. SQLite additionally serializes
|
||||||
|
the absent-row check because its deferred transactions otherwise permit
|
||||||
|
both workers to read the gap before either writes. Other backends recover
|
||||||
|
a lost insert race inside a savepoint so the caller's event transaction
|
||||||
|
remains usable and atomic.
|
||||||
|
"""
|
||||||
owner = owner or FALLBACK_OWNER
|
owner = owner or FALLBACK_OWNER
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||||
if not cal:
|
if cal:
|
||||||
|
return cal
|
||||||
|
|
||||||
|
dialect = db.get_bind().dialect.name
|
||||||
|
if dialect == "sqlite":
|
||||||
|
_begin_sqlite_default_write(db)
|
||||||
|
# Another worker may have committed while BEGIN IMMEDIATE waited.
|
||||||
|
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||||
|
if cal:
|
||||||
|
return cal
|
||||||
|
|
||||||
|
collision_index = 0
|
||||||
|
while True:
|
||||||
|
default_id = _default_calendar_id(owner, collision_index)
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
|
||||||
|
# another SQLite writer cannot rename, delete, or claim this slot
|
||||||
|
# until the caller commits or rolls back.
|
||||||
|
occupant = db.query(CalendarCal).filter(
|
||||||
|
CalendarCal.id == default_id,
|
||||||
|
).first()
|
||||||
|
if occupant is not None:
|
||||||
|
if occupant.owner == owner:
|
||||||
|
return occupant
|
||||||
|
collision_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
cal = CalendarCal(
|
cal = CalendarCal(
|
||||||
id=str(uuid.uuid4()),
|
id=default_id,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
name="Personal",
|
name="Personal",
|
||||||
color="#5b8abf",
|
color="#5b8abf",
|
||||||
source="local",
|
source="local",
|
||||||
)
|
)
|
||||||
db.add(cal)
|
|
||||||
db.commit()
|
if dialect == "sqlite":
|
||||||
db.refresh(cal)
|
db.add(cal)
|
||||||
return cal
|
db.flush()
|
||||||
|
return cal
|
||||||
|
|
||||||
|
try:
|
||||||
|
# A uniqueness failure rolls back only this savepoint, not an event
|
||||||
|
# or reminder already staged by the caller's outer transaction.
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(cal)
|
||||||
|
db.flush()
|
||||||
|
return cal
|
||||||
|
except IntegrityError:
|
||||||
|
# Use a locking/current read so repeatable-read backends can observe
|
||||||
|
# the row that won after our transaction's original empty snapshot.
|
||||||
|
occupant = db.query(CalendarCal).filter(
|
||||||
|
CalendarCal.id == default_id,
|
||||||
|
).with_for_update().first()
|
||||||
|
if occupant is None:
|
||||||
|
# Do not misclassify an unrelated integrity failure as an ID
|
||||||
|
# collision and loop forever. A concurrently deleted winner is
|
||||||
|
# safe for the caller to retry as a fresh transaction.
|
||||||
|
raise
|
||||||
|
if occupant.owner == owner:
|
||||||
|
return occupant
|
||||||
|
# A renamed calendar owns this deterministic slot. Advance to the
|
||||||
|
# next stable slot; concurrent callers for this owner will still
|
||||||
|
# converge there.
|
||||||
|
collision_index += 1
|
||||||
|
|
||||||
|
|
||||||
# Per-request user time context. chat_routes sets this from browser timezone
|
# Per-request user time context. chat_routes sets this from browser timezone
|
||||||
@@ -697,9 +802,18 @@ def _expand_rrule(
|
|||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
def setup_calendar_routes() -> APIRouter:
|
def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||||
|
|
||||||
|
def _reserve_calendar_uploads(request: Request, *values) -> None:
|
||||||
|
missing_id = reserve_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
effective_user(request),
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
|
||||||
|
|
||||||
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _get_caldav_accounts(owner: str) -> list:
|
def _get_caldav_accounts(owner: str) -> list:
|
||||||
@@ -913,7 +1027,24 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
'</d:prop></d:propfind>'
|
'</d:prop></d:propfind>'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False) as cx:
|
# Build an SSL context that trusts the operator's custom CA bundle
|
||||||
|
# (SSL_CERT_FILE / REQUESTS_CA_BUNDLE) so self-signed CalDAV servers
|
||||||
|
# pass the pre-flight the same way they pass the real sync.
|
||||||
|
# trust_env=False is kept to block proxy/auth env leakage; the CA
|
||||||
|
# bundle is loaded explicitly instead.
|
||||||
|
import ssl as _ssl
|
||||||
|
_ssl_ctx = _ssl.create_default_context()
|
||||||
|
# Disable VERIFY_X509_STRICT so certs without a keyUsage extension
|
||||||
|
# (common in self-signed setups) are accepted, matching the
|
||||||
|
# requests/urllib3 behavior used by the CalDAV sync path.
|
||||||
|
_ssl_ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
|
||||||
|
_ca_bundle = _os.environ.get("SSL_CERT_FILE") or _os.environ.get("REQUESTS_CA_BUNDLE")
|
||||||
|
if _ca_bundle:
|
||||||
|
if _os.path.isfile(_ca_bundle):
|
||||||
|
_ssl_ctx.load_verify_locations(_ca_bundle)
|
||||||
|
else:
|
||||||
|
logger.warning("CalDAV test: CA bundle %s not found, using system CAs", _ca_bundle)
|
||||||
|
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False, verify=_ssl_ctx) as cx:
|
||||||
r = await cx.request(
|
r = await cx.request(
|
||||||
"PROPFIND", url,
|
"PROPFIND", url,
|
||||||
auth=(user, pw),
|
auth=(user, pw),
|
||||||
@@ -988,6 +1119,9 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_ensure_default_calendar(db, owner)
|
_ensure_default_calendar(db, owner)
|
||||||
|
# Listing calendars intentionally lazily creates a durable default.
|
||||||
|
# Other callers commit it with the event they are creating.
|
||||||
|
db.commit()
|
||||||
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
||||||
return {"calendars": [
|
return {"calendars": [
|
||||||
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
|
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
|
||||||
@@ -996,6 +1130,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
logger.error("Failed to list calendars: %s", e)
|
logger.error("Failed to list calendars: %s", e)
|
||||||
raise HTTPException(500, "Failed to list calendars")
|
raise HTTPException(500, "Failed to list calendars")
|
||||||
finally:
|
finally:
|
||||||
@@ -1070,6 +1205,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
async def create_event(request: Request, data: EventCreate):
|
async def create_event(request: Request, data: EventCreate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = None
|
cal = None
|
||||||
@@ -1131,6 +1267,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.put("/events/{uid}")
|
@router.put("/events/{uid}")
|
||||||
async def update_event(request: Request, uid: str, data: EventUpdate):
|
async def update_event(request: Request, uid: str, data: EventUpdate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1224,6 +1361,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.post("/calendars")
|
@router.post("/calendars")
|
||||||
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, color)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = CalendarCal(
|
cal = CalendarCal(
|
||||||
@@ -1246,6 +1384,7 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
@router.put("/calendars/{cal_id}")
|
@router.put("/calendars/{cal_id}")
|
||||||
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
_reserve_calendar_uploads(request, color)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = _get_or_404_calendar(db, cal_id, owner)
|
cal = _get_or_404_calendar(db, cal_id, owner)
|
||||||
|
|||||||
+77
-22
@@ -5,6 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ from src.context_compactor import maybe_compact, trim_for_context
|
|||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
|
from src.attachment_refs import attachment_ref
|
||||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -55,6 +57,9 @@ def _is_casual_low_signal(text: str) -> bool:
|
|||||||
# the background work (extraction, auto-naming) silently never runs.
|
# the background work (extraction, auto-naming) silently never runs.
|
||||||
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
||||||
_BG_TASKS: set[asyncio.Task] = set()
|
_BG_TASKS: set[asyncio.Task] = set()
|
||||||
|
_INCOGNITO_CONTEXTS: dict[str, dict[str, Any]] = {}
|
||||||
|
_INCOGNITO_CONTEXT_TTL_SECONDS = 6 * 60 * 60
|
||||||
|
_INCOGNITO_CONTEXT_MAX_MESSAGES = 80
|
||||||
|
|
||||||
|
|
||||||
def _spawn_bg(coro) -> asyncio.Task:
|
def _spawn_bg(coro) -> asyncio.Task:
|
||||||
@@ -65,6 +70,40 @@ def _spawn_bg(coro) -> asyncio.Task:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_incognito_contexts(now: float | None = None):
|
||||||
|
now = now or time.time()
|
||||||
|
stale = [
|
||||||
|
sid for sid, bundle in _INCOGNITO_CONTEXTS.items()
|
||||||
|
if now - float(bundle.get("updated_at") or 0) > _INCOGNITO_CONTEXT_TTL_SECONDS
|
||||||
|
]
|
||||||
|
for sid in stale:
|
||||||
|
_INCOGNITO_CONTEXTS.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _incognito_messages(session_id: str) -> list[dict[str, Any]]:
|
||||||
|
_prune_incognito_contexts()
|
||||||
|
bundle = _INCOGNITO_CONTEXTS.get(str(session_id or ""))
|
||||||
|
if not bundle:
|
||||||
|
return []
|
||||||
|
return [dict(m) for m in bundle.get("messages", []) if isinstance(m, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _append_incognito_message(session_id: str, role: str, content: Any, metadata: dict | None = None):
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
_prune_incognito_contexts()
|
||||||
|
bundle = _INCOGNITO_CONTEXTS.setdefault(sid, {"messages": [], "updated_at": time.time()})
|
||||||
|
msg: dict[str, Any] = {"role": role, "content": content}
|
||||||
|
if metadata:
|
||||||
|
msg["metadata"] = dict(metadata)
|
||||||
|
messages = bundle.setdefault("messages", [])
|
||||||
|
messages.append(msg)
|
||||||
|
if len(messages) > _INCOGNITO_CONTEXT_MAX_MESSAGES:
|
||||||
|
del messages[:-_INCOGNITO_CONTEXT_MAX_MESSAGES]
|
||||||
|
bundle["updated_at"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
# ── Data containers ────────────────────────────────────────────────────── #
|
# ── Data containers ────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -418,24 +457,28 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
|
|||||||
except Exception:
|
except Exception:
|
||||||
path = None
|
path = None
|
||||||
|
|
||||||
manifest.append({
|
ref = attachment_ref({**info, "id": info.get("id") or str(att_id)})
|
||||||
"id": info.get("id") or str(att_id),
|
ref.update({
|
||||||
"name": info.get("name") or info.get("original_name") or str(att_id),
|
"id": ref["attachment_id"],
|
||||||
"mime": info.get("mime", ""),
|
"uri": f"odysseus://attachment/{ref['attachment_id']}",
|
||||||
"size": info.get("size", 0),
|
"read_policy": "owner_checked_upload",
|
||||||
|
# Transitional compatibility: existing built-in tools can still use
|
||||||
|
# this path, but only after owner, upload-root, and tool-root checks.
|
||||||
"path": path,
|
"path": path,
|
||||||
})
|
})
|
||||||
|
manifest.append(ref)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||||
"""Add user message to session history and update session name.
|
"""Add user message to session history and update session name.
|
||||||
In incognito mode, still add to in-memory history (for conversation context)
|
Incognito messages must not mutate persistent session history, even in
|
||||||
but skip session name update (which would persist)."""
|
memory, because a later normal turn can persist the same session object."""
|
||||||
|
if incognito:
|
||||||
|
return
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||||
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
||||||
if not incognito:
|
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
||||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
|
||||||
|
|
||||||
|
|
||||||
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
||||||
@@ -664,8 +707,14 @@ async def build_chat_context(
|
|||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user message to history
|
# Add user message to history. Nobody/incognito uses a request-local
|
||||||
add_user_message(sess, chat_handler, preprocessed, incognito=incognito)
|
# transcript store instead of session history so stale saved chats cannot
|
||||||
|
# bleed into context and the turn is not persisted.
|
||||||
|
if incognito:
|
||||||
|
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||||
|
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||||
|
else:
|
||||||
|
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||||
|
|
||||||
# Fire events
|
# Fire events
|
||||||
if not incognito:
|
if not incognito:
|
||||||
@@ -756,8 +805,10 @@ async def build_chat_context(
|
|||||||
if norm:
|
if norm:
|
||||||
sess.model = norm
|
sess.model = norm
|
||||||
|
|
||||||
# Build messages
|
# Build messages. In Nobody/incognito mode, never read saved session
|
||||||
messages = preface + sess.get_context_messages()
|
# history: the session id may be a temporary wrapper or, in buggy clients, a
|
||||||
|
# stale normal session id. Only the ephemeral incognito transcript is safe.
|
||||||
|
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
|
||||||
|
|
||||||
# Current date/time — injected as a standalone *user*-role context message
|
# Current date/time — injected as a standalone *user*-role context message
|
||||||
# placed immediately before the latest user turn, NOT folded into the
|
# placed immediately before the latest user turn, NOT folded into the
|
||||||
@@ -1023,7 +1074,12 @@ def save_assistant_response(
|
|||||||
tool_events: list = None,
|
tool_events: list = None,
|
||||||
incognito: bool = False,
|
incognito: bool = False,
|
||||||
):
|
):
|
||||||
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence."""
|
"""Add assistant response to session history.
|
||||||
|
|
||||||
|
Incognito responses are intentionally not added to the session object. The
|
||||||
|
session may later be saved by a normal turn, so "in-memory only" is not
|
||||||
|
private enough.
|
||||||
|
"""
|
||||||
md = dict(last_metrics) if last_metrics else {}
|
md = dict(last_metrics) if last_metrics else {}
|
||||||
def _model_value(value) -> str:
|
def _model_value(value) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -1063,19 +1119,18 @@ def save_assistant_response(
|
|||||||
_content = _think_info["reply"]
|
_content = _think_info["reply"]
|
||||||
else:
|
else:
|
||||||
_content = full_response
|
_content = full_response
|
||||||
|
if incognito:
|
||||||
|
_append_incognito_message(session_id, "assistant", _content, md)
|
||||||
|
return None
|
||||||
sess.add_message(ChatMessage("assistant", _content, metadata=md))
|
sess.add_message(ChatMessage("assistant", _content, metadata=md))
|
||||||
|
|
||||||
if not incognito:
|
from core.database import update_session_last_accessed
|
||||||
from core.database import update_session_last_accessed
|
update_session_last_accessed(session_id)
|
||||||
update_session_last_accessed(session_id)
|
session_manager.save_sessions()
|
||||||
session_manager.save_sessions()
|
|
||||||
|
|
||||||
# Return the persisted message's DB id so the stream can wire it onto the
|
# Return the persisted message's DB id so the stream can wire it onto the
|
||||||
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
|
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
|
||||||
# without reloading. Incognito returns None: those messages are ephemeral,
|
# without reloading.
|
||||||
# so we don't hand out an edit/delete handle for them.
|
|
||||||
if incognito:
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
_last = sess.history[-1]
|
_last = sess.history[-1]
|
||||||
_meta = getattr(_last, "metadata", None)
|
_meta = getattr(_last, "metadata", None)
|
||||||
|
|||||||
+325
-37
@@ -21,6 +21,7 @@ from src import agent_runs
|
|||||||
from src.model_context import estimate_tokens
|
from src.model_context import estimate_tokens
|
||||||
from src.chat_helpers import coerce_message_and_session
|
from src.chat_helpers import coerce_message_and_session
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
||||||
|
from src.foreground_model_routing import build_foreground_model_candidates
|
||||||
from src.session_search import search_session_messages
|
from src.session_search import search_session_messages
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.exceptions import SessionNotFoundError
|
from core.exceptions import SessionNotFoundError
|
||||||
@@ -42,6 +43,7 @@ from routes.chat_helpers import (
|
|||||||
_enforce_chat_privileges,
|
_enforce_chat_privileges,
|
||||||
)
|
)
|
||||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||||
|
from src.image_model_ids import looks_like_image_generation_model
|
||||||
from src.tool_policy import (
|
from src.tool_policy import (
|
||||||
WEB_TOOL_NAMES,
|
WEB_TOOL_NAMES,
|
||||||
build_effective_tool_policy,
|
build_effective_tool_policy,
|
||||||
@@ -53,7 +55,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Track active streams for partial-save safety net
|
# Track active streams for partial-save safety net
|
||||||
_active_streams: Dict[str, dict] = {}
|
_active_streams: Dict[str, dict] = {}
|
||||||
_IMAGE_MODEL_PREFIXES = ("gpt-image", "dall-e", "chatgpt-image")
|
|
||||||
|
|
||||||
|
|
||||||
def _stream_set(session_id: str, **fields) -> None:
|
def _stream_set(session_id: str, **fields) -> None:
|
||||||
@@ -111,7 +112,8 @@ def _ensure_current_request_is_latest_user(messages: List[Dict[str, Any]], curre
|
|||||||
_WEB_FOLLOWUP_RE = re.compile(
|
_WEB_FOLLOWUP_RE = re.compile(
|
||||||
r"^\s*(?:(?:can|could|would|will)\s+you\s+)?"
|
r"^\s*(?:(?:can|could|would|will)\s+you\s+)?"
|
||||||
r"(?:check|try\s+again|look(?:\s+now|\s+it\s+up)?|search(?:\s+now|\s+online|\s+it)?|"
|
r"(?:check|try\s+again|look(?:\s+now|\s+it\s+up)?|search(?:\s+now|\s+online|\s+it)?|"
|
||||||
r"do\s+it|again)\??\s*$",
|
r"do\s+it|again|approved|approve(?:d)?|yes|ok(?:ay)?|proceed|go\s+ahead|"
|
||||||
|
r"send(?:\s+it)?|submit(?:\s+it)?|email(?:\s+them|\s+it)?)\??\s*$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
_RECENT_WEB_CONTEXT_RE = re.compile(
|
_RECENT_WEB_CONTEXT_RE = re.compile(
|
||||||
@@ -119,6 +121,26 @@ _RECENT_WEB_CONTEXT_RE = re.compile(
|
|||||||
r"price|current|latest|search|look\s+up|online)\b",
|
r"price|current|latest|search|look\s+up|online)\b",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
_RECENT_BROWSER_CONTEXT_RE = re.compile(
|
||||||
|
r"\b(?:browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|click|"
|
||||||
|
r"fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|contact\s+form|web\s*form|"
|
||||||
|
r"form\s+submission|playwright|automation)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_BROWSER_MCP_TOOLS = {
|
||||||
|
"mcp__builtin_browser__browser_navigate",
|
||||||
|
"mcp__builtin_browser__browser_snapshot",
|
||||||
|
"mcp__builtin_browser__browser_click",
|
||||||
|
"mcp__builtin_browser__browser_type",
|
||||||
|
"mcp__builtin_browser__browser_fill_form",
|
||||||
|
"mcp__builtin_browser__browser_select_option",
|
||||||
|
"mcp__builtin_browser__browser_press_key",
|
||||||
|
"mcp__builtin_browser__browser_wait_for",
|
||||||
|
"mcp__builtin_browser__browser_take_screenshot",
|
||||||
|
"mcp__builtin_browser__browser_drag",
|
||||||
|
"mcp__builtin_browser__browser_navigate_back",
|
||||||
|
"mcp__builtin_browser__browser_close",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
|
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
|
||||||
@@ -141,6 +163,13 @@ def _is_contextual_web_followup(message: str, sess) -> bool:
|
|||||||
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
|
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_contextual_browser_followup(message: str, sess) -> bool:
|
||||||
|
"""Treat short retry replies as browser tasks when recent context was forms/browser automation."""
|
||||||
|
if not message or not _WEB_FOLLOWUP_RE.search(message):
|
||||||
|
return False
|
||||||
|
return bool(_RECENT_BROWSER_CONTEXT_RE.search(_recent_session_text(sess, limit=12, max_chars=4000)))
|
||||||
|
|
||||||
|
|
||||||
def _resolve_request_workspace(request, raw_value) -> tuple:
|
def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||||
"""Resolve the posted workspace for this request: (workspace, rejected).
|
"""Resolve the posted workspace for this request: (workspace, rejected).
|
||||||
|
|
||||||
@@ -168,6 +197,46 @@ def _resolve_request_workspace(request, raw_value) -> tuple:
|
|||||||
return workspace, (requested if not workspace else "")
|
return workspace, (requested if not workspace else "")
|
||||||
|
|
||||||
|
|
||||||
|
_ABS_PATH_RE = re.compile(r"(?<!\S)(~?/[^\"'\s`<>]+)")
|
||||||
|
_LOCAL_FILE_TASK_RE = re.compile(
|
||||||
|
r"\b(?:file|folder|directory|path|workspace|repo|project|movie|video|"
|
||||||
|
r"subtitle|subtitles|srt|vtt|ass|download|save|rename|move|copy|extract|"
|
||||||
|
r"convert|ffmpeg|run|execute|open|read|inspect|fix|debug|test|build)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workspace_from_message_path(request, message: str) -> tuple[str, str]:
|
||||||
|
"""Auto-bind a workspace only when the user names an explicit safe path.
|
||||||
|
|
||||||
|
This is intentionally deterministic rather than LLM/RAG-driven: RAG can
|
||||||
|
choose the tool family, but filesystem binding must not let a prompt infer
|
||||||
|
or probe arbitrary host paths. For a file path, bind its parent directory.
|
||||||
|
For a directory path, bind that directory.
|
||||||
|
"""
|
||||||
|
text = str(message or "")
|
||||||
|
if not text or not _LOCAL_FILE_TASK_RE.search(text):
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
from src.tool_security import owner_is_admin_or_single_user
|
||||||
|
if not owner_is_admin_or_single_user(get_current_user(request)):
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
from src.tool_execution import vet_workspace
|
||||||
|
|
||||||
|
for match in _ABS_PATH_RE.finditer(text):
|
||||||
|
raw = match.group(1).rstrip(".,;:)]}")
|
||||||
|
expanded = os.path.realpath(os.path.expanduser(raw))
|
||||||
|
candidates = [expanded]
|
||||||
|
if os.path.isfile(expanded):
|
||||||
|
candidates.insert(0, os.path.dirname(expanded))
|
||||||
|
for candidate in candidates:
|
||||||
|
workspace = vet_workspace(candidate) or ""
|
||||||
|
if workspace:
|
||||||
|
return workspace, ""
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||||
if not session_url or not endpoint_base:
|
if not session_url or not endpoint_base:
|
||||||
return False
|
return False
|
||||||
@@ -243,7 +312,7 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
|
|||||||
models into the image-generation path.
|
models into the image-generation path.
|
||||||
"""
|
"""
|
||||||
model = (getattr(sess, "model", "") or "").strip()
|
model = (getattr(sess, "model", "") or "").strip()
|
||||||
if any(model.lower().startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
|
if looks_like_image_generation_model(model):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||||
@@ -271,6 +340,29 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the first attached image file that this owner can read."""
|
||||||
|
upload_handler = getattr(chat_handler, "upload_handler", None)
|
||||||
|
if not upload_handler:
|
||||||
|
return None
|
||||||
|
for att_id in att_ids or []:
|
||||||
|
try:
|
||||||
|
info = upload_handler.resolve_upload(att_id, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to resolve image edit upload %s", att_id, exc_info=e)
|
||||||
|
continue
|
||||||
|
if not info:
|
||||||
|
continue
|
||||||
|
name = info.get("name") or info.get("original_name") or info.get("id") or ""
|
||||||
|
mime = info.get("mime", "")
|
||||||
|
try:
|
||||||
|
if upload_handler.is_image_file(name, mime):
|
||||||
|
return info
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||||
|
|
||||||
@@ -381,9 +473,85 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.warning("Failed to recover empty session model for %s: %s", session_id, e)
|
logger.warning("Failed to recover empty session model for %s: %s", session_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_selected_route_from_request(
|
||||||
|
request: Request,
|
||||||
|
sess,
|
||||||
|
session_id: str,
|
||||||
|
form_data,
|
||||||
|
owner: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply the model route the browser selected before streaming.
|
||||||
|
|
||||||
|
The frontend creates a pending chat first and only materializes it on first
|
||||||
|
send. Startup/default-model refreshes can race with that UI state, so the
|
||||||
|
stream request includes the route that was selected at click/send time.
|
||||||
|
Trust only registered endpoint ids, or the session's existing endpoint URL.
|
||||||
|
"""
|
||||||
|
selected_model = str(form_data.get("selected_model") or "").strip()
|
||||||
|
selected_endpoint_id = str(form_data.get("selected_endpoint_id") or "").strip()
|
||||||
|
selected_endpoint_url = str(form_data.get("selected_endpoint_url") or "").strip()
|
||||||
|
if not selected_model:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
endpoint_url = ""
|
||||||
|
headers = None
|
||||||
|
if selected_endpoint_id or selected_endpoint_url:
|
||||||
|
try:
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.endpoint_resolver import build_headers, normalize_base
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
|
if selected_endpoint_id:
|
||||||
|
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
|
||||||
|
if owner:
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
|
||||||
|
ep = None
|
||||||
|
for cand in candidates:
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
if selected_endpoint_id or _session_url_matches_endpoint(selected_endpoint_url, cand.base_url or ""):
|
||||||
|
ep = cand
|
||||||
|
break
|
||||||
|
if not ep:
|
||||||
|
return False
|
||||||
|
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
|
||||||
|
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to resolve selected endpoint %s/%s for %s: %s", selected_endpoint_id, selected_endpoint_url, session_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not endpoint_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
selected_model == (getattr(sess, "model", "") or "")
|
||||||
|
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
sess.model = selected_model
|
||||||
|
sess.endpoint_url = endpoint_url
|
||||||
|
sess.headers = headers or {}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
|
||||||
|
if db_session:
|
||||||
|
db_session.model = selected_model
|
||||||
|
db_session.endpoint_url = endpoint_url
|
||||||
|
db_session.headers = sess.headers or {}
|
||||||
|
db_session.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
logger.info("Reconciled selected route for %s: model=%r endpoint=%s", session_id, selected_model, redact_url(endpoint_url))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _set_user_time_from_request(request: Request) -> None:
|
def _set_user_time_from_request(request: Request) -> None:
|
||||||
@@ -565,9 +733,7 @@ def setup_chat_routes(
|
|||||||
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
|
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
|
||||||
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
||||||
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
||||||
# Plan mode is not part of the merge-ready UI. Ignore stale clients or
|
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
|
||||||
# manual form posts that still send plan_mode=true.
|
|
||||||
plan_mode = False
|
|
||||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||||
# Workspace: confine the agent's file/shell tools to this folder.
|
# Workspace: confine the agent's file/shell tools to this folder.
|
||||||
workspace, workspace_rejected = _resolve_request_workspace(
|
workspace, workspace_rejected = _resolve_request_workspace(
|
||||||
@@ -589,6 +755,25 @@ def setup_chat_routes(
|
|||||||
# not chats we quietly promoted for a notes/calendar intent.
|
# not chats we quietly promoted for a notes/calendar intent.
|
||||||
user_requested_agent = (chat_mode == "agent")
|
user_requested_agent = (chat_mode == "agent")
|
||||||
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
|
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
|
||||||
|
_explicit_web_intent = False
|
||||||
|
_explicit_browser_intent = False
|
||||||
|
if isinstance(message, str):
|
||||||
|
_msg_l = message.lower()
|
||||||
|
_explicit_web_intent = bool(re.search(
|
||||||
|
r"\b(search|look\s*up|lookup|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||||
|
_msg_l,
|
||||||
|
))
|
||||||
|
_explicit_browser_intent = bool(re.search(
|
||||||
|
r"\b(browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|"
|
||||||
|
r"click|fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|"
|
||||||
|
r"contact\s+form|web\s*form|form\s+submission)\b",
|
||||||
|
_msg_l,
|
||||||
|
))
|
||||||
|
_allow_browser_for_web_turn = bool(
|
||||||
|
_explicit_browser_intent
|
||||||
|
or _explicit_web_intent
|
||||||
|
or _search_enabled
|
||||||
|
)
|
||||||
# Intent auto-escalation: if the user is clearly asking the assistant
|
# Intent auto-escalation: if the user is clearly asking the assistant
|
||||||
# to create a todo, reminder, or calendar event, promote chat → agent
|
# to create a todo, reminder, or calendar event, promote chat → agent
|
||||||
# for this turn so the LLM has access to manage_notes / manage_calendar.
|
# for this turn so the LLM has access to manage_notes / manage_calendar.
|
||||||
@@ -598,9 +783,13 @@ def setup_chat_routes(
|
|||||||
# shell disabled).
|
# shell disabled).
|
||||||
auto_escalated = False
|
auto_escalated = False
|
||||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||||
|
_workspace_agent_intent = False
|
||||||
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
|
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
|
||||||
|
if _workspace_agent_intent:
|
||||||
|
allow_bash = "true"
|
||||||
logger.info(
|
logger.info(
|
||||||
"chat→agent auto-escalation: category=%s reason=%s",
|
"chat→agent auto-escalation: category=%s reason=%s",
|
||||||
_tool_intent.category,
|
_tool_intent.category,
|
||||||
@@ -610,6 +799,10 @@ def setup_chat_routes(
|
|||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
logger.info("chat→agent auto-escalation: search enabled")
|
logger.info("chat→agent auto-escalation: search enabled")
|
||||||
|
elif chat_mode == "chat" and _explicit_web_intent:
|
||||||
|
chat_mode = "agent"
|
||||||
|
auto_escalated = True
|
||||||
|
logger.info("chat→agent auto-escalation: explicit web intent")
|
||||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||||
|
|
||||||
@@ -688,6 +881,7 @@ def setup_chat_routes(
|
|||||||
_verify_session_owner(request, session)
|
_verify_session_owner(request, session)
|
||||||
sess = session_manager.get_session(session)
|
sess = session_manager.get_session(session)
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
|
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||||
# Issue #587: picker shows a model from the endpoint cache but
|
# Issue #587: picker shows a model from the endpoint cache but
|
||||||
@@ -711,11 +905,28 @@ def setup_chat_routes(
|
|||||||
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
|
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
|
||||||
chat_mode = "agent"
|
chat_mode = "agent"
|
||||||
auto_escalated = True
|
auto_escalated = True
|
||||||
|
_workspace_agent_intent = False
|
||||||
logger.info(
|
logger.info(
|
||||||
"chat→agent auto-escalation: category=%s reason=%s",
|
"chat→agent auto-escalation: category=%s reason=%s",
|
||||||
_tool_intent.category,
|
_tool_intent.category,
|
||||||
_tool_intent.reason,
|
_tool_intent.reason,
|
||||||
)
|
)
|
||||||
|
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
||||||
|
_explicit_browser_intent = True
|
||||||
|
if chat_mode == "chat":
|
||||||
|
chat_mode = "agent"
|
||||||
|
auto_escalated = True
|
||||||
|
_workspace_agent_intent = False
|
||||||
|
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
|
||||||
|
if not workspace and isinstance(message, str):
|
||||||
|
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
|
||||||
|
if _auto_workspace:
|
||||||
|
workspace = _auto_workspace
|
||||||
|
chat_mode = "agent"
|
||||||
|
auto_escalated = True
|
||||||
|
_workspace_agent_intent = True
|
||||||
|
allow_bash = "true"
|
||||||
|
logger.info("chat→agent auto-escalation: explicit path workspace=%s", workspace)
|
||||||
except SessionNotFoundError as e:
|
except SessionNotFoundError as e:
|
||||||
raise HTTPException(404, str(e))
|
raise HTTPException(404, str(e))
|
||||||
except (ValueError, ValidationError):
|
except (ValueError, ValidationError):
|
||||||
@@ -750,7 +961,12 @@ def setup_chat_routes(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
||||||
|
|
||||||
|
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
|
||||||
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
||||||
|
if image_generation_session:
|
||||||
|
no_memory = True
|
||||||
|
use_rag = "false"
|
||||||
|
search_context = None
|
||||||
pre_context_tool_policy = build_effective_tool_policy(
|
pre_context_tool_policy = build_effective_tool_policy(
|
||||||
last_user_message=message,
|
last_user_message=message,
|
||||||
)
|
)
|
||||||
@@ -879,7 +1095,7 @@ def setup_chat_routes(
|
|||||||
# explicitly enable it.
|
# explicitly enable it.
|
||||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||||
disabled_tools.add("bash")
|
disabled_tools.add("bash")
|
||||||
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
_explicit_web_intent = _explicit_web_intent or bool(_tool_intent and _tool_intent.category == "web")
|
||||||
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
|
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
|
||||||
disabled_tools.update(WEB_TOOL_NAMES)
|
disabled_tools.update(WEB_TOOL_NAMES)
|
||||||
if _explicit_web_intent:
|
if _explicit_web_intent:
|
||||||
@@ -893,7 +1109,7 @@ def setup_chat_routes(
|
|||||||
"create_document", "edit_document", "update_document",
|
"create_document", "edit_document", "update_document",
|
||||||
"send_email", "reply_to_email",
|
"send_email", "reply_to_email",
|
||||||
"manage_notes", "manage_calendar", "manage_tasks",
|
"manage_notes", "manage_calendar", "manage_tasks",
|
||||||
"api_call", "builtin_browser",
|
"api_call",
|
||||||
})
|
})
|
||||||
if _search_enabled:
|
if _search_enabled:
|
||||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||||
@@ -909,6 +1125,11 @@ def setup_chat_routes(
|
|||||||
"manage_memory", # persistent memory store
|
"manage_memory", # persistent memory store
|
||||||
"search_chats", # past chat history
|
"search_chats", # past chat history
|
||||||
"manage_skills", # skill presets tied to user
|
"manage_skills", # skill presets tied to user
|
||||||
|
"create_session",
|
||||||
|
"list_sessions",
|
||||||
|
"manage_session",
|
||||||
|
"send_to_session",
|
||||||
|
"chat_with_model",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Active email reader open → strip the tools that let the agent drift
|
# Active email reader open → strip the tools that let the agent drift
|
||||||
@@ -935,7 +1156,7 @@ def setup_chat_routes(
|
|||||||
if not _privs.get("can_use_bash", True):
|
if not _privs.get("can_use_bash", True):
|
||||||
disabled_tools.update({"bash", "python", "read_file", "write_file"})
|
disabled_tools.update({"bash", "python", "read_file", "write_file"})
|
||||||
if not _privs.get("can_use_browser", True):
|
if not _privs.get("can_use_browser", True):
|
||||||
disabled_tools.add("builtin_browser")
|
disabled_tools.update(_BROWSER_MCP_TOOLS)
|
||||||
if not _privs.get("can_use_documents", True):
|
if not _privs.get("can_use_documents", True):
|
||||||
disabled_tools.update({"create_document", "edit_document", "update_document", "suggest_document"})
|
disabled_tools.update({"create_document", "edit_document", "update_document", "suggest_document"})
|
||||||
if not _privs.get("can_generate_images", True):
|
if not _privs.get("can_generate_images", True):
|
||||||
@@ -958,10 +1179,12 @@ def setup_chat_routes(
|
|||||||
# the heavy "do things on the computer" tools — otherwise the model
|
# the heavy "do things on the computer" tools — otherwise the model
|
||||||
# tries to shell out for a request that never needed it, then fails
|
# tries to shell out for a request that never needed it, then fails
|
||||||
# (and looks broken when the shell is disabled).
|
# (and looks broken when the shell is disabled).
|
||||||
if auto_escalated:
|
if auto_escalated and not _workspace_agent_intent:
|
||||||
disabled_tools.update({
|
disabled_tools.update({
|
||||||
"bash", "python", "read_file", "write_file", "builtin_browser",
|
"bash", "python", "read_file", "write_file",
|
||||||
})
|
})
|
||||||
|
if not _allow_browser_for_web_turn:
|
||||||
|
disabled_tools.update(_BROWSER_MCP_TOOLS)
|
||||||
|
|
||||||
# Disable document tools in compare sessions — they break the pane UI
|
# Disable document tools in compare sessions — they break the pane UI
|
||||||
if sess.name and sess.name.startswith("[CMP]"):
|
if sess.name and sess.name.startswith("[CMP]"):
|
||||||
@@ -1177,14 +1400,14 @@ def setup_chat_routes(
|
|||||||
thinking_response = ""
|
thinking_response = ""
|
||||||
last_metrics = None
|
last_metrics = None
|
||||||
|
|
||||||
# Configured fallback chain for the default chat model. Tried in
|
# Foreground Chat and Agent requests use one owner-aware policy
|
||||||
# order if the session's primary model fails before producing
|
# boundary. Legacy `default_model_fallbacks` data is not eligible.
|
||||||
# output. Resolved once per request.
|
_foreground_candidates = build_foreground_model_candidates(
|
||||||
try:
|
sess.endpoint_url,
|
||||||
from src.endpoint_resolver import resolve_chat_fallback_candidates
|
sess.model,
|
||||||
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
|
sess.headers,
|
||||||
except Exception:
|
owner=_user,
|
||||||
_fallback_candidates = []
|
)
|
||||||
|
|
||||||
# Send model name early so the frontend can show it during streaming
|
# Send model name early so the frontend can show it during streaming
|
||||||
_model_suffix = "Research" if effective_do_research else None
|
_model_suffix = "Research" if effective_do_research else None
|
||||||
@@ -1195,7 +1418,7 @@ def setup_chat_routes(
|
|||||||
_model_info["character_name"] = ctx.preset.character_name
|
_model_info["character_name"] = ctx.preset.character_name
|
||||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||||
|
|
||||||
if _is_image_generation_session(sess, owner=_user):
|
if image_generation_session:
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
if tool_policy.blocks("generate_image"):
|
if tool_policy.blocks("generate_image"):
|
||||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||||
@@ -1208,26 +1431,85 @@ def setup_chat_routes(
|
|||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
return
|
return
|
||||||
from src.ai_interaction import do_generate_image
|
from src.ai_interaction import do_edit_image, do_generate_image
|
||||||
_user_msg = message or ""
|
_user_msg = message or ""
|
||||||
yield f'data: {json.dumps({"type": "tool_start", "tool": "generate_image", "command": _user_msg[:100]})}\n\n'
|
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
|
||||||
|
_image_tool_name = "edit_image" if _image_upload else "generate_image"
|
||||||
|
yield f'data: {json.dumps({"type": "tool_start", "tool": _image_tool_name, "command": _user_msg[:100]})}\n\n'
|
||||||
yield ": heartbeat\n\n"
|
yield ": heartbeat\n\n"
|
||||||
_img_result = await do_generate_image(f"{_user_msg}\n{sess.model}", session, owner=_user)
|
_progress_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
async def _image_progress_callback(progress: Dict[str, Any]):
|
||||||
|
try:
|
||||||
|
_progress_queue.put_nowait(progress)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _image_upload:
|
||||||
|
_img_task = asyncio.create_task(do_edit_image(
|
||||||
|
_user_msg,
|
||||||
|
_image_upload.get("path", ""),
|
||||||
|
model_spec=sess.model,
|
||||||
|
session_id=session,
|
||||||
|
owner=_user,
|
||||||
|
size="1024x1024",
|
||||||
|
progress_callback=_image_progress_callback,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user))
|
||||||
|
_img_started = time.time()
|
||||||
|
_img_tick = 0
|
||||||
|
while not _img_task.done():
|
||||||
|
try:
|
||||||
|
_progress = await asyncio.wait_for(_progress_queue.get(), timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_progress = None
|
||||||
|
_img_tick += 1
|
||||||
|
_elapsed = int(time.time() - _img_started)
|
||||||
|
_label = "Editing image" if _image_upload else "Generating image"
|
||||||
|
yield ": image generation still running\n\n"
|
||||||
|
_progress_data = {"type": "tool_progress", "tool": _image_tool_name, "message": f"{_label}… {_elapsed}s", "elapsed": _elapsed, "tick": _img_tick}
|
||||||
|
if isinstance(_progress, dict) and _progress.get("total"):
|
||||||
|
_step = int(_progress.get("step") or 0)
|
||||||
|
_total = int(_progress.get("total") or 0)
|
||||||
|
_percent = _progress.get("percent")
|
||||||
|
_progress_data.update({
|
||||||
|
"step": _step,
|
||||||
|
"total": _total,
|
||||||
|
"percent": _percent,
|
||||||
|
"message": f"{_label}… {_step}/{_total}",
|
||||||
|
})
|
||||||
|
yield f'data: {json.dumps(_progress_data)}\n\n'
|
||||||
|
_img_result = await _img_task
|
||||||
_img_output = _img_result.get("results", _img_result.get("error", ""))
|
_img_output = _img_result.get("results", _img_result.get("error", ""))
|
||||||
_img_tool_data = {"type": "tool_output", "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
_img_tool_data = {"type": "tool_output", "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||||
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||||
if _k in _img_result:
|
if _k in _img_result:
|
||||||
_img_tool_data[_k] = _img_result[_k]
|
_img_tool_data[_k] = _img_result[_k]
|
||||||
|
if _image_upload:
|
||||||
|
_img_tool_data["source_image"] = {
|
||||||
|
"id": _image_upload.get("id"),
|
||||||
|
"name": _image_upload.get("name") or _image_upload.get("original_name"),
|
||||||
|
}
|
||||||
yield f'data: {json.dumps(_img_tool_data)}\n\n'
|
yield f'data: {json.dumps(_img_tool_data)}\n\n'
|
||||||
|
if _img_result.get("image_url"):
|
||||||
|
_img_event = {"type": "generated_image", "url": _img_result.get("image_url")}
|
||||||
|
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||||
|
if _img_result.get(_k):
|
||||||
|
_img_event[_k] = _img_result[_k]
|
||||||
|
yield f'data: {json.dumps(_img_event)}\n\n'
|
||||||
_desc = _img_result.get("results", _img_result.get("error", "Image generation complete"))
|
_desc = _img_result.get("results", _img_result.get("error", "Image generation complete"))
|
||||||
full_response = _desc
|
full_response = _desc
|
||||||
yield f'data: {json.dumps({"delta": _desc})}\n\n'
|
yield f'data: {json.dumps({"delta": _desc})}\n\n'
|
||||||
# Save to session history
|
# Save to session history
|
||||||
if not incognito:
|
if not incognito:
|
||||||
_ev = {"round": 1, "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
_ev = {"round": 1, "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||||
for _ek in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
for _ek in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||||
if _img_result.get(_ek):
|
if _img_result.get(_ek):
|
||||||
_ev[_ek] = _img_result[_ek]
|
_ev[_ek] = _img_result[_ek]
|
||||||
|
if _image_upload:
|
||||||
|
_ev["source_image_id"] = _image_upload.get("id")
|
||||||
|
_ev["source_image_name"] = _image_upload.get("name") or _image_upload.get("original_name")
|
||||||
sess.add_message(ChatMessage("assistant", full_response, metadata={"tool_events": [_ev], "model": sess.model}))
|
sess.add_message(ChatMessage("assistant", full_response, metadata={"tool_events": [_ev], "model": sess.model}))
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
yield f'data: {json.dumps({"type": "metrics", "data": {"total_time": 0}})}\n\n'
|
yield f'data: {json.dumps({"type": "metrics", "data": {"total_time": 0}})}\n\n'
|
||||||
@@ -1241,9 +1523,8 @@ def setup_chat_routes(
|
|||||||
_actual_model = None
|
_actual_model = None
|
||||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||||
try:
|
try:
|
||||||
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
|
|
||||||
async for chunk in stream_llm_with_fallback(
|
async for chunk in stream_llm_with_fallback(
|
||||||
_chat_candidates,
|
_foreground_candidates,
|
||||||
messages,
|
messages,
|
||||||
temperature=ctx.preset.temperature,
|
temperature=ctx.preset.temperature,
|
||||||
# Respect the preset; 0/unset = let the server decide (no
|
# Respect the preset; 0/unset = let the server decide (no
|
||||||
@@ -1292,8 +1573,10 @@ def setup_chat_routes(
|
|||||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||||
if ctx.context_length and last_metrics.get("input_tokens"):
|
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
|
||||||
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
|
last_metrics["request_context_tokens"] = request_context_tokens
|
||||||
|
if ctx.context_length and request_context_tokens:
|
||||||
|
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
|
||||||
last_metrics["context_percent"] = pct
|
last_metrics["context_percent"] = pct
|
||||||
last_metrics["context_length"] = ctx.context_length
|
last_metrics["context_length"] = ctx.context_length
|
||||||
# The frontend reads `tokens_per_second`; the raw usage event
|
# The frontend reads `tokens_per_second`; the raw usage event
|
||||||
@@ -1326,6 +1609,7 @@ def setup_chat_routes(
|
|||||||
"input_tokens": _est_in,
|
"input_tokens": _est_in,
|
||||||
"output_tokens": _est_out,
|
"output_tokens": _est_out,
|
||||||
"tokens_per_second": _tps,
|
"tokens_per_second": _tps,
|
||||||
|
"request_context_tokens": _est_in,
|
||||||
"context_percent": _ctx_pct,
|
"context_percent": _ctx_pct,
|
||||||
"context_length": ctx.context_length,
|
"context_length": ctx.context_length,
|
||||||
"model": _actual_model or _answered_by or _requested_model,
|
"model": _actual_model or _answered_by or _requested_model,
|
||||||
@@ -1360,7 +1644,7 @@ def setup_chat_routes(
|
|||||||
_stream_set(session, status="done")
|
_stream_set(session, status="done")
|
||||||
yield chunk
|
yield chunk
|
||||||
except (asyncio.CancelledError, GeneratorExit):
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
if full_response:
|
if full_response and not incognito:
|
||||||
logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response))
|
logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response))
|
||||||
_stopped_content, _stopped_md = clean_thinking_for_save(
|
_stopped_content, _stopped_md = clean_thinking_for_save(
|
||||||
full_response,
|
full_response,
|
||||||
@@ -1371,8 +1655,7 @@ def setup_chat_routes(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
|
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
|
||||||
if not incognito:
|
session_manager.save_sessions()
|
||||||
session_manager.save_sessions()
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
@@ -1405,6 +1688,10 @@ def setup_chat_routes(
|
|||||||
_forced_tools = None
|
_forced_tools = None
|
||||||
if _search_enabled:
|
if _search_enabled:
|
||||||
_forced_tools = set(WEB_TOOL_NAMES)
|
_forced_tools = set(WEB_TOOL_NAMES)
|
||||||
|
if _explicit_browser_intent:
|
||||||
|
_forced_tools |= set(_BROWSER_MCP_TOOLS)
|
||||||
|
elif _explicit_browser_intent:
|
||||||
|
_forced_tools = set(_BROWSER_MCP_TOOLS)
|
||||||
|
|
||||||
async for chunk in stream_agent_loop(
|
async for chunk in stream_agent_loop(
|
||||||
sess.endpoint_url,
|
sess.endpoint_url,
|
||||||
@@ -1423,7 +1710,7 @@ def setup_chat_routes(
|
|||||||
disabled_tools=disabled_tools if disabled_tools else None,
|
disabled_tools=disabled_tools if disabled_tools else None,
|
||||||
tool_policy=tool_policy,
|
tool_policy=tool_policy,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
fallbacks=_fallback_candidates,
|
fallbacks=_foreground_candidates[1:],
|
||||||
plan_mode=plan_mode,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace or None,
|
workspace=workspace or None,
|
||||||
@@ -1450,7 +1737,9 @@ def setup_chat_routes(
|
|||||||
"tool_start", "tool_output", "agent_step",
|
"tool_start", "tool_output", "agent_step",
|
||||||
"doc_stream_open", "doc_stream_delta",
|
"doc_stream_open", "doc_stream_delta",
|
||||||
"doc_update", "doc_suggestions", "ui_control",
|
"doc_update", "doc_suggestions", "ui_control",
|
||||||
"rounds_exhausted",
|
"rounds_exhausted", "budget_exceeded",
|
||||||
|
"loop_breaker_triggered",
|
||||||
|
"intent_nudge_exhausted",
|
||||||
"ask_user",
|
"ask_user",
|
||||||
"plan_update",
|
"plan_update",
|
||||||
):
|
):
|
||||||
@@ -1527,7 +1816,7 @@ def setup_chat_routes(
|
|||||||
# outer finally from running and left _active_streams
|
# outer finally from running and left _active_streams
|
||||||
# with a stale entry).
|
# with a stale entry).
|
||||||
try:
|
try:
|
||||||
if full_response:
|
if full_response and not incognito:
|
||||||
logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response))
|
logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response))
|
||||||
_stopped_content2, _stopped_md2 = clean_thinking_for_save(
|
_stopped_content2, _stopped_md2 = clean_thinking_for_save(
|
||||||
full_response,
|
full_response,
|
||||||
@@ -1538,8 +1827,7 @@ def setup_chat_routes(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
||||||
if not incognito:
|
session_manager.save_sessions()
|
||||||
session_manager.save_sessions()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to save partial response on disconnect (session %s)", session)
|
logger.exception("Failed to save partial response on disconnect (session %s)", session)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Cleanup route domain package (slice 2g, #4082/#4071).
|
||||||
|
|
||||||
|
Contains cleanup_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/cleanup_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# routes/cleanup_routes.py
|
||||||
|
"""Routes for cleanup operations."""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from src.cleanup_service import get_cleanup_preview, cleanup_sessions
|
||||||
|
from src.auth_helpers import get_current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def setup_cleanup_routes(session_manager):
|
||||||
|
"""
|
||||||
|
Setup cleanup-related routes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_manager: SessionManager instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
APIRouter instance with cleanup routes
|
||||||
|
"""
|
||||||
|
router = APIRouter(prefix="/api/cleanup")
|
||||||
|
|
||||||
|
@router.get("/preview")
|
||||||
|
async def cleanup_preview(request: Request):
|
||||||
|
"""
|
||||||
|
Preview what would be cleaned up without making any changes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with lists of sessions that would be archived/deleted and estimated space savings
|
||||||
|
"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
try:
|
||||||
|
preview = await get_cleanup_preview(owner=user)
|
||||||
|
return preview
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup preview failed: {e}")
|
||||||
|
raise HTTPException(500, "Cleanup preview generation failed")
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def cleanup_endpoint(request: Request):
|
||||||
|
"""
|
||||||
|
Perform cleanup operations:
|
||||||
|
1. Archive inactive sessions (not accessed for 7 days)
|
||||||
|
2. Delete old sessions (archived, not important, not accessed for 14+ days, with fewer than 10 messages)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with counts of deleted and archived sessions, and space freed
|
||||||
|
"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
try:
|
||||||
|
archived_count, deleted_count, space_freed_mb = await cleanup_sessions(session_manager, owner=user)
|
||||||
|
return {
|
||||||
|
"archived_count": archived_count,
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"space_freed_mb": round(space_freed_mb, 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup failed: {e}")
|
||||||
|
raise HTTPException(500, "Cleanup operation failed")
|
||||||
|
|
||||||
|
return router
|
||||||
+13
-56
@@ -1,60 +1,17 @@
|
|||||||
# routes/cleanup_routes.py
|
"""Backward-compat shim — canonical location is routes/cleanup/cleanup_routes.py.
|
||||||
"""Routes for cleanup operations."""
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from src.cleanup_service import get_cleanup_preview, cleanup_sessions
|
|
||||||
from src.auth_helpers import get_current_user
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
|
that ``import routes.cleanup_routes``, ``from routes.cleanup_routes import X``,
|
||||||
|
``importlib.import_module("routes.cleanup_routes")``, and the string-targeted
|
||||||
|
``monkeypatch.setattr("routes.cleanup_routes.get_cleanup_preview", ...)`` /
|
||||||
|
``"routes.cleanup_routes.get_current_user"`` / ``"routes.cleanup_routes.
|
||||||
|
cleanup_sessions"`` pattern used by test_cleanup_owner_scope.py all operate
|
||||||
|
on the *same* object the application actually uses. Keeps existing import
|
||||||
|
paths working after slice 2g (#4082/#4071).
|
||||||
|
"""
|
||||||
|
|
||||||
def setup_cleanup_routes(session_manager):
|
import sys as _sys
|
||||||
"""
|
|
||||||
Setup cleanup-related routes.
|
|
||||||
|
|
||||||
Args:
|
from routes.cleanup import cleanup_routes as _canonical # noqa: F401
|
||||||
session_manager: SessionManager instance
|
|
||||||
|
|
||||||
Returns:
|
_sys.modules[__name__] = _canonical
|
||||||
APIRouter instance with cleanup routes
|
|
||||||
"""
|
|
||||||
router = APIRouter(prefix="/api/cleanup")
|
|
||||||
|
|
||||||
@router.get("/preview")
|
|
||||||
async def cleanup_preview(request: Request):
|
|
||||||
"""
|
|
||||||
Preview what would be cleaned up without making any changes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with lists of sessions that would be archived/deleted and estimated space savings
|
|
||||||
"""
|
|
||||||
user = get_current_user(request)
|
|
||||||
try:
|
|
||||||
preview = await get_cleanup_preview(owner=user)
|
|
||||||
return preview
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Cleanup preview failed: {e}")
|
|
||||||
raise HTTPException(500, "Cleanup preview generation failed")
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
async def cleanup_endpoint(request: Request):
|
|
||||||
"""
|
|
||||||
Perform cleanup operations:
|
|
||||||
1. Archive inactive sessions (not accessed for 7 days)
|
|
||||||
2. Delete old sessions (archived, not important, not accessed for 14+ days, with fewer than 10 messages)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with counts of deleted and archived sessions, and space freed
|
|
||||||
"""
|
|
||||||
user = get_current_user(request)
|
|
||||||
try:
|
|
||||||
archived_count, deleted_count, space_freed_mb = await cleanup_sessions(session_manager, owner=user)
|
|
||||||
return {
|
|
||||||
"archived_count": archived_count,
|
|
||||||
"deleted_count": deleted_count,
|
|
||||||
"space_freed_mb": round(space_freed_mb, 2)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Cleanup failed: {e}")
|
|
||||||
raise HTTPException(500, "Cleanup operation failed")
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Compare route domain package (slice 2i, #4082/#4071).
|
||||||
|
|
||||||
|
Contains compare_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/compare_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# routes/compare_routes.py
|
||||||
|
"""Model A/B comparison routes."""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from core.database import Comparison, SessionLocal
|
||||||
|
from core.session_manager import SessionManager
|
||||||
|
from src.auth_helpers import get_current_user
|
||||||
|
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_endpoint_by_url(db, base_url, owner):
|
||||||
|
"""ModelEndpoint whose base_url == `base_url` and is VISIBLE to `owner`
|
||||||
|
(their own rows + legacy null-owner "shared" rows); None otherwise.
|
||||||
|
|
||||||
|
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
||||||
|
owner = private, "the model picker only shows the endpoint to that user") and
|
||||||
|
holds a decrypted `api_key`. start_comparison copies the matched row's api_key
|
||||||
|
into the caller-owned [CMP] session's headers, which then drives that session's
|
||||||
|
/api/chat_stream calls — so an UNSCOPED base_url match would let a user mint a
|
||||||
|
comparison bound to ANOTHER user's private endpoint and spend that owner's
|
||||||
|
api_key / reach whatever base_url they configured. Mirrors
|
||||||
|
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
||||||
|
legacy mode).
|
||||||
|
"""
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url)
|
||||||
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_endpoint_by_id(db, endpoint_id, owner):
|
||||||
|
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
|
||||||
|
own rows + legacy null-owner "shared" rows); None otherwise.
|
||||||
|
|
||||||
|
Preferred over _owned_endpoint_by_url for credential resolution: two visible
|
||||||
|
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
|
||||||
|
accounts on the same provider). A base_url-only match returns whichever row
|
||||||
|
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
|
||||||
|
An id pins the exact registered endpoint, so /api/compare/start prefers it and
|
||||||
|
only falls back to URL matching for legacy / admin raw-URL callers. Owner
|
||||||
|
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
|
||||||
|
"""
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
|
||||||
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordVoteRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
models: List[str]
|
||||||
|
winner: str # model name or "tie"
|
||||||
|
is_blind: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def setup_compare_routes(session_manager: SessionManager):
|
||||||
|
"""Setup comparison routes."""
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
def start_comparison(
|
||||||
|
request: Request,
|
||||||
|
prompt: str = Form(...),
|
||||||
|
model_a: str = Form(...),
|
||||||
|
model_b: str = Form(...),
|
||||||
|
endpoint_a: str = Form(""),
|
||||||
|
endpoint_b: str = Form(""),
|
||||||
|
endpoint_a_id: str = Form(""),
|
||||||
|
endpoint_b_id: str = Form(""),
|
||||||
|
is_blind: str = Form("true"),
|
||||||
|
):
|
||||||
|
"""Create two ephemeral sessions and a comparison record.
|
||||||
|
|
||||||
|
Returns the comparison ID and the two session IDs so the client
|
||||||
|
can fire two independent SSE streams to /api/chat_stream.
|
||||||
|
"""
|
||||||
|
user = getattr(request.state, 'current_user', None)
|
||||||
|
comp_id = str(uuid.uuid4())
|
||||||
|
sid_a = str(uuid.uuid4())
|
||||||
|
sid_b = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Blind mapping: randomly assign left/right
|
||||||
|
blind = str(is_blind).lower() == "true"
|
||||||
|
if blind:
|
||||||
|
mapping = {"left": "a", "right": "b"}
|
||||||
|
if random.random() > 0.5:
|
||||||
|
mapping = {"left": "b", "right": "a"}
|
||||||
|
else:
|
||||||
|
mapping = {"left": "a", "right": "b"}
|
||||||
|
|
||||||
|
# Map session IDs to left/right based on blind mapping
|
||||||
|
session_left = sid_a if mapping["left"] == "a" else sid_b
|
||||||
|
session_right = sid_a if mapping["right"] == "a" else sid_b
|
||||||
|
|
||||||
|
# In blind mode, name the helper sessions by their neutral slot
|
||||||
|
# ("Model A" / "Model B") instead of the real model. Otherwise the
|
||||||
|
# session name leaks the model in the sidebar and GET /api/sessions,
|
||||||
|
# de-anonymizing the comparison before the user votes (issue #1285).
|
||||||
|
slot_name = {session_left: "Model A", session_right: "Model B"}
|
||||||
|
|
||||||
|
# SECURITY: resolve and validate BOTH endpoints before creating any
|
||||||
|
# session. Compare copies a registered endpoint's Authorization header
|
||||||
|
# into the [CMP] session, so validating one endpoint while creating its
|
||||||
|
# session, then rejecting the other, would leave a partial compare
|
||||||
|
# session behind with that header attached. Doing all the owner-scope
|
||||||
|
# resolution + raw-URL rejection up front means a 403 on either endpoint
|
||||||
|
# aborts the whole request with nothing created and no header copied.
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
||||||
|
resolved = []
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for sid, model, endpoint, endpoint_id in [
|
||||||
|
(sid_a, model_a, endpoint_a, endpoint_a_id),
|
||||||
|
(sid_b, model_b, endpoint_b, endpoint_b_id),
|
||||||
|
]:
|
||||||
|
# Prefer an explicit endpoint id: it pins the EXACT registered
|
||||||
|
# endpoint (and its api_key), even when two endpoints visible to
|
||||||
|
# the caller share a base_url with different keys — a URL-only
|
||||||
|
# match would copy whichever row sorts first, i.e. possibly the
|
||||||
|
# wrong key. Fall back to URL resolution only for legacy / admin
|
||||||
|
# raw-URL callers that don't send an id.
|
||||||
|
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
|
||||||
|
if eid:
|
||||||
|
ep = _owned_endpoint_by_id(db, eid, user)
|
||||||
|
if ep is None:
|
||||||
|
# An id the caller can't see (wrong owner / deleted) must
|
||||||
|
# NOT silently fall back to a same-URL row with a different
|
||||||
|
# key — that's exactly the mix-up ids exist to prevent.
|
||||||
|
raise HTTPException(404, "Model endpoint not found")
|
||||||
|
# The id already resolved the endpoint; ignore any raw URL the
|
||||||
|
# caller also sent and dial the stored config instead.
|
||||||
|
endpoint = ep.base_url
|
||||||
|
elif not endpoint:
|
||||||
|
raise HTTPException(
|
||||||
|
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Resolve the supplied URL to a ModelEndpoint the caller owns
|
||||||
|
# (their own rows + legacy null-owner shared rows), scoped so a
|
||||||
|
# comparison can't borrow another user's private endpoint key.
|
||||||
|
base = normalize_base(endpoint)
|
||||||
|
ep = _owned_endpoint_by_url(db, base, user)
|
||||||
|
# Reject *unregistered* raw URLs for signed-in non-admins; a
|
||||||
|
# matched registered endpoint supplies an id so the caller can
|
||||||
|
# still compare endpoints they own. Blanket-rejecting here (the
|
||||||
|
# earlier `endpoint_id=None` call) locked non-admins out of
|
||||||
|
# compare entirely, since compare resolves endpoints by URL with
|
||||||
|
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
|
||||||
|
# Raised here (phase 1), before any session exists.
|
||||||
|
_reject_raw_endpoint_url_for_non_admin(
|
||||||
|
request, user, str(ep.id) if ep is not None else None, endpoint
|
||||||
|
)
|
||||||
|
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
||||||
|
# caller-supplied string. When the URL matches a registered
|
||||||
|
# endpoint visible to the caller, use that row's own normalized
|
||||||
|
# base URL (the same value owner scoping + endpoint validation
|
||||||
|
# already vetted) so the session dials exactly where the stored
|
||||||
|
# config points. The raw `endpoint` only survives for callers
|
||||||
|
# allowed to pass one — admins / single-user mode, where
|
||||||
|
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
|
||||||
|
# is None. Mirrors the registered-endpoint path in session_routes.
|
||||||
|
session_endpoint_url = (
|
||||||
|
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
|
||||||
|
)
|
||||||
|
# Headers come only from a matched endpoint's key; None when
|
||||||
|
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||||
|
# never inherit another user's key/headers.
|
||||||
|
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||||
|
resolved.append((sid, model, session_endpoint_url, headers))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Both endpoints validated — only now create the ephemeral [CMP]
|
||||||
|
# sessions and copy any resolved headers.
|
||||||
|
for sid, model, session_endpoint_url, headers in resolved:
|
||||||
|
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||||
|
session_manager.create_session(
|
||||||
|
session_id=sid,
|
||||||
|
name=name,
|
||||||
|
endpoint_url=session_endpoint_url,
|
||||||
|
model=model,
|
||||||
|
rag=False,
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
if headers:
|
||||||
|
s = session_manager.sessions.get(sid)
|
||||||
|
if s:
|
||||||
|
s.headers = headers
|
||||||
|
|
||||||
|
# Store comparison record
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
comp = Comparison(
|
||||||
|
id=comp_id,
|
||||||
|
prompt=prompt,
|
||||||
|
model_a=model_a,
|
||||||
|
model_b=model_b,
|
||||||
|
# Record the URL the session actually dials. For URL callers this
|
||||||
|
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
||||||
|
# fall back to the resolved endpoint URL so the column stays
|
||||||
|
# meaningful and non-null. resolved is in [a, b] order.
|
||||||
|
endpoint_a=endpoint_a or resolved[0][2],
|
||||||
|
endpoint_b=endpoint_b or resolved[1][2],
|
||||||
|
is_blind=blind,
|
||||||
|
blind_mapping=json.dumps(mapping),
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
db.add(comp)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# In blind mode, withhold the model identities AND the left/right
|
||||||
|
# mapping from the response. The client already knows model_a/model_b
|
||||||
|
# (it sent them), so returning either would defeat blind mode. They are
|
||||||
|
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
|
||||||
|
return {
|
||||||
|
"id": comp_id,
|
||||||
|
"session_left": session_left,
|
||||||
|
"session_right": session_right,
|
||||||
|
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
|
||||||
|
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
|
||||||
|
"is_blind": blind,
|
||||||
|
"mapping": None if blind else mapping,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/{comp_id}/vote")
|
||||||
|
def vote_comparison(
|
||||||
|
request: Request,
|
||||||
|
comp_id: str,
|
||||||
|
winner: str = Form(...), # "left", "right", or "tie"
|
||||||
|
):
|
||||||
|
"""Record the user's vote and reveal model names if blind."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||||
|
if not comp:
|
||||||
|
raise HTTPException(404, "Comparison not found")
|
||||||
|
# SECURITY: strict ownership — null-owner Comparisons were
|
||||||
|
# accessible to every user.
|
||||||
|
if user and comp.owner != user:
|
||||||
|
raise HTTPException(404, "Comparison not found")
|
||||||
|
if comp.winner:
|
||||||
|
raise HTTPException(400, "Already voted")
|
||||||
|
|
||||||
|
mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"}
|
||||||
|
|
||||||
|
if winner == "tie":
|
||||||
|
comp.winner = "tie"
|
||||||
|
elif winner == "left":
|
||||||
|
comp.winner = mapping["left"]
|
||||||
|
elif winner == "right":
|
||||||
|
comp.winner = mapping["right"]
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "winner must be 'left', 'right', or 'tie'")
|
||||||
|
|
||||||
|
comp.voted_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"winner": comp.winner,
|
||||||
|
"model_a": comp.model_a,
|
||||||
|
"model_b": comp.model_b,
|
||||||
|
"revealed": {
|
||||||
|
"left": comp.model_a if mapping["left"] == "a" else comp.model_b,
|
||||||
|
"right": comp.model_a if mapping["right"] == "a" else comp.model_b,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/record")
|
||||||
|
def record_comparison(request: Request, body: RecordVoteRequest):
|
||||||
|
"""Lightweight endpoint to record a comparison vote from the frontend."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
comp_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
model_a = body.models[0] if len(body.models) > 0 else ""
|
||||||
|
model_b = body.models[1] if len(body.models) > 1 else ""
|
||||||
|
|
||||||
|
# For N>2 models, store the full list as JSON in blind_mapping
|
||||||
|
if len(body.models) > 2:
|
||||||
|
blind_mapping = json.dumps({"models": body.models})
|
||||||
|
else:
|
||||||
|
blind_mapping = None
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
comp = Comparison(
|
||||||
|
id=comp_id,
|
||||||
|
prompt=body.prompt[:500],
|
||||||
|
model_a=model_a,
|
||||||
|
model_b=model_b,
|
||||||
|
endpoint_a="",
|
||||||
|
endpoint_b="",
|
||||||
|
winner=body.winner,
|
||||||
|
is_blind=body.is_blind,
|
||||||
|
blind_mapping=blind_mapping,
|
||||||
|
voted_at=datetime.utcnow(),
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
db.add(comp)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"status": "ok", "id": comp_id}
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
def list_comparisons(request: Request):
|
||||||
|
"""List past comparisons."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(Comparison)
|
||||||
|
if user:
|
||||||
|
q = q.filter(Comparison.owner == user)
|
||||||
|
comps = q.order_by(Comparison.created_at.desc()).limit(50).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": c.id,
|
||||||
|
"prompt": c.prompt[:100],
|
||||||
|
"model_a": c.model_a,
|
||||||
|
"model_b": c.model_b,
|
||||||
|
"winner": c.winner,
|
||||||
|
"is_blind": c.is_blind,
|
||||||
|
"voted_at": c.voted_at.isoformat() if c.voted_at else None,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
}
|
||||||
|
for c in comps
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.delete("/{comp_id}")
|
||||||
|
def delete_comparison(request: Request, comp_id: str):
|
||||||
|
"""Delete a comparison and its ephemeral sessions."""
|
||||||
|
user = get_current_user(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||||
|
if not comp:
|
||||||
|
raise HTTPException(404, "Comparison not found")
|
||||||
|
# SECURITY: strict ownership — null-owner Comparisons were
|
||||||
|
# accessible to every user.
|
||||||
|
if user and comp.owner != user:
|
||||||
|
raise HTTPException(404, "Comparison not found")
|
||||||
|
db.delete(comp)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return router
|
||||||
+14
-361
@@ -1,365 +1,18 @@
|
|||||||
# routes/compare_routes.py
|
"""Backward-compat shim — canonical location is routes/compare/compare_routes.py.
|
||||||
"""Model A/B comparison routes."""
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import random
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
|
||||||
from typing import List
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from core.database import Comparison, SessionLocal
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
from core.session_manager import SessionManager
|
that ``import routes.compare_routes``, ``from routes.compare_routes import X``,
|
||||||
from src.auth_helpers import get_current_user
|
``importlib.import_module("routes.compare_routes")``, and the
|
||||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
``import ... as cr`` + ``monkeypatch.setattr(cr, "SessionLocal", ...)`` /
|
||||||
|
``"_owned_endpoint_by_url"`` / ``"_owned_endpoint_by_id"`` pattern used by
|
||||||
|
test_endpoint_owner_scope_followup.py all operate on the *same* object the
|
||||||
|
application actually uses. Keeps existing import paths working after
|
||||||
|
slice 2i (#4082/#4071). Source-introspection tests read the canonical file
|
||||||
|
by path.
|
||||||
|
"""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
import sys as _sys
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
from routes.compare import compare_routes as _canonical # noqa: F401
|
||||||
|
|
||||||
|
_sys.modules[__name__] = _canonical
|
||||||
def _owned_endpoint_by_url(db, base_url, owner):
|
|
||||||
"""ModelEndpoint whose base_url == `base_url` and is VISIBLE to `owner`
|
|
||||||
(their own rows + legacy null-owner "shared" rows); None otherwise.
|
|
||||||
|
|
||||||
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
|
|
||||||
owner = private, "the model picker only shows the endpoint to that user") and
|
|
||||||
holds a decrypted `api_key`. start_comparison copies the matched row's api_key
|
|
||||||
into the caller-owned [CMP] session's headers, which then drives that session's
|
|
||||||
/api/chat_stream calls — so an UNSCOPED base_url match would let a user mint a
|
|
||||||
comparison bound to ANOTHER user's private endpoint and spend that owner's
|
|
||||||
api_key / reach whatever base_url they configured. Mirrors
|
|
||||||
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
|
|
||||||
legacy mode).
|
|
||||||
"""
|
|
||||||
from core.database import ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url)
|
|
||||||
return owner_filter(q, ModelEndpoint, owner).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _owned_endpoint_by_id(db, endpoint_id, owner):
|
|
||||||
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
|
|
||||||
own rows + legacy null-owner "shared" rows); None otherwise.
|
|
||||||
|
|
||||||
Preferred over _owned_endpoint_by_url for credential resolution: two visible
|
|
||||||
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
|
|
||||||
accounts on the same provider). A base_url-only match returns whichever row
|
|
||||||
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
|
|
||||||
An id pins the exact registered endpoint, so /api/compare/start prefers it and
|
|
||||||
only falls back to URL matching for legacy / admin raw-URL callers. Owner
|
|
||||||
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
|
|
||||||
"""
|
|
||||||
from core.database import ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
|
|
||||||
return owner_filter(q, ModelEndpoint, owner).first()
|
|
||||||
|
|
||||||
|
|
||||||
class RecordVoteRequest(BaseModel):
|
|
||||||
prompt: str
|
|
||||||
models: List[str]
|
|
||||||
winner: str # model name or "tie"
|
|
||||||
is_blind: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
def setup_compare_routes(session_manager: SessionManager):
|
|
||||||
"""Setup comparison routes."""
|
|
||||||
|
|
||||||
@router.post("/start")
|
|
||||||
def start_comparison(
|
|
||||||
request: Request,
|
|
||||||
prompt: str = Form(...),
|
|
||||||
model_a: str = Form(...),
|
|
||||||
model_b: str = Form(...),
|
|
||||||
endpoint_a: str = Form(""),
|
|
||||||
endpoint_b: str = Form(""),
|
|
||||||
endpoint_a_id: str = Form(""),
|
|
||||||
endpoint_b_id: str = Form(""),
|
|
||||||
is_blind: str = Form("true"),
|
|
||||||
):
|
|
||||||
"""Create two ephemeral sessions and a comparison record.
|
|
||||||
|
|
||||||
Returns the comparison ID and the two session IDs so the client
|
|
||||||
can fire two independent SSE streams to /api/chat_stream.
|
|
||||||
"""
|
|
||||||
user = getattr(request.state, 'current_user', None)
|
|
||||||
comp_id = str(uuid.uuid4())
|
|
||||||
sid_a = str(uuid.uuid4())
|
|
||||||
sid_b = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Blind mapping: randomly assign left/right
|
|
||||||
blind = str(is_blind).lower() == "true"
|
|
||||||
if blind:
|
|
||||||
mapping = {"left": "a", "right": "b"}
|
|
||||||
if random.random() > 0.5:
|
|
||||||
mapping = {"left": "b", "right": "a"}
|
|
||||||
else:
|
|
||||||
mapping = {"left": "a", "right": "b"}
|
|
||||||
|
|
||||||
# Map session IDs to left/right based on blind mapping
|
|
||||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
|
||||||
session_right = sid_a if mapping["right"] == "a" else sid_b
|
|
||||||
|
|
||||||
# In blind mode, name the helper sessions by their neutral slot
|
|
||||||
# ("Model A" / "Model B") instead of the real model. Otherwise the
|
|
||||||
# session name leaks the model in the sidebar and GET /api/sessions,
|
|
||||||
# de-anonymizing the comparison before the user votes (issue #1285).
|
|
||||||
slot_name = {session_left: "Model A", session_right: "Model B"}
|
|
||||||
|
|
||||||
# SECURITY: resolve and validate BOTH endpoints before creating any
|
|
||||||
# session. Compare copies a registered endpoint's Authorization header
|
|
||||||
# into the [CMP] session, so validating one endpoint while creating its
|
|
||||||
# session, then rejecting the other, would leave a partial compare
|
|
||||||
# session behind with that header attached. Doing all the owner-scope
|
|
||||||
# resolution + raw-URL rejection up front means a 403 on either endpoint
|
|
||||||
# aborts the whole request with nothing created and no header copied.
|
|
||||||
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
|
||||||
resolved = []
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
for sid, model, endpoint, endpoint_id in [
|
|
||||||
(sid_a, model_a, endpoint_a, endpoint_a_id),
|
|
||||||
(sid_b, model_b, endpoint_b, endpoint_b_id),
|
|
||||||
]:
|
|
||||||
# Prefer an explicit endpoint id: it pins the EXACT registered
|
|
||||||
# endpoint (and its api_key), even when two endpoints visible to
|
|
||||||
# the caller share a base_url with different keys — a URL-only
|
|
||||||
# match would copy whichever row sorts first, i.e. possibly the
|
|
||||||
# wrong key. Fall back to URL resolution only for legacy / admin
|
|
||||||
# raw-URL callers that don't send an id.
|
|
||||||
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
|
|
||||||
if eid:
|
|
||||||
ep = _owned_endpoint_by_id(db, eid, user)
|
|
||||||
if ep is None:
|
|
||||||
# An id the caller can't see (wrong owner / deleted) must
|
|
||||||
# NOT silently fall back to a same-URL row with a different
|
|
||||||
# key — that's exactly the mix-up ids exist to prevent.
|
|
||||||
raise HTTPException(404, "Model endpoint not found")
|
|
||||||
# The id already resolved the endpoint; ignore any raw URL the
|
|
||||||
# caller also sent and dial the stored config instead.
|
|
||||||
endpoint = ep.base_url
|
|
||||||
elif not endpoint:
|
|
||||||
raise HTTPException(
|
|
||||||
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Resolve the supplied URL to a ModelEndpoint the caller owns
|
|
||||||
# (their own rows + legacy null-owner shared rows), scoped so a
|
|
||||||
# comparison can't borrow another user's private endpoint key.
|
|
||||||
base = normalize_base(endpoint)
|
|
||||||
ep = _owned_endpoint_by_url(db, base, user)
|
|
||||||
# Reject *unregistered* raw URLs for signed-in non-admins; a
|
|
||||||
# matched registered endpoint supplies an id so the caller can
|
|
||||||
# still compare endpoints they own. Blanket-rejecting here (the
|
|
||||||
# earlier `endpoint_id=None` call) locked non-admins out of
|
|
||||||
# compare entirely, since compare resolves endpoints by URL with
|
|
||||||
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
|
|
||||||
# Raised here (phase 1), before any session exists.
|
|
||||||
_reject_raw_endpoint_url_for_non_admin(
|
|
||||||
request, user, str(ep.id) if ep is not None else None, endpoint
|
|
||||||
)
|
|
||||||
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
|
||||||
# caller-supplied string. When the URL matches a registered
|
|
||||||
# endpoint visible to the caller, use that row's own normalized
|
|
||||||
# base URL (the same value owner scoping + endpoint validation
|
|
||||||
# already vetted) so the session dials exactly where the stored
|
|
||||||
# config points. The raw `endpoint` only survives for callers
|
|
||||||
# allowed to pass one — admins / single-user mode, where
|
|
||||||
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
|
|
||||||
# is None. Mirrors the registered-endpoint path in session_routes.
|
|
||||||
session_endpoint_url = (
|
|
||||||
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
|
|
||||||
)
|
|
||||||
# Headers come only from a matched endpoint's key; None when
|
|
||||||
# `ep` is None (raw admin URL or no match), so a comparison can
|
|
||||||
# never inherit another user's key/headers.
|
|
||||||
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
|
||||||
resolved.append((sid, model, session_endpoint_url, headers))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Both endpoints validated — only now create the ephemeral [CMP]
|
|
||||||
# sessions and copy any resolved headers.
|
|
||||||
for sid, model, session_endpoint_url, headers in resolved:
|
|
||||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
|
||||||
session_manager.create_session(
|
|
||||||
session_id=sid,
|
|
||||||
name=name,
|
|
||||||
endpoint_url=session_endpoint_url,
|
|
||||||
model=model,
|
|
||||||
rag=False,
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
if headers:
|
|
||||||
s = session_manager.sessions.get(sid)
|
|
||||||
if s:
|
|
||||||
s.headers = headers
|
|
||||||
|
|
||||||
# Store comparison record
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
comp = Comparison(
|
|
||||||
id=comp_id,
|
|
||||||
prompt=prompt,
|
|
||||||
model_a=model_a,
|
|
||||||
model_b=model_b,
|
|
||||||
# Record the URL the session actually dials. For URL callers this
|
|
||||||
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
|
||||||
# fall back to the resolved endpoint URL so the column stays
|
|
||||||
# meaningful and non-null. resolved is in [a, b] order.
|
|
||||||
endpoint_a=endpoint_a or resolved[0][2],
|
|
||||||
endpoint_b=endpoint_b or resolved[1][2],
|
|
||||||
is_blind=blind,
|
|
||||||
blind_mapping=json.dumps(mapping),
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
db.add(comp)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# In blind mode, withhold the model identities AND the left/right
|
|
||||||
# mapping from the response. The client already knows model_a/model_b
|
|
||||||
# (it sent them), so returning either would defeat blind mode. They are
|
|
||||||
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
|
|
||||||
return {
|
|
||||||
"id": comp_id,
|
|
||||||
"session_left": session_left,
|
|
||||||
"session_right": session_right,
|
|
||||||
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
|
|
||||||
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
|
|
||||||
"is_blind": blind,
|
|
||||||
"mapping": None if blind else mapping,
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/{comp_id}/vote")
|
|
||||||
def vote_comparison(
|
|
||||||
request: Request,
|
|
||||||
comp_id: str,
|
|
||||||
winner: str = Form(...), # "left", "right", or "tie"
|
|
||||||
):
|
|
||||||
"""Record the user's vote and reveal model names if blind."""
|
|
||||||
user = get_current_user(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
|
||||||
if not comp:
|
|
||||||
raise HTTPException(404, "Comparison not found")
|
|
||||||
# SECURITY: strict ownership — null-owner Comparisons were
|
|
||||||
# accessible to every user.
|
|
||||||
if user and comp.owner != user:
|
|
||||||
raise HTTPException(404, "Comparison not found")
|
|
||||||
if comp.winner:
|
|
||||||
raise HTTPException(400, "Already voted")
|
|
||||||
|
|
||||||
mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"}
|
|
||||||
|
|
||||||
if winner == "tie":
|
|
||||||
comp.winner = "tie"
|
|
||||||
elif winner == "left":
|
|
||||||
comp.winner = mapping["left"]
|
|
||||||
elif winner == "right":
|
|
||||||
comp.winner = mapping["right"]
|
|
||||||
else:
|
|
||||||
raise HTTPException(400, "winner must be 'left', 'right', or 'tie'")
|
|
||||||
|
|
||||||
comp.voted_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"winner": comp.winner,
|
|
||||||
"model_a": comp.model_a,
|
|
||||||
"model_b": comp.model_b,
|
|
||||||
"revealed": {
|
|
||||||
"left": comp.model_a if mapping["left"] == "a" else comp.model_b,
|
|
||||||
"right": comp.model_a if mapping["right"] == "a" else comp.model_b,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.post("/record")
|
|
||||||
def record_comparison(request: Request, body: RecordVoteRequest):
|
|
||||||
"""Lightweight endpoint to record a comparison vote from the frontend."""
|
|
||||||
user = get_current_user(request)
|
|
||||||
comp_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
model_a = body.models[0] if len(body.models) > 0 else ""
|
|
||||||
model_b = body.models[1] if len(body.models) > 1 else ""
|
|
||||||
|
|
||||||
# For N>2 models, store the full list as JSON in blind_mapping
|
|
||||||
if len(body.models) > 2:
|
|
||||||
blind_mapping = json.dumps({"models": body.models})
|
|
||||||
else:
|
|
||||||
blind_mapping = None
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
comp = Comparison(
|
|
||||||
id=comp_id,
|
|
||||||
prompt=body.prompt[:500],
|
|
||||||
model_a=model_a,
|
|
||||||
model_b=model_b,
|
|
||||||
endpoint_a="",
|
|
||||||
endpoint_b="",
|
|
||||||
winner=body.winner,
|
|
||||||
is_blind=body.is_blind,
|
|
||||||
blind_mapping=blind_mapping,
|
|
||||||
voted_at=datetime.utcnow(),
|
|
||||||
owner=user,
|
|
||||||
)
|
|
||||||
db.add(comp)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return {"status": "ok", "id": comp_id}
|
|
||||||
|
|
||||||
@router.get("/history")
|
|
||||||
def list_comparisons(request: Request):
|
|
||||||
"""List past comparisons."""
|
|
||||||
user = get_current_user(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
q = db.query(Comparison)
|
|
||||||
if user:
|
|
||||||
q = q.filter(Comparison.owner == user)
|
|
||||||
comps = q.order_by(Comparison.created_at.desc()).limit(50).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": c.id,
|
|
||||||
"prompt": c.prompt[:100],
|
|
||||||
"model_a": c.model_a,
|
|
||||||
"model_b": c.model_b,
|
|
||||||
"winner": c.winner,
|
|
||||||
"is_blind": c.is_blind,
|
|
||||||
"voted_at": c.voted_at.isoformat() if c.voted_at else None,
|
|
||||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
|
||||||
}
|
|
||||||
for c in comps
|
|
||||||
]
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.delete("/{comp_id}")
|
|
||||||
def delete_comparison(request: Request, comp_id: str):
|
|
||||||
"""Delete a comparison and its ephemeral sessions."""
|
|
||||||
user = get_current_user(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
|
||||||
if not comp:
|
|
||||||
raise HTTPException(404, "Comparison not found")
|
|
||||||
# SECURITY: strict ownership — null-owner Comparisons were
|
|
||||||
# accessible to every user.
|
|
||||||
if user and comp.owner != user:
|
|
||||||
raise HTTPException(404, "Comparison not found")
|
|
||||||
db.delete(comp)
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted"}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
@@ -463,14 +463,22 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" if sz == 0 and os.path.isdir(snap):",
|
" if sz == 0 and os.path.isdir(snap):",
|
||||||
" sz2, nf2, ic2 = snapshot_size()",
|
" sz2, nf2, ic2 = snapshot_size()",
|
||||||
" sz, nf, ic = sz2, nf2, ic or ic2",
|
" sz, nf, ic = sz2, nf2, ic or ic2",
|
||||||
" is_diffusion = False; gguf_files = []",
|
" is_video = bool(re.search(r'(?i)(^|/)Lightricks/LTX-|(^|/)LTX[-_/]|video|text-to-video|image-to-video', rid))",
|
||||||
|
" is_diffusion = is_video; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', rid)); gguf_files = []",
|
||||||
" if os.path.isdir(snap):",
|
" if os.path.isdir(snap):",
|
||||||
" for sd in os.listdir(snap):",
|
" for sd in os.listdir(snap):",
|
||||||
" sf = os.path.join(snap, sd)",
|
" sf = os.path.join(snap, sd)",
|
||||||
" if not os.path.isdir(sf): continue",
|
" if not os.path.isdir(sf): continue",
|
||||||
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
|
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
|
||||||
|
" if os.path.exists(os.path.join(sf, 'adapter_config.json')) or os.path.exists(os.path.join(sf, 'adapter_model.safetensors')): is_adapter = True",
|
||||||
|
" for _root, _dirs, _fns in safe_walk(sf):",
|
||||||
|
" for _fn in _fns:",
|
||||||
|
" _lfn = _fn.lower()",
|
||||||
|
" if _lfn.endswith('.safetensors') and re.search(r'(?i)(ltx|video|upscaler)', _lfn): is_video = True; is_diffusion = True",
|
||||||
|
" if _lfn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in _lfn:",
|
||||||
|
" is_adapter = True",
|
||||||
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
|
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
|
||||||
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_video':is_video,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||||
"def hf_cache_paths():",
|
"def hf_cache_paths():",
|
||||||
" candidates = []",
|
" candidates = []",
|
||||||
" def add(p):",
|
" def add(p):",
|
||||||
@@ -505,11 +513,12 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" fp = os.path.join(p, d)",
|
" fp = os.path.join(p, d)",
|
||||||
" if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue",
|
" if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue",
|
||||||
" if d in seen: continue",
|
" if d in seen: continue",
|
||||||
" is_model = False; gguf_files = []",
|
" is_model = False; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', d)); gguf_files = []",
|
||||||
" for root, dirs, fns in safe_walk(fp):",
|
" for root, dirs, fns in safe_walk(fp):",
|
||||||
" for fn in fns:",
|
" for fn in fns:",
|
||||||
" if fn.lower().endswith('.gguf'): is_model = True",
|
" if fn.lower().endswith('.gguf'): is_model = True",
|
||||||
" elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True",
|
" elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True",
|
||||||
|
" if fn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in fn.lower(): is_adapter = True",
|
||||||
" if is_model: break",
|
" if is_model: break",
|
||||||
" if not is_model: continue",
|
" if not is_model: continue",
|
||||||
" gguf_files = collect_ggufs(fp)",
|
" gguf_files = collect_ggufs(fp)",
|
||||||
@@ -520,7 +529,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))",
|
" try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))",
|
||||||
" except Exception: pass",
|
" except Exception: pass",
|
||||||
" is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))",
|
" is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))",
|
||||||
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||||
"def parse_size(num, unit):",
|
"def parse_size(num, unit):",
|
||||||
" try: n = float(num)",
|
" try: n = float(num)",
|
||||||
" except Exception: return 0",
|
" except Exception: return 0",
|
||||||
@@ -1320,6 +1329,26 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"MLX LM is not installed on this server.",
|
"MLX LM is not installed on this server.",
|
||||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||||
|
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||||
|
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['\"]?mflux",
|
||||||
|
"MLX image serving requires mflux on this Apple Silicon server.",
|
||||||
|
[{"label": "install mflux in Cookbook Dependencies", "op": "dependency", "package": "mflux"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"mlx-lama-swift|odysseus-mlx-inpaint|mlx-lama-serve|LaMa / MI-GAN MLX inpainting models require",
|
||||||
|
"LaMa / MI-GAN MLX inpainting requires an Odysseus-compatible mlx-lama-swift bridge on this Apple Silicon server.",
|
||||||
|
[{"label": "build mlx-lama-swift bridge and put odysseus-mlx-inpaint or mlx-lama-serve on PATH", "op": "dependency", "package": "mlx_lama_swift"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"mlx-ddcolor-swift|odysseus-mlx-colorize|mlx-ddcolor-serve|DDColor MLX models require",
|
||||||
|
"DDColor MLX colorization requires an Odysseus-compatible mlx-ddcolor-swift bridge on this Apple Silicon server.",
|
||||||
|
[{"label": "build mlx-ddcolor-swift bridge and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH", "op": "dependency", "package": "mlx_ddcolor_swift"}],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||||
@@ -1358,9 +1387,9 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
|
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||||
"Diffusion serving requires PyTorch and diffusers.",
|
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||||
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
|
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||||
|
|||||||
+227
-30
@@ -73,6 +73,47 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_local_pid_record_line(pid_path: Path, ready_path: Path) -> str:
|
||||||
|
"""Build the Git Bash prelude that records a Win32-stoppable PID.
|
||||||
|
|
||||||
|
Python publishes the detached outer process's Win32 PID first, then touches
|
||||||
|
``ready_path``. The inner Git Bash runner waits for that publication before
|
||||||
|
replacing the fallback with its own Win32 PID from /proc/<msys-pid>/winpid.
|
||||||
|
|
||||||
|
Missing, malformed, or late mappings leave the valid outer PID untouched.
|
||||||
|
"""
|
||||||
|
pp = shlex.quote(pid_path.as_posix())
|
||||||
|
rp = shlex.quote(ready_path.as_posix())
|
||||||
|
return (
|
||||||
|
"i=0; "
|
||||||
|
f"while [ ! -e {rp} ] && [ \"$i\" -lt 500 ]; do "
|
||||||
|
"i=$((i+1)); sleep 0.01; done; "
|
||||||
|
f"if [ -e {rp} ]; then "
|
||||||
|
"winpid=\"$(cat /proc/$$/winpid 2>/dev/null || true)\"; "
|
||||||
|
"case \"$winpid\" in ''|*[!0-9]*) ;; "
|
||||||
|
f"*) printf '%s\\n' \"$winpid\" > {pp} ;; esac; "
|
||||||
|
"fi; "
|
||||||
|
f"rm -f {rp}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
|
||||||
|
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
|
||||||
|
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
|
||||||
|
try:
|
||||||
|
script = script_path.read_text(encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to read mlx_image_server.py: %s", e)
|
||||||
|
runner_lines.append('echo "ERROR: Odysseus could not prepare the MLX image server helper."')
|
||||||
|
runner_lines.append('ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
return
|
||||||
|
runner_lines.append('mkdir -p scripts')
|
||||||
|
runner_lines.append("cat > scripts/mlx_image_server.py <<'PY'")
|
||||||
|
runner_lines.extend(script.splitlines())
|
||||||
|
runner_lines.append("PY")
|
||||||
|
runner_lines.append('chmod +x scripts/mlx_image_server.py 2>/dev/null || true')
|
||||||
|
|
||||||
|
|
||||||
def _venv_root_from_serve_cmd(cmd: str) -> str:
|
def _venv_root_from_serve_cmd(cmd: str) -> str:
|
||||||
"""Best-effort venv root from an absolute venv python in a serve command."""
|
"""Best-effort venv root from an absolute venv python in a serve command."""
|
||||||
try:
|
try:
|
||||||
@@ -492,6 +533,11 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"MLX LM is not installed on this server.",
|
"MLX LM is not installed on this server.",
|
||||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||||
|
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||||
|
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||||
@@ -530,9 +576,9 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
|
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||||
"Diffusion serving requires PyTorch and diffusers.",
|
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||||
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
|
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||||
@@ -956,15 +1002,18 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
directly (simple commands only). Returns the launched job record."""
|
directly (simple commands only). Returns the launched job record."""
|
||||||
log_path = TMUX_LOG_DIR / f"{session_id}.log"
|
log_path = TMUX_LOG_DIR / f"{session_id}.log"
|
||||||
pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
|
pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
|
||||||
|
pid_ready_path: Path | None = None
|
||||||
bash = find_bash()
|
bash = find_bash()
|
||||||
if bash:
|
if bash:
|
||||||
# Run the existing bash wrapper verbatim through Git Bash, redirecting
|
# Run the existing bash wrapper verbatim through Git Bash, redirecting
|
||||||
# all output to the log the poller reads. Paths handed to bash use
|
# all output to the log the poller reads. Paths handed to bash use
|
||||||
# POSIX form + shell-quoting so drive paths / spaces survive.
|
# POSIX form + shell-quoting so drive paths / spaces survive.
|
||||||
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||||
pp = shlex.quote(pid_path.as_posix())
|
pid_ready_path = TMUX_LOG_DIR / f"{session_id}.pid.ready"
|
||||||
|
pid_ready_path.unlink(missing_ok=True)
|
||||||
inner.write_text(
|
inner.write_text(
|
||||||
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
|
_windows_local_pid_record_line(pid_path, pid_ready_path) + "\n"
|
||||||
|
+ "\n".join(bash_lines) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
lp = shlex.quote(log_path.as_posix())
|
lp = shlex.quote(log_path.as_posix())
|
||||||
@@ -998,7 +1047,18 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
env=env,
|
env=env,
|
||||||
**detached_popen_kwargs(),
|
**detached_popen_kwargs(),
|
||||||
)
|
)
|
||||||
|
# Publish a valid Win32 ancestor first. The Git Bash runner may then
|
||||||
|
# replace it with its own Win32 pid, but never before this fallback exists.
|
||||||
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
||||||
|
if pid_ready_path is not None:
|
||||||
|
try:
|
||||||
|
pid_ready_path.touch()
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not publish Windows local PID handoff for %s: %s",
|
||||||
|
session_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
return {"pid": proc.pid, "log_path": str(log_path)}
|
return {"pid": proc.pid, "log_path": str(log_path)}
|
||||||
|
|
||||||
@router.post("/api/model/download")
|
@router.post("/api/model/download")
|
||||||
@@ -1433,9 +1493,11 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"nb_files": m["nb_files"],
|
"nb_files": m["nb_files"],
|
||||||
"has_incomplete": m["has_incomplete"],
|
"has_incomplete": m["has_incomplete"],
|
||||||
"status": "downloading" if m["has_incomplete"] else "ready",
|
"status": "downloading" if m["has_incomplete"] else "ready",
|
||||||
"path": m.get("path", ""),
|
"path": m.get("path", ""),
|
||||||
"is_diffusion": m.get("is_diffusion", False),
|
"is_diffusion": m.get("is_diffusion", False),
|
||||||
}
|
"is_video": m.get("is_video", False),
|
||||||
|
"is_adapter": m.get("is_adapter", False),
|
||||||
|
}
|
||||||
if m.get("is_local_dir"):
|
if m.get("is_local_dir"):
|
||||||
entry["is_local_dir"] = True
|
entry["is_local_dir"] = True
|
||||||
if m.get("is_gguf"):
|
if m.get("is_gguf"):
|
||||||
@@ -1460,6 +1522,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||||
import re
|
import re
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.settings import load_settings, save_settings
|
||||||
|
|
||||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||||
@@ -1477,6 +1540,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# Friendly display name from repo_id
|
# Friendly display name from repo_id
|
||||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||||
display_name = f"{short_name} (image)"
|
display_name = f"{short_name} (image)"
|
||||||
|
pinned_models = [req.repo_id] if req.repo_id else []
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -1486,7 +1550,16 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
existing.is_enabled = True
|
existing.is_enabled = True
|
||||||
existing.model_type = "image"
|
existing.model_type = "image"
|
||||||
existing.name = display_name
|
existing.name = display_name
|
||||||
|
existing.endpoint_kind = "local"
|
||||||
|
existing.model_refresh_mode = "manual"
|
||||||
|
if pinned_models:
|
||||||
|
existing.cached_models = json.dumps(pinned_models)
|
||||||
|
existing.pinned_models = json.dumps(pinned_models)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
settings = load_settings()
|
||||||
|
if settings.get("image_gen_enabled") is not True:
|
||||||
|
settings["image_gen_enabled"] = True
|
||||||
|
save_settings(settings)
|
||||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||||
return existing.id
|
return existing.id
|
||||||
|
|
||||||
@@ -1498,9 +1571,18 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
api_key=None,
|
api_key=None,
|
||||||
is_enabled=True,
|
is_enabled=True,
|
||||||
model_type="image",
|
model_type="image",
|
||||||
|
endpoint_kind="local",
|
||||||
|
model_refresh_mode="manual",
|
||||||
|
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
|
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
)
|
)
|
||||||
db.add(ep)
|
db.add(ep)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
settings = load_settings()
|
||||||
|
settings["image_gen_enabled"] = True
|
||||||
|
if not settings.get("image_model"):
|
||||||
|
settings["image_model"] = req.repo_id
|
||||||
|
save_settings(settings)
|
||||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||||
return ep_id
|
return ep_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2356,24 +2438,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
elif "sglang.launch_server" in req.cmd:
|
elif "sglang.launch_server" in req.cmd:
|
||||||
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||||
runner_lines.append('if ! command -v sglang &>/dev/null; then')
|
|
||||||
runner_lines.append(' echo "ERROR: SGLang is not installed."')
|
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
|
||||||
runner_lines.append('elif ! ODYSSEUS_SGLANG_IMPORT_ERROR="$(python3 -c "import sglang" 2>&1)"; then')
|
|
||||||
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import."')
|
|
||||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
|
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
|
||||||
runner_lines.append('fi')
|
|
||||||
elif "mlx_lm.server" in req.cmd:
|
|
||||||
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
|
||||||
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$(python3 -c "import mlx_lm" 2>&1)"; then')
|
|
||||||
runner_lines.append(' echo "ERROR: MLX LM is not installed."')
|
|
||||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
|
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
|
||||||
runner_lines.append('fi')
|
|
||||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||||
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
runner_lines.append('ODYSSEUS_SGLANG_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
runner_lines.append(' ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
|
||||||
runner_lines.append('import shlex, sys')
|
runner_lines.append('import shlex, sys')
|
||||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||||
runner_lines.append('py = "python3"')
|
runner_lines.append('py = "python3"')
|
||||||
@@ -2384,6 +2450,36 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
runner_lines.append('print(py)')
|
runner_lines.append('print(py)')
|
||||||
runner_lines.append('PY')
|
runner_lines.append('PY')
|
||||||
runner_lines.append(')"')
|
runner_lines.append(')"')
|
||||||
|
runner_lines.append('if ! "$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" &>/dev/null; then')
|
||||||
|
runner_lines.append(' if ! command -v sglang &>/dev/null; then')
|
||||||
|
runner_lines.append(' echo "ERROR: SGLang is not installed."')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import in the launch Python."')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' ODYSSEUS_SGLANG_IMPORT_ERROR="$("$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" 2>&1)"')
|
||||||
|
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
elif "mlx_lm.server" in req.cmd:
|
||||||
|
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||||
|
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||||
|
runner_lines.append('ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
|
runner_lines.append('import shlex, sys')
|
||||||
|
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||||
|
runner_lines.append('py = "python3"')
|
||||||
|
runner_lines.append('for i, part in enumerate(parts):')
|
||||||
|
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||||
|
runner_lines.append(' py = part')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append('print(py)')
|
||||||
|
runner_lines.append('PY')
|
||||||
|
runner_lines.append(')"')
|
||||||
|
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$("$ODYSSEUS_MLX_CMD_PY" -c "import mlx_lm" 2>&1)"; then')
|
||||||
|
runner_lines.append(' echo "ERROR: MLX LM is not installed in the launch Python: $ODYSSEUS_MLX_CMD_PY"')
|
||||||
|
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
||||||
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
runner_lines.append('import json, os, shlex, sys')
|
runner_lines.append('import json, os, shlex, sys')
|
||||||
runner_lines.append('from pathlib import Path')
|
runner_lines.append('from pathlib import Path')
|
||||||
@@ -2474,10 +2570,111 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
runner_lines.append('PY')
|
runner_lines.append('PY')
|
||||||
runner_lines.append(')"')
|
runner_lines.append(')"')
|
||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
|
elif "scripts/mlx_image_server.py" in req.cmd or ".mlx_image_server.py" in req.cmd:
|
||||||
|
_append_mlx_image_server_script(runner_lines)
|
||||||
|
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||||
|
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||||
|
runner_lines.append('ODYSSEUS_MLX_IMAGE_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
|
runner_lines.append('import shlex, sys')
|
||||||
|
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||||
|
runner_lines.append('py = "python3"')
|
||||||
|
runner_lines.append('for part in parts:')
|
||||||
|
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||||
|
runner_lines.append(' py = part')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append('print(py)')
|
||||||
|
runner_lines.append('PY')
|
||||||
|
runner_lines.append(')"')
|
||||||
|
runner_lines.append('ODYSSEUS_MLX_IMAGE_BIN_DIR="$(dirname "$ODYSSEUS_MLX_IMAGE_CMD_PY" 2>/dev/null || true)"')
|
||||||
|
runner_lines.append('if [ -n "$ODYSSEUS_MLX_IMAGE_BIN_DIR" ]; then export PATH="$ODYSSEUS_MLX_IMAGE_BIN_DIR:$PATH"; fi')
|
||||||
|
runner_lines.append('if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import fastapi, uvicorn, multipart" >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: MLX image serving requires FastAPI + uvicorn + python-multipart in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY. Install the MLX image dependencies in Cookbook Dependencies."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('ODYSSEUS_MLX_IMAGE_MODEL="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
|
runner_lines.append('import shlex, sys')
|
||||||
|
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||||
|
runner_lines.append('model = ""')
|
||||||
|
runner_lines.append('for i, part in enumerate(parts):')
|
||||||
|
runner_lines.append(' if part == "--model" and i + 1 < len(parts):')
|
||||||
|
runner_lines.append(' model = parts[i + 1]')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append('print(model)')
|
||||||
|
runner_lines.append('PY')
|
||||||
|
runner_lines.append(')"')
|
||||||
|
runner_lines.append('if printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi hidream; then')
|
||||||
|
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import mlx, mlx_vlm, transformers, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: HiDream MLX serving needs the model requirements in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||||
|
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm \'transformers>=4.57.0,<6.0\' huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi boogu; then')
|
||||||
|
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import boogu_image_mlx, mlx, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: Boogu MLX serving needs boogu-image-mlx in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||||
|
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "ddcolor"; then')
|
||||||
|
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: DDColor MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||||
|
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if ! command -v odysseus-mlx-colorize >/dev/null 2>&1 && ! command -v mlx-ddcolor-serve >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: DDColor MLX serving requires the Odysseus mlx-ddcolor-swift bridge on PATH: odysseus-mlx-colorize or mlx-ddcolor-serve."')
|
||||||
|
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' ODYSSEUS_DDCOLOR_BIN="$(command -v odysseus-mlx-colorize 2>/dev/null || command -v mlx-ddcolor-serve 2>/dev/null || true)"')
|
||||||
|
runner_lines.append(' if [ -n "$ODYSSEUS_DDCOLOR_BIN" ]; then')
|
||||||
|
runner_lines.append(' ODYSSEUS_DDCOLOR_DIR="$(dirname "$ODYSSEUS_DDCOLOR_BIN")"')
|
||||||
|
runner_lines.append(' if [ ! -f "$ODYSSEUS_DDCOLOR_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_DDCOLOR_DIR/default.metallib" ]; then')
|
||||||
|
runner_lines.append(' echo "ERROR: DDColor MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
|
||||||
|
runner_lines.append(' echo "Run the DDColor MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "mi-gan|migan|lama"; then')
|
||||||
|
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||||
|
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if ! command -v odysseus-mlx-inpaint >/dev/null 2>&1 && ! command -v mlx-lama-serve >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving requires the Odysseus mlx-lama-swift bridge on PATH: odysseus-mlx-inpaint or mlx-lama-serve."')
|
||||||
|
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' ODYSSEUS_INPAINT_BIN="$(command -v odysseus-mlx-inpaint 2>/dev/null || command -v mlx-lama-serve 2>/dev/null || true)"')
|
||||||
|
runner_lines.append(' if [ -n "$ODYSSEUS_INPAINT_BIN" ]; then')
|
||||||
|
runner_lines.append(' ODYSSEUS_INPAINT_DIR="$(dirname "$ODYSSEUS_INPAINT_BIN")"')
|
||||||
|
runner_lines.append(' if [ ! -f "$ODYSSEUS_INPAINT_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_INPAINT_DIR/default.metallib" ]; then')
|
||||||
|
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
|
||||||
|
runner_lines.append(' echo "Run the LaMa / MI-GAN MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append('elif ! command -v mflux-generate >/dev/null 2>&1 && ! command -v mflux-generate-qwen >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' echo "ERROR: mflux-compatible MLX image serving requires mflux-generate or mflux-generate-qwen in PATH for launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||||
|
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U mflux fastapi uvicorn python-multipart"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
|
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
|
||||||
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||||
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$(python3 -c "import torch, diffusers" 2>&1)"; then')
|
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||||
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + diffusers."')
|
runner_lines.append('ODYSSEUS_DIFFUSION_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||||
|
runner_lines.append('import shlex, sys')
|
||||||
|
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||||
|
runner_lines.append('py = "python3"')
|
||||||
|
runner_lines.append('for part in parts:')
|
||||||
|
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||||
|
runner_lines.append(' py = part')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append('print(py)')
|
||||||
|
runner_lines.append('PY')
|
||||||
|
runner_lines.append(')"')
|
||||||
|
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$("$ODYSSEUS_DIFFUSION_CMD_PY" -c "import torch, torchvision, diffusers" 2>&1)"; then')
|
||||||
|
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + Torchvision + diffusers in the launch Python: $ODYSSEUS_DIFFUSION_CMD_PY."')
|
||||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"')
|
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"')
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
@@ -2588,8 +2785,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# endpoint; any other real model serve (i.e. not a pip-install task) gets
|
# endpoint; any other real model serve (i.e. not a pip-install task) gets
|
||||||
# a local LLM endpoint pointed at its /v1.
|
# a local LLM endpoint pointed at its /v1.
|
||||||
endpoint_id = None
|
endpoint_id = None
|
||||||
is_diffusion = "diffusion_server.py" in req.cmd
|
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||||
if is_diffusion:
|
if is_image_endpoint:
|
||||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
endpoint_id = _auto_register_image_endpoint(req, remote)
|
||||||
elif not is_pip_install:
|
elif not is_pip_install:
|
||||||
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
||||||
@@ -2605,7 +2802,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# if N != 0 within the watch window, delete the endpoint we just
|
# if N != 0 within the watch window, delete the endpoint we just
|
||||||
# created. Skipped for diffusion (different image-endpoint cleanup
|
# created. Skipped for diffusion (different image-endpoint cleanup
|
||||||
# path) and pip-install tasks (no endpoint to drop).
|
# path) and pip-install tasks (no endpoint to drop).
|
||||||
if endpoint_id and not is_diffusion and not is_pip_install:
|
if endpoint_id and not is_image_endpoint and not is_pip_install:
|
||||||
asyncio.create_task(_serve_crash_watchdog(
|
asyncio.create_task(_serve_crash_watchdog(
|
||||||
endpoint_id=endpoint_id,
|
endpoint_id=endpoint_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Document route domain package (slice 2m, #4082/#4071).
|
||||||
|
|
||||||
|
Contains document_routes.py and document_helpers.py, migrated from the flat
|
||||||
|
routes/ directory. Backward-compat shims at routes/document_routes.py and
|
||||||
|
routes/document_helpers.py re-export from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
||||||
|
|
||||||
|
"""Document routes — CRUD for living documents with version history."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.database import Document, DocumentVersion
|
||||||
|
from core.database import Session as DbSession
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Request schemas ----
|
||||||
|
|
||||||
|
class DocumentCreate(BaseModel):
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
title: str = "Untitled"
|
||||||
|
language: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
class DocumentUpdate(BaseModel):
|
||||||
|
content: str
|
||||||
|
summary: Optional[str] = None
|
||||||
|
force_version: bool = False
|
||||||
|
|
||||||
|
class DocumentPatch(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
session_id: Optional[str] = None # link/unlink document to a session
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Helpers ----
|
||||||
|
|
||||||
|
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": doc.id,
|
||||||
|
"session_id": doc.session_id,
|
||||||
|
"title": doc.title,
|
||||||
|
"language": doc.language,
|
||||||
|
"current_content": doc.current_content,
|
||||||
|
"version_count": doc.version_count,
|
||||||
|
"is_active": doc.is_active,
|
||||||
|
"archived": bool(getattr(doc, "archived", False)),
|
||||||
|
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
||||||
|
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
||||||
|
# Source-email provenance (set when doc was created from an email
|
||||||
|
# attachment) — drives the "Send signed reply" menu item.
|
||||||
|
"source_email_uid": getattr(doc, "source_email_uid", None),
|
||||||
|
"source_email_folder": getattr(doc, "source_email_folder", None),
|
||||||
|
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
||||||
|
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": v.id,
|
||||||
|
"document_id": v.document_id,
|
||||||
|
"version_number": v.version_number,
|
||||||
|
"content": v.content,
|
||||||
|
"summary": v.summary,
|
||||||
|
"source": v.source,
|
||||||
|
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_doc_owner(db, doc: Document, user: str):
|
||||||
|
"""Verify `user` owns this document. Raise 404 if not.
|
||||||
|
|
||||||
|
Documents now carry their own `owner` column, so a doc whose session
|
||||||
|
was deleted (session_id → NULL) can still prove ownership and stay
|
||||||
|
openable / cloneable. We trust that column first and only fall back to
|
||||||
|
the session join for any not-yet-backfilled legacy row.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
if _auth_disabled():
|
||||||
|
return # Single-user / no-auth mode: allow access
|
||||||
|
raise HTTPException(403, "Authentication required")
|
||||||
|
if doc.owner is not None:
|
||||||
|
if doc.owner != user:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
return
|
||||||
|
# Legacy fallback: derive ownership from the linked session.
|
||||||
|
if not doc.session_id:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
||||||
|
if not session or session.owner != user:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_session_filter(q, user):
|
||||||
|
"""Restrict a documents query to those owned by `user`.
|
||||||
|
|
||||||
|
Documents now carry their own `owner` column (backfilled at boot from
|
||||||
|
the linked session, or assigned to the admin user for legacy/orphaned
|
||||||
|
docs). We filter on that directly rather than on a session join, so a
|
||||||
|
document whose session was deleted (session_id → NULL) still shows up
|
||||||
|
for its owner instead of silently vanishing from the Library + search.
|
||||||
|
|
||||||
|
The owner backfill runs in init_db before the app serves requests, so
|
||||||
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
|
if not user:
|
||||||
|
if user == "" or _auth_disabled():
|
||||||
|
return q
|
||||||
|
return q.filter(False)
|
||||||
|
return q.filter(Document.owner == user)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(name: str) -> str:
|
||||||
|
"""Filesystem-friendly version of a document title.
|
||||||
|
|
||||||
|
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
||||||
|
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
s = (name or "").strip()
|
||||||
|
# Drop the trailing extension if the title happens to include one
|
||||||
|
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
||||||
|
s = _re.sub(r'\s+', '_', s)
|
||||||
|
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
||||||
|
s = _re.sub(r'_+', '_', s).strip('_')
|
||||||
|
return s or "form"
|
||||||
|
|
||||||
|
|
||||||
|
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
||||||
|
_PDF_RENDER_SCALE = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
||||||
|
base = os.path.realpath(upload_dir)
|
||||||
|
p = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
return os.path.commonpath([base, p]) == base
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_upload_path(
|
||||||
|
upload_handler: Any,
|
||||||
|
upload_id: str,
|
||||||
|
owner: Optional[str],
|
||||||
|
auth_manager=None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve an upload id to a filesystem path the caller may read."""
|
||||||
|
if upload_handler is None:
|
||||||
|
return None
|
||||||
|
resolved = upload_handler.resolve_upload(
|
||||||
|
upload_id,
|
||||||
|
owner=owner,
|
||||||
|
auth_manager=auth_manager,
|
||||||
|
)
|
||||||
|
if not isinstance(resolved, dict) or not resolved:
|
||||||
|
return None
|
||||||
|
path = resolved.get("path")
|
||||||
|
upload_dir = getattr(upload_handler, "upload_dir", None)
|
||||||
|
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
||||||
|
logger.warning("Upload path outside upload directory: %s", path)
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_upload(
|
||||||
|
upload_dir: str,
|
||||||
|
file_id: str,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
auth_manager=None,
|
||||||
|
upload_handler: Any = None,
|
||||||
|
):
|
||||||
|
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
||||||
|
if upload_handler is None:
|
||||||
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
||||||
|
upload_handler = UploadHandler(base_dir, upload_dir)
|
||||||
|
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_pdf_marker_upload_owned(
|
||||||
|
request: Request,
|
||||||
|
content: str,
|
||||||
|
user: Optional[str],
|
||||||
|
upload_handler: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Reject document content whose pdf_source marker points at another user's upload."""
|
||||||
|
if upload_handler is None:
|
||||||
|
return
|
||||||
|
from src.pdf_form_doc import find_source_upload_id
|
||||||
|
|
||||||
|
upload_id = find_source_upload_id(content or "")
|
||||||
|
if not upload_id:
|
||||||
|
return
|
||||||
|
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||||
|
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Document PDF marker references an upload you do not own",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_title(content: str) -> str:
|
||||||
|
"""Derive a title from document content."""
|
||||||
|
import re
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return "Untitled"
|
||||||
|
text = content.strip()
|
||||||
|
if not text:
|
||||||
|
return "Untitled"
|
||||||
|
|
||||||
|
# Markdown header
|
||||||
|
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
||||||
|
if md:
|
||||||
|
title = md.group(1).strip()
|
||||||
|
if len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title
|
||||||
|
|
||||||
|
# HTML heading
|
||||||
|
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
||||||
|
if html:
|
||||||
|
title = html.group(1).strip()
|
||||||
|
if len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title
|
||||||
|
|
||||||
|
# First non-empty line (if short enough)
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line and 2 <= len(line) <= 60:
|
||||||
|
title = re.sub(r'[:#*`]+$', '', line).strip()
|
||||||
|
if title and len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title or "Untitled"
|
||||||
|
|
||||||
|
return "Untitled"
|
||||||
File diff suppressed because it is too large
Load Diff
+10
-239
@@ -1,243 +1,14 @@
|
|||||||
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
"""Backward-compat shim — canonical location is routes/document/document_helpers.py.
|
||||||
|
|
||||||
"""Document routes — CRUD for living documents with version history."""
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
|
that ``import routes.document_helpers``, ``from routes.document_helpers import
|
||||||
|
X``, and the ``sys.modules.pop("routes.document_helpers")`` + re-import
|
||||||
|
pattern used by test_security_regressions.py all operate on the *same* object.
|
||||||
|
Keeps existing import paths working after slice 2m (#4082/#4071).
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import sys as _sys
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from routes.document import document_helpers as _canonical # noqa: F401
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from core.database import Document, DocumentVersion
|
_sys.modules[__name__] = _canonical
|
||||||
from core.database import Session as DbSession
|
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
from src.upload_handler import UploadHandler
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Request schemas ----
|
|
||||||
|
|
||||||
class DocumentCreate(BaseModel):
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
title: str = "Untitled"
|
|
||||||
language: Optional[str] = None
|
|
||||||
content: str = ""
|
|
||||||
|
|
||||||
class DocumentUpdate(BaseModel):
|
|
||||||
content: str
|
|
||||||
summary: Optional[str] = None
|
|
||||||
force_version: bool = False
|
|
||||||
|
|
||||||
class DocumentPatch(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
language: Optional[str] = None
|
|
||||||
session_id: Optional[str] = None # link/unlink document to a session
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
|
||||||
|
|
||||||
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": doc.id,
|
|
||||||
"session_id": doc.session_id,
|
|
||||||
"title": doc.title,
|
|
||||||
"language": doc.language,
|
|
||||||
"current_content": doc.current_content,
|
|
||||||
"version_count": doc.version_count,
|
|
||||||
"is_active": doc.is_active,
|
|
||||||
"archived": bool(getattr(doc, "archived", False)),
|
|
||||||
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
|
||||||
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
|
||||||
# Source-email provenance (set when doc was created from an email
|
|
||||||
# attachment) — drives the "Send signed reply" menu item.
|
|
||||||
"source_email_uid": getattr(doc, "source_email_uid", None),
|
|
||||||
"source_email_folder": getattr(doc, "source_email_folder", None),
|
|
||||||
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
|
||||||
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": v.id,
|
|
||||||
"document_id": v.document_id,
|
|
||||||
"version_number": v.version_number,
|
|
||||||
"content": v.content,
|
|
||||||
"summary": v.summary,
|
|
||||||
"source": v.source,
|
|
||||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_doc_owner(db, doc: Document, user: str):
|
|
||||||
"""Verify `user` owns this document. Raise 404 if not.
|
|
||||||
|
|
||||||
Documents now carry their own `owner` column, so a doc whose session
|
|
||||||
was deleted (session_id → NULL) can still prove ownership and stay
|
|
||||||
openable / cloneable. We trust that column first and only fall back to
|
|
||||||
the session join for any not-yet-backfilled legacy row.
|
|
||||||
"""
|
|
||||||
if user is None:
|
|
||||||
if _auth_disabled():
|
|
||||||
return # Single-user / no-auth mode: allow access
|
|
||||||
raise HTTPException(403, "Authentication required")
|
|
||||||
if doc.owner is not None:
|
|
||||||
if doc.owner != user:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
return
|
|
||||||
# Legacy fallback: derive ownership from the linked session.
|
|
||||||
if not doc.session_id:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
|
||||||
if not session or session.owner != user:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_session_filter(q, user):
|
|
||||||
"""Restrict a documents query to those owned by `user`.
|
|
||||||
|
|
||||||
Documents now carry their own `owner` column (backfilled at boot from
|
|
||||||
the linked session, or assigned to the admin user for legacy/orphaned
|
|
||||||
docs). We filter on that directly rather than on a session join, so a
|
|
||||||
document whose session was deleted (session_id → NULL) still shows up
|
|
||||||
for its owner instead of silently vanishing from the Library + search.
|
|
||||||
|
|
||||||
The owner backfill runs in init_db before the app serves requests, so
|
|
||||||
by the time this filter is live there are no NULL-owner rows to leak;
|
|
||||||
we therefore match the owner strictly for authenticated callers."""
|
|
||||||
if not user:
|
|
||||||
if user == "" or _auth_disabled():
|
|
||||||
return q
|
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Document.owner == user)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _slug(name: str) -> str:
|
|
||||||
"""Filesystem-friendly version of a document title.
|
|
||||||
|
|
||||||
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
|
||||||
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
|
||||||
"""
|
|
||||||
import re as _re
|
|
||||||
s = (name or "").strip()
|
|
||||||
# Drop the trailing extension if the title happens to include one
|
|
||||||
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
|
||||||
s = _re.sub(r'\s+', '_', s)
|
|
||||||
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
|
||||||
s = _re.sub(r'_+', '_', s).strip('_')
|
|
||||||
return s or "form"
|
|
||||||
|
|
||||||
|
|
||||||
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
|
||||||
_PDF_RENDER_SCALE = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
|
||||||
base = os.path.realpath(upload_dir)
|
|
||||||
p = os.path.realpath(path)
|
|
||||||
try:
|
|
||||||
return os.path.commonpath([base, p]) == base
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_user_upload_path(
|
|
||||||
upload_handler: Any,
|
|
||||||
upload_id: str,
|
|
||||||
owner: Optional[str],
|
|
||||||
auth_manager=None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Resolve an upload id to a filesystem path the caller may read."""
|
|
||||||
if upload_handler is None:
|
|
||||||
return None
|
|
||||||
resolved = upload_handler.resolve_upload(
|
|
||||||
upload_id,
|
|
||||||
owner=owner,
|
|
||||||
auth_manager=auth_manager,
|
|
||||||
)
|
|
||||||
if not isinstance(resolved, dict) or not resolved:
|
|
||||||
return None
|
|
||||||
path = resolved.get("path")
|
|
||||||
upload_dir = getattr(upload_handler, "upload_dir", None)
|
|
||||||
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
|
||||||
logger.warning("Upload path outside upload directory: %s", path)
|
|
||||||
return None
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _locate_upload(
|
|
||||||
upload_dir: str,
|
|
||||||
file_id: str,
|
|
||||||
owner: Optional[str] = None,
|
|
||||||
auth_manager=None,
|
|
||||||
upload_handler: Any = None,
|
|
||||||
):
|
|
||||||
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
|
||||||
if upload_handler is None:
|
|
||||||
from src.upload_handler import UploadHandler
|
|
||||||
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
|
||||||
upload_handler = UploadHandler(base_dir, upload_dir)
|
|
||||||
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_pdf_marker_upload_owned(
|
|
||||||
request: Request,
|
|
||||||
content: str,
|
|
||||||
user: Optional[str],
|
|
||||||
upload_handler: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Reject document content whose pdf_source marker points at another user's upload."""
|
|
||||||
if upload_handler is None:
|
|
||||||
return
|
|
||||||
from src.pdf_form_doc import find_source_upload_id
|
|
||||||
|
|
||||||
upload_id = find_source_upload_id(content or "")
|
|
||||||
if not upload_id:
|
|
||||||
return
|
|
||||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
|
||||||
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
"Document PDF marker references an upload you do not own",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_title(content: str) -> str:
|
|
||||||
"""Derive a title from document content."""
|
|
||||||
import re
|
|
||||||
if not isinstance(content, str):
|
|
||||||
return "Untitled"
|
|
||||||
text = content.strip()
|
|
||||||
if not text:
|
|
||||||
return "Untitled"
|
|
||||||
|
|
||||||
# Markdown header
|
|
||||||
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
|
||||||
if md:
|
|
||||||
title = md.group(1).strip()
|
|
||||||
if len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title
|
|
||||||
|
|
||||||
# HTML heading
|
|
||||||
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
|
||||||
if html:
|
|
||||||
title = html.group(1).strip()
|
|
||||||
if len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title
|
|
||||||
|
|
||||||
# First non-empty line (if short enough)
|
|
||||||
for line in text.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line and 2 <= len(line) <= 60:
|
|
||||||
title = re.sub(r'[:#*`]+$', '', line).strip()
|
|
||||||
if title and len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title or "Untitled"
|
|
||||||
|
|
||||||
return "Untitled"
|
|
||||||
|
|||||||
+13
-1795
File diff suppressed because it is too large
Load Diff
+136
-3
@@ -246,6 +246,8 @@ import re as _re_reply
|
|||||||
# serves replies and summaries (any fenced final-output block).
|
# serves replies and summaries (any fenced final-output block).
|
||||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
||||||
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
||||||
|
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
||||||
|
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
|
||||||
|
|
||||||
|
|
||||||
def _extract_reply(text: str) -> str:
|
def _extract_reply(text: str) -> str:
|
||||||
@@ -272,9 +274,129 @@ def _extract_reply(text: str) -> str:
|
|||||||
# Drop any stray/duplicate marker tokens, then strip think markup.
|
# Drop any stray/duplicate marker tokens, then strip think markup.
|
||||||
t = _REPLY_OPEN_RE.sub("", t)
|
t = _REPLY_OPEN_RE.sub("", t)
|
||||||
t = _REPLY_CLOSE_RE.sub("", t)
|
t = _REPLY_CLOSE_RE.sub("", t)
|
||||||
|
t = _REPLY_ROLE_MARKER_RE.sub("", t)
|
||||||
return _strip_think(t).strip()
|
return _strip_think(t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are an email summarizer. Format: 1-3 short bullet points "
|
||||||
|
"(use '- '). Cover: main point, action items, deadlines. If the "
|
||||||
|
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
|
||||||
|
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
|
||||||
|
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
|
||||||
|
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
|
||||||
|
"markers, each on its own line:\n"
|
||||||
|
"<<<SUMMARY>>>\n"
|
||||||
|
"- ...\n"
|
||||||
|
"<<<END>>>\n"
|
||||||
|
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
|
||||||
|
"<think>...</think>). Only the text between the markers is kept."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
|
||||||
|
"\n\n---\n\nSummarize the email. Output the bullets between "
|
||||||
|
"<<<SUMMARY>>> and <<<END>>>."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_email_summary(
|
||||||
|
url: str,
|
||||||
|
model: str,
|
||||||
|
sender: str,
|
||||||
|
subject: str,
|
||||||
|
body_for_llm: str,
|
||||||
|
*,
|
||||||
|
headers: dict | None = None,
|
||||||
|
max_tokens: int = 8192,
|
||||||
|
timeout: int = 180,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
|
||||||
|
raw = await llm_call_async(
|
||||||
|
url=url,
|
||||||
|
model=model,
|
||||||
|
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
workload="foreground",
|
||||||
|
)
|
||||||
|
return _normalize_email_summary(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_scheduled_email_summary(
|
||||||
|
url: str,
|
||||||
|
model: str,
|
||||||
|
sender: str,
|
||||||
|
subject: str,
|
||||||
|
body_for_llm: str,
|
||||||
|
*,
|
||||||
|
headers: dict | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
max_tokens: int = 8192,
|
||||||
|
timeout: int = 180,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a scheduled summary through the background task candidate chain."""
|
||||||
|
from src.task_endpoint import task_llm_call_async
|
||||||
|
|
||||||
|
raw = await task_llm_call_async(
|
||||||
|
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||||
|
fallback_url=url,
|
||||||
|
fallback_model=model,
|
||||||
|
fallback_headers=headers,
|
||||||
|
owner=owner,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return _normalize_email_summary(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email_summary(raw) -> str:
|
||||||
|
"""Extract a stable cache/UI summary from provider output."""
|
||||||
|
raw_text = raw or ""
|
||||||
|
if _REPLY_OPEN_RE.search(raw_text):
|
||||||
|
summary = _extract_reply(raw_text)
|
||||||
|
if summary:
|
||||||
|
return summary
|
||||||
|
|
||||||
|
cleaned = _strip_think(raw_text).strip()
|
||||||
|
bullets = [
|
||||||
|
line.strip()
|
||||||
|
for line in cleaned.splitlines()
|
||||||
|
if _SUMMARY_BULLET_RE.match(line.strip())
|
||||||
|
]
|
||||||
|
if bullets:
|
||||||
|
return "\n".join(bullets)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
|
||||||
|
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
|
||||||
|
|
||||||
|
|
||||||
|
def _email_summary_failure_log_detail(exc: BaseException) -> str:
|
||||||
|
"""Return useful provider-failure metadata without echoing exception text."""
|
||||||
|
detail = f"type={type(exc).__name__}"
|
||||||
|
status = getattr(exc, "status_code", None)
|
||||||
|
if status is None:
|
||||||
|
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||||
|
if isinstance(status, int):
|
||||||
|
detail += f" status={status}"
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def _apply_email_style_mechanics(text: str) -> str:
|
def _apply_email_style_mechanics(text: str) -> str:
|
||||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -1035,13 +1157,23 @@ def _coerce_imap_timeout_seconds(raw: str | None) -> int:
|
|||||||
_IMAP_TIMEOUT_SECONDS = _coerce_imap_timeout_seconds(os.environ.get("ODYSSEUS_IMAP_TIMEOUT_SECONDS"))
|
_IMAP_TIMEOUT_SECONDS = _coerce_imap_timeout_seconds(os.environ.get("ODYSSEUS_IMAP_TIMEOUT_SECONDS"))
|
||||||
|
|
||||||
|
|
||||||
def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int = _IMAP_TIMEOUT_SECONDS):
|
def _open_imap_connection(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
starttls: bool,
|
||||||
|
timeout: int = _IMAP_TIMEOUT_SECONDS,
|
||||||
|
ssl_context=None,
|
||||||
|
):
|
||||||
"""Open an IMAP connection using the configured security mode."""
|
"""Open an IMAP connection using the configured security mode."""
|
||||||
port = int(port or 993)
|
port = int(port or 993)
|
||||||
if starttls:
|
if starttls:
|
||||||
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
conn.starttls()
|
if ssl_context:
|
||||||
|
conn.starttls(ssl_context=ssl_context)
|
||||||
|
else:
|
||||||
|
conn.starttls()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't leak the open plain socket if the STARTTLS upgrade is
|
# Don't leak the open plain socket if the STARTTLS upgrade is
|
||||||
# rejected; close it before propagating. (#3174)
|
# rejected; close it before propagating. (#3174)
|
||||||
@@ -1051,7 +1183,8 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
elif port == 993:
|
elif port == 993:
|
||||||
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout)
|
kwargs = {"ssl_context": ssl_context} if ssl_context else {}
|
||||||
|
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
|
||||||
else:
|
else:
|
||||||
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
conn = imaplib.IMAP4(host, port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+393
-34
@@ -40,6 +40,7 @@ from routes.email_helpers import (
|
|||||||
_pre_retrieve_context,
|
_pre_retrieve_context,
|
||||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||||
|
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -100,6 +101,272 @@ def _owner_for_email_account(account_id: str | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _email_date_only(value: str | None):
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_AUTO_REPLY_KEYS = {
|
||||||
|
"email_auto_reply",
|
||||||
|
"email_auto_reply_start",
|
||||||
|
"email_auto_reply_end",
|
||||||
|
"email_auto_reply_subject",
|
||||||
|
"email_auto_reply_message",
|
||||||
|
"email_auto_reply_cooldown",
|
||||||
|
"email_auto_reply_scope",
|
||||||
|
"email_auto_reply_account_id",
|
||||||
|
"email_auto_reply_exclude_automated",
|
||||||
|
"email_auto_reply_pause_notifications",
|
||||||
|
"email_auto_reply_enabled_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_settings_for_email_account(settings: dict, account_id: str | None) -> dict:
|
||||||
|
"""Overlay per-account auto-reply settings onto global settings.
|
||||||
|
|
||||||
|
Other automation toggles remain global. This lets each mailbox have its own
|
||||||
|
away reply while preserving existing installs that only have global keys.
|
||||||
|
"""
|
||||||
|
effective = dict(settings or {})
|
||||||
|
key = str(account_id or "").strip()
|
||||||
|
by_account = effective.get("email_auto_reply_by_account") or {}
|
||||||
|
account_cfg = by_account.get(key) if key and isinstance(by_account, dict) else None
|
||||||
|
if isinstance(account_cfg, dict):
|
||||||
|
for k in _AUTO_REPLY_KEYS:
|
||||||
|
if k in account_cfg:
|
||||||
|
effective[k] = account_cfg[k]
|
||||||
|
return effective
|
||||||
|
|
||||||
|
|
||||||
|
def _away_reply_active(settings: dict, account_id: str | None) -> bool:
|
||||||
|
if not settings.get("email_auto_reply", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
scope = str(settings.get("email_auto_reply_scope") or "all").strip().lower()
|
||||||
|
if scope == "account":
|
||||||
|
selected = str(settings.get("email_auto_reply_account_id") or "").strip()
|
||||||
|
if selected and selected != str(account_id or ""):
|
||||||
|
return False
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
start = _email_date_only(settings.get("email_auto_reply_start"))
|
||||||
|
end = _email_date_only(settings.get("email_auto_reply_end"))
|
||||||
|
if start and today < start:
|
||||||
|
return False
|
||||||
|
if end and today > end:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _message_after_away_enabled(settings: dict, msg) -> bool:
|
||||||
|
enabled_at = (settings.get("email_auto_reply_enabled_at") or "").strip()
|
||||||
|
if not enabled_at:
|
||||||
|
# Existing installs may already have the toggle on before this feature
|
||||||
|
# existed. Do not back-reply old mail until the user saves/toggles it.
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
enabled_dt = datetime.fromisoformat(enabled_at.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
msg_dt = email.utils.parsedate_to_datetime(msg.get("Date", ""))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if enabled_dt.tzinfo and not msg_dt.tzinfo:
|
||||||
|
msg_dt = msg_dt.replace(tzinfo=enabled_dt.tzinfo)
|
||||||
|
elif msg_dt.tzinfo and not enabled_dt.tzinfo:
|
||||||
|
enabled_dt = enabled_dt.replace(tzinfo=msg_dt.tzinfo)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return msg_dt >= enabled_dt
|
||||||
|
|
||||||
|
|
||||||
|
def _away_reply_period_key(settings: dict) -> str:
|
||||||
|
start = (settings.get("email_auto_reply_start") or "").strip()
|
||||||
|
end = (settings.get("email_auto_reply_end") or "").strip()
|
||||||
|
return f"{start or '*'}..{end or '*'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _away_reply_cooldown_seconds(settings: dict) -> int | None:
|
||||||
|
raw = str(settings.get("email_auto_reply_cooldown") or "period").strip().lower()
|
||||||
|
if raw == "1d":
|
||||||
|
return 24 * 60 * 60
|
||||||
|
if raw == "3d":
|
||||||
|
return 3 * 24 * 60 * 60
|
||||||
|
if raw == "7d":
|
||||||
|
return 7 * 24 * 60 * 60
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_away_reply_table():
|
||||||
|
import sqlite3 as _sql3
|
||||||
|
conn = _sql3.connect(SCHEDULED_DB)
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_away_replies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT DEFAULT '',
|
||||||
|
account_id TEXT DEFAULT '',
|
||||||
|
message_id TEXT DEFAULT '',
|
||||||
|
sender_addr TEXT DEFAULT '',
|
||||||
|
subject TEXT DEFAULT '',
|
||||||
|
period_key TEXT DEFAULT '',
|
||||||
|
sent_at TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_msg ON email_away_replies(owner, account_id, message_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_sender ON email_away_replies(owner, account_id, sender_addr, sent_at)")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sender_is_automated(msg, sender_addr: str) -> bool:
|
||||||
|
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
|
||||||
|
if auto_submitted and auto_submitted != "no":
|
||||||
|
return True
|
||||||
|
precedence = (msg.get("Precedence") or "").strip().lower()
|
||||||
|
if precedence in {"bulk", "junk", "list"}:
|
||||||
|
return True
|
||||||
|
if msg.get("List-Id") or msg.get("List-Unsubscribe"):
|
||||||
|
return True
|
||||||
|
local = (sender_addr or "").split("@", 1)[0].lower()
|
||||||
|
return local in {
|
||||||
|
"no-reply", "noreply", "do-not-reply", "donotreply",
|
||||||
|
"notification", "notifications", "automated", "mailer-daemon",
|
||||||
|
"postmaster",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _away_reply_already_sent(settings: dict, account_owner: str, account_id: str | None,
|
||||||
|
message_id: str, sender_addr: str) -> bool:
|
||||||
|
import sqlite3 as _sql3
|
||||||
|
_ensure_away_reply_table()
|
||||||
|
owner = account_owner or ""
|
||||||
|
aid = account_id or ""
|
||||||
|
sender = (sender_addr or "").strip().lower()
|
||||||
|
conn = _sql3.connect(SCHEDULED_DB)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND message_id=? LIMIT 1",
|
||||||
|
(owner, aid, message_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return True
|
||||||
|
|
||||||
|
cooldown = _away_reply_cooldown_seconds(settings)
|
||||||
|
if cooldown is None:
|
||||||
|
period_key = _away_reply_period_key(settings)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? AND period_key=? LIMIT 1",
|
||||||
|
(owner, aid, sender, period_key),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
since = datetime.utcnow().timestamp() - cooldown
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT sent_at FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? ORDER BY sent_at DESC LIMIT 5",
|
||||||
|
(owner, aid, sender),
|
||||||
|
).fetchall()
|
||||||
|
for (sent_at,) in rows:
|
||||||
|
try:
|
||||||
|
if datetime.fromisoformat(sent_at).timestamp() >= since:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_away_reply(settings: dict, account_owner: str, account_id: str | None,
|
||||||
|
message_id: str, sender_addr: str, subject: str):
|
||||||
|
import sqlite3 as _sql3
|
||||||
|
_ensure_away_reply_table()
|
||||||
|
conn = _sql3.connect(SCHEDULED_DB)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO email_away_replies
|
||||||
|
(owner, account_id, message_id, sender_addr, subject, period_key, sent_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
account_owner or "",
|
||||||
|
account_id or "",
|
||||||
|
message_id,
|
||||||
|
(sender_addr or "").strip().lower(),
|
||||||
|
subject or "",
|
||||||
|
_away_reply_period_key(settings),
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_away_reply(settings: dict, account_owner: str, account_id: str | None,
|
||||||
|
msg, message_id: str, sender: str, subject: str):
|
||||||
|
sender_name, sender_addr = email.utils.parseaddr(sender or "")
|
||||||
|
sender_addr = (sender_addr or "").strip()
|
||||||
|
if not sender_addr:
|
||||||
|
return False, "missing sender"
|
||||||
|
|
||||||
|
cfg = _get_email_config(account_id, owner=account_owner)
|
||||||
|
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
|
||||||
|
if not from_addr:
|
||||||
|
return False, "missing from address"
|
||||||
|
if sender_addr.lower() == from_addr.lower():
|
||||||
|
return False, "self mail"
|
||||||
|
if settings.get("email_auto_reply_exclude_automated", True) and _sender_is_automated(msg, sender_addr):
|
||||||
|
return False, "automated sender"
|
||||||
|
if _away_reply_already_sent(settings, account_owner, account_id, message_id, sender_addr):
|
||||||
|
return False, "already sent"
|
||||||
|
|
||||||
|
body = (settings.get("email_auto_reply_message") or "").strip()
|
||||||
|
if not body:
|
||||||
|
body = "Thanks for your email. I'm away and may be slower to reply."
|
||||||
|
|
||||||
|
subject_template = (settings.get("email_auto_reply_subject") or "(Away) {subject}").strip()
|
||||||
|
if subject_template:
|
||||||
|
original_subject = subject or ""
|
||||||
|
reply_subject = (
|
||||||
|
subject_template
|
||||||
|
.replace("{subject}", original_subject)
|
||||||
|
.replace("{original_subject}", original_subject)
|
||||||
|
).strip() or "Re:"
|
||||||
|
else:
|
||||||
|
reply_subject = subject or ""
|
||||||
|
if not reply_subject.lower().lstrip().startswith("re:"):
|
||||||
|
reply_subject = f"Re: {reply_subject}" if reply_subject else "Re:"
|
||||||
|
|
||||||
|
outer = MIMEMultipart("alternative")
|
||||||
|
display = cfg.get("display_name") or ""
|
||||||
|
outer["From"] = email.utils.formataddr((display, from_addr)) if display else from_addr
|
||||||
|
outer["To"] = email.utils.formataddr((sender_name, sender_addr)) if sender_name else sender_addr
|
||||||
|
outer["Subject"] = reply_subject
|
||||||
|
outer["Date"] = email.utils.formatdate(localtime=False)
|
||||||
|
outer["Message-ID"] = email.utils.make_msgid()
|
||||||
|
outer["Auto-Submitted"] = "auto-replied"
|
||||||
|
outer["X-Auto-Response-Suppress"] = "All"
|
||||||
|
if message_id:
|
||||||
|
outer["In-Reply-To"] = message_id
|
||||||
|
refs = (msg.get("References") or "").strip()
|
||||||
|
outer["References"] = f"{refs} {message_id}".strip()
|
||||||
|
outer.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
_send_smtp_message(cfg, from_addr, [sender_addr], outer.as_string())
|
||||||
|
_record_away_reply(settings, account_owner, account_id, message_id, sender_addr, subject)
|
||||||
|
return True, sender_addr
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
async def _emit_progress(progress_cb, message: str):
|
async def _emit_progress(progress_cb, message: str):
|
||||||
@@ -125,9 +392,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
|||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
prev = {k: settings.get(k, False) for k in
|
prev = {k: settings.get(k, False) for k in
|
||||||
("email_auto_summarize", "email_auto_reply", "email_auto_tag",
|
("email_auto_summarize", "email_auto_reply", "email_auto_tag",
|
||||||
"email_auto_spam", "email_auto_calendar")}
|
"email_auto_spam", "email_auto_calendar", "_email_auto_reply_draft_only")}
|
||||||
settings["email_auto_summarize"] = bool(do_summary)
|
settings["email_auto_summarize"] = bool(do_summary)
|
||||||
settings["email_auto_reply"] = bool(do_reply)
|
settings["email_auto_reply"] = bool(do_reply)
|
||||||
|
settings["_email_auto_reply_draft_only"] = bool(do_reply)
|
||||||
settings["email_auto_tag"] = bool(do_tag)
|
settings["email_auto_tag"] = bool(do_tag)
|
||||||
settings["email_auto_spam"] = bool(do_spam)
|
settings["email_auto_spam"] = bool(do_spam)
|
||||||
settings["email_auto_calendar"] = bool(do_calendar)
|
settings["email_auto_calendar"] = bool(do_calendar)
|
||||||
@@ -142,7 +410,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
|||||||
finally:
|
finally:
|
||||||
s2 = _load_settings()
|
s2 = _load_settings()
|
||||||
for k, v in prev.items():
|
for k, v in prev.items():
|
||||||
s2[k] = v
|
if v is None and k.startswith("_"):
|
||||||
|
s2.pop(k, None)
|
||||||
|
else:
|
||||||
|
s2[k] = v
|
||||||
_save_settings(s2)
|
_save_settings(s2)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,7 +447,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
|
|||||||
return [], reconnect()
|
return [], reconnect()
|
||||||
|
|
||||||
|
|
||||||
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
|
||||||
"""Single pass of the auto-summarize/reply scan.
|
"""Single pass of the auto-summarize/reply scan.
|
||||||
|
|
||||||
When account_id is None, iterates over every enabled account in
|
When account_id is None, iterates over every enabled account in
|
||||||
@@ -208,6 +479,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
|||||||
account_id=(ids[0] if ids else None),
|
account_id=(ids[0] if ids else None),
|
||||||
max_process=max_process,
|
max_process=max_process,
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
|
away_only=away_only,
|
||||||
)
|
)
|
||||||
outs = []
|
outs = []
|
||||||
for idx, aid in enumerate(ids, start=1):
|
for idx, aid in enumerate(ids, start=1):
|
||||||
@@ -218,6 +490,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
|||||||
account_id=aid,
|
account_id=aid,
|
||||||
max_process=max_process,
|
max_process=max_process,
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
|
away_only=away_only,
|
||||||
)
|
)
|
||||||
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
|
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -229,23 +502,32 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
max_process=max_process,
|
max_process=max_process,
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
|
away_only=away_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
|
||||||
"""Single pass of the auto-summarize/reply scan for ONE account.
|
"""Single pass of the auto-summarize/reply scan for ONE account.
|
||||||
Reads current settings flags."""
|
Reads current settings flags."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import sqlite3 as _sql3
|
import sqlite3 as _sql3
|
||||||
from src.llm_core import _uses_max_completion_tokens
|
from src.llm_core import _uses_max_completion_tokens
|
||||||
|
|
||||||
settings = _load_settings()
|
settings = _effective_settings_for_email_account(_load_settings(), account_id)
|
||||||
auto_sum = settings.get("email_auto_summarize", False)
|
auto_sum = settings.get("email_auto_summarize", False)
|
||||||
auto_reply = settings.get("email_auto_reply", False)
|
auto_reply = settings.get("email_auto_reply", False)
|
||||||
|
auto_reply_draft = bool(auto_reply and settings.get("_email_auto_reply_draft_only", False))
|
||||||
|
auto_reply_away = bool(auto_reply and not auto_reply_draft and _away_reply_active(settings, account_id))
|
||||||
auto_tag = settings.get("email_auto_tag", False)
|
auto_tag = settings.get("email_auto_tag", False)
|
||||||
auto_spam = settings.get("email_auto_spam", False)
|
auto_spam = settings.get("email_auto_spam", False)
|
||||||
auto_cal = settings.get("email_auto_calendar", False)
|
auto_cal = settings.get("email_auto_calendar", False)
|
||||||
if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal:
|
if away_only:
|
||||||
|
auto_sum = False
|
||||||
|
auto_reply_draft = False
|
||||||
|
auto_tag = False
|
||||||
|
auto_spam = False
|
||||||
|
auto_cal = False
|
||||||
|
if not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam and not auto_cal:
|
||||||
return "Nothing to do"
|
return "Nothing to do"
|
||||||
|
|
||||||
# Owner of the account being processed. All calendar + mailbox reads/writes
|
# Owner of the account being processed. All calendar + mailbox reads/writes
|
||||||
@@ -304,11 +586,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
|
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
|
||||||
_sum_existing = {r[0] for r in _c.execute(
|
_sum_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||||
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
|
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
|
||||||
_cache_owner_params,
|
_cache_owner_params,
|
||||||
).fetchall()}
|
).fetchall()}
|
||||||
_reply_existing = {r[0] for r in _c.execute(
|
_reply_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||||
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
|
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
|
||||||
_cache_owner_params,
|
_cache_owner_params,
|
||||||
).fetchall()}
|
).fetchall()}
|
||||||
@@ -325,7 +607,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
).fetchall()}
|
).fetchall()}
|
||||||
else:
|
else:
|
||||||
_tag_existing = set()
|
_tag_existing = set()
|
||||||
_cal_existing = {r[0] for r in _c.execute(
|
_cal_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||||
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
|
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
|
||||||
_cache_owner_params,
|
_cache_owner_params,
|
||||||
).fetchall()} if auto_cal else set()
|
).fetchall()} if auto_cal else set()
|
||||||
@@ -351,27 +633,40 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
if auto_spam and not spam_folder:
|
if auto_spam and not spam_folder:
|
||||||
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
|
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
|
||||||
|
|
||||||
task_candidates = resolve_task_candidates(owner=account_owner)
|
needs_llm = bool(auto_sum or auto_reply_draft or auto_tag or auto_spam or auto_cal)
|
||||||
if not task_candidates:
|
if needs_llm:
|
||||||
return "No model configured"
|
task_candidates = resolve_task_candidates(owner=account_owner)
|
||||||
url, model, headers = task_candidates[0]
|
if not task_candidates:
|
||||||
|
return "No model configured"
|
||||||
|
url, model, headers = task_candidates[0]
|
||||||
|
else:
|
||||||
|
url, model, headers = None, "", None
|
||||||
|
|
||||||
writing_style = settings.get("email_writing_style", "")
|
by_account_styles = settings.get("email_writing_styles_by_account") or {}
|
||||||
|
writing_style = ""
|
||||||
|
if account_id and isinstance(by_account_styles, dict):
|
||||||
|
writing_style = str(by_account_styles.get(str(account_id)) or "")
|
||||||
|
if not writing_style:
|
||||||
|
writing_style = settings.get("email_writing_style", "")
|
||||||
processed = 0
|
processed = 0
|
||||||
already_cached = 0
|
already_cached = 0
|
||||||
too_short = 0
|
too_short = 0
|
||||||
no_msgid = 0
|
no_msgid = 0
|
||||||
examined = 0
|
examined = 0
|
||||||
_summaries_created = 0
|
_summaries_created = 0
|
||||||
|
_summary_failed = 0
|
||||||
_events_created = 0
|
_events_created = 0
|
||||||
_replies_drafted = 0
|
_replies_drafted = 0
|
||||||
_reply_failed = 0
|
_reply_failed = 0
|
||||||
|
_away_replies_sent = 0
|
||||||
|
_away_replies_skipped = 0
|
||||||
|
_away_replies_failed = 0
|
||||||
_detail_lines = []
|
_detail_lines = []
|
||||||
_current_folder = "INBOX"
|
_current_folder = "INBOX"
|
||||||
# Calendar extraction is sequential and each row can involve a model
|
# Calendar extraction is sequential and each row can involve a model
|
||||||
# call plus a calendar write. Keep the scheduled calendar-only pass
|
# call plus a calendar write. Keep the scheduled calendar-only pass
|
||||||
# below the 5-minute action budget instead of timing out mid-run.
|
# below the 5-minute action budget instead of timing out mid-run.
|
||||||
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply and not auto_tag and not auto_spam) else 5
|
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam) else 5
|
||||||
try:
|
try:
|
||||||
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
|
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -402,10 +697,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}"
|
seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}"
|
||||||
message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>"
|
message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>"
|
||||||
no_msgid += 1
|
no_msgid += 1
|
||||||
need_sum = auto_sum and message_id not in _sum_existing
|
|
||||||
need_reply = auto_reply and message_id not in _reply_existing
|
|
||||||
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
|
|
||||||
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
|
|
||||||
# Only check urgency on INBOX (received mail), not Sent
|
# Only check urgency on INBOX (received mail), not Sent
|
||||||
# Skip messages that are themselves urgency alerts, or that
|
# Skip messages that are themselves urgency alerts, or that
|
||||||
# we sent to ourselves — otherwise the alert loop re-flags
|
# we sent to ourselves — otherwise the alert loop re-flags
|
||||||
@@ -422,17 +713,45 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
except Exception:
|
except Exception:
|
||||||
_from_addr_only = ""
|
_from_addr_only = ""
|
||||||
_is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr
|
_is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr
|
||||||
|
need_sum = auto_sum and message_id not in _sum_existing
|
||||||
|
need_reply = auto_reply_draft and message_id not in _reply_existing
|
||||||
|
need_away_reply = bool(
|
||||||
|
auto_reply_away
|
||||||
|
and _folder.upper() == "INBOX"
|
||||||
|
and not _is_self_mail
|
||||||
|
and (away_only or _message_after_away_enabled(settings, msg))
|
||||||
|
and not _away_reply_already_sent(settings, account_owner, account_id, message_id, _from_addr_only)
|
||||||
|
)
|
||||||
|
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
|
||||||
|
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
|
||||||
need_urgent = (auto_urgent and message_id not in _urgent_existing
|
need_urgent = (auto_urgent and message_id not in _urgent_existing
|
||||||
and not _folder.lower().startswith("sent")
|
and not _folder.lower().startswith("sent")
|
||||||
and "sent" not in _folder.lower()
|
and "sent" not in _folder.lower()
|
||||||
and not _is_alert_echo
|
and not _is_alert_echo
|
||||||
and not _is_self_mail)
|
and not _is_self_mail)
|
||||||
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
|
if not need_sum and not need_reply and not need_away_reply and not need_class and not need_cal and not need_urgent:
|
||||||
already_cached += 1
|
already_cached += 1
|
||||||
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
|
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
|
||||||
continue
|
continue
|
||||||
subject = _decode_header(msg.get("Subject", ""))
|
subject = _decode_header(msg.get("Subject", ""))
|
||||||
sender = _decode_header(msg.get("From", ""))
|
sender = _decode_header(msg.get("From", ""))
|
||||||
|
if need_away_reply:
|
||||||
|
try:
|
||||||
|
sent_away, away_detail = _send_away_reply(
|
||||||
|
settings, account_owner, account_id, msg, message_id, sender, subject
|
||||||
|
)
|
||||||
|
if sent_away:
|
||||||
|
_away_replies_sent += 1
|
||||||
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
|
_detail_lines.append(f"away reply · {_folder}#{_uid_text} · {subject or '(no subject)'} — {away_detail}")
|
||||||
|
else:
|
||||||
|
_away_replies_skipped += 1
|
||||||
|
logger.info(f"Away reply skipped for uid={uid}: {away_detail}")
|
||||||
|
except Exception as e:
|
||||||
|
_away_replies_failed += 1
|
||||||
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
|
_detail_lines.append(f"away reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}")
|
||||||
|
logger.warning(f"Away reply {uid} failed: {e}")
|
||||||
body = _extract_text(msg)
|
body = _extract_text(msg)
|
||||||
# Pull text out of any PDFs / text attachments and append to
|
# Pull text out of any PDFs / text attachments and append to
|
||||||
# the body so summaries / replies can actually reason about
|
# the body so summaries / replies can actually reason about
|
||||||
@@ -454,7 +773,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
elif need_reply:
|
elif need_reply:
|
||||||
if not body:
|
if not body:
|
||||||
body = subject
|
body = subject
|
||||||
elif (not body or len(body) < 100) and not att_text:
|
elif not need_away_reply and (not body or len(body) < 100) and not att_text:
|
||||||
too_short += 1
|
too_short += 1
|
||||||
continue
|
continue
|
||||||
# Augmented body sent to the LLM: original body + attachment text.
|
# Augmented body sent to the LLM: original body + attachment text.
|
||||||
@@ -468,16 +787,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
if need_sum:
|
if need_sum:
|
||||||
try:
|
try:
|
||||||
summary = await task_llm_call_async(
|
summary = await _generate_scheduled_email_summary(
|
||||||
messages=[
|
url=url,
|
||||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
model=model,
|
||||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
sender=sender,
|
||||||
],
|
subject=subject,
|
||||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
body_for_llm=body_for_llm,
|
||||||
|
headers=req_headers,
|
||||||
owner=account_owner or None,
|
owner=account_owner or None,
|
||||||
temperature=0.3, max_tokens=16384, timeout=240,
|
max_tokens=16384,
|
||||||
|
timeout=240,
|
||||||
)
|
)
|
||||||
summary = _extract_reply((summary or "").strip())
|
|
||||||
if summary:
|
if summary:
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
@@ -491,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_summaries_created += 1
|
_summaries_created += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
|
else:
|
||||||
|
_summary_failed += 1
|
||||||
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
|
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_summary_failed += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
logger.warning(f"Auto-summary {uid} failed: {e}")
|
logger.warning(
|
||||||
|
"Auto-summary uid=%s failed %s",
|
||||||
|
_uid_text,
|
||||||
|
_email_summary_failure_log_detail(e),
|
||||||
|
)
|
||||||
|
|
||||||
if need_reply:
|
if need_reply:
|
||||||
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
||||||
@@ -993,7 +1322,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
# Build a clear status message
|
# Build a clear status message
|
||||||
ops = []
|
ops = []
|
||||||
if auto_sum: ops.append("summary")
|
if auto_sum: ops.append("summary")
|
||||||
if auto_reply: ops.append("reply")
|
if auto_reply_draft: ops.append("reply")
|
||||||
|
if auto_reply_away: ops.append("away")
|
||||||
if auto_tag: ops.append("tag")
|
if auto_tag: ops.append("tag")
|
||||||
if auto_spam: ops.append("spam")
|
if auto_spam: ops.append("spam")
|
||||||
ops_label = "/".join(ops) or "none"
|
ops_label = "/".join(ops) or "none"
|
||||||
@@ -1002,10 +1332,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
parts.append(f"processed {processed} new")
|
parts.append(f"processed {processed} new")
|
||||||
if auto_sum:
|
if auto_sum:
|
||||||
parts.append(f"summarized {_summaries_created}")
|
parts.append(f"summarized {_summaries_created}")
|
||||||
if auto_reply:
|
if _summary_failed:
|
||||||
|
parts.append(f"{_summary_failed} summary failed")
|
||||||
|
if auto_reply_draft:
|
||||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||||
if _reply_failed:
|
if _reply_failed:
|
||||||
parts.append(f"{_reply_failed} reply failed")
|
parts.append(f"{_reply_failed} reply failed")
|
||||||
|
if auto_reply_away:
|
||||||
|
parts.append(f"sent {_away_replies_sent} away repl" + ("y" if _away_replies_sent == 1 else "ies"))
|
||||||
|
if _away_replies_failed:
|
||||||
|
parts.append(f"{_away_replies_failed} away failed")
|
||||||
if already_cached:
|
if already_cached:
|
||||||
parts.append(f"{already_cached} already cached")
|
parts.append(f"{already_cached} already cached")
|
||||||
if too_short:
|
if too_short:
|
||||||
@@ -1032,12 +1368,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
|
|
||||||
async def _auto_summarize_poller():
|
async def _auto_summarize_poller():
|
||||||
"""Background loop kept for backward compatibility — calls _auto_summarize_pass every 60s.
|
"""Background loop kept for backward compatibility — calls _auto_summarize_pass periodically.
|
||||||
Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies)."""
|
Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies)."""
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await _asyncio.sleep(1800)
|
settings = _load_settings()
|
||||||
|
await _asyncio.sleep(60 if settings.get("email_auto_reply", False) else 1800)
|
||||||
await _auto_summarize_pass()
|
await _auto_summarize_pass()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Auto-summarize poller crash: {e}")
|
logger.error(f"Auto-summarize poller crash: {e}")
|
||||||
@@ -1068,6 +1405,28 @@ def _scheduled_poll_once() -> dict:
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
sid = r[0]
|
sid = r[0]
|
||||||
try:
|
try:
|
||||||
|
# Atomically claim this row before doing any work. Two
|
||||||
|
# pollers can race here (the in-process asyncio task and an
|
||||||
|
# externally cron-driven `odysseus-mail poll-scheduled`, or
|
||||||
|
# an admin running the CLI manually alongside the in-process
|
||||||
|
# one despite the ODYSSEUS_INPROCESS_POLLERS=0 guidance) -
|
||||||
|
# both can SELECT the same 'pending' row before either has
|
||||||
|
# updated its status. The UPDATE...WHERE status='pending' is
|
||||||
|
# the atomicity boundary: only the poller whose UPDATE
|
||||||
|
# actually changes a row (rowcount == 1) proceeds to send;
|
||||||
|
# a loser sees rowcount == 0 and skips it instead of sending
|
||||||
|
# a duplicate.
|
||||||
|
claim_conn = sqlite3.connect(SCHEDULED_DB)
|
||||||
|
claim_cur = claim_conn.execute(
|
||||||
|
"UPDATE scheduled_emails SET status='sending' WHERE id=? AND status='pending'",
|
||||||
|
(sid,),
|
||||||
|
)
|
||||||
|
claim_conn.commit()
|
||||||
|
claimed = claim_cur.rowcount == 1
|
||||||
|
claim_conn.close()
|
||||||
|
if not claimed:
|
||||||
|
continue
|
||||||
|
|
||||||
attachments = json.loads(r[8] or "[]")
|
attachments = json.loads(r[8] or "[]")
|
||||||
row_account_id = r[9] if len(r) > 9 else None
|
row_account_id = r[9] if len(r) > 9 else None
|
||||||
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
|
||||||
|
|||||||
+1112
-176
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
"""Gallery routes — browsable library for photos and AI-generated images."""
|
"""Gallery routes — browsable library for photos and AI-generated images."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
@@ -27,6 +29,181 @@ from routes.gallery.gallery_helpers import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SAM_STATE: Dict[str, Any] = {}
|
||||||
|
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
|
||||||
|
if not image_b64:
|
||||||
|
raise HTTPException(400, "Missing image")
|
||||||
|
if "," in image_b64 and image_b64.split(",", 1)[0].startswith("data:"):
|
||||||
|
image_b64 = image_b64.split(",", 1)[1]
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
raw = base64.b64decode(image_b64)
|
||||||
|
return Image.open(io.BytesIO(raw)).convert(mode)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, "Invalid image") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format=fmt)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sam_backend():
|
||||||
|
model_id = os.getenv("ODYSSEUS_SAM_MODEL", "facebook/sam-vit-base")
|
||||||
|
cached = _SAM_STATE.get(model_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from transformers import SamModel, SamProcessor
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
501,
|
||||||
|
"SAM mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
device = "cpu"
|
||||||
|
try:
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
device = "cuda"
|
||||||
|
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||||
|
device = "mps"
|
||||||
|
except Exception:
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
try:
|
||||||
|
processor = SamProcessor.from_pretrained(model_id)
|
||||||
|
model = SamModel.from_pretrained(model_id)
|
||||||
|
model.to(device)
|
||||||
|
model.eval()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Failed to load SAM model {model_id}: {exc}") from exc
|
||||||
|
|
||||||
|
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
|
||||||
|
_SAM_STATE[model_id] = cached
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _load_grounding_backend():
|
||||||
|
model_id = os.getenv("ODYSSEUS_GROUNDING_MODEL", "google/owlvit-base-patch32")
|
||||||
|
cached = _GROUNDING_STATE.get(model_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from transformers import OwlViTForObjectDetection, OwlViTProcessor
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
501,
|
||||||
|
"Object mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
device = "cpu"
|
||||||
|
try:
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
device = "cuda"
|
||||||
|
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||||
|
device = "mps"
|
||||||
|
except Exception:
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
try:
|
||||||
|
processor = OwlViTProcessor.from_pretrained(model_id)
|
||||||
|
model = OwlViTForObjectDetection.from_pretrained(model_id)
|
||||||
|
model.to(device)
|
||||||
|
model.eval()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Failed to load object mask model {model_id}: {exc}") from exc
|
||||||
|
|
||||||
|
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
|
||||||
|
_GROUNDING_STATE[model_id] = cached
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _model_input_to_device(value, device: str, torch):
|
||||||
|
if not hasattr(value, "to"):
|
||||||
|
return value
|
||||||
|
if (
|
||||||
|
device == "mps"
|
||||||
|
and hasattr(torch, "float64")
|
||||||
|
and getattr(value, "dtype", None) == torch.float64
|
||||||
|
):
|
||||||
|
return value.to(device=device, dtype=torch.float32)
|
||||||
|
return value.to(device)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_inputs_to_device(inputs, device: str, torch) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: _model_input_to_device(value, device, torch)
|
||||||
|
for key, value in inputs.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||||
|
query = (text or "").strip()
|
||||||
|
if not query:
|
||||||
|
raise HTTPException(400, "Missing object text")
|
||||||
|
backend = _load_grounding_backend()
|
||||||
|
torch = backend["torch"]
|
||||||
|
processor = backend["processor"]
|
||||||
|
model = backend["model"]
|
||||||
|
device = backend["device"]
|
||||||
|
|
||||||
|
labels = [query]
|
||||||
|
if not query.lower().startswith(("a ", "an ", "the ")):
|
||||||
|
labels.append(f"a photo of {query}")
|
||||||
|
try:
|
||||||
|
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
||||||
|
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model(**model_inputs)
|
||||||
|
target_sizes = torch.tensor([[image.height, image.width]])
|
||||||
|
if hasattr(processor, "post_process_object_detection"):
|
||||||
|
results = processor.post_process_object_detection(
|
||||||
|
outputs=outputs,
|
||||||
|
target_sizes=target_sizes,
|
||||||
|
threshold=float(threshold),
|
||||||
|
)
|
||||||
|
elif hasattr(processor, "post_process_grounded_object_detection"):
|
||||||
|
results = processor.post_process_grounded_object_detection(
|
||||||
|
outputs=outputs,
|
||||||
|
target_sizes=target_sizes,
|
||||||
|
threshold=float(threshold),
|
||||||
|
text_labels=[labels],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(500, "Installed Transformers does not expose OWL-ViT object detection post-processing")
|
||||||
|
boxes = results[0].get("boxes")
|
||||||
|
scores = results[0].get("scores")
|
||||||
|
labels_idx = results[0].get("labels")
|
||||||
|
text_labels = results[0].get("text_labels") or results[0].get("labels_text")
|
||||||
|
if boxes is None or scores is None or len(boxes) == 0:
|
||||||
|
raise HTTPException(404, f"No visible object matched '{query}'")
|
||||||
|
idx = int(torch.argmax(scores).item())
|
||||||
|
box = [float(v) for v in boxes[idx].detach().cpu().tolist()]
|
||||||
|
label_idx = int(labels_idx[idx].detach().cpu().item()) if labels_idx is not None and len(labels_idx) else 0
|
||||||
|
label = labels[min(label_idx, len(labels) - 1)]
|
||||||
|
if text_labels and len(text_labels) > idx:
|
||||||
|
label = str(text_labels[idx])
|
||||||
|
return {
|
||||||
|
"box": box,
|
||||||
|
"score": float(scores[idx].detach().cpu().item()),
|
||||||
|
"label": label,
|
||||||
|
"model": backend["model_id"],
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ground_text_to_box failed")
|
||||||
|
raise HTTPException(500, f"Object mask failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||||
if not user:
|
if not user:
|
||||||
@@ -1240,20 +1417,89 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
|
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
|
||||||
|
|
||||||
# Self-hosted diffusion server path
|
# Self-hosted diffusion server path. Newer Odysseus image
|
||||||
|
# wrappers expose the OpenAI-compatible /v1/images/edits
|
||||||
|
# multipart route even when they are local/self-hosted. Older
|
||||||
|
# diffusion_server.py exposes /v1/images/inpaint as JSON. Try the
|
||||||
|
# OpenAI-compatible local route first, then fall back.
|
||||||
try:
|
try:
|
||||||
# Forward chosen_model so the diffusion server can route if it ever
|
# Forward chosen_model so the diffusion server can route if it ever
|
||||||
# supports multiple models per process. Harmless if ignored.
|
# supports multiple models per process. Harmless if ignored.
|
||||||
if chosen_model:
|
if chosen_model:
|
||||||
body["model"] = chosen_model
|
body["model"] = chosen_model
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=240) as client:
|
||||||
|
try:
|
||||||
|
import base64, io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img_bytes = base64.b64decode(body["image"])
|
||||||
|
mask_bytes = base64.b64decode(body["mask"])
|
||||||
|
# Normalize both inputs to PNG bytes. Local MLX and
|
||||||
|
# Diffusers wrappers expect white mask pixels to mean
|
||||||
|
# "edit this region", which matches the editor's mask.
|
||||||
|
source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||||
|
mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L")
|
||||||
|
src_buf = io.BytesIO()
|
||||||
|
source_png.save(src_buf, format="PNG")
|
||||||
|
mask_buf = io.BytesIO()
|
||||||
|
mask_png.save(mask_buf, format="PNG")
|
||||||
|
files = {
|
||||||
|
"image": ("source.png", src_buf.getvalue(), "image/png"),
|
||||||
|
"mask": ("mask.png", mask_buf.getvalue(), "image/png"),
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"model": chosen_model or body.get("model") or "",
|
||||||
|
"prompt": body.get("prompt", ""),
|
||||||
|
"size": f"{int(body.get('width') or source_png.width)}x{int(body.get('height') or source_png.height)}",
|
||||||
|
"n": "1",
|
||||||
|
}
|
||||||
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), data=data, files=files)
|
||||||
|
if r.status_code == 200:
|
||||||
|
result = r.json()
|
||||||
|
if isinstance(result, dict) and result.get("data"):
|
||||||
|
item = result["data"][0]
|
||||||
|
if item.get("b64_json"):
|
||||||
|
return {"image": item["b64_json"]}
|
||||||
|
if item.get("url"):
|
||||||
|
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
|
if raw_b64:
|
||||||
|
return {"image": raw_b64}
|
||||||
|
if isinstance(result, dict) and result.get("image"):
|
||||||
|
return {"image": result["image"]}
|
||||||
|
raise HTTPException(502, "Image edit endpoint returned no image")
|
||||||
|
if r.status_code not in (404, 405):
|
||||||
|
logger.warning("inpaint_proxy self-hosted edits: status %s", r.status_code)
|
||||||
|
detail = "Image edit request failed"
|
||||||
|
try:
|
||||||
|
err = r.json()
|
||||||
|
detail = err.get("detail") or err.get("error") or detail
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# A plain SD/SDXL checkpoint often exposes
|
||||||
|
# generation only at /images/edits.
|
||||||
|
# That does not mean the endpoint cannot inpaint:
|
||||||
|
# Odysseus diffusion_server.py has a dedicated
|
||||||
|
# /images/inpaint route that can derive/fallback to
|
||||||
|
# inpaint, img2img crop+composite, or txt2img
|
||||||
|
# crop+composite. Fall through to that route instead
|
||||||
|
# of surfacing "does not support image edits".
|
||||||
|
if r.status_code == 400 and "does not support image edits" in str(detail).lower():
|
||||||
|
logger.info("inpaint_proxy self-hosted edits unsupported; falling back to /images/inpaint")
|
||||||
|
else:
|
||||||
|
raise HTTPException(r.status_code, detail)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("inpaint_proxy: failed to prepare self-hosted edit request")
|
||||||
|
raise HTTPException(400, "Failed to prepare inpaint request")
|
||||||
|
|
||||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
|
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
|
||||||
raise HTTPException(r.status_code, "Inpaint request failed")
|
raise HTTPException(r.status_code, "Inpaint request failed")
|
||||||
return r.json()
|
return r.json()
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
raise HTTPException(504, "Inpaint request timed out (120s)")
|
raise HTTPException(504, "Inpaint request timed out (240s)")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1588,6 +1834,132 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return {"error": "AI upscale failed"}
|
return {"error": "AI upscale failed"}
|
||||||
|
|
||||||
# ---- POST /api/image/remove-bg ----
|
# ---- POST /api/image/remove-bg ----
|
||||||
|
@router.post("/api/image/mask")
|
||||||
|
async def smart_mask(request: Request):
|
||||||
|
"""Create a neutral segmentation mask from user-provided points or a box.
|
||||||
|
|
||||||
|
This endpoint intentionally does not inspect edit prompts. It only
|
||||||
|
turns explicit visual selection hints into a binary mask that the
|
||||||
|
editor can reuse for wand/layer-mask/inpaint workflows.
|
||||||
|
"""
|
||||||
|
require_privilege(request, "can_generate_images")
|
||||||
|
body = await request.json()
|
||||||
|
image = _b64_to_pil_image(body.get("image") or "", mode="RGB")
|
||||||
|
points = body.get("points") or []
|
||||||
|
box = body.get("box")
|
||||||
|
text = (body.get("text") or body.get("query") or "").strip()
|
||||||
|
grounded = None
|
||||||
|
|
||||||
|
if not points and not box and text:
|
||||||
|
grounded = _ground_text_to_box(image, text)
|
||||||
|
box = grounded["box"]
|
||||||
|
|
||||||
|
if not points and not box:
|
||||||
|
raise HTTPException(400, "Provide at least one point, box, or object text")
|
||||||
|
|
||||||
|
backend = _load_sam_backend()
|
||||||
|
torch = backend["torch"]
|
||||||
|
processor = backend["processor"]
|
||||||
|
model = backend["model"]
|
||||||
|
device = backend["device"]
|
||||||
|
|
||||||
|
kwargs: Dict[str, Any] = {"return_tensors": "pt"}
|
||||||
|
input_points = []
|
||||||
|
if points:
|
||||||
|
input_labels = []
|
||||||
|
for p in points:
|
||||||
|
try:
|
||||||
|
input_points.append([float(p["x"]), float(p["y"])])
|
||||||
|
input_labels.append(int(p.get("label", 1)))
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, "Invalid point format") from exc
|
||||||
|
kwargs["input_points"] = [input_points]
|
||||||
|
kwargs["input_labels"] = [input_labels]
|
||||||
|
if box:
|
||||||
|
if not isinstance(box, list) or len(box) != 4:
|
||||||
|
raise HTTPException(400, "Box must be [x1, y1, x2, y2]")
|
||||||
|
try:
|
||||||
|
kwargs["input_boxes"] = [[[float(v) for v in box]]]
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, "Invalid box format") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
inputs = processor(image, **kwargs)
|
||||||
|
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model(**model_inputs)
|
||||||
|
masks = processor.image_processor.post_process_masks(
|
||||||
|
outputs.pred_masks.detach().cpu(),
|
||||||
|
inputs["original_sizes"].detach().cpu(),
|
||||||
|
inputs["reshaped_input_sizes"].detach().cpu(),
|
||||||
|
)
|
||||||
|
mask_tensor = masks[0]
|
||||||
|
while getattr(mask_tensor, "ndim", 0) > 3:
|
||||||
|
mask_tensor = mask_tensor[0]
|
||||||
|
if getattr(mask_tensor, "ndim", 0) == 3:
|
||||||
|
scores = outputs.iou_scores.detach().cpu()[0]
|
||||||
|
while getattr(scores, "ndim", 0) > 1:
|
||||||
|
scores = scores[0]
|
||||||
|
# SAM commonly returns multiple candidates for a click. The
|
||||||
|
# highest-IoU candidate can be the entire image, which is
|
||||||
|
# useless as an editor selection. Prefer a candidate that
|
||||||
|
# contains the clicked point while keeping area reasonable.
|
||||||
|
point_xy = None
|
||||||
|
if input_points:
|
||||||
|
try:
|
||||||
|
point_xy = (
|
||||||
|
int(round(float(input_points[0][0]))),
|
||||||
|
int(round(float(input_points[0][1]))),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
point_xy = None
|
||||||
|
best_idx = 0
|
||||||
|
best_rank = None
|
||||||
|
total_px = max(1, int(mask_tensor.shape[-1]) * int(mask_tensor.shape[-2]))
|
||||||
|
for i in range(int(mask_tensor.shape[0])):
|
||||||
|
candidate = mask_tensor[i]
|
||||||
|
area_ratio = float(candidate.sum().item()) / float(total_px)
|
||||||
|
if area_ratio >= 0.985:
|
||||||
|
continue
|
||||||
|
contains_click = True
|
||||||
|
if point_xy:
|
||||||
|
px = max(0, min(int(candidate.shape[-1]) - 1, point_xy[0]))
|
||||||
|
py = max(0, min(int(candidate.shape[-2]) - 1, point_xy[1]))
|
||||||
|
contains_click = bool(candidate[py, px].item())
|
||||||
|
if not contains_click:
|
||||||
|
continue
|
||||||
|
score = float(scores[min(i, len(scores) - 1)].item()) if len(scores) else 0.0
|
||||||
|
# Strongly penalize broad masks; a click-selection should
|
||||||
|
# usually be local unless the user gives a box.
|
||||||
|
rank = score - (area_ratio * 0.35)
|
||||||
|
if best_rank is None or rank > best_rank:
|
||||||
|
best_rank = rank
|
||||||
|
best_idx = i
|
||||||
|
if best_rank is None and len(scores):
|
||||||
|
best_idx = int(torch.argmax(scores).item())
|
||||||
|
mask_tensor = mask_tensor[min(best_idx, mask_tensor.shape[0] - 1)]
|
||||||
|
mask_array = (mask_tensor.numpy() > 0).astype("uint8") * 255
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
mask_img = Image.fromarray(mask_array, mode="L")
|
||||||
|
if mask_img.size != image.size:
|
||||||
|
mask_img = mask_img.resize(image.size, Image.NEAREST)
|
||||||
|
bbox = mask_img.getbbox()
|
||||||
|
result = {
|
||||||
|
"mask": _pil_image_to_b64(mask_img),
|
||||||
|
"bbox": list(bbox) if bbox else None,
|
||||||
|
"model": backend["model_id"],
|
||||||
|
"device": device,
|
||||||
|
}
|
||||||
|
if grounded:
|
||||||
|
result["grounding"] = grounded
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("smart_mask failed")
|
||||||
|
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
|
||||||
|
|
||||||
@router.post("/api/image/remove-bg")
|
@router.post("/api/image/remove-bg")
|
||||||
async def remove_background(request: Request):
|
async def remove_background(request: Request):
|
||||||
"""Remove background from an image. If the client passes a `hint_mask`
|
"""Remove background from an image. If the client passes a `hint_mask`
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from fastapi import APIRouter, Request, HTTPException
|
|||||||
|
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||||
|
from src.auth_helpers import effective_user
|
||||||
from src.topic_analyzer import analyze_topics
|
from src.topic_analyzer import analyze_topics
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
from routes.session_routes import (
|
from routes.session_routes import (
|
||||||
_message_role,
|
_message_role,
|
||||||
_message_text,
|
_message_text,
|
||||||
@@ -98,9 +100,29 @@ def _merge_continue_rows_to_delete(db_messages, db1, db2):
|
|||||||
return to_delete
|
return to_delete
|
||||||
|
|
||||||
|
|
||||||
def setup_history_routes(session_manager) -> APIRouter:
|
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(tags=["history"])
|
router = APIRouter(tags=["history"])
|
||||||
|
|
||||||
|
def _reserve_message_uploads(
|
||||||
|
request: Request,
|
||||||
|
content: Any,
|
||||||
|
metadata: Any = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
missing_id = reserve_message_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
effective_user(request),
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
)
|
||||||
|
|
||||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
||||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||||
meta = {}
|
meta = {}
|
||||||
@@ -138,6 +160,8 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
)
|
)
|
||||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||||
page_offset = max(0, min(page_offset, total))
|
page_offset = max(0, min(page_offset, total))
|
||||||
|
# Keep display pagination page-scoped. ``get_session`` is the
|
||||||
|
# full model-context hydration seam and must not be entered here.
|
||||||
rows = (
|
rows = (
|
||||||
db.query(DbChatMessage)
|
db.query(DbChatMessage)
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
@@ -190,7 +214,10 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
entry["metadata"] = msg["metadata"]
|
entry["metadata"] = msg["metadata"]
|
||||||
history_dict.append(entry)
|
history_dict.append(entry)
|
||||||
|
|
||||||
# Fallback: load from DB if in-memory is empty
|
# Fallback: load from DB if in-memory renders empty. Display only —
|
||||||
|
# get_session above is the hydration seam, so nothing here writes back
|
||||||
|
# into session.history — rebuilding it from raw rows would overwrite
|
||||||
|
# parsed multimodal content and the _db_id edit/delete keys it just set.
|
||||||
if not history_dict:
|
if not history_dict:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -200,20 +227,10 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
.order_by(DbChatMessage.timestamp)
|
.order_by(DbChatMessage.timestamp)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
db_history = []
|
|
||||||
for m in db_messages:
|
|
||||||
db_history.append(_db_history_entry(m))
|
|
||||||
if db_history:
|
|
||||||
# Rebuild in-memory history from the full set so hidden
|
|
||||||
# messages (e.g. compaction summaries) are kept for AI context.
|
|
||||||
session.history = [
|
|
||||||
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
|
|
||||||
for m in db_history
|
|
||||||
]
|
|
||||||
# Response excludes hidden messages, matching the in-memory path.
|
# Response excludes hidden messages, matching the in-memory path.
|
||||||
history_dict = [
|
history_dict = [
|
||||||
m for m in db_history
|
entry for entry in (_db_history_entry(m) for m in db_messages)
|
||||||
if not (m.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||||
@@ -251,7 +268,9 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
content = body.get("content", "")
|
content = body.get("content", "")
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(400, "content is required")
|
raise HTTPException(400, "content is required")
|
||||||
msg = ChatMessage(role=role, content=content, metadata=body.get("metadata"))
|
metadata = body.get("metadata")
|
||||||
|
_reserve_message_uploads(request, content, metadata)
|
||||||
|
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||||
session_manager.add_message(session_id, msg)
|
session_manager.add_message(session_id, msg)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -331,6 +350,8 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
if not msg_id or content is None:
|
if not msg_id or content is None:
|
||||||
raise HTTPException(400, "msg_id and content are required")
|
raise HTTPException(400, "msg_id and content are required")
|
||||||
|
|
||||||
|
_reserve_message_uploads(request, content)
|
||||||
|
|
||||||
session = session_manager.get_session(session_id)
|
session = session_manager.get_session(session_id)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -576,8 +597,14 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
|
|
||||||
# Get the source session
|
# Get the source session. keep_count indexes into source.history,
|
||||||
source = session_manager.sessions.get(session_id)
|
# so this must go through get_session — reading the cache directly
|
||||||
|
# forks an empty transcript out of a metadata-only session after a
|
||||||
|
# restart (display pagination no longer hydrates it).
|
||||||
|
try:
|
||||||
|
source = session_manager.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(404, "Session not found")
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
@@ -630,6 +657,55 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, f"Topic analysis failed: {e}")
|
raise HTTPException(500, f"Topic analysis failed: {e}")
|
||||||
|
|
||||||
|
@router.get("/api/session/{session_id}/context")
|
||||||
|
async def get_session_context_usage(request: Request, session_id: str) -> Dict[str, Any]:
|
||||||
|
"""Return an estimated whole-chat context usage for the session's model.
|
||||||
|
|
||||||
|
Streaming footers report the prompt size for the last request. This
|
||||||
|
endpoint estimates the persisted session context so the header can show
|
||||||
|
when the whole chat is approaching compaction.
|
||||||
|
"""
|
||||||
|
_verify_session_owner(request, session_id)
|
||||||
|
try:
|
||||||
|
session = session_manager.get_session(session_id)
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.model_context import estimate_tokens, get_context_length
|
||||||
|
|
||||||
|
messages = session.get_context_messages()
|
||||||
|
used = int(estimate_tokens(messages))
|
||||||
|
ctx_len = int(get_context_length(session.endpoint_url, session.model) or 0)
|
||||||
|
pct = round((used / ctx_len) * 100, 1) if ctx_len else 0.0
|
||||||
|
pct = max(0.0, min(100.0, pct))
|
||||||
|
visible_messages = sum(
|
||||||
|
1 for m in session.history
|
||||||
|
if not (getattr(m, "metadata", None) or {}).get("hidden")
|
||||||
|
)
|
||||||
|
compacted_messages = sum(
|
||||||
|
1 for m in session.history
|
||||||
|
if (getattr(m, "metadata", None) or {}).get("compacted")
|
||||||
|
)
|
||||||
|
can_compact = used > 0
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"model": session.model,
|
||||||
|
"endpoint_url": session.endpoint_url,
|
||||||
|
"used_tokens": used,
|
||||||
|
"context_length": ctx_len,
|
||||||
|
"context_percent": pct,
|
||||||
|
"messages": visible_messages,
|
||||||
|
"context_messages": len(messages),
|
||||||
|
"compacted_messages": compacted_messages,
|
||||||
|
"can_compact": can_compact,
|
||||||
|
"should_compact": pct >= 70,
|
||||||
|
"auto_compact_threshold": 85,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Context usage error {session_id}: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
@router.post("/api/session/{session_id}/compact")
|
@router.post("/api/session/{session_id}/compact")
|
||||||
async def compact_session(request: Request, session_id: str):
|
async def compact_session(request: Request, session_id: str):
|
||||||
"""Manually trigger context compaction for a session."""
|
"""Manually trigger context compaction for a session."""
|
||||||
@@ -674,7 +750,7 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
compact_model = util_model or session.model
|
compact_model = util_model or session.model
|
||||||
compact_headers = util_headers if util_url else session.headers
|
compact_headers = util_headers if util_url else session.headers
|
||||||
|
|
||||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
|
||||||
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||||
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
||||||
summary = await llm_call_async(
|
summary = await llm_call_async(
|
||||||
@@ -686,6 +762,7 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
temperature=0.2, max_tokens=1024,
|
temperature=0.2, max_tokens=1024,
|
||||||
headers=compact_headers, timeout=30,
|
headers=compact_headers, timeout=30,
|
||||||
)
|
)
|
||||||
|
summary = normalize_compaction_summary(summary)
|
||||||
|
|
||||||
# Replace session history: summary as system message + recent messages
|
# Replace session history: summary as system message + recent messages
|
||||||
# System message holds the full summary for AI context
|
# System message holds the full summary for AI context
|
||||||
|
|||||||
+21
-5
@@ -429,11 +429,27 @@ def setup_hwfit_routes():
|
|||||||
system["available_ram_gb"] = 0
|
system["available_ram_gb"] = 0
|
||||||
system["total_ram_gb"] = 0
|
system["total_ram_gb"] = 0
|
||||||
system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend)
|
system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend)
|
||||||
# Image models use a single GPU — always use per-GPU VRAM
|
try:
|
||||||
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
|
requested_gpu_count = int(gpu_count) if gpu_count != "" else None
|
||||||
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
|
except ValueError:
|
||||||
system["gpu_vram_gb"] = single_vram
|
requested_gpu_count = None
|
||||||
system["gpu_count"] = 1 if single_vram > 0 else 0
|
if requested_gpu_count == 0:
|
||||||
|
# Respect the UI's RAM toggle. Before this route always rewrote the
|
||||||
|
# system to best-single-GPU VRAM, so image rows never changed when
|
||||||
|
# switching RAM/GPU.
|
||||||
|
system["has_gpu"] = False
|
||||||
|
system["gpu_vram_gb"] = 0
|
||||||
|
system["gpu_count"] = 0
|
||||||
|
system["gpu_only"] = False
|
||||||
|
else:
|
||||||
|
# Image diffusion backends generally use one device per pipeline,
|
||||||
|
# so rank GPU mode against the best single GPU rather than total
|
||||||
|
# multi-GPU VRAM.
|
||||||
|
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
|
||||||
|
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
|
||||||
|
system["gpu_vram_gb"] = single_vram
|
||||||
|
system["gpu_count"] = 1 if single_vram > 0 else 0
|
||||||
|
system["gpu_only"] = True if single_vram > 0 else False
|
||||||
results = rank_image_models(system, search=search or None, sort=sort)
|
results = rank_image_models(system, search=search or None, sort=sort)
|
||||||
return {"system": system, "models": results}
|
return {"system": system, "models": results}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""MCP route domain package (slice 2o, #4082/#4071).
|
||||||
|
|
||||||
|
Contains mcp_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/mcp_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
# routes/mcp_routes.py
|
||||||
|
"""MCP (Model Context Protocol) server management routes."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import urllib.parse
|
||||||
|
import html
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from core.database import McpServer, SessionLocal
|
||||||
|
from core.middleware import require_admin
|
||||||
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
|
from src.mcp_manager import McpManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_oauth_base_dir() -> Path:
|
||||||
|
"""Directory that may contain OAuth files managed by Odysseus."""
|
||||||
|
return Path(MCP_OAUTH_DIR).resolve(strict=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
||||||
|
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
|
||||||
|
raw = str(raw_path or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
base = _mcp_oauth_base_dir()
|
||||||
|
path = Path(os.path.expanduser(raw))
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = base / path
|
||||||
|
resolved = path.resolve(strict=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(base)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"Invalid OAuth {field_name}: path must stay under {base}",
|
||||||
|
) from exc
|
||||||
|
return str(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mcp_oauth_config(oauth_cfg):
|
||||||
|
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
|
||||||
|
if not oauth_cfg:
|
||||||
|
return oauth_cfg
|
||||||
|
if not isinstance(oauth_cfg, dict):
|
||||||
|
return {}
|
||||||
|
sanitized = dict(oauth_cfg)
|
||||||
|
for field_name in ("keys_file", "token_file"):
|
||||||
|
if sanitized.get(field_name):
|
||||||
|
sanitized[field_name] = _resolve_mcp_oauth_path(
|
||||||
|
sanitized[field_name],
|
||||||
|
field_name,
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
|
||||||
|
"""Check token existence without letting legacy bad paths break listing."""
|
||||||
|
if not isinstance(oauth_cfg, dict):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
|
||||||
|
except HTTPException:
|
||||||
|
if strict:
|
||||||
|
raise
|
||||||
|
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
|
||||||
|
return True
|
||||||
|
return bool(token_file and not os.path.exists(token_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
|
||||||
|
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
|
||||||
|
if not oauth_cfg or not isinstance(env, dict):
|
||||||
|
return
|
||||||
|
keys_file = oauth_cfg.get("keys_file")
|
||||||
|
token_file = oauth_cfg.get("token_file")
|
||||||
|
if keys_file:
|
||||||
|
env["GMAIL_OAUTH_PATH"] = keys_file
|
||||||
|
if token_file:
|
||||||
|
env["GMAIL_CREDENTIALS_PATH"] = token_file
|
||||||
|
|
||||||
|
|
||||||
|
def _load_disabled_map():
|
||||||
|
"""Load per-server disabled tool sets from DB."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
disabled_map = {}
|
||||||
|
for srv in db.query(McpServer).all():
|
||||||
|
if srv.disabled_tools:
|
||||||
|
try:
|
||||||
|
names = json.loads(srv.disabled_tools)
|
||||||
|
if names:
|
||||||
|
disabled_map[srv.id] = set(names)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return disabled_map
|
||||||
|
finally:
|
||||||
|
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."""
|
||||||
|
|
||||||
|
@router.get("/servers")
|
||||||
|
def list_servers(request: Request):
|
||||||
|
"""List all configured MCP servers with connection status."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
servers = db.query(McpServer).all()
|
||||||
|
result = []
|
||||||
|
for srv in servers:
|
||||||
|
status = mcp_manager.get_server_status(srv.id)
|
||||||
|
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
|
||||||
|
needs_oauth = False
|
||||||
|
if oauth_cfg:
|
||||||
|
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
|
||||||
|
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||||
|
total_tools = status.get("tool_count", 0)
|
||||||
|
result.append({
|
||||||
|
"id": srv.id,
|
||||||
|
"name": srv.name,
|
||||||
|
"transport": srv.transport,
|
||||||
|
"command": srv.command,
|
||||||
|
"args": json.loads(srv.args) if srv.args else [],
|
||||||
|
"env": json.loads(srv.env) if srv.env else {},
|
||||||
|
"url": srv.url,
|
||||||
|
"is_enabled": srv.is_enabled,
|
||||||
|
"status": status.get("status", "disconnected"),
|
||||||
|
"tool_count": total_tools,
|
||||||
|
"disabled_tool_count": len(disabled_list),
|
||||||
|
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
|
||||||
|
"error": status.get("error"),
|
||||||
|
"auth_url": status.get("auth_url"),
|
||||||
|
"has_oauth": oauth_cfg is not None,
|
||||||
|
"needs_oauth": needs_oauth,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/servers")
|
||||||
|
async def add_server(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(...),
|
||||||
|
transport: str = Form("stdio"),
|
||||||
|
command: str = Form(None),
|
||||||
|
args: str = Form("[]"),
|
||||||
|
env: str = Form("{}"),
|
||||||
|
url: str = Form(None),
|
||||||
|
oauth_file: str = Form(None),
|
||||||
|
oauth_config: str = Form(None),
|
||||||
|
):
|
||||||
|
"""Add a new MCP server config and attempt connection. Admin-only:
|
||||||
|
registering a stdio server is equivalent to executing arbitrary
|
||||||
|
binaries on the host."""
|
||||||
|
require_admin(request)
|
||||||
|
server_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if transport == "stdio" and not command:
|
||||||
|
raise HTTPException(400, "command is required for stdio transport")
|
||||||
|
if transport == "sse" and not url:
|
||||||
|
raise HTTPException(400, "url is required for SSE transport")
|
||||||
|
if transport == "http" and not url:
|
||||||
|
raise HTTPException(400, "url is required for HTTP transport")
|
||||||
|
|
||||||
|
# Parse JSON fields
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(args) if args else []
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed_args = []
|
||||||
|
try:
|
||||||
|
parsed_env = json.loads(env) if env else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed_env = {}
|
||||||
|
if not isinstance(parsed_env, dict):
|
||||||
|
parsed_env = {}
|
||||||
|
|
||||||
|
# Parse OAuth config
|
||||||
|
parsed_oauth_config = None
|
||||||
|
if oauth_config:
|
||||||
|
try:
|
||||||
|
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
|
||||||
|
|
||||||
|
# Write OAuth credentials file if provided (for Google MCP servers)
|
||||||
|
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
|
||||||
|
if oauth_file:
|
||||||
|
try:
|
||||||
|
oauth_data = json.loads(oauth_file)
|
||||||
|
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
|
||||||
|
oauth_filename = oauth_data.get("filename", "")
|
||||||
|
client_id = oauth_data.get("client_id", "")
|
||||||
|
client_secret = oauth_data.get("client_secret", "")
|
||||||
|
if oauth_dir and oauth_filename and client_id and client_secret:
|
||||||
|
filepath = _resolve_mcp_oauth_path(
|
||||||
|
Path(oauth_dir) / str(oauth_filename),
|
||||||
|
"filename",
|
||||||
|
)
|
||||||
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||||
|
creds = {
|
||||||
|
"installed": {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uris": ["http://localhost"],
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://accounts.google.com/o/oauth2/token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(creds, f, indent=2)
|
||||||
|
logger.info(f"Wrote OAuth credentials to {filepath}")
|
||||||
|
parsed_env.pop("GOOGLE_CLIENT_ID", None)
|
||||||
|
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning(f"Failed to write OAuth file: {e}")
|
||||||
|
|
||||||
|
# Save to DB
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = McpServer(
|
||||||
|
id=server_id,
|
||||||
|
name=name,
|
||||||
|
transport=transport,
|
||||||
|
command=command,
|
||||||
|
args=json.dumps(parsed_args),
|
||||||
|
env=json.dumps(parsed_env),
|
||||||
|
url=url,
|
||||||
|
is_enabled=True,
|
||||||
|
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
|
||||||
|
)
|
||||||
|
db.add(srv)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Check if OAuth token already exists — skip connection attempt if not
|
||||||
|
needs_oauth = False
|
||||||
|
if parsed_oauth_config:
|
||||||
|
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
if not needs_oauth:
|
||||||
|
connected = await mcp_manager.connect_server(
|
||||||
|
server_id=server_id,
|
||||||
|
name=name,
|
||||||
|
transport=transport,
|
||||||
|
command=command,
|
||||||
|
args=parsed_args,
|
||||||
|
env=parsed_env,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = mcp_manager.get_server_status(server_id)
|
||||||
|
needs_auth = status.get("status") == "needs_auth"
|
||||||
|
return {
|
||||||
|
"id": server_id,
|
||||||
|
"name": name,
|
||||||
|
"connected": connected,
|
||||||
|
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
|
||||||
|
"tool_count": status.get("tool_count", 0),
|
||||||
|
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
|
||||||
|
"needs_oauth": needs_oauth,
|
||||||
|
"needs_auth": needs_auth,
|
||||||
|
"auth_url": status.get("auth_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/servers/{server_id}/reconnect")
|
||||||
|
async def reconnect_server(server_id: str, request: Request):
|
||||||
|
"""Reconnect to an MCP server."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
await mcp_manager.disconnect_server(server_id)
|
||||||
|
|
||||||
|
args = json.loads(srv.args) if srv.args else []
|
||||||
|
env = json.loads(srv.env) if srv.env else {}
|
||||||
|
connected = await mcp_manager.connect_server(
|
||||||
|
server_id=server_id,
|
||||||
|
name=srv.name,
|
||||||
|
transport=srv.transport,
|
||||||
|
command=srv.command,
|
||||||
|
args=args,
|
||||||
|
env=env,
|
||||||
|
url=srv.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = mcp_manager.get_server_status(server_id)
|
||||||
|
return {
|
||||||
|
"connected": connected,
|
||||||
|
"status": status.get("status", "disconnected"),
|
||||||
|
"tool_count": status.get("tool_count", 0),
|
||||||
|
"error": status.get("error"),
|
||||||
|
"auth_url": status.get("auth_url"),
|
||||||
|
"needs_auth": status.get("status") == "needs_auth",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.patch("/servers/{server_id}")
|
||||||
|
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
|
||||||
|
"""Enable or disable an MCP server."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
enabled = str(is_enabled).lower() == "true"
|
||||||
|
srv.is_enabled = enabled
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
args = json.loads(srv.args) if srv.args else []
|
||||||
|
env = json.loads(srv.env) if srv.env else {}
|
||||||
|
await mcp_manager.connect_server(
|
||||||
|
server_id=server_id,
|
||||||
|
name=srv.name,
|
||||||
|
transport=srv.transport,
|
||||||
|
command=srv.command,
|
||||||
|
args=args,
|
||||||
|
env=env,
|
||||||
|
url=srv.url,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await mcp_manager.disconnect_server(server_id)
|
||||||
|
|
||||||
|
return {"id": server_id, "is_enabled": enabled}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.delete("/servers/{server_id}")
|
||||||
|
async def delete_server(server_id: str, request: Request):
|
||||||
|
"""Remove an MCP server."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
await mcp_manager.disconnect_server(server_id)
|
||||||
|
|
||||||
|
db.delete(srv)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.get("/tools")
|
||||||
|
def list_tools(request: Request):
|
||||||
|
"""List all discovered MCP tools across all connected servers."""
|
||||||
|
require_admin(request)
|
||||||
|
disabled_map = _load_disabled_map()
|
||||||
|
return mcp_manager.get_all_tools(disabled_map)
|
||||||
|
|
||||||
|
@router.get("/servers/{server_id}/tools")
|
||||||
|
def list_server_tools(server_id: str, request: Request):
|
||||||
|
"""List all tools for a specific MCP server with enabled/disabled state."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||||
|
disabled_set = set(disabled_list)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
all_tools = mcp_manager.get_all_tools()
|
||||||
|
server_tools = [t for t in all_tools if t["server_id"] == server_id]
|
||||||
|
for t in server_tools:
|
||||||
|
t["is_disabled"] = t["name"] in disabled_set
|
||||||
|
return server_tools
|
||||||
|
|
||||||
|
@router.patch("/servers/{server_id}/tools")
|
||||||
|
async def update_disabled_tools(server_id: str, request: Request):
|
||||||
|
"""Bulk update disabled tools list for a server.
|
||||||
|
|
||||||
|
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||||
|
"""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
disabled = body.get("disabled", [])
|
||||||
|
if not isinstance(disabled, list):
|
||||||
|
raise HTTPException(400, "disabled must be a list of tool names")
|
||||||
|
|
||||||
|
srv.disabled_tools = json.dumps(disabled) if disabled else None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"id": server_id, "disabled_count": len(disabled)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# ── OAuth flow for Google MCP servers ──────────────────────────
|
||||||
|
|
||||||
|
@router.get("/oauth/authorize/{server_id}")
|
||||||
|
def oauth_authorize(server_id: str, request: Request):
|
||||||
|
"""Show OAuth authorization page with Google sign-in link."""
|
||||||
|
require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
raise HTTPException(404, "Server not found")
|
||||||
|
if not srv.oauth_config:
|
||||||
|
raise HTTPException(400, "Server has no OAuth config")
|
||||||
|
|
||||||
|
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||||
|
keys_file = oauth_cfg.get("keys_file", "")
|
||||||
|
if not keys_file or not os.path.exists(keys_file):
|
||||||
|
raise HTTPException(400, "OAuth keys file not found")
|
||||||
|
|
||||||
|
with open(keys_file, encoding="utf-8") as f:
|
||||||
|
keys_data = json.load(f)
|
||||||
|
keys = keys_data.get("installed") or keys_data.get("web")
|
||||||
|
if not keys:
|
||||||
|
raise HTTPException(400, "Invalid OAuth keys file format")
|
||||||
|
|
||||||
|
client_id = keys["client_id"]
|
||||||
|
scopes = oauth_cfg.get("scopes", [])
|
||||||
|
|
||||||
|
# For Desktop App creds, default to localhost — the user will
|
||||||
|
# paste the resulting URL back if they're on a different device.
|
||||||
|
redirect_uri = _mcp_oauth_redirect_uri()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
"access_type": "offline",
|
||||||
|
"prompt": "consent",
|
||||||
|
"state": server_id,
|
||||||
|
}
|
||||||
|
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
|
||||||
|
|
||||||
|
# Determine if user is accessing from the same machine
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
|
||||||
|
|
||||||
|
if is_local:
|
||||||
|
# Same machine — just redirect, callback will work directly
|
||||||
|
return RedirectResponse(auth_url)
|
||||||
|
else:
|
||||||
|
# Remote device — show paste-back page
|
||||||
|
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.get("/oauth/callback")
|
||||||
|
async def oauth_callback(code: str, state: str, request: Request):
|
||||||
|
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
||||||
|
pending-state registry; Google flows fall through to the legacy path."""
|
||||||
|
require_admin(request)
|
||||||
|
from src.mcp_oauth import resolve_pending
|
||||||
|
if resolve_pending(state, code):
|
||||||
|
return HTMLResponse(_oauth_result_page(
|
||||||
|
"Authorization Successful",
|
||||||
|
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||||
|
success=True,
|
||||||
|
))
|
||||||
|
# Legacy Google path: state is the server_id
|
||||||
|
return await _exchange_and_connect(state, code, request)
|
||||||
|
|
||||||
|
@router.post("/oauth/exchange/{server_id}")
|
||||||
|
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
||||||
|
"""Manual code exchange — user pastes the callback URL from their browser."""
|
||||||
|
require_admin(request)
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(callback_url)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
code = params.get("code", [None])[0]
|
||||||
|
if not code:
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
|
||||||
|
except Exception:
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
||||||
|
|
||||||
|
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
||||||
|
# resolve it directly (the background connect finishes the handshake).
|
||||||
|
state = params.get("state", [None])[0]
|
||||||
|
from src.mcp_oauth import resolve_pending
|
||||||
|
if state and resolve_pending(state, code):
|
||||||
|
return HTMLResponse(_oauth_result_page(
|
||||||
|
"Authorization Successful",
|
||||||
|
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||||
|
success=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
return await _exchange_and_connect(server_id, code, request)
|
||||||
|
|
||||||
|
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
||||||
|
"""Exchange auth code for tokens and connect the MCP server."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
if not srv:
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
|
||||||
|
if not srv.oauth_config:
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
|
||||||
|
|
||||||
|
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||||
|
keys_file = oauth_cfg.get("keys_file", "")
|
||||||
|
token_file = oauth_cfg.get("token_file", "")
|
||||||
|
if not keys_file or not token_file:
|
||||||
|
raise HTTPException(400, "OAuth keys/token file not configured")
|
||||||
|
|
||||||
|
with open(keys_file, encoding="utf-8") as f:
|
||||||
|
keys_data = json.load(f)
|
||||||
|
keys = keys_data.get("installed") or keys_data.get("web")
|
||||||
|
client_id = keys["client_id"]
|
||||||
|
client_secret = keys["client_secret"]
|
||||||
|
|
||||||
|
redirect_uri = _mcp_oauth_redirect_uri()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data={
|
||||||
|
"code": code,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
err = resp.text
|
||||||
|
logger.error(f"OAuth token exchange failed: {err}")
|
||||||
|
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
|
||||||
|
|
||||||
|
tokens = resp.json()
|
||||||
|
logger.info(f"OAuth tokens received for server {server_id}")
|
||||||
|
|
||||||
|
# Save tokens to the file the MCP package expects
|
||||||
|
os.makedirs(os.path.dirname(token_file), exist_ok=True)
|
||||||
|
with open(token_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(tokens, f, indent=2)
|
||||||
|
logger.info(f"Saved OAuth tokens to {token_file}")
|
||||||
|
|
||||||
|
# Attempt to connect the MCP server now
|
||||||
|
args = json.loads(srv.args) if srv.args else []
|
||||||
|
env = json.loads(srv.env) if srv.env else {}
|
||||||
|
connected = await mcp_manager.connect_server(
|
||||||
|
server_id=server_id,
|
||||||
|
name=srv.name,
|
||||||
|
transport=srv.transport,
|
||||||
|
command=srv.command,
|
||||||
|
args=args,
|
||||||
|
env=env,
|
||||||
|
url=srv.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if connected:
|
||||||
|
status = mcp_manager.get_server_status(server_id)
|
||||||
|
tool_count = status.get("tool_count", 0)
|
||||||
|
return HTMLResponse(_oauth_result_page(
|
||||||
|
"Authorization Successful",
|
||||||
|
f"{srv.name} connected with {tool_count} tools. You can close this window.",
|
||||||
|
success=True,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
status = mcp_manager.get_server_status(server_id)
|
||||||
|
return HTMLResponse(_oauth_result_page(
|
||||||
|
"Authorized but Connection Failed",
|
||||||
|
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
|
||||||
|
))
|
||||||
|
except HTTPException as e:
|
||||||
|
logger.warning(f"OAuth callback rejected: {e.detail}")
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"OAuth callback error: {e}")
|
||||||
|
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||||
|
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||||
|
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||||
|
padding: 2rem; max-width: 480px; text-align: center; }}
|
||||||
|
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||||
|
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
|
||||||
|
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
|
||||||
|
.step b {{ color: #e06c75; }}
|
||||||
|
a.auth-link {{
|
||||||
|
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
|
||||||
|
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
|
||||||
|
font-weight: 600; font-size: 0.9rem;
|
||||||
|
}}
|
||||||
|
a.auth-link:hover {{ background: #c55; }}
|
||||||
|
input[type=text] {{
|
||||||
|
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
|
||||||
|
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
|
||||||
|
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
|
||||||
|
}}
|
||||||
|
input:focus {{ outline: none; border-color: #e06c75; }}
|
||||||
|
button {{
|
||||||
|
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
|
||||||
|
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
|
||||||
|
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
|
||||||
|
}}
|
||||||
|
button:hover {{ background: #c55; }}
|
||||||
|
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="card">
|
||||||
|
<h2>Authorize Google Account</h2>
|
||||||
|
<div class="step">
|
||||||
|
<b>1.</b> Click the button below to sign in with Google<br>
|
||||||
|
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
|
||||||
|
<b>3.</b> Copy the full URL from your browser's address bar<br>
|
||||||
|
<b>4.</b> Paste it below and click Connect
|
||||||
|
</div>
|
||||||
|
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
||||||
|
<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="{redirect_uri}?code=..." required>
|
||||||
|
<br><button type="submit">Connect</button>
|
||||||
|
</form>
|
||||||
|
</div></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
|
||||||
|
"""Generate a simple HTML page for the OAuth result."""
|
||||||
|
safe_title = html.escape(title)
|
||||||
|
safe_message = html.escape(message)
|
||||||
|
color = "#00661a" if success else "#e06c75"
|
||||||
|
icon = "✓" if success else "✗"
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8"><title>{safe_title}</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||||
|
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||||
|
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||||
|
padding: 2rem; max-width: 420px; text-align: center; }}
|
||||||
|
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
|
||||||
|
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||||
|
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="card">
|
||||||
|
<div class="icon">{icon}</div>
|
||||||
|
<h2>{safe_title}</h2>
|
||||||
|
<p>{safe_message}</p>
|
||||||
|
</div></body></html>"""
|
||||||
+14
-693
@@ -1,697 +1,18 @@
|
|||||||
# routes/mcp_routes.py
|
"""Backward-compat shim — canonical location is routes/mcp/mcp_routes.py.
|
||||||
"""MCP (Model Context Protocol) server management routes."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import urllib.parse
|
|
||||||
import html
|
|
||||||
from pathlib import Path
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
|
||||||
import logging
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
from core.middleware import require_admin
|
that ``import routes.mcp_routes``, ``from routes.mcp_routes import X``,
|
||||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
``importlib.import_module("routes.mcp_routes")``, the
|
||||||
from src.mcp_manager import McpManager
|
``sys.modules.pop("routes.mcp_routes")`` + re-import pattern in
|
||||||
|
test_security_regressions.py, and the ``monkeypatch.setattr(mcp_routes,
|
||||||
|
"MCP_OAUTH_DIR", ...)`` pattern all operate on the *same* object. This also
|
||||||
|
makes ``mcp_routes.__file__`` resolve to the canonical file (which the
|
||||||
|
source-introspection at line 839 reads). Keeps existing import paths working
|
||||||
|
after slice 2o (#4082/#4071).
|
||||||
|
"""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
import sys as _sys
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
from routes.mcp import mcp_routes as _canonical # noqa: F401
|
||||||
|
|
||||||
|
_sys.modules[__name__] = _canonical
|
||||||
def _mcp_oauth_base_dir() -> Path:
|
|
||||||
"""Directory that may contain OAuth files managed by Odysseus."""
|
|
||||||
return Path(MCP_OAUTH_DIR).resolve(strict=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
|
||||||
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
|
|
||||||
raw = str(raw_path or "").strip()
|
|
||||||
if not raw:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
base = _mcp_oauth_base_dir()
|
|
||||||
path = Path(os.path.expanduser(raw))
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = base / path
|
|
||||||
resolved = path.resolve(strict=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved.relative_to(base)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
f"Invalid OAuth {field_name}: path must stay under {base}",
|
|
||||||
) from exc
|
|
||||||
return str(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_mcp_oauth_config(oauth_cfg):
|
|
||||||
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
|
|
||||||
if not oauth_cfg:
|
|
||||||
return oauth_cfg
|
|
||||||
if not isinstance(oauth_cfg, dict):
|
|
||||||
return {}
|
|
||||||
sanitized = dict(oauth_cfg)
|
|
||||||
for field_name in ("keys_file", "token_file"):
|
|
||||||
if sanitized.get(field_name):
|
|
||||||
sanitized[field_name] = _resolve_mcp_oauth_path(
|
|
||||||
sanitized[field_name],
|
|
||||||
field_name,
|
|
||||||
)
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
|
|
||||||
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
|
|
||||||
"""Check token existence without letting legacy bad paths break listing."""
|
|
||||||
if not isinstance(oauth_cfg, dict):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
|
|
||||||
except HTTPException:
|
|
||||||
if strict:
|
|
||||||
raise
|
|
||||||
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
|
|
||||||
return True
|
|
||||||
return bool(token_file and not os.path.exists(token_file))
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
|
|
||||||
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
|
|
||||||
if not oauth_cfg or not isinstance(env, dict):
|
|
||||||
return
|
|
||||||
keys_file = oauth_cfg.get("keys_file")
|
|
||||||
token_file = oauth_cfg.get("token_file")
|
|
||||||
if keys_file:
|
|
||||||
env["GMAIL_OAUTH_PATH"] = keys_file
|
|
||||||
if token_file:
|
|
||||||
env["GMAIL_CREDENTIALS_PATH"] = token_file
|
|
||||||
|
|
||||||
|
|
||||||
def _load_disabled_map():
|
|
||||||
"""Load per-server disabled tool sets from DB."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
disabled_map = {}
|
|
||||||
for srv in db.query(McpServer).all():
|
|
||||||
if srv.disabled_tools:
|
|
||||||
try:
|
|
||||||
names = json.loads(srv.disabled_tools)
|
|
||||||
if names:
|
|
||||||
disabled_map[srv.id] = set(names)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
return disabled_map
|
|
||||||
finally:
|
|
||||||
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."""
|
|
||||||
|
|
||||||
@router.get("/servers")
|
|
||||||
def list_servers(request: Request):
|
|
||||||
"""List all configured MCP servers with connection status."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
servers = db.query(McpServer).all()
|
|
||||||
result = []
|
|
||||||
for srv in servers:
|
|
||||||
status = mcp_manager.get_server_status(srv.id)
|
|
||||||
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
|
|
||||||
needs_oauth = False
|
|
||||||
if oauth_cfg:
|
|
||||||
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
|
|
||||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
|
||||||
total_tools = status.get("tool_count", 0)
|
|
||||||
result.append({
|
|
||||||
"id": srv.id,
|
|
||||||
"name": srv.name,
|
|
||||||
"transport": srv.transport,
|
|
||||||
"command": srv.command,
|
|
||||||
"args": json.loads(srv.args) if srv.args else [],
|
|
||||||
"env": json.loads(srv.env) if srv.env else {},
|
|
||||||
"url": srv.url,
|
|
||||||
"is_enabled": srv.is_enabled,
|
|
||||||
"status": status.get("status", "disconnected"),
|
|
||||||
"tool_count": total_tools,
|
|
||||||
"disabled_tool_count": len(disabled_list),
|
|
||||||
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
|
|
||||||
"error": status.get("error"),
|
|
||||||
"auth_url": status.get("auth_url"),
|
|
||||||
"has_oauth": oauth_cfg is not None,
|
|
||||||
"needs_oauth": needs_oauth,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.post("/servers")
|
|
||||||
async def add_server(
|
|
||||||
request: Request,
|
|
||||||
name: str = Form(...),
|
|
||||||
transport: str = Form("stdio"),
|
|
||||||
command: str = Form(None),
|
|
||||||
args: str = Form("[]"),
|
|
||||||
env: str = Form("{}"),
|
|
||||||
url: str = Form(None),
|
|
||||||
oauth_file: str = Form(None),
|
|
||||||
oauth_config: str = Form(None),
|
|
||||||
):
|
|
||||||
"""Add a new MCP server config and attempt connection. Admin-only:
|
|
||||||
registering a stdio server is equivalent to executing arbitrary
|
|
||||||
binaries on the host."""
|
|
||||||
require_admin(request)
|
|
||||||
server_id = str(uuid.uuid4())[:8]
|
|
||||||
|
|
||||||
# Validate
|
|
||||||
if transport == "stdio" and not command:
|
|
||||||
raise HTTPException(400, "command is required for stdio transport")
|
|
||||||
if transport == "sse" and not url:
|
|
||||||
raise HTTPException(400, "url is required for SSE transport")
|
|
||||||
if transport == "http" and not url:
|
|
||||||
raise HTTPException(400, "url is required for HTTP transport")
|
|
||||||
|
|
||||||
# Parse JSON fields
|
|
||||||
try:
|
|
||||||
parsed_args = json.loads(args) if args else []
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
parsed_args = []
|
|
||||||
try:
|
|
||||||
parsed_env = json.loads(env) if env else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
parsed_env = {}
|
|
||||||
if not isinstance(parsed_env, dict):
|
|
||||||
parsed_env = {}
|
|
||||||
|
|
||||||
# Parse OAuth config
|
|
||||||
parsed_oauth_config = None
|
|
||||||
if oauth_config:
|
|
||||||
try:
|
|
||||||
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
|
|
||||||
|
|
||||||
# Write OAuth credentials file if provided (for Google MCP servers)
|
|
||||||
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
|
|
||||||
if oauth_file:
|
|
||||||
try:
|
|
||||||
oauth_data = json.loads(oauth_file)
|
|
||||||
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
|
|
||||||
oauth_filename = oauth_data.get("filename", "")
|
|
||||||
client_id = oauth_data.get("client_id", "")
|
|
||||||
client_secret = oauth_data.get("client_secret", "")
|
|
||||||
if oauth_dir and oauth_filename and client_id and client_secret:
|
|
||||||
filepath = _resolve_mcp_oauth_path(
|
|
||||||
Path(oauth_dir) / str(oauth_filename),
|
|
||||||
"filename",
|
|
||||||
)
|
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
||||||
creds = {
|
|
||||||
"installed": {
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uris": ["http://localhost"],
|
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
||||||
"token_uri": "https://accounts.google.com/o/oauth2/token",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(creds, f, indent=2)
|
|
||||||
logger.info(f"Wrote OAuth credentials to {filepath}")
|
|
||||||
parsed_env.pop("GOOGLE_CLIENT_ID", None)
|
|
||||||
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
logger.warning(f"Failed to write OAuth file: {e}")
|
|
||||||
|
|
||||||
# Save to DB
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = McpServer(
|
|
||||||
id=server_id,
|
|
||||||
name=name,
|
|
||||||
transport=transport,
|
|
||||||
command=command,
|
|
||||||
args=json.dumps(parsed_args),
|
|
||||||
env=json.dumps(parsed_env),
|
|
||||||
url=url,
|
|
||||||
is_enabled=True,
|
|
||||||
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
|
|
||||||
)
|
|
||||||
db.add(srv)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Check if OAuth token already exists — skip connection attempt if not
|
|
||||||
needs_oauth = False
|
|
||||||
if parsed_oauth_config:
|
|
||||||
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
|
|
||||||
|
|
||||||
connected = False
|
|
||||||
if not needs_oauth:
|
|
||||||
connected = await mcp_manager.connect_server(
|
|
||||||
server_id=server_id,
|
|
||||||
name=name,
|
|
||||||
transport=transport,
|
|
||||||
command=command,
|
|
||||||
args=parsed_args,
|
|
||||||
env=parsed_env,
|
|
||||||
url=url,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = mcp_manager.get_server_status(server_id)
|
|
||||||
needs_auth = status.get("status") == "needs_auth"
|
|
||||||
return {
|
|
||||||
"id": server_id,
|
|
||||||
"name": name,
|
|
||||||
"connected": connected,
|
|
||||||
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
|
|
||||||
"tool_count": status.get("tool_count", 0),
|
|
||||||
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
|
|
||||||
"needs_oauth": needs_oauth,
|
|
||||||
"needs_auth": needs_auth,
|
|
||||||
"auth_url": status.get("auth_url"),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/reconnect")
|
|
||||||
async def reconnect_server(server_id: str, request: Request):
|
|
||||||
"""Reconnect to an MCP server."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
|
|
||||||
await mcp_manager.disconnect_server(server_id)
|
|
||||||
|
|
||||||
args = json.loads(srv.args) if srv.args else []
|
|
||||||
env = json.loads(srv.env) if srv.env else {}
|
|
||||||
connected = await mcp_manager.connect_server(
|
|
||||||
server_id=server_id,
|
|
||||||
name=srv.name,
|
|
||||||
transport=srv.transport,
|
|
||||||
command=srv.command,
|
|
||||||
args=args,
|
|
||||||
env=env,
|
|
||||||
url=srv.url,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = mcp_manager.get_server_status(server_id)
|
|
||||||
return {
|
|
||||||
"connected": connected,
|
|
||||||
"status": status.get("status", "disconnected"),
|
|
||||||
"tool_count": status.get("tool_count", 0),
|
|
||||||
"error": status.get("error"),
|
|
||||||
"auth_url": status.get("auth_url"),
|
|
||||||
"needs_auth": status.get("status") == "needs_auth",
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.patch("/servers/{server_id}")
|
|
||||||
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
|
|
||||||
"""Enable or disable an MCP server."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
|
|
||||||
enabled = str(is_enabled).lower() == "true"
|
|
||||||
srv.is_enabled = enabled
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
if enabled:
|
|
||||||
args = json.loads(srv.args) if srv.args else []
|
|
||||||
env = json.loads(srv.env) if srv.env else {}
|
|
||||||
await mcp_manager.connect_server(
|
|
||||||
server_id=server_id,
|
|
||||||
name=srv.name,
|
|
||||||
transport=srv.transport,
|
|
||||||
command=srv.command,
|
|
||||||
args=args,
|
|
||||||
env=env,
|
|
||||||
url=srv.url,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await mcp_manager.disconnect_server(server_id)
|
|
||||||
|
|
||||||
return {"id": server_id, "is_enabled": enabled}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.delete("/servers/{server_id}")
|
|
||||||
async def delete_server(server_id: str, request: Request):
|
|
||||||
"""Remove an MCP server."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
|
|
||||||
await mcp_manager.disconnect_server(server_id)
|
|
||||||
|
|
||||||
db.delete(srv)
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted"}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.get("/tools")
|
|
||||||
def list_tools(request: Request):
|
|
||||||
"""List all discovered MCP tools across all connected servers."""
|
|
||||||
require_admin(request)
|
|
||||||
disabled_map = _load_disabled_map()
|
|
||||||
return mcp_manager.get_all_tools(disabled_map)
|
|
||||||
|
|
||||||
@router.get("/servers/{server_id}/tools")
|
|
||||||
def list_server_tools(server_id: str, request: Request):
|
|
||||||
"""List all tools for a specific MCP server with enabled/disabled state."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
|
||||||
disabled_set = set(disabled_list)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
all_tools = mcp_manager.get_all_tools()
|
|
||||||
server_tools = [t for t in all_tools if t["server_id"] == server_id]
|
|
||||||
for t in server_tools:
|
|
||||||
t["is_disabled"] = t["name"] in disabled_set
|
|
||||||
return server_tools
|
|
||||||
|
|
||||||
@router.patch("/servers/{server_id}/tools")
|
|
||||||
async def update_disabled_tools(server_id: str, request: Request):
|
|
||||||
"""Bulk update disabled tools list for a server.
|
|
||||||
|
|
||||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
|
||||||
"""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
|
|
||||||
body = await request.json()
|
|
||||||
disabled = body.get("disabled", [])
|
|
||||||
if not isinstance(disabled, list):
|
|
||||||
raise HTTPException(400, "disabled must be a list of tool names")
|
|
||||||
|
|
||||||
srv.disabled_tools = json.dumps(disabled) if disabled else None
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {"id": server_id, "disabled_count": len(disabled)}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# ── OAuth flow for Google MCP servers ──────────────────────────
|
|
||||||
|
|
||||||
@router.get("/oauth/authorize/{server_id}")
|
|
||||||
def oauth_authorize(server_id: str, request: Request):
|
|
||||||
"""Show OAuth authorization page with Google sign-in link."""
|
|
||||||
require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
raise HTTPException(404, "Server not found")
|
|
||||||
if not srv.oauth_config:
|
|
||||||
raise HTTPException(400, "Server has no OAuth config")
|
|
||||||
|
|
||||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
|
||||||
keys_file = oauth_cfg.get("keys_file", "")
|
|
||||||
if not keys_file or not os.path.exists(keys_file):
|
|
||||||
raise HTTPException(400, "OAuth keys file not found")
|
|
||||||
|
|
||||||
with open(keys_file, encoding="utf-8") as f:
|
|
||||||
keys_data = json.load(f)
|
|
||||||
keys = keys_data.get("installed") or keys_data.get("web")
|
|
||||||
if not keys:
|
|
||||||
raise HTTPException(400, "Invalid OAuth keys file format")
|
|
||||||
|
|
||||||
client_id = keys["client_id"]
|
|
||||||
scopes = oauth_cfg.get("scopes", [])
|
|
||||||
|
|
||||||
# For Desktop App creds, default to localhost — the user will
|
|
||||||
# paste the resulting URL back if they're on a different device.
|
|
||||||
redirect_uri = _mcp_oauth_redirect_uri()
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"client_id": client_id,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": " ".join(scopes),
|
|
||||||
"access_type": "offline",
|
|
||||||
"prompt": "consent",
|
|
||||||
"state": server_id,
|
|
||||||
}
|
|
||||||
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
|
|
||||||
|
|
||||||
# Determine if user is accessing from the same machine
|
|
||||||
host = request.headers.get("host", "")
|
|
||||||
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
|
|
||||||
|
|
||||||
if is_local:
|
|
||||||
# Same machine — just redirect, callback will work directly
|
|
||||||
return RedirectResponse(auth_url)
|
|
||||||
else:
|
|
||||||
# Remote device — show paste-back page
|
|
||||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.get("/oauth/callback")
|
|
||||||
async def oauth_callback(code: str, state: str, request: Request):
|
|
||||||
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
|
||||||
pending-state registry; Google flows fall through to the legacy path."""
|
|
||||||
require_admin(request)
|
|
||||||
from src.mcp_oauth import resolve_pending
|
|
||||||
if resolve_pending(state, code):
|
|
||||||
return HTMLResponse(_oauth_result_page(
|
|
||||||
"Authorization Successful",
|
|
||||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
|
||||||
success=True,
|
|
||||||
))
|
|
||||||
# Legacy Google path: state is the server_id
|
|
||||||
return await _exchange_and_connect(state, code, request)
|
|
||||||
|
|
||||||
@router.post("/oauth/exchange/{server_id}")
|
|
||||||
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
|
||||||
"""Manual code exchange — user pastes the callback URL from their browser."""
|
|
||||||
require_admin(request)
|
|
||||||
try:
|
|
||||||
parsed = urllib.parse.urlparse(callback_url)
|
|
||||||
params = urllib.parse.parse_qs(parsed.query)
|
|
||||||
code = params.get("code", [None])[0]
|
|
||||||
if not code:
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
|
|
||||||
except Exception:
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
|
||||||
|
|
||||||
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
|
||||||
# resolve it directly (the background connect finishes the handshake).
|
|
||||||
state = params.get("state", [None])[0]
|
|
||||||
from src.mcp_oauth import resolve_pending
|
|
||||||
if state and resolve_pending(state, code):
|
|
||||||
return HTMLResponse(_oauth_result_page(
|
|
||||||
"Authorization Successful",
|
|
||||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
|
||||||
success=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
return await _exchange_and_connect(server_id, code, request)
|
|
||||||
|
|
||||||
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
|
||||||
"""Exchange auth code for tokens and connect the MCP server."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
|
||||||
if not srv:
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
|
|
||||||
if not srv.oauth_config:
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
|
|
||||||
|
|
||||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
|
||||||
keys_file = oauth_cfg.get("keys_file", "")
|
|
||||||
token_file = oauth_cfg.get("token_file", "")
|
|
||||||
if not keys_file or not token_file:
|
|
||||||
raise HTTPException(400, "OAuth keys/token file not configured")
|
|
||||||
|
|
||||||
with open(keys_file, encoding="utf-8") as f:
|
|
||||||
keys_data = json.load(f)
|
|
||||||
keys = keys_data.get("installed") or keys_data.get("web")
|
|
||||||
client_id = keys["client_id"]
|
|
||||||
client_secret = keys["client_secret"]
|
|
||||||
|
|
||||||
redirect_uri = _mcp_oauth_redirect_uri()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
"https://oauth2.googleapis.com/token",
|
|
||||||
data={
|
|
||||||
"code": code,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
err = resp.text
|
|
||||||
logger.error(f"OAuth token exchange failed: {err}")
|
|
||||||
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
|
|
||||||
|
|
||||||
tokens = resp.json()
|
|
||||||
logger.info(f"OAuth tokens received for server {server_id}")
|
|
||||||
|
|
||||||
# Save tokens to the file the MCP package expects
|
|
||||||
os.makedirs(os.path.dirname(token_file), exist_ok=True)
|
|
||||||
with open(token_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(tokens, f, indent=2)
|
|
||||||
logger.info(f"Saved OAuth tokens to {token_file}")
|
|
||||||
|
|
||||||
# Attempt to connect the MCP server now
|
|
||||||
args = json.loads(srv.args) if srv.args else []
|
|
||||||
env = json.loads(srv.env) if srv.env else {}
|
|
||||||
connected = await mcp_manager.connect_server(
|
|
||||||
server_id=server_id,
|
|
||||||
name=srv.name,
|
|
||||||
transport=srv.transport,
|
|
||||||
command=srv.command,
|
|
||||||
args=args,
|
|
||||||
env=env,
|
|
||||||
url=srv.url,
|
|
||||||
)
|
|
||||||
|
|
||||||
if connected:
|
|
||||||
status = mcp_manager.get_server_status(server_id)
|
|
||||||
tool_count = status.get("tool_count", 0)
|
|
||||||
return HTMLResponse(_oauth_result_page(
|
|
||||||
"Authorization Successful",
|
|
||||||
f"{srv.name} connected with {tool_count} tools. You can close this window.",
|
|
||||||
success=True,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
status = mcp_manager.get_server_status(server_id)
|
|
||||||
return HTMLResponse(_oauth_result_page(
|
|
||||||
"Authorized but Connection Failed",
|
|
||||||
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
|
|
||||||
))
|
|
||||||
except HTTPException as e:
|
|
||||||
logger.warning(f"OAuth callback rejected: {e.detail}")
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"OAuth callback error: {e}")
|
|
||||||
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
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>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
|
||||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
|
||||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
|
||||||
padding: 2rem; max-width: 480px; text-align: center; }}
|
|
||||||
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
|
||||||
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
|
|
||||||
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
|
|
||||||
.step b {{ color: #e06c75; }}
|
|
||||||
a.auth-link {{
|
|
||||||
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
|
|
||||||
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
|
|
||||||
font-weight: 600; font-size: 0.9rem;
|
|
||||||
}}
|
|
||||||
a.auth-link:hover {{ background: #c55; }}
|
|
||||||
input[type=text] {{
|
|
||||||
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
|
|
||||||
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
|
|
||||||
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
|
|
||||||
}}
|
|
||||||
input:focus {{ outline: none; border-color: #e06c75; }}
|
|
||||||
button {{
|
|
||||||
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
|
|
||||||
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
|
|
||||||
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
|
|
||||||
}}
|
|
||||||
button:hover {{ background: #c55; }}
|
|
||||||
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
|
|
||||||
</style></head>
|
|
||||||
<body><div class="card">
|
|
||||||
<h2>Authorize Google Account</h2>
|
|
||||||
<div class="step">
|
|
||||||
<b>1.</b> Click the button below to sign in with Google<br>
|
|
||||||
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
|
|
||||||
<b>3.</b> Copy the full URL from your browser's address bar<br>
|
|
||||||
<b>4.</b> Paste it below and click Connect
|
|
||||||
</div>
|
|
||||||
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
|
||||||
<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="{redirect_uri}?code=..." required>
|
|
||||||
<br><button type="submit">Connect</button>
|
|
||||||
</form>
|
|
||||||
</div></body></html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
|
|
||||||
"""Generate a simple HTML page for the OAuth result."""
|
|
||||||
safe_title = html.escape(title)
|
|
||||||
safe_message = html.escape(message)
|
|
||||||
color = "#00661a" if success else "#e06c75"
|
|
||||||
icon = "✓" if success else "✗"
|
|
||||||
return f"""<!DOCTYPE html>
|
|
||||||
<html><head>
|
|
||||||
<meta charset="UTF-8"><title>{safe_title}</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
|
||||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
|
||||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
|
||||||
padding: 2rem; max-width: 420px; text-align: center; }}
|
|
||||||
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
|
|
||||||
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
|
||||||
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
|
||||||
</style></head>
|
|
||||||
<body><div class="card">
|
|
||||||
<div class="icon">{icon}</div>
|
|
||||||
<h2>{safe_title}</h2>
|
|
||||||
<p>{safe_message}</p>
|
|
||||||
</div></body></html>"""
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
||||||
|
|
||||||
from services.memory import MemoryManager
|
from services.memory import MemoryManager, MemoryStoreUnreadable
|
||||||
from core.session_manager import SessionManager
|
from core.session_manager import SessionManager
|
||||||
from src.request_models import MemoryAddRequest
|
from src.request_models import MemoryAddRequest
|
||||||
from core.database import SessionLocal
|
from core.database import SessionLocal
|
||||||
@@ -35,6 +35,22 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
|
||||||
|
"""Load the whole store for a read-modify-write cycle.
|
||||||
|
|
||||||
|
A transient read failure must not look like an empty store: the caller
|
||||||
|
would append to ``[]`` and save that back, atomically destroying every
|
||||||
|
existing memory (issue #5673). Surface it as a 503 and change nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return memory_manager.load_all_for_update()
|
||||||
|
except MemoryStoreUnreadable as e:
|
||||||
|
logger.error("Refusing to rewrite the memory store: %s", e)
|
||||||
|
raise HTTPException(
|
||||||
|
503, "Memory store is temporarily unreadable — no changes were made."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||||
"""Set up memory-related routes."""
|
"""Set up memory-related routes."""
|
||||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||||
@@ -116,7 +132,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||||
if memory_data.session_id:
|
if memory_data.session_id:
|
||||||
new_entry["session_id"] = memory_data.session_id
|
new_entry["session_id"] = memory_data.session_id
|
||||||
all_mem = memory_manager.load_all()
|
all_mem = _load_for_update(memory_manager)
|
||||||
all_mem.append(new_entry)
|
all_mem.append(new_entry)
|
||||||
memory_manager.save(all_mem)
|
memory_manager.save(all_mem)
|
||||||
# Sync vector index
|
# Sync vector index
|
||||||
@@ -487,7 +503,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
||||||
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = memory_manager.load_all()
|
all_mem = _load_for_update(memory_manager)
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -512,7 +528,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
||||||
"""Update an existing memory item with new text and optional category."""
|
"""Update an existing memory item with new text and optional category."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = memory_manager.load_all()
|
all_mem = _load_for_update(memory_manager)
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -534,7 +550,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def delete_memory(request: Request, memory_id: str):
|
def delete_memory(request: Request, memory_id: str):
|
||||||
"""Delete a memory item by its ID."""
|
"""Delete a memory item by its ID."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = memory_manager.load_all()
|
all_mem = _load_for_update(memory_manager)
|
||||||
|
|
||||||
# Find and verify ownership before deleting
|
# Find and verify ownership before deleting
|
||||||
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
||||||
|
|||||||
+217
-49
@@ -46,10 +46,11 @@ _ENDPOINT_SETTING_FIELDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ENDPOINT_FALLBACK_FIELDS = {
|
_ENDPOINT_FALLBACK_FIELDS = {
|
||||||
"default_model_fallbacks": "Default Model Fallbacks",
|
|
||||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||||
}
|
}
|
||||||
|
# `default_model_fallbacks` is intentionally absent. The legacy data remains
|
||||||
|
# stored as-is even when an endpoint is removed, but no longer affects routing.
|
||||||
|
|
||||||
|
|
||||||
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
|
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
|
||||||
@@ -472,7 +473,11 @@ def _endpoint_kind(ep: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str:
|
def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str:
|
||||||
return _normalize_refresh_mode(getattr(ep, "model_refresh_mode", None), endpoint_kind or _endpoint_kind(ep))
|
return _normalize_endpoint_refresh_mode(
|
||||||
|
getattr(ep, "model_refresh_mode", None),
|
||||||
|
endpoint_kind or _endpoint_kind(ep),
|
||||||
|
getattr(ep, "base_url", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _endpoint_refresh_interval(ep: Any, category: str) -> float:
|
def _endpoint_refresh_interval(ep: Any, category: str) -> float:
|
||||||
@@ -851,6 +856,99 @@ def _ollama_model_names(data: Any) -> List[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _is_google_api_base(base_url: str) -> bool:
|
||||||
|
try:
|
||||||
|
return (urlparse(base_url).hostname or "").lower() == "generativelanguage.googleapis.com"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_endpoint_refresh_mode(value: Any, endpoint_kind: str = "auto", base_url: str = "") -> str:
|
||||||
|
if not str(value or "").strip() and _is_google_api_base(base_url):
|
||||||
|
return "manual"
|
||||||
|
return _normalize_refresh_mode(value, endpoint_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _google_native_root(base_url: str) -> str:
|
||||||
|
"""Return the Gemini native API root for a Google endpoint.
|
||||||
|
|
||||||
|
Chat calls may be configured against Google's OpenAI-compatible
|
||||||
|
`/openai` path, but model catalog reads should use the native Models API
|
||||||
|
so we get Google's current Model resource shape.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
except Exception:
|
||||||
|
return "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
if path.endswith("/openai"):
|
||||||
|
path = path[: -len("/openai")].rstrip("/")
|
||||||
|
if not path:
|
||||||
|
path = "/v1beta"
|
||||||
|
return urlunparse(parsed._replace(path=path, query="", fragment="")).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _google_native_models_url(base_url: str) -> str:
|
||||||
|
return _google_native_root(base_url) + "/models"
|
||||||
|
|
||||||
|
|
||||||
|
def _google_model_id_from_item(item: Any) -> str:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return ""
|
||||||
|
value = item.get("baseModelId") or item.get("name") or item.get("model") or ""
|
||||||
|
return str(value or "").strip().removeprefix("models/")
|
||||||
|
|
||||||
|
|
||||||
|
def _google_model_supports_chat(item: Any) -> bool:
|
||||||
|
"""Return whether a native Google Model resource supports chat generation."""
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return False
|
||||||
|
methods = item.get("supportedGenerationMethods")
|
||||||
|
if not isinstance(methods, list):
|
||||||
|
return False
|
||||||
|
chat_methods = {"generateContent", "generateMessage", "generateText", "generateAnswer"}
|
||||||
|
return any(method in chat_methods for method in methods)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_google_models(base_url: str, api_key: str = None, timeout: int = 5, page_size: int = 1000) -> List[str]:
|
||||||
|
"""Read Google's native paginated Models API.
|
||||||
|
|
||||||
|
This intentionally returns only provider-reported model IDs. Capability
|
||||||
|
mapping is handled by the model capability reader and must not infer from
|
||||||
|
names here.
|
||||||
|
"""
|
||||||
|
url = _google_native_models_url(base_url)
|
||||||
|
try:
|
||||||
|
page_size = min(max(int(page_size or 1000), 1), 1000)
|
||||||
|
except Exception:
|
||||||
|
page_size = 1000
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["x-goog-api-key"] = api_key
|
||||||
|
params: Dict[str, Any] = {"pageSize": page_size}
|
||||||
|
models: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
page_token = ""
|
||||||
|
for _ in range(20):
|
||||||
|
request_params = dict(params)
|
||||||
|
if page_token:
|
||||||
|
request_params["pageToken"] = page_token
|
||||||
|
r = httpx.get(url, headers=headers, params=request_params, timeout=timeout, verify=llm_verify())
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
for item in data.get("models") or []:
|
||||||
|
if not _google_model_supports_chat(item):
|
||||||
|
continue
|
||||||
|
model_id = _google_model_id_from_item(item)
|
||||||
|
if model_id and model_id not in seen:
|
||||||
|
seen.add(model_id)
|
||||||
|
models.append(model_id)
|
||||||
|
page_token = str(data.get("nextPageToken") or "").strip()
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
||||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
"""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."""
|
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||||
@@ -863,6 +961,17 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
if api_key:
|
if api_key:
|
||||||
return fetch_available_models(api_key, timeout=timeout)
|
return fetch_available_models(api_key, timeout=timeout)
|
||||||
return []
|
return []
|
||||||
|
if _is_google_api_base(base):
|
||||||
|
try:
|
||||||
|
models = _probe_google_models(base, api_key, timeout=timeout)
|
||||||
|
if models:
|
||||||
|
return models
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
status = e.response.status_code if e.response is not None else "unknown"
|
||||||
|
logger.warning(f"Google native models probe failed: HTTP {status}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Google native models probe failed: {e}")
|
||||||
|
return []
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
# Try Anthropic's /v1/models endpoint first
|
# Try Anthropic's /v1/models endpoint first
|
||||||
url = _safe_build_models_url(base)
|
url = _safe_build_models_url(base)
|
||||||
@@ -1202,6 +1311,56 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
|
|||||||
return [m for m in merged if m not in hidden]
|
return [m for m in merged if m not in hidden]
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_requires_pinning(base_url: str, kind: str) -> bool:
|
||||||
|
return _classify_endpoint(base_url, kind) == "api"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_explicit_pinned_models(ep) -> bool:
|
||||||
|
"""Whether pinned_models was deliberately written for this endpoint.
|
||||||
|
|
||||||
|
API endpoints use pinned_models as an allow-list. An explicit empty JSON
|
||||||
|
list means "show no models"; it must not fall back to the old hidden-list
|
||||||
|
migration behavior.
|
||||||
|
"""
|
||||||
|
raw = getattr(ep, "pinned_models", None)
|
||||||
|
return raw is not None and str(raw).strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_visible_api_models(ep) -> List[str]:
|
||||||
|
"""Return API models selected under the old hidden-list picker.
|
||||||
|
|
||||||
|
Before API endpoints switched to an explicit allow-list, selected models
|
||||||
|
were represented as cached_models minus hidden_models. Existing OpenRouter
|
||||||
|
rows can therefore have many checked models and an empty pinned_models
|
||||||
|
field. Treat that old state as the initial pinned list so settings and chat
|
||||||
|
agree after upgrade.
|
||||||
|
"""
|
||||||
|
return _visible_models(
|
||||||
|
_cached_model_ids(ep),
|
||||||
|
getattr(ep, "hidden_models", None),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||||
|
"""Return model IDs that should appear in the picker for an endpoint.
|
||||||
|
|
||||||
|
API providers expose remote inventory from /v1/models. Treat that cache as
|
||||||
|
inventory, not approval: only manually pinned API models should appear in
|
||||||
|
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
|
||||||
|
"""
|
||||||
|
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||||
|
if _picker_requires_pinning(base_url, kind):
|
||||||
|
if not _has_explicit_pinned_models(ep):
|
||||||
|
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
|
||||||
|
return pinned, pinned
|
||||||
|
return _visible_models(
|
||||||
|
_cached_model_ids(ep),
|
||||||
|
getattr(ep, "hidden_models", None),
|
||||||
|
pinned,
|
||||||
|
), pinned
|
||||||
|
|
||||||
|
|
||||||
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
||||||
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
||||||
key = (api_key or "").strip()
|
key = (api_key or "").strip()
|
||||||
@@ -1400,24 +1559,18 @@ def setup_model_routes(model_discovery):
|
|||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
provider = _safe_detect_provider(base)
|
provider = _safe_detect_provider(base)
|
||||||
# Merge cached + pinned models, then filter out hidden ones
|
|
||||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||||
model_ids = _visible_models(
|
|
||||||
_cached_model_ids(ep),
|
|
||||||
ep.hidden_models,
|
|
||||||
getattr(ep, "pinned_models", None),
|
|
||||||
)
|
|
||||||
# Build correct URL based on provider
|
# Build correct URL based on provider
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
kind = _effective_endpoint_kind(ep, base)
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
category = _classify_endpoint(base, kind)
|
category = _classify_endpoint(base, kind)
|
||||||
|
model_ids, pinned = _picker_models_for_endpoint(ep, base, kind)
|
||||||
|
|
||||||
if model_ids:
|
if model_ids:
|
||||||
curated_key = _match_provider_curated(base, None)
|
curated_key = _match_provider_curated(base, None)
|
||||||
curated, extra = _curate_models(model_ids, curated_key)
|
curated, extra = _curate_models(model_ids, curated_key)
|
||||||
# Pinned models are admin-selected — they always belong in the
|
# Pinned models are admin-selected — they always belong in the
|
||||||
# primary curated list, not buried in extras.
|
# primary curated list, not buried in extras.
|
||||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
|
||||||
for m in pinned:
|
for m in pinned:
|
||||||
if m not in curated:
|
if m not in curated:
|
||||||
curated.append(m)
|
curated.append(m)
|
||||||
@@ -1779,18 +1932,24 @@ def setup_model_routes(model_discovery):
|
|||||||
_invalidate_models_cache()
|
_invalidate_models_cache()
|
||||||
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
||||||
results = []
|
results = []
|
||||||
|
upgraded_legacy_pins = False
|
||||||
for r in rows:
|
for r in rows:
|
||||||
all_models = _cached_model_ids(r)
|
all_models = _cached_model_ids(r)
|
||||||
hidden = _hidden_model_ids(r)
|
hidden = _hidden_model_ids(r)
|
||||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
|
||||||
# Keep the list route cache-only. It feeds Settings →
|
# Keep the list route cache-only. It feeds Settings →
|
||||||
# Added Models and must render immediately; explicit
|
# Added Models and must render immediately; explicit
|
||||||
# Refresh/Probe endpoints do the network work.
|
# Refresh/Probe endpoints do the network work.
|
||||||
status = "online" if (all_models or pinned) else ("empty" if r.is_enabled else "offline")
|
|
||||||
ping = None
|
ping = None
|
||||||
base = _normalize_base(r.base_url)
|
base = _normalize_base(r.base_url)
|
||||||
kind = _effective_endpoint_kind(r, base)
|
kind = _effective_endpoint_kind(r, base)
|
||||||
|
visible, pinned = _picker_models_for_endpoint(r, base, kind)
|
||||||
|
if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r):
|
||||||
|
r.pinned_models = json.dumps(pinned)
|
||||||
|
upgraded_legacy_pins = True
|
||||||
|
model_inventory_count = len(_merge_model_ids(all_models, pinned))
|
||||||
|
picker_requires_pinning = _picker_requires_pinning(base, kind)
|
||||||
|
status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline")
|
||||||
results.append({
|
results.append({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
@@ -1799,6 +1958,8 @@ def setup_model_routes(model_discovery):
|
|||||||
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
|
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
|
||||||
"is_enabled": r.is_enabled,
|
"is_enabled": r.is_enabled,
|
||||||
"models": visible,
|
"models": visible,
|
||||||
|
"model_count": model_inventory_count,
|
||||||
|
"picker_requires_pinning": picker_requires_pinning,
|
||||||
"pinned_models": pinned,
|
"pinned_models": pinned,
|
||||||
"hidden_count": len(hidden),
|
"hidden_count": len(hidden),
|
||||||
"online": status != "offline",
|
"online": status != "offline",
|
||||||
@@ -1812,6 +1973,9 @@ def setup_model_routes(model_discovery):
|
|||||||
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
||||||
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
||||||
})
|
})
|
||||||
|
if upgraded_legacy_pins:
|
||||||
|
db.commit()
|
||||||
|
_invalidate_models_cache()
|
||||||
return results
|
return results
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1854,7 +2018,7 @@ def setup_model_routes(model_discovery):
|
|||||||
name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
|
name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
|
||||||
|
|
||||||
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
||||||
refresh_mode = _normalize_refresh_mode(model_refresh_mode, requested_kind)
|
refresh_mode = _normalize_endpoint_refresh_mode(model_refresh_mode, requested_kind, base_url)
|
||||||
refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
|
refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
|
||||||
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
||||||
require_model_list = _truthy(require_models)
|
require_model_list = _truthy(require_models)
|
||||||
@@ -1915,6 +2079,10 @@ def setup_model_routes(model_discovery):
|
|||||||
if refresh_timeout is not None:
|
if refresh_timeout is not None:
|
||||||
existing.model_refresh_timeout = refresh_timeout
|
existing.model_refresh_timeout = refresh_timeout
|
||||||
changed = True
|
changed = True
|
||||||
|
incoming_model_type = (model_type or "").strip() or "llm"
|
||||||
|
if incoming_model_type and (getattr(existing, "model_type", None) or "llm") != incoming_model_type:
|
||||||
|
existing.model_type = incoming_model_type
|
||||||
|
changed = True
|
||||||
if api_key.strip() and not existing.api_key:
|
if api_key.strip() and not existing.api_key:
|
||||||
existing.api_key = api_key.strip()
|
existing.api_key = api_key.strip()
|
||||||
changed = True
|
changed = True
|
||||||
@@ -2147,9 +2315,10 @@ def setup_model_routes(model_discovery):
|
|||||||
raise HTTPException(404, "Endpoint not found")
|
raise HTTPException(404, "Endpoint not found")
|
||||||
hidden = _hidden_model_ids(ep)
|
hidden = _hidden_model_ids(ep)
|
||||||
all_models = _cached_model_ids(ep)
|
all_models = _cached_model_ids(ep)
|
||||||
|
base = _normalize_base(ep.base_url)
|
||||||
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
|
picker_requires_pinning = _picker_requires_pinning(base, kind)
|
||||||
if refresh:
|
if refresh:
|
||||||
base = _normalize_base(ep.base_url)
|
|
||||||
kind = _effective_endpoint_kind(ep, base)
|
|
||||||
category = _classify_endpoint(base, kind)
|
category = _classify_endpoint(base, kind)
|
||||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||||
try:
|
try:
|
||||||
@@ -2168,6 +2337,8 @@ def setup_model_routes(model_discovery):
|
|||||||
response.headers["X-Model-Refresh-Status"] = "failed"
|
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||||
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
||||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||||
|
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
|
||||||
|
pinned = _legacy_visible_api_models(ep)
|
||||||
pinned_set = set(pinned)
|
pinned_set = set(pinned)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -2175,6 +2346,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"display": m.split("/")[-1],
|
"display": m.split("/")[-1],
|
||||||
"is_hidden": m in hidden,
|
"is_hidden": m in hidden,
|
||||||
"is_pinned": m in pinned_set,
|
"is_pinned": m in pinned_set,
|
||||||
|
"picker_requires_pinning": picker_requires_pinning,
|
||||||
}
|
}
|
||||||
for m in _merge_model_ids(all_models, pinned)
|
for m in _merge_model_ids(all_models, pinned)
|
||||||
]
|
]
|
||||||
@@ -2203,11 +2375,28 @@ def setup_model_routes(model_discovery):
|
|||||||
hidden = body.get("hidden")
|
hidden = body.get("hidden")
|
||||||
if not isinstance(hidden, list):
|
if not isinstance(hidden, list):
|
||||||
raise HTTPException(400, "hidden must be a list of model IDs")
|
raise HTTPException(400, "hidden must be a list of model IDs")
|
||||||
ep.hidden_models = json.dumps(hidden) if hidden else None
|
base = _normalize_base(ep.base_url)
|
||||||
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
|
if _picker_requires_pinning(base, kind):
|
||||||
|
# Compatibility for older/admin UI paths that still submit
|
||||||
|
# the previous hide-list shape. API pickers are allow-lists:
|
||||||
|
# convert "unchecked models" into an explicit pinned list so
|
||||||
|
# Settings summary, /api/models, and chat agree.
|
||||||
|
selected = _visible_models(_cached_model_ids(ep), hidden, None)
|
||||||
|
ep.pinned_models = json.dumps(selected)
|
||||||
|
ep.hidden_models = None
|
||||||
|
else:
|
||||||
|
ep.hidden_models = json.dumps(hidden) if hidden else None
|
||||||
# Accept either "pinned" or "pinned_models" for the manual IDs list.
|
# Accept either "pinned" or "pinned_models" for the manual IDs list.
|
||||||
if "pinned_models" in body or "pinned" in body:
|
if "pinned_models" in body or "pinned" in body:
|
||||||
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
|
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
|
||||||
ep.pinned_models = json.dumps(pinned) if pinned else None
|
base = _normalize_base(ep.base_url)
|
||||||
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
|
if _picker_requires_pinning(base, kind):
|
||||||
|
ep.pinned_models = json.dumps(pinned)
|
||||||
|
ep.hidden_models = None
|
||||||
|
else:
|
||||||
|
ep.pinned_models = json.dumps(pinned) if pinned else None
|
||||||
db.commit()
|
db.commit()
|
||||||
_invalidate_models_cache()
|
_invalidate_models_cache()
|
||||||
hidden_count = len(json.loads(ep.hidden_models)) if ep.hidden_models else 0
|
hidden_count = len(json.loads(ep.hidden_models)) if ep.hidden_models else 0
|
||||||
@@ -2249,7 +2438,6 @@ def setup_model_routes(model_discovery):
|
|||||||
_user_prefs = _load_for_user(_user) or {}
|
_user_prefs = _load_for_user(_user) or {}
|
||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") or "").strip()
|
model = (_user_prefs.get("default_model") or "").strip()
|
||||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
|
||||||
# If user has no personal default, fall back to global default
|
# If user has no personal default, fall back to global default
|
||||||
# But only based on the "share_defaults_with_users" flag
|
# But only based on the "share_defaults_with_users" flag
|
||||||
# (only if share_defaults_with_users is enabled)
|
# (only if share_defaults_with_users is enabled)
|
||||||
@@ -2258,12 +2446,9 @@ def setup_model_routes(model_discovery):
|
|||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
if not model:
|
if not model:
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
if not _fallbacks:
|
|
||||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = None
|
ep = None
|
||||||
@@ -2278,33 +2463,6 @@ def setup_model_routes(model_discovery):
|
|||||||
if _user and not _is_admin:
|
if _user and not _is_admin:
|
||||||
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
||||||
ep = ep_q.first()
|
ep = ep_q.first()
|
||||||
# Configured fallback chain — when the chosen default endpoint is
|
|
||||||
# gone/disabled, honor the user's configured `default_model_fallbacks`
|
|
||||||
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
|
|
||||||
# (Previously this jumped straight to "first enabled", which is why
|
|
||||||
# deleting/changing the main endpoint silently reassigned the default
|
|
||||||
# chat to some unrelated endpoint instead of the fallback.)
|
|
||||||
if not ep:
|
|
||||||
for entry in _fallbacks:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
fid = (entry.get("endpoint_id") or "").strip()
|
|
||||||
if not fid:
|
|
||||||
continue
|
|
||||||
cand_q = db.query(ModelEndpoint).filter(
|
|
||||||
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
|
|
||||||
)
|
|
||||||
if _user and not _is_admin:
|
|
||||||
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
|
|
||||||
cand = cand_q.first()
|
|
||||||
if cand:
|
|
||||||
ep = cand
|
|
||||||
# Use the fallback entry's model. Reset even when empty
|
|
||||||
# so we don't carry the prior endpoint's stale model onto
|
|
||||||
# this fallback — the cached-models lookup below then
|
|
||||||
# fills it from the fallback endpoint.
|
|
||||||
model = (entry.get("model") or "").strip()
|
|
||||||
break
|
|
||||||
# Last resort: first enabled endpoint owned by THIS user. Do not
|
# Last resort: first enabled endpoint owned by THIS user. Do not
|
||||||
# include null-owner/shared endpoints here: a brand-new user with
|
# include null-owner/shared endpoints here: a brand-new user with
|
||||||
# no explicit default should not auto-open a pending chat using an
|
# no explicit default should not auto-open a pending chat using an
|
||||||
@@ -2360,11 +2518,21 @@ def setup_model_routes(model_discovery):
|
|||||||
ep.model_type = body["model_type"].strip() or ep.model_type
|
ep.model_type = body["model_type"].strip() or ep.model_type
|
||||||
if "pinned_models" in body:
|
if "pinned_models" in body:
|
||||||
_pinned = _normalize_model_ids(body["pinned_models"])
|
_pinned = _normalize_model_ids(body["pinned_models"])
|
||||||
ep.pinned_models = json.dumps(_pinned) if _pinned else None
|
_base_for_pins = _normalize_base(ep.base_url)
|
||||||
|
_kind_for_pins = _effective_endpoint_kind(ep, _base_for_pins)
|
||||||
|
if _picker_requires_pinning(_base_for_pins, _kind_for_pins):
|
||||||
|
ep.pinned_models = json.dumps(_pinned)
|
||||||
|
ep.hidden_models = None
|
||||||
|
else:
|
||||||
|
ep.pinned_models = json.dumps(_pinned) if _pinned else None
|
||||||
if "endpoint_kind" in body:
|
if "endpoint_kind" in body:
|
||||||
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
|
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
|
||||||
if "model_refresh_mode" in body:
|
if "model_refresh_mode" in body:
|
||||||
ep.model_refresh_mode = _normalize_refresh_mode(body.get("model_refresh_mode"), _endpoint_kind(ep))
|
ep.model_refresh_mode = _normalize_endpoint_refresh_mode(
|
||||||
|
body.get("model_refresh_mode"),
|
||||||
|
_endpoint_kind(ep),
|
||||||
|
ep.base_url,
|
||||||
|
)
|
||||||
if "model_refresh_interval" in body:
|
if "model_refresh_interval" in body:
|
||||||
interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400)
|
interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400)
|
||||||
ep.model_refresh_interval = interval
|
ep.model_refresh_interval = interval
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Note route domain package (slice 2f, #4082/#4071).
|
||||||
|
|
||||||
|
Contains note_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/note_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,937 @@
|
|||||||
|
# routes/note_routes.py
|
||||||
|
"""Google Keep-style notes / checklists API."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.database import SessionLocal, Note
|
||||||
|
from core.middleware import INTERNAL_TOOL_USER
|
||||||
|
from src.auth_helpers import require_user
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class NoteCreate(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
content: Optional[str] = None
|
||||||
|
items: Optional[list] = None
|
||||||
|
note_type: str = "note"
|
||||||
|
color: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
pinned: bool = False
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
source: str = "user"
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
repeat: Optional[str] = "none"
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
items: Optional[list] = None
|
||||||
|
note_type: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
pinned: Optional[bool] = None
|
||||||
|
archived: Optional[bool] = None
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
repeat: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
agent_session_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _note_to_dict(note: Note) -> Dict[str, Any]:
|
||||||
|
items = None
|
||||||
|
if note.items:
|
||||||
|
try:
|
||||||
|
items = json.loads(note.items)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
items = None
|
||||||
|
ai_cls = None
|
||||||
|
raw_ai = getattr(note, "ai_classification", None)
|
||||||
|
if raw_ai:
|
||||||
|
try:
|
||||||
|
ai_cls = json.loads(raw_ai)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
ai_cls = None
|
||||||
|
return {
|
||||||
|
"id": note.id,
|
||||||
|
"owner": note.owner,
|
||||||
|
"title": note.title,
|
||||||
|
"content": note.content,
|
||||||
|
"items": items,
|
||||||
|
"note_type": note.note_type,
|
||||||
|
"color": note.color,
|
||||||
|
"label": note.label,
|
||||||
|
"pinned": note.pinned,
|
||||||
|
"archived": note.archived,
|
||||||
|
"due_date": note.due_date,
|
||||||
|
"source": note.source,
|
||||||
|
"session_id": note.session_id,
|
||||||
|
"sort_order": note.sort_order or 0,
|
||||||
|
"image_url": note.image_url,
|
||||||
|
"repeat": note.repeat or "none",
|
||||||
|
"ai_classification": ai_cls,
|
||||||
|
"ai_content_hash": getattr(note, "ai_content_hash", None),
|
||||||
|
"agent_session_id": getattr(note, "agent_session_id", None),
|
||||||
|
"created_at": note.created_at.isoformat() if note.created_at else None,
|
||||||
|
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
|
||||||
|
"""Return the reminder title/body from a stored note row."""
|
||||||
|
title = (note.title or "Note reminder").strip() or "Note reminder"
|
||||||
|
if note.items:
|
||||||
|
try:
|
||||||
|
items = json.loads(note.items)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
items = None
|
||||||
|
if isinstance(items, list):
|
||||||
|
pending: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("done") or item.get("checked"):
|
||||||
|
continue
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
pending.append(text)
|
||||||
|
if pending:
|
||||||
|
shown = "\n".join(f"- {text}" for text in pending[:8])
|
||||||
|
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
|
||||||
|
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
|
||||||
|
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
|
||||||
|
return title, (note.content or "").strip()[:400]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reminder dispatch — module-level so background tasks (built-in actions)
|
||||||
|
# can call it directly without an HTTP roundtrip + auth cookie. The route
|
||||||
|
# version below is a thin wrapper that pulls `owner` from the request.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Scheduler reference — set by setup_note_routes() so dispatch_reminder can
|
||||||
|
# push a parallel in-app notification (frontend polls the scheduler's queue
|
||||||
|
# and fires real browser Notification(...) popups). Optional; works without it.
|
||||||
|
_scheduler_ref = None
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_reminder(
|
||||||
|
title: str,
|
||||||
|
note_body: str,
|
||||||
|
note_id: str,
|
||||||
|
owner: str = "",
|
||||||
|
queue_browser: bool = True,
|
||||||
|
settings_override: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Fire a reminder via the configured channel (browser/email/ntfy/webhook).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: short headline shown to the user
|
||||||
|
note_body: longer body text
|
||||||
|
note_id: stable id (used as tag/dedupe in browser notifications)
|
||||||
|
owner: the user this reminder belongs to — scopes SMTP config to
|
||||||
|
their account so we don't cross-leak credentials
|
||||||
|
|
||||||
|
Returns: {synthesis, email_sent, ntfy_sent}. Browser channel is wired via
|
||||||
|
the in-memory notification queue picked up by the frontend poller, so
|
||||||
|
nothing is "sent" synchronously for it — the channel just routes there.
|
||||||
|
"""
|
||||||
|
from src.settings import load_settings
|
||||||
|
settings = {**load_settings(), **(settings_override or {})}
|
||||||
|
channel = settings.get("reminder_channel", "browser")
|
||||||
|
llm_on = bool(settings.get("reminder_llm_synthesis", False))
|
||||||
|
title = (title or "").strip()
|
||||||
|
note_body = (note_body or "").strip()
|
||||||
|
cache_key = str(note_id) if note_id else ""
|
||||||
|
cache = {}
|
||||||
|
cache_path = None
|
||||||
|
if cache_key:
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
|
from pathlib import Path as _P
|
||||||
|
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
|
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
||||||
|
if cache_path.exists():
|
||||||
|
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
last = cache.get(cache_key)
|
||||||
|
if last:
|
||||||
|
last_channel = None
|
||||||
|
if isinstance(last, dict):
|
||||||
|
last_channel = last.get("channel")
|
||||||
|
last = last.get("at")
|
||||||
|
last_dt = _dt.fromisoformat(str(last))
|
||||||
|
if last_dt.tzinfo is None:
|
||||||
|
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||||||
|
# Legacy cache values were plain timestamps and could be
|
||||||
|
# written by the frontend even when the email/ntfy send failed.
|
||||||
|
# Treat those as browser-only dedupe so email reminders can be
|
||||||
|
# retried by the backend scanner after a failed frontend path.
|
||||||
|
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
|
||||||
|
if should_skip and channel in ("email", "ntfy", "webhook"):
|
||||||
|
should_skip = last_channel == channel
|
||||||
|
if should_skip:
|
||||||
|
return {
|
||||||
|
"synthesis": None,
|
||||||
|
"email_sent": False,
|
||||||
|
"ntfy_sent": False,
|
||||||
|
"webhook_sent": False,
|
||||||
|
"browser_sent": True,
|
||||||
|
"skipped": True,
|
||||||
|
}
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(f"dispatch_reminder: cache read failed: {_e}")
|
||||||
|
|
||||||
|
synthesis = None
|
||||||
|
_SYNTH_FAILED_TAG = "[utility model unavailable — no summary generated]"
|
||||||
|
if llm_on:
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from src.reminder_personas import synthesis_system_prompt
|
||||||
|
url, model, headers = resolve_endpoint("utility", owner=owner or None)
|
||||||
|
if not url:
|
||||||
|
url, model, headers = resolve_endpoint("default", owner=owner or None)
|
||||||
|
if url and model:
|
||||||
|
persona_id = (settings.get("reminder_llm_persona") or "").strip()
|
||||||
|
sys_prompt = synthesis_system_prompt(persona_id)
|
||||||
|
raw = await llm_call_async(
|
||||||
|
url=url, model=model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": sys_prompt},
|
||||||
|
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
|
||||||
|
],
|
||||||
|
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
|
||||||
|
)
|
||||||
|
from src.text_helpers import strip_think as _strip_think
|
||||||
|
# prose=True strips untagged "The user wants me to…" chain-of-thought.
|
||||||
|
# prompt_echo=True strips Qwen-style "Thinking Process:" / leaked
|
||||||
|
# prompt prefixes. Both are safe here because this is a
|
||||||
|
# one-sentence LLM-only output, not user-pasted content.
|
||||||
|
synthesis = _strip_think(raw or "", prose=True, prompt_echo=True)
|
||||||
|
# Reminder synthesis is supposed to be ONE sentence. Strip-think's
|
||||||
|
# paragraph-based heuristic misses cases where the model puts
|
||||||
|
# reasoning + answer on consecutive lines inside one paragraph
|
||||||
|
# (e.g. "I should write... [\n] You have one task waiting...").
|
||||||
|
# Walk lines, drop reasoning/prompt-echo lines, then keep the
|
||||||
|
# last surviving line — that's the actual warm sentence.
|
||||||
|
if synthesis:
|
||||||
|
import re as _re
|
||||||
|
# Tightened: target ACTUAL self-talk (model narrating what
|
||||||
|
# it'll do) rather than any first-person sentence. The old
|
||||||
|
# pattern killed legit warm sentences like "I'll see you
|
||||||
|
# tomorrow" or "I should be done by then". New rules:
|
||||||
|
# • "I (need|should|have|'ll|will) (write|draft|reply|…)"
|
||||||
|
# only matches when followed by a TASK verb taking an
|
||||||
|
# OBJECT (so first-person + intransitive verb passes).
|
||||||
|
# • Self-instructional patterns the model emits verbatim:
|
||||||
|
# "I should write something that reminds them…",
|
||||||
|
# "I need to draft…", "Let me think…".
|
||||||
|
# • Explicit instructions echoed back from the prompt:
|
||||||
|
# "Keep it under 25 words", "No greetings".
|
||||||
|
_reasoning = _re.compile(
|
||||||
|
r"^\s*(?:"
|
||||||
|
# "I should write/draft/compose…" with a task-object follow
|
||||||
|
r"i (?:need|should|have|'ll|will|am going|am)\s+to\s+"
|
||||||
|
r"(?:write|draft|compose|craft|generate|produce|create|"
|
||||||
|
r"summarize|answer|provide|note|address|remind|output)"
|
||||||
|
r"\s+(?:a |an |the |something|this|that|here|them|him|her|"
|
||||||
|
r"you|user|reply|response|sentence|message|line|warm)|"
|
||||||
|
# The model literally narrating about the user
|
||||||
|
r"the user (?:wants|is asking|asks|needs|wrote|said|requested) (?:me )?(?:to|for|that|about|something)|"
|
||||||
|
# "Let me [think/write/draft/…] (about/for/the …)"
|
||||||
|
r"let me (?:think|write|draft|consider|note|see|check)\b\s+(?:about|for|the|this|that|if|whether)|"
|
||||||
|
# "Looking at the/this/that …"
|
||||||
|
r"looking at (?:the|this|that)\b|"
|
||||||
|
# "Based on the/this/what …"
|
||||||
|
r"based on (?:the|this|what|context|that)\b|"
|
||||||
|
# Prompt-echo of length / style instructions
|
||||||
|
r"keep it under \d+ words\b|"
|
||||||
|
r"(?:no greetings|no preamble|no hashtags|just output the)\b"
|
||||||
|
r").*",
|
||||||
|
_re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Echo of the prompt's "Pending:" / "<N> pending" tail.
|
||||||
|
_echo = _re.compile(
|
||||||
|
r"^\s*(?:pending\s*[:.]|(?:\d+|one|two|three|four|five)\s+pending\b)",
|
||||||
|
_re.IGNORECASE,
|
||||||
|
)
|
||||||
|
lines = [ln for ln in synthesis.splitlines() if ln.strip()]
|
||||||
|
cleaned = [ln for ln in lines if not _reasoning.match(ln) and not _echo.match(ln)]
|
||||||
|
if cleaned:
|
||||||
|
# The model's actual answer is normally the LAST surviving
|
||||||
|
# line — reasoning leads, answer trails.
|
||||||
|
synthesis = cleaned[-1].strip()
|
||||||
|
else:
|
||||||
|
synthesis = _SYNTH_FAILED_TAG
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Reminder LLM synthesis failed: {e}")
|
||||||
|
synthesis = _SYNTH_FAILED_TAG
|
||||||
|
if synthesis:
|
||||||
|
_s = synthesis.strip(); _low = _s.lower()
|
||||||
|
if (not _s or _low.startswith("error:") or _low.startswith("[error")
|
||||||
|
or "operation failed" in _low
|
||||||
|
or ("upstream" in _low and "failed" in _low)) and synthesis != _SYNTH_FAILED_TAG:
|
||||||
|
logger.warning(f"Reminder synthesis looked like an error, replacing: {_s[:120]!r}")
|
||||||
|
synthesis = _SYNTH_FAILED_TAG
|
||||||
|
|
||||||
|
email_sent = False
|
||||||
|
email_error = ""
|
||||||
|
if channel == "email":
|
||||||
|
try:
|
||||||
|
from routes.email_routes import _get_email_config, _smtp_ready
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
# `reminder_email_account_id` lets the user pick WHICH email
|
||||||
|
# account to send reminders from (when they have several
|
||||||
|
# configured in Integrations). Falls back to the default
|
||||||
|
# account when no explicit choice is saved.
|
||||||
|
_acc_id = (settings.get("reminder_email_account_id") or "").strip() or None
|
||||||
|
cfg = _get_email_config(account_id=_acc_id, owner=owner or "")
|
||||||
|
if not _smtp_ready(cfg):
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal as _SL, EmailAccount as _EA
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
db = _SL()
|
||||||
|
try:
|
||||||
|
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||||
|
if owner:
|
||||||
|
unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||||
|
same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner)
|
||||||
|
q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox)))
|
||||||
|
for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all():
|
||||||
|
trial = _get_email_config(account_id=row.id, owner=owner or "")
|
||||||
|
if _smtp_ready(trial):
|
||||||
|
cfg = trial
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as _fallback_error:
|
||||||
|
logger.debug(f"Reminder SMTP fallback lookup failed: {_fallback_error}")
|
||||||
|
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
|
||||||
|
recipient = (settings.get("reminder_email_to") or "").strip() or from_addr
|
||||||
|
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||||
|
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||||
|
logger.info(
|
||||||
|
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||||
|
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||||
|
note_id, owner,
|
||||||
|
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||||
|
bool(from_addr), bool(recipient),
|
||||||
|
)
|
||||||
|
missing = []
|
||||||
|
if not cfg.get("smtp_host"):
|
||||||
|
missing.append("SMTP host")
|
||||||
|
if not cfg.get("smtp_user"):
|
||||||
|
missing.append("SMTP user")
|
||||||
|
if not (cfg.get("smtp_password") or cfg.get("oauth_provider")):
|
||||||
|
missing.append("SMTP credentials")
|
||||||
|
if not from_addr:
|
||||||
|
missing.append("from address")
|
||||||
|
if not recipient:
|
||||||
|
missing.append("recipient")
|
||||||
|
if missing:
|
||||||
|
email_error = "Missing " + ", ".join(missing)
|
||||||
|
logger.warning(
|
||||||
|
"Reminder email not sent for note_id=%s account=%r: %s",
|
||||||
|
note_id, cfg.get("account_name"), email_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = from_addr
|
||||||
|
msg["To"] = recipient
|
||||||
|
_t = title or 'Note'
|
||||||
|
_t = _t[len('Reminder:'):].strip() if _t.lower().startswith('reminder:') else _t
|
||||||
|
msg["Subject"] = f"Reminder (Odysseus): {_t}"
|
||||||
|
msg["Date"] = _dt.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||||
|
msg["X-Odysseus-Origin"] = "odysseus-ui"
|
||||||
|
msg["X-Odysseus-Kind"] = "reminder"
|
||||||
|
msg["X-Odysseus-Ref"] = str(note_id)
|
||||||
|
# Body shape: synthesis (warm sentence) → blank line → bold
|
||||||
|
# title header → note details. The title was previously only
|
||||||
|
# in the subject line, so the email read like a faceless
|
||||||
|
# to-do list with no anchor to which note triggered it.
|
||||||
|
_body_chunks = []
|
||||||
|
if synthesis:
|
||||||
|
_body_chunks.append(synthesis)
|
||||||
|
if _t:
|
||||||
|
_body_chunks.append(_t)
|
||||||
|
if note_body:
|
||||||
|
_body_chunks.append(note_body)
|
||||||
|
plain = "\n\n".join(_body_chunks) if _body_chunks else title
|
||||||
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||||
|
|
||||||
|
def _smtp_send():
|
||||||
|
from routes.email_helpers import _send_smtp_message
|
||||||
|
_send_smtp_message(cfg, from_addr, [recipient], msg.as_string())
|
||||||
|
|
||||||
|
import asyncio as _aio
|
||||||
|
await _aio.to_thread(_smtp_send)
|
||||||
|
email_sent = True
|
||||||
|
except Exception as e:
|
||||||
|
email_error = str(e) or e.__class__.__name__
|
||||||
|
logger.warning(f"Reminder email send failed: {e}")
|
||||||
|
|
||||||
|
webhook_sent = False
|
||||||
|
webhook_error = ""
|
||||||
|
if channel == "webhook":
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
import json as _wjson
|
||||||
|
from src.integrations import load_integrations
|
||||||
|
# Built-in payload defaults for known presets so users don't have
|
||||||
|
# to configure a template just to use a standard service.
|
||||||
|
_PRESET_TEMPLATE_DEFAULTS = {
|
||||||
|
"discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
|
||||||
|
}
|
||||||
|
intg_id = settings.get("reminder_webhook_integration_id", "").strip()
|
||||||
|
template = settings.get("reminder_webhook_payload_template", "").strip()
|
||||||
|
if not intg_id:
|
||||||
|
webhook_error = "No webhook integration selected"
|
||||||
|
else:
|
||||||
|
intg = next(
|
||||||
|
(i for i in load_integrations()
|
||||||
|
if i.get("id") == intg_id and i.get("base_url")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not intg:
|
||||||
|
webhook_error = f"Integration {intg_id!r} not found or missing base URL"
|
||||||
|
else:
|
||||||
|
# Fall back to a built-in default for known presets so
|
||||||
|
# users don't have to configure a template for standard
|
||||||
|
# services like Discord.
|
||||||
|
if not template:
|
||||||
|
template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "")
|
||||||
|
if not template:
|
||||||
|
webhook_error = "No payload template configured"
|
||||||
|
else:
|
||||||
|
# Render template: JSON-escape the values so the result
|
||||||
|
# is always valid JSON regardless of special characters.
|
||||||
|
# dumps() returns `"value"` — strip outer quotes.
|
||||||
|
msg = (synthesis or note_body or title or "Reminder")[:4000]
|
||||||
|
_t = _wjson.dumps(title or "Reminder")[1:-1]
|
||||||
|
_m = _wjson.dumps(msg)[1:-1]
|
||||||
|
rendered = template.replace("{{title}}", _t).replace("{{message}}", _m)
|
||||||
|
hdrs = {"Content-Type": "application/json"}
|
||||||
|
api_key = intg.get("api_key", "")
|
||||||
|
auth_type = (intg.get("auth_type") or "none").lower()
|
||||||
|
if api_key:
|
||||||
|
if auth_type == "bearer":
|
||||||
|
hdrs["Authorization"] = f"Bearer {api_key}"
|
||||||
|
elif auth_type == "header":
|
||||||
|
hdrs[intg.get("auth_header") or "Authorization"] = api_key
|
||||||
|
url = intg["base_url"].rstrip("/")
|
||||||
|
# SSRF guard — matches the pattern used by webhook_routes,
|
||||||
|
# CalDAV, search, and embeddings. Blocks link-local / metadata
|
||||||
|
# addresses (169.254.x.x) by default; set
|
||||||
|
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block
|
||||||
|
# RFC-1918 ranges for locked-down deployments.
|
||||||
|
import os as _os
|
||||||
|
from src.url_safety import check_outbound_url as _chk
|
||||||
|
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||||
|
_ok, _reason = _chk(url, block_private=_block)
|
||||||
|
if not _ok:
|
||||||
|
webhook_error = f"Webhook URL rejected: {_reason}"
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(url, content=rendered.encode(), headers=hdrs)
|
||||||
|
webhook_sent = resp.is_success
|
||||||
|
if not webhook_sent:
|
||||||
|
webhook_error = f"Webhook returned HTTP {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
webhook_error = str(e) or e.__class__.__name__
|
||||||
|
logger.warning(f"Reminder webhook send failed: {e}")
|
||||||
|
|
||||||
|
ntfy_sent = False
|
||||||
|
ntfy_error = ""
|
||||||
|
if channel == "ntfy":
|
||||||
|
try:
|
||||||
|
from src.integrations import load_integrations
|
||||||
|
import httpx
|
||||||
|
intg = next(
|
||||||
|
(i for i in load_integrations()
|
||||||
|
if i.get("preset") == "ntfy" and i.get("enabled", True) and i.get("base_url")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if intg:
|
||||||
|
base = intg["base_url"].rstrip("/")
|
||||||
|
topic = settings.get("reminder_ntfy_topic") or "reminders"
|
||||||
|
ntfy_body = synthesis or note_body or title
|
||||||
|
# ntfy Title is an ASCII HTTP header; sanitize Unicode and cap its length.
|
||||||
|
_clean_title = (title or "Reminder").encode("ascii", "replace").decode("ascii")[:200]
|
||||||
|
hdrs = {"Title": _clean_title, "Priority": "high", "Tags": "bell"}
|
||||||
|
api_key = intg.get("api_key", "")
|
||||||
|
if api_key:
|
||||||
|
hdrs["Authorization"] = f"Bearer {api_key}"
|
||||||
|
# SSRF guard — same check (and env knob) as the webhook branch
|
||||||
|
# above: link-local / metadata addresses are always rejected;
|
||||||
|
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
|
||||||
|
# so a ntfy base_url can't be pointed at internal services.
|
||||||
|
import os as _os
|
||||||
|
from src.url_safety import check_outbound_url as _chk
|
||||||
|
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||||
|
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
|
||||||
|
if not _ok:
|
||||||
|
ntfy_error = f"ntfy URL rejected: {_reason}"
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
||||||
|
ntfy_sent = resp.is_success
|
||||||
|
if not ntfy_sent:
|
||||||
|
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
|
||||||
|
else:
|
||||||
|
ntfy_error = "No enabled ntfy integration"
|
||||||
|
except Exception as e:
|
||||||
|
ntfy_error = str(e) or e.__class__.__name__
|
||||||
|
logger.warning(f"Reminder ntfy send failed: {e}")
|
||||||
|
|
||||||
|
# In-app browser notification ALWAYS fires (regardless of channel). The
|
||||||
|
# frontend polls `/api/tasks/notifications` and turns any entry with a
|
||||||
|
# `body` into a real `Notification(...)` — same surface as task-success
|
||||||
|
# popups. Lets the user see reminders inside the app even when the
|
||||||
|
# primary channel is email/ntfy and the tab is open.
|
||||||
|
browser_sent = False
|
||||||
|
local_browser_sent = (not queue_browser and channel == "browser")
|
||||||
|
if queue_browser and _scheduler_ref is not None:
|
||||||
|
try:
|
||||||
|
_scheduler_ref.add_notification(
|
||||||
|
task_name=title or "Reminder",
|
||||||
|
status="success",
|
||||||
|
task_id=f"reminder-{note_id}",
|
||||||
|
owner=owner or None,
|
||||||
|
body=(synthesis or note_body or title or "").strip()[:500] or "Reminder",
|
||||||
|
)
|
||||||
|
browser_sent = True
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(f"dispatch_reminder: in-app notif push failed: {_e}")
|
||||||
|
|
||||||
|
# Dedupe across paths: write to the same cache file `action_ping_notes`
|
||||||
|
# reads, so the background scanner's REPING_MIN window suppresses a
|
||||||
|
# second send for the same note within 25 min. Without this, a note
|
||||||
|
# whose due_date fires while the user has the app open got TWO emails
|
||||||
|
# (frontend-fired here + background-fired by ping_notes 0–5 min later).
|
||||||
|
if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id:
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
from pathlib import Path as _P
|
||||||
|
# Per-owner cache so the scanner's prune step on user A's run
|
||||||
|
# doesn't drop user B's just-fired entry (review C4).
|
||||||
|
_STATE = cache_path
|
||||||
|
if _STATE is None:
|
||||||
|
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
|
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
||||||
|
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
|
||||||
|
except Exception:
|
||||||
|
_cache = {}
|
||||||
|
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser"
|
||||||
|
_cache[cache_key or str(note_id)] = {
|
||||||
|
"at": _dt.now(_tz.utc).isoformat(),
|
||||||
|
"channel": sent_channel,
|
||||||
|
}
|
||||||
|
_STATE.write_text(_json.dumps(_cache), encoding="utf-8")
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"channel": channel,
|
||||||
|
"synthesis": synthesis,
|
||||||
|
"email_sent": email_sent,
|
||||||
|
"email_error": email_error,
|
||||||
|
"ntfy_sent": ntfy_sent,
|
||||||
|
"ntfy_error": ntfy_error,
|
||||||
|
"webhook_sent": webhook_sent,
|
||||||
|
"webhook_error": webhook_error,
|
||||||
|
"browser_sent": browser_sent or local_browser_sent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||||
|
# Expose the scheduler to module-level `dispatch_reminder` so reminders
|
||||||
|
# can also push to the in-app notification queue (the polling system
|
||||||
|
# turns each entry into a real browser Notification + the existing
|
||||||
|
# tasks-tab badge / dot system).
|
||||||
|
global _scheduler_ref
|
||||||
|
_scheduler_ref = task_scheduler
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||||
|
|
||||||
|
def _owner(request: Request) -> Optional[str]:
|
||||||
|
# require_user, not bare get_current_user: a request that reaches
|
||||||
|
# these owner-scoped routes with NO identity (auth-middleware
|
||||||
|
# regression, SSRF from a sibling service) must fail closed (401)
|
||||||
|
# when auth is configured — not be treated as the single-user mode
|
||||||
|
# and handed blanket access to every account's notes. The documented
|
||||||
|
# anonymous modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback,
|
||||||
|
# unconfigured first-run) still resolve to None, the single-user
|
||||||
|
# path. fire_reminder below already gated this way; the CRUD routes
|
||||||
|
# did not.
|
||||||
|
return require_user(request) or None
|
||||||
|
|
||||||
|
def _reserve_note_uploads(owner: Optional[str], *values) -> None:
|
||||||
|
missing_id = reserve_upload_references(upload_handler, owner, *values)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
|
||||||
|
|
||||||
|
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
||||||
|
if user == INTERNAL_TOOL_USER:
|
||||||
|
return True
|
||||||
|
if not user:
|
||||||
|
# require_user() already admitted this request, which only happens
|
||||||
|
# for auth-disabled, loopback-bypass, or unconfigured single-user
|
||||||
|
# modes. There is no separate non-admin account boundary there.
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from core.auth import AuthManager
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
|
||||||
|
if not getattr(auth_mgr, "is_configured", True):
|
||||||
|
return True
|
||||||
|
return bool(auth_mgr.is_admin(user))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- LIST ---
|
||||||
|
@router.get("")
|
||||||
|
def list_notes(
|
||||||
|
request: Request,
|
||||||
|
archived: Optional[bool] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(Note)
|
||||||
|
if user is not None:
|
||||||
|
q = q.filter(Note.owner == user)
|
||||||
|
if archived is not None:
|
||||||
|
q = q.filter(Note.archived == archived)
|
||||||
|
else:
|
||||||
|
q = q.filter(Note.archived == False)
|
||||||
|
if label:
|
||||||
|
q = q.filter(Note.label == label)
|
||||||
|
# Archived view: most recently archived first. Active view: pin + manual order.
|
||||||
|
if archived is True:
|
||||||
|
notes = q.order_by(Note.updated_at.desc()).all()
|
||||||
|
else:
|
||||||
|
notes = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).all()
|
||||||
|
return {"notes": [_note_to_dict(n) for n in notes]}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- CREATE ---
|
||||||
|
@router.post("")
|
||||||
|
def create_note(request: Request, body: NoteCreate):
|
||||||
|
user = _owner(request)
|
||||||
|
_reserve_note_uploads(
|
||||||
|
user,
|
||||||
|
body.image_url,
|
||||||
|
body.color,
|
||||||
|
body.content,
|
||||||
|
json.dumps(body.items) if body.items is not None else None,
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = Note(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
owner=user,
|
||||||
|
title=body.title,
|
||||||
|
content=body.content,
|
||||||
|
items=json.dumps(body.items) if body.items is not None else None,
|
||||||
|
note_type=body.note_type,
|
||||||
|
color=body.color,
|
||||||
|
label=body.label,
|
||||||
|
pinned=body.pinned,
|
||||||
|
due_date=body.due_date,
|
||||||
|
source=body.source,
|
||||||
|
session_id=body.session_id,
|
||||||
|
image_url=body.image_url,
|
||||||
|
repeat=body.repeat or "none",
|
||||||
|
sort_order=body.sort_order if body.sort_order is not None else 0,
|
||||||
|
)
|
||||||
|
db.add(note)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_to_dict(note)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- GET ONE ---
|
||||||
|
@router.get("/{note_id}")
|
||||||
|
def get_note(request: Request, note_id: str):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
return _note_to_dict(note)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- UPDATE ---
|
||||||
|
@router.put("/{note_id}")
|
||||||
|
def update_note(request: Request, note_id: str, body: NoteUpdate):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
|
||||||
|
_reserve_note_uploads(
|
||||||
|
user,
|
||||||
|
body.image_url,
|
||||||
|
body.color,
|
||||||
|
body.content,
|
||||||
|
json.dumps(body.items) if body.items is not None else None,
|
||||||
|
)
|
||||||
|
if body.title is not None:
|
||||||
|
note.title = body.title
|
||||||
|
if body.content is not None:
|
||||||
|
note.content = body.content
|
||||||
|
if body.items is not None:
|
||||||
|
note.items = json.dumps(body.items)
|
||||||
|
flag_modified(note, "items")
|
||||||
|
if body.note_type is not None:
|
||||||
|
note.note_type = body.note_type
|
||||||
|
if body.color is not None:
|
||||||
|
note.color = body.color
|
||||||
|
if body.label is not None:
|
||||||
|
note.label = body.label
|
||||||
|
if body.pinned is not None:
|
||||||
|
note.pinned = body.pinned
|
||||||
|
if body.archived is not None:
|
||||||
|
note.archived = body.archived
|
||||||
|
if body.due_date is not None:
|
||||||
|
note.due_date = body.due_date
|
||||||
|
if body.image_url is not None:
|
||||||
|
note.image_url = body.image_url
|
||||||
|
if body.repeat is not None:
|
||||||
|
note.repeat = body.repeat
|
||||||
|
if body.sort_order is not None:
|
||||||
|
note.sort_order = body.sort_order
|
||||||
|
if body.agent_session_id is not None:
|
||||||
|
note.agent_session_id = body.agent_session_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(note)
|
||||||
|
return _note_to_dict(note)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- DELETE ---
|
||||||
|
@router.delete("/{note_id}")
|
||||||
|
def delete_note(request: Request, note_id: str):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
db.delete(note)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- TOGGLE PIN ---
|
||||||
|
@router.post("/{note_id}/pin")
|
||||||
|
def toggle_pin(request: Request, note_id: str):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
note.pinned = not note.pinned
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "pinned": note.pinned}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- TOGGLE ARCHIVE ---
|
||||||
|
@router.post("/{note_id}/archive")
|
||||||
|
def toggle_archive(request: Request, note_id: str):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
note.archived = not note.archived
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "archived": note.archived}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- TOGGLE CHECKLIST ITEM ---
|
||||||
|
@router.post("/{note_id}/items/{index}/toggle")
|
||||||
|
def toggle_item(request: Request, note_id: str, index: int):
|
||||||
|
user = _owner(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
||||||
|
# let any user touch a row whose owner field was null/empty.
|
||||||
|
if user is not None and note.owner != user:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
if not note.items:
|
||||||
|
raise HTTPException(400, "Note has no checklist items")
|
||||||
|
items = json.loads(note.items)
|
||||||
|
if index < 0 or index >= len(items):
|
||||||
|
raise HTTPException(400, f"Item index {index} out of range")
|
||||||
|
items[index]["done"] = not items[index].get("done", False)
|
||||||
|
note.items = json.dumps(items)
|
||||||
|
flag_modified(note, "items")
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "items": items}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- FIRE REMINDER ---
|
||||||
|
@router.post("/fire-reminder")
|
||||||
|
async def fire_reminder(request: Request):
|
||||||
|
"""Dispatch a reminder according to user settings.
|
||||||
|
|
||||||
|
Called by the frontend when a reminder fires. Optionally generates an
|
||||||
|
LLM synthesis line and/or sends an email through configured SMTP.
|
||||||
|
Returns {synthesis, email_sent}.
|
||||||
|
"""
|
||||||
|
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
||||||
|
user = require_user(request)
|
||||||
|
body = await request.json()
|
||||||
|
note_id = str(body.get("note_id") or "").strip()
|
||||||
|
if not note_id:
|
||||||
|
raise HTTPException(400, "note_id required")
|
||||||
|
|
||||||
|
caller = _owner(request)
|
||||||
|
is_test = note_id.startswith("test-")
|
||||||
|
is_admin = _is_admin_or_single_user(request, user or caller)
|
||||||
|
_override: dict = {}
|
||||||
|
if is_test:
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
|
||||||
|
note_body = (body.get("body") or "").strip()
|
||||||
|
# Optional overrides let the admin settings test button pass the
|
||||||
|
# current UI values directly so it never races a pending save.
|
||||||
|
if body.get("channel"):
|
||||||
|
_override["reminder_channel"] = body["channel"]
|
||||||
|
if body.get("webhook_integration_id"):
|
||||||
|
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
|
||||||
|
if body.get("webhook_payload_template"):
|
||||||
|
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
|
||||||
|
# Mirror the in-UI AI Synthesis toggle + persona so the test
|
||||||
|
# actually exercises the synthesis path before/without a Save.
|
||||||
|
if "llm_synthesis" in body:
|
||||||
|
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
|
||||||
|
if "llm_persona" in body:
|
||||||
|
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
|
||||||
|
else:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
note = db.query(Note).filter(Note.id == note_id).first()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
if caller is not None and note.owner != caller:
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
title, note_body = _reminder_text_from_note(note)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return await dispatch_reminder(
|
||||||
|
title=title, note_body=note_body, note_id=note_id,
|
||||||
|
owner=caller or "",
|
||||||
|
queue_browser=False,
|
||||||
|
settings_override=_override or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- REORDER NOTES ---
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_notes(request: Request):
|
||||||
|
"""Update sort_order for a list of note IDs in the order provided."""
|
||||||
|
user = _owner(request)
|
||||||
|
body = await request.json()
|
||||||
|
ids = body.get("ids", [])
|
||||||
|
if not isinstance(ids, list):
|
||||||
|
raise HTTPException(400, "ids must be a list")
|
||||||
|
# v2 review HIGH-12: drop the legacy `(owner == user) | (owner ==
|
||||||
|
# None)` OR which let an authenticated user silently reorder
|
||||||
|
# every legacy-null-owner note belonging to other accounts. In
|
||||||
|
# an unconfigured (single-user) auth deploy the OR is still safe
|
||||||
|
# because there's no second user to attack; we keep that branch
|
||||||
|
# explicit and gated on AuthManager.is_configured.
|
||||||
|
try:
|
||||||
|
from core.auth import AuthManager
|
||||||
|
_allow_null = not AuthManager().is_configured
|
||||||
|
except Exception:
|
||||||
|
_allow_null = False
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for i, nid in enumerate(ids):
|
||||||
|
q = db.query(Note).filter(Note.id == nid)
|
||||||
|
if user is not None:
|
||||||
|
if _allow_null:
|
||||||
|
q = q.filter((Note.owner == user) | (Note.owner == None)) # noqa: E711
|
||||||
|
else:
|
||||||
|
q = q.filter(Note.owner == user)
|
||||||
|
note = q.first()
|
||||||
|
if note:
|
||||||
|
note.sort_order = i
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "count": len(ids)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return router
|
||||||
+14
-911
@@ -1,915 +1,18 @@
|
|||||||
# routes/note_routes.py
|
"""Backward-compat shim — canonical location is routes/note/note_routes.py.
|
||||||
"""Google Keep-style notes / checklists API."""
|
|
||||||
|
|
||||||
import json
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
import uuid
|
that ``import routes.note_routes``, ``from routes.note_routes import X``,
|
||||||
import logging
|
``importlib.import_module("routes.note_routes")``, and the
|
||||||
from typing import Dict, Any, Optional
|
``import ... as note_routes`` + ``monkeypatch.setattr(note_routes, "SessionLocal",
|
||||||
|
...)`` pattern used by test_note_reminder_fire_scope.py /
|
||||||
|
test_notes_fail_closed_auth.py all operate on the *same* object the
|
||||||
|
application actually uses. Keeps existing import paths working after
|
||||||
|
slice 2f (#4082/#4071). Source-introspection tests read the canonical file
|
||||||
|
by path.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
import sys as _sys
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from core.database import SessionLocal, Note
|
from routes.note import note_routes as _canonical # noqa: F401
|
||||||
from core.middleware import INTERNAL_TOOL_USER
|
|
||||||
from src.auth_helpers import require_user
|
|
||||||
from src.constants import DATA_DIR
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request models
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class NoteCreate(BaseModel):
|
|
||||||
title: str = ""
|
|
||||||
content: Optional[str] = None
|
|
||||||
items: Optional[list] = None
|
|
||||||
note_type: str = "note"
|
|
||||||
color: Optional[str] = None
|
|
||||||
label: Optional[str] = None
|
|
||||||
pinned: bool = False
|
|
||||||
due_date: Optional[str] = None
|
|
||||||
source: str = "user"
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
repeat: Optional[str] = "none"
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class NoteUpdate(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
content: Optional[str] = None
|
|
||||||
items: Optional[list] = None
|
|
||||||
note_type: Optional[str] = None
|
|
||||||
color: Optional[str] = None
|
|
||||||
label: Optional[str] = None
|
|
||||||
pinned: Optional[bool] = None
|
|
||||||
archived: Optional[bool] = None
|
|
||||||
due_date: Optional[str] = None
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
repeat: Optional[str] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
agent_session_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _note_to_dict(note: Note) -> Dict[str, Any]:
|
|
||||||
items = None
|
|
||||||
if note.items:
|
|
||||||
try:
|
|
||||||
items = json.loads(note.items)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
items = None
|
|
||||||
ai_cls = None
|
|
||||||
raw_ai = getattr(note, "ai_classification", None)
|
|
||||||
if raw_ai:
|
|
||||||
try:
|
|
||||||
ai_cls = json.loads(raw_ai)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
ai_cls = None
|
|
||||||
return {
|
|
||||||
"id": note.id,
|
|
||||||
"owner": note.owner,
|
|
||||||
"title": note.title,
|
|
||||||
"content": note.content,
|
|
||||||
"items": items,
|
|
||||||
"note_type": note.note_type,
|
|
||||||
"color": note.color,
|
|
||||||
"label": note.label,
|
|
||||||
"pinned": note.pinned,
|
|
||||||
"archived": note.archived,
|
|
||||||
"due_date": note.due_date,
|
|
||||||
"source": note.source,
|
|
||||||
"session_id": note.session_id,
|
|
||||||
"sort_order": note.sort_order or 0,
|
|
||||||
"image_url": note.image_url,
|
|
||||||
"repeat": note.repeat or "none",
|
|
||||||
"ai_classification": ai_cls,
|
|
||||||
"ai_content_hash": getattr(note, "ai_content_hash", None),
|
|
||||||
"agent_session_id": getattr(note, "agent_session_id", None),
|
|
||||||
"created_at": note.created_at.isoformat() if note.created_at else None,
|
|
||||||
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
|
|
||||||
"""Return the reminder title/body from a stored note row."""
|
|
||||||
title = (note.title or "Note reminder").strip() or "Note reminder"
|
|
||||||
if note.items:
|
|
||||||
try:
|
|
||||||
items = json.loads(note.items)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
items = None
|
|
||||||
if isinstance(items, list):
|
|
||||||
pending: list[str] = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
if item.get("done") or item.get("checked"):
|
|
||||||
continue
|
|
||||||
text = str(item.get("text") or "").strip()
|
|
||||||
if text:
|
|
||||||
pending.append(text)
|
|
||||||
if pending:
|
|
||||||
shown = "\n".join(f"- {text}" for text in pending[:8])
|
|
||||||
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
|
|
||||||
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
|
|
||||||
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
|
|
||||||
return title, (note.content or "").strip()[:400]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reminder dispatch — module-level so background tasks (built-in actions)
|
|
||||||
# can call it directly without an HTTP roundtrip + auth cookie. The route
|
|
||||||
# version below is a thin wrapper that pulls `owner` from the request.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Scheduler reference — set by setup_note_routes() so dispatch_reminder can
|
|
||||||
# push a parallel in-app notification (frontend polls the scheduler's queue
|
|
||||||
# and fires real browser Notification(...) popups). Optional; works without it.
|
|
||||||
_scheduler_ref = None
|
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_reminder(
|
|
||||||
title: str,
|
|
||||||
note_body: str,
|
|
||||||
note_id: str,
|
|
||||||
owner: str = "",
|
|
||||||
queue_browser: bool = True,
|
|
||||||
settings_override: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Fire a reminder via the configured channel (browser/email/ntfy/webhook).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: short headline shown to the user
|
|
||||||
note_body: longer body text
|
|
||||||
note_id: stable id (used as tag/dedupe in browser notifications)
|
|
||||||
owner: the user this reminder belongs to — scopes SMTP config to
|
|
||||||
their account so we don't cross-leak credentials
|
|
||||||
|
|
||||||
Returns: {synthesis, email_sent, ntfy_sent}. Browser channel is wired via
|
|
||||||
the in-memory notification queue picked up by the frontend poller, so
|
|
||||||
nothing is "sent" synchronously for it — the channel just routes there.
|
|
||||||
"""
|
|
||||||
from src.settings import load_settings
|
|
||||||
settings = {**load_settings(), **(settings_override or {})}
|
|
||||||
channel = settings.get("reminder_channel", "browser")
|
|
||||||
llm_on = bool(settings.get("reminder_llm_synthesis", False))
|
|
||||||
title = (title or "").strip()
|
|
||||||
note_body = (note_body or "").strip()
|
|
||||||
cache_key = str(note_id) if note_id else ""
|
|
||||||
cache = {}
|
|
||||||
cache_path = None
|
|
||||||
if cache_key:
|
|
||||||
try:
|
|
||||||
import json as _json
|
|
||||||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
|
||||||
from pathlib import Path as _P
|
|
||||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
|
||||||
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
|
||||||
if cache_path.exists():
|
|
||||||
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
|
|
||||||
last = cache.get(cache_key)
|
|
||||||
if last:
|
|
||||||
last_channel = None
|
|
||||||
if isinstance(last, dict):
|
|
||||||
last_channel = last.get("channel")
|
|
||||||
last = last.get("at")
|
|
||||||
last_dt = _dt.fromisoformat(str(last))
|
|
||||||
if last_dt.tzinfo is None:
|
|
||||||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
|
||||||
# Legacy cache values were plain timestamps and could be
|
|
||||||
# written by the frontend even when the email/ntfy send failed.
|
|
||||||
# Treat those as browser-only dedupe so email reminders can be
|
|
||||||
# retried by the backend scanner after a failed frontend path.
|
|
||||||
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
|
|
||||||
if should_skip and channel in ("email", "ntfy", "webhook"):
|
|
||||||
should_skip = last_channel == channel
|
|
||||||
if should_skip:
|
|
||||||
return {
|
|
||||||
"synthesis": None,
|
|
||||||
"email_sent": False,
|
|
||||||
"ntfy_sent": False,
|
|
||||||
"webhook_sent": False,
|
|
||||||
"browser_sent": True,
|
|
||||||
"skipped": True,
|
|
||||||
}
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug(f"dispatch_reminder: cache read failed: {_e}")
|
|
||||||
|
|
||||||
synthesis = None
|
|
||||||
_SYNTH_FAILED_TAG = "[utility model unavailable — no summary generated]"
|
|
||||||
if llm_on:
|
|
||||||
try:
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from src.reminder_personas import synthesis_system_prompt
|
|
||||||
url, model, headers = resolve_endpoint("utility", owner=owner or None)
|
|
||||||
if not url:
|
|
||||||
url, model, headers = resolve_endpoint("default", owner=owner or None)
|
|
||||||
if url and model:
|
|
||||||
persona_id = (settings.get("reminder_llm_persona") or "").strip()
|
|
||||||
sys_prompt = synthesis_system_prompt(persona_id)
|
|
||||||
raw = await llm_call_async(
|
|
||||||
url=url, model=model,
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": sys_prompt},
|
|
||||||
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
|
|
||||||
],
|
|
||||||
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
|
|
||||||
)
|
|
||||||
from src.text_helpers import strip_think as _strip_think
|
|
||||||
# prose=True strips untagged "The user wants me to…" chain-of-thought.
|
|
||||||
# prompt_echo=True strips Qwen-style "Thinking Process:" / leaked
|
|
||||||
# prompt prefixes. Both are safe here because this is a
|
|
||||||
# one-sentence LLM-only output, not user-pasted content.
|
|
||||||
synthesis = _strip_think(raw or "", prose=True, prompt_echo=True)
|
|
||||||
# Reminder synthesis is supposed to be ONE sentence. Strip-think's
|
|
||||||
# paragraph-based heuristic misses cases where the model puts
|
|
||||||
# reasoning + answer on consecutive lines inside one paragraph
|
|
||||||
# (e.g. "I should write... [\n] You have one task waiting...").
|
|
||||||
# Walk lines, drop reasoning/prompt-echo lines, then keep the
|
|
||||||
# last surviving line — that's the actual warm sentence.
|
|
||||||
if synthesis:
|
|
||||||
import re as _re
|
|
||||||
# Tightened: target ACTUAL self-talk (model narrating what
|
|
||||||
# it'll do) rather than any first-person sentence. The old
|
|
||||||
# pattern killed legit warm sentences like "I'll see you
|
|
||||||
# tomorrow" or "I should be done by then". New rules:
|
|
||||||
# • "I (need|should|have|'ll|will) (write|draft|reply|…)"
|
|
||||||
# only matches when followed by a TASK verb taking an
|
|
||||||
# OBJECT (so first-person + intransitive verb passes).
|
|
||||||
# • Self-instructional patterns the model emits verbatim:
|
|
||||||
# "I should write something that reminds them…",
|
|
||||||
# "I need to draft…", "Let me think…".
|
|
||||||
# • Explicit instructions echoed back from the prompt:
|
|
||||||
# "Keep it under 25 words", "No greetings".
|
|
||||||
_reasoning = _re.compile(
|
|
||||||
r"^\s*(?:"
|
|
||||||
# "I should write/draft/compose…" with a task-object follow
|
|
||||||
r"i (?:need|should|have|'ll|will|am going|am)\s+to\s+"
|
|
||||||
r"(?:write|draft|compose|craft|generate|produce|create|"
|
|
||||||
r"summarize|answer|provide|note|address|remind|output)"
|
|
||||||
r"\s+(?:a |an |the |something|this|that|here|them|him|her|"
|
|
||||||
r"you|user|reply|response|sentence|message|line|warm)|"
|
|
||||||
# The model literally narrating about the user
|
|
||||||
r"the user (?:wants|is asking|asks|needs|wrote|said|requested) (?:me )?(?:to|for|that|about|something)|"
|
|
||||||
# "Let me [think/write/draft/…] (about/for/the …)"
|
|
||||||
r"let me (?:think|write|draft|consider|note|see|check)\b\s+(?:about|for|the|this|that|if|whether)|"
|
|
||||||
# "Looking at the/this/that …"
|
|
||||||
r"looking at (?:the|this|that)\b|"
|
|
||||||
# "Based on the/this/what …"
|
|
||||||
r"based on (?:the|this|what|context|that)\b|"
|
|
||||||
# Prompt-echo of length / style instructions
|
|
||||||
r"keep it under \d+ words\b|"
|
|
||||||
r"(?:no greetings|no preamble|no hashtags|just output the)\b"
|
|
||||||
r").*",
|
|
||||||
_re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# Echo of the prompt's "Pending:" / "<N> pending" tail.
|
|
||||||
_echo = _re.compile(
|
|
||||||
r"^\s*(?:pending\s*[:.]|(?:\d+|one|two|three|four|five)\s+pending\b)",
|
|
||||||
_re.IGNORECASE,
|
|
||||||
)
|
|
||||||
lines = [ln for ln in synthesis.splitlines() if ln.strip()]
|
|
||||||
cleaned = [ln for ln in lines if not _reasoning.match(ln) and not _echo.match(ln)]
|
|
||||||
if cleaned:
|
|
||||||
# The model's actual answer is normally the LAST surviving
|
|
||||||
# line — reasoning leads, answer trails.
|
|
||||||
synthesis = cleaned[-1].strip()
|
|
||||||
else:
|
|
||||||
synthesis = _SYNTH_FAILED_TAG
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Reminder LLM synthesis failed: {e}")
|
|
||||||
synthesis = _SYNTH_FAILED_TAG
|
|
||||||
if synthesis:
|
|
||||||
_s = synthesis.strip(); _low = _s.lower()
|
|
||||||
if (not _s or _low.startswith("error:") or _low.startswith("[error")
|
|
||||||
or "operation failed" in _low
|
|
||||||
or ("upstream" in _low and "failed" in _low)) and synthesis != _SYNTH_FAILED_TAG:
|
|
||||||
logger.warning(f"Reminder synthesis looked like an error, replacing: {_s[:120]!r}")
|
|
||||||
synthesis = _SYNTH_FAILED_TAG
|
|
||||||
|
|
||||||
email_sent = False
|
|
||||||
email_error = ""
|
|
||||||
if channel == "email":
|
|
||||||
try:
|
|
||||||
from routes.email_routes import _get_email_config
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from datetime import datetime as _dt
|
|
||||||
# `reminder_email_account_id` lets the user pick WHICH email
|
|
||||||
# account to send reminders from (when they have several
|
|
||||||
# configured in Integrations). Falls back to the default
|
|
||||||
# account when no explicit choice is saved.
|
|
||||||
_acc_id = (settings.get("reminder_email_account_id") or "").strip() or None
|
|
||||||
cfg = _get_email_config(account_id=_acc_id, owner=owner or "")
|
|
||||||
if not (cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")):
|
|
||||||
try:
|
|
||||||
from core.database import SessionLocal as _SL, EmailAccount as _EA
|
|
||||||
from sqlalchemy import and_, or_
|
|
||||||
db = _SL()
|
|
||||||
try:
|
|
||||||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
|
||||||
if owner:
|
|
||||||
unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711
|
|
||||||
same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner)
|
|
||||||
q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox)))
|
|
||||||
for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all():
|
|
||||||
trial = _get_email_config(account_id=row.id, owner=owner or "")
|
|
||||||
if trial.get("smtp_host") and trial.get("smtp_user") and trial.get("smtp_password"):
|
|
||||||
cfg = trial
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
except Exception as _fallback_error:
|
|
||||||
logger.debug(f"Reminder SMTP fallback lookup failed: {_fallback_error}")
|
|
||||||
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
|
|
||||||
recipient = (settings.get("reminder_email_to") or "").strip() or from_addr
|
|
||||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
|
||||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
|
||||||
logger.info(
|
|
||||||
"dispatch_reminder[email] note_id=%s owner=%r "
|
|
||||||
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
|
||||||
note_id, owner,
|
|
||||||
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
|
||||||
bool(from_addr), bool(recipient),
|
|
||||||
)
|
|
||||||
missing = []
|
|
||||||
if not cfg.get("smtp_host"):
|
|
||||||
missing.append("SMTP host")
|
|
||||||
if not cfg.get("smtp_user"):
|
|
||||||
missing.append("SMTP user")
|
|
||||||
if not cfg.get("smtp_password"):
|
|
||||||
missing.append("SMTP password")
|
|
||||||
if not from_addr:
|
|
||||||
missing.append("from address")
|
|
||||||
if not recipient:
|
|
||||||
missing.append("recipient")
|
|
||||||
if missing:
|
|
||||||
email_error = "Missing " + ", ".join(missing)
|
|
||||||
logger.warning(
|
|
||||||
"Reminder email not sent for note_id=%s account=%r: %s",
|
|
||||||
note_id, cfg.get("account_name"), email_error,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = MIMEMultipart("alternative")
|
|
||||||
msg["From"] = from_addr
|
|
||||||
msg["To"] = recipient
|
|
||||||
_t = title or 'Note'
|
|
||||||
_t = _t[len('Reminder:'):].strip() if _t.lower().startswith('reminder:') else _t
|
|
||||||
msg["Subject"] = f"Reminder (Odysseus): {_t}"
|
|
||||||
msg["Date"] = _dt.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
|
||||||
msg["X-Odysseus-Origin"] = "odysseus-ui"
|
|
||||||
msg["X-Odysseus-Kind"] = "reminder"
|
|
||||||
msg["X-Odysseus-Ref"] = str(note_id)
|
|
||||||
# Body shape: synthesis (warm sentence) → blank line → bold
|
|
||||||
# title header → note details. The title was previously only
|
|
||||||
# in the subject line, so the email read like a faceless
|
|
||||||
# to-do list with no anchor to which note triggered it.
|
|
||||||
_body_chunks = []
|
|
||||||
if synthesis:
|
|
||||||
_body_chunks.append(synthesis)
|
|
||||||
if _t:
|
|
||||||
_body_chunks.append(_t)
|
|
||||||
if note_body:
|
|
||||||
_body_chunks.append(note_body)
|
|
||||||
plain = "\n\n".join(_body_chunks) if _body_chunks else title
|
|
||||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
|
||||||
|
|
||||||
def _smtp_send():
|
|
||||||
from routes.email_helpers import _send_smtp_message
|
|
||||||
_send_smtp_message(cfg, from_addr, [recipient], msg.as_string())
|
|
||||||
|
|
||||||
import asyncio as _aio
|
|
||||||
await _aio.to_thread(_smtp_send)
|
|
||||||
email_sent = True
|
|
||||||
except Exception as e:
|
|
||||||
email_error = str(e) or e.__class__.__name__
|
|
||||||
logger.warning(f"Reminder email send failed: {e}")
|
|
||||||
|
|
||||||
webhook_sent = False
|
|
||||||
webhook_error = ""
|
|
||||||
if channel == "webhook":
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
import json as _wjson
|
|
||||||
from src.integrations import load_integrations
|
|
||||||
# Built-in payload defaults for known presets so users don't have
|
|
||||||
# to configure a template just to use a standard service.
|
|
||||||
_PRESET_TEMPLATE_DEFAULTS = {
|
|
||||||
"discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
|
|
||||||
}
|
|
||||||
intg_id = settings.get("reminder_webhook_integration_id", "").strip()
|
|
||||||
template = settings.get("reminder_webhook_payload_template", "").strip()
|
|
||||||
if not intg_id:
|
|
||||||
webhook_error = "No webhook integration selected"
|
|
||||||
else:
|
|
||||||
intg = next(
|
|
||||||
(i for i in load_integrations()
|
|
||||||
if i.get("id") == intg_id and i.get("base_url")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not intg:
|
|
||||||
webhook_error = f"Integration {intg_id!r} not found or missing base URL"
|
|
||||||
else:
|
|
||||||
# Fall back to a built-in default for known presets so
|
|
||||||
# users don't have to configure a template for standard
|
|
||||||
# services like Discord.
|
|
||||||
if not template:
|
|
||||||
template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "")
|
|
||||||
if not template:
|
|
||||||
webhook_error = "No payload template configured"
|
|
||||||
else:
|
|
||||||
# Render template: JSON-escape the values so the result
|
|
||||||
# is always valid JSON regardless of special characters.
|
|
||||||
# dumps() returns `"value"` — strip outer quotes.
|
|
||||||
msg = (synthesis or note_body or title or "Reminder")[:4000]
|
|
||||||
_t = _wjson.dumps(title or "Reminder")[1:-1]
|
|
||||||
_m = _wjson.dumps(msg)[1:-1]
|
|
||||||
rendered = template.replace("{{title}}", _t).replace("{{message}}", _m)
|
|
||||||
hdrs = {"Content-Type": "application/json"}
|
|
||||||
api_key = intg.get("api_key", "")
|
|
||||||
auth_type = (intg.get("auth_type") or "none").lower()
|
|
||||||
if api_key:
|
|
||||||
if auth_type == "bearer":
|
|
||||||
hdrs["Authorization"] = f"Bearer {api_key}"
|
|
||||||
elif auth_type == "header":
|
|
||||||
hdrs[intg.get("auth_header") or "Authorization"] = api_key
|
|
||||||
url = intg["base_url"].rstrip("/")
|
|
||||||
# SSRF guard — matches the pattern used by webhook_routes,
|
|
||||||
# CalDAV, search, and embeddings. Blocks link-local / metadata
|
|
||||||
# addresses (169.254.x.x) by default; set
|
|
||||||
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block
|
|
||||||
# RFC-1918 ranges for locked-down deployments.
|
|
||||||
import os as _os
|
|
||||||
from src.url_safety import check_outbound_url as _chk
|
|
||||||
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
|
||||||
_ok, _reason = _chk(url, block_private=_block)
|
|
||||||
if not _ok:
|
|
||||||
webhook_error = f"Webhook URL rejected: {_reason}"
|
|
||||||
else:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.post(url, content=rendered.encode(), headers=hdrs)
|
|
||||||
webhook_sent = resp.is_success
|
|
||||||
if not webhook_sent:
|
|
||||||
webhook_error = f"Webhook returned HTTP {resp.status_code}"
|
|
||||||
except Exception as e:
|
|
||||||
webhook_error = str(e) or e.__class__.__name__
|
|
||||||
logger.warning(f"Reminder webhook send failed: {e}")
|
|
||||||
|
|
||||||
ntfy_sent = False
|
|
||||||
ntfy_error = ""
|
|
||||||
if channel == "ntfy":
|
|
||||||
try:
|
|
||||||
from src.integrations import load_integrations
|
|
||||||
import httpx
|
|
||||||
intg = next(
|
|
||||||
(i for i in load_integrations()
|
|
||||||
if i.get("preset") == "ntfy" and i.get("enabled", True) and i.get("base_url")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if intg:
|
|
||||||
base = intg["base_url"].rstrip("/")
|
|
||||||
topic = settings.get("reminder_ntfy_topic") or "reminders"
|
|
||||||
ntfy_body = synthesis or note_body or title
|
|
||||||
hdrs = {"Title": title or "Reminder", "Priority": "high", "Tags": "bell"}
|
|
||||||
api_key = intg.get("api_key", "")
|
|
||||||
if api_key:
|
|
||||||
hdrs["Authorization"] = f"Bearer {api_key}"
|
|
||||||
# SSRF guard — same check (and env knob) as the webhook branch
|
|
||||||
# above: link-local / metadata addresses are always rejected;
|
|
||||||
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
|
|
||||||
# so a ntfy base_url can't be pointed at internal services.
|
|
||||||
import os as _os
|
|
||||||
from src.url_safety import check_outbound_url as _chk
|
|
||||||
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
|
||||||
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
|
|
||||||
if not _ok:
|
|
||||||
ntfy_error = f"ntfy URL rejected: {_reason}"
|
|
||||||
else:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
|
|
||||||
ntfy_sent = resp.is_success
|
|
||||||
if not ntfy_sent:
|
|
||||||
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
|
|
||||||
else:
|
|
||||||
ntfy_error = "No enabled ntfy integration"
|
|
||||||
except Exception as e:
|
|
||||||
ntfy_error = str(e) or e.__class__.__name__
|
|
||||||
logger.warning(f"Reminder ntfy send failed: {e}")
|
|
||||||
|
|
||||||
# In-app browser notification ALWAYS fires (regardless of channel). The
|
|
||||||
# frontend polls `/api/tasks/notifications` and turns any entry with a
|
|
||||||
# `body` into a real `Notification(...)` — same surface as task-success
|
|
||||||
# popups. Lets the user see reminders inside the app even when the
|
|
||||||
# primary channel is email/ntfy and the tab is open.
|
|
||||||
browser_sent = False
|
|
||||||
local_browser_sent = (not queue_browser and channel == "browser")
|
|
||||||
if queue_browser and _scheduler_ref is not None:
|
|
||||||
try:
|
|
||||||
_scheduler_ref.add_notification(
|
|
||||||
task_name=title or "Reminder",
|
|
||||||
status="success",
|
|
||||||
task_id=f"reminder-{note_id}",
|
|
||||||
owner=owner or None,
|
|
||||||
body=(synthesis or note_body or title or "").strip()[:500] or "Reminder",
|
|
||||||
)
|
|
||||||
browser_sent = True
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug(f"dispatch_reminder: in-app notif push failed: {_e}")
|
|
||||||
|
|
||||||
# Dedupe across paths: write to the same cache file `action_ping_notes`
|
|
||||||
# reads, so the background scanner's REPING_MIN window suppresses a
|
|
||||||
# second send for the same note within 25 min. Without this, a note
|
|
||||||
# whose due_date fires while the user has the app open got TWO emails
|
|
||||||
# (frontend-fired here + background-fired by ping_notes 0–5 min later).
|
|
||||||
if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id:
|
|
||||||
try:
|
|
||||||
import json as _json
|
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
|
||||||
from pathlib import Path as _P
|
|
||||||
# Per-owner cache so the scanner's prune step on user A's run
|
|
||||||
# doesn't drop user B's just-fired entry (review C4).
|
|
||||||
_STATE = cache_path
|
|
||||||
if _STATE is None:
|
|
||||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
|
||||||
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
|
||||||
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
|
|
||||||
except Exception:
|
|
||||||
_cache = {}
|
|
||||||
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser"
|
|
||||||
_cache[cache_key or str(note_id)] = {
|
|
||||||
"at": _dt.now(_tz.utc).isoformat(),
|
|
||||||
"channel": sent_channel,
|
|
||||||
}
|
|
||||||
_STATE.write_text(_json.dumps(_cache), encoding="utf-8")
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"channel": channel,
|
|
||||||
"synthesis": synthesis,
|
|
||||||
"email_sent": email_sent,
|
|
||||||
"email_error": email_error,
|
|
||||||
"ntfy_sent": ntfy_sent,
|
|
||||||
"ntfy_error": ntfy_error,
|
|
||||||
"webhook_sent": webhook_sent,
|
|
||||||
"webhook_error": webhook_error,
|
|
||||||
"browser_sent": browser_sent or local_browser_sent,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Router factory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def setup_note_routes(task_scheduler=None):
|
|
||||||
# Expose the scheduler to module-level `dispatch_reminder` so reminders
|
|
||||||
# can also push to the in-app notification queue (the polling system
|
|
||||||
# turns each entry into a real browser Notification + the existing
|
|
||||||
# tasks-tab badge / dot system).
|
|
||||||
global _scheduler_ref
|
|
||||||
_scheduler_ref = task_scheduler
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
|
||||||
|
|
||||||
def _owner(request: Request) -> Optional[str]:
|
|
||||||
# require_user, not bare get_current_user: a request that reaches
|
|
||||||
# these owner-scoped routes with NO identity (auth-middleware
|
|
||||||
# regression, SSRF from a sibling service) must fail closed (401)
|
|
||||||
# when auth is configured — not be treated as the single-user mode
|
|
||||||
# and handed blanket access to every account's notes. The documented
|
|
||||||
# anonymous modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback,
|
|
||||||
# unconfigured first-run) still resolve to None, the single-user
|
|
||||||
# path. fire_reminder below already gated this way; the CRUD routes
|
|
||||||
# did not.
|
|
||||||
return require_user(request) or None
|
|
||||||
|
|
||||||
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
|
|
||||||
if user == INTERNAL_TOOL_USER:
|
|
||||||
return True
|
|
||||||
if not user:
|
|
||||||
# require_user() already admitted this request, which only happens
|
|
||||||
# for auth-disabled, loopback-bypass, or unconfigured single-user
|
|
||||||
# modes. There is no separate non-admin account boundary there.
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
from core.auth import AuthManager
|
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
|
|
||||||
if not getattr(auth_mgr, "is_configured", True):
|
|
||||||
return True
|
|
||||||
return bool(auth_mgr.is_admin(user))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- LIST ---
|
|
||||||
@router.get("")
|
|
||||||
def list_notes(
|
|
||||||
request: Request,
|
|
||||||
archived: Optional[bool] = None,
|
|
||||||
label: Optional[str] = None,
|
|
||||||
):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
q = db.query(Note)
|
|
||||||
if user is not None:
|
|
||||||
q = q.filter(Note.owner == user)
|
|
||||||
if archived is not None:
|
|
||||||
q = q.filter(Note.archived == archived)
|
|
||||||
else:
|
|
||||||
q = q.filter(Note.archived == False)
|
|
||||||
if label:
|
|
||||||
q = q.filter(Note.label == label)
|
|
||||||
# Archived view: most recently archived first. Active view: pin + manual order.
|
|
||||||
if archived is True:
|
|
||||||
notes = q.order_by(Note.updated_at.desc()).all()
|
|
||||||
else:
|
|
||||||
notes = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).all()
|
|
||||||
return {"notes": [_note_to_dict(n) for n in notes]}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- CREATE ---
|
|
||||||
@router.post("")
|
|
||||||
def create_note(request: Request, body: NoteCreate):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = Note(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
owner=user,
|
|
||||||
title=body.title,
|
|
||||||
content=body.content,
|
|
||||||
items=json.dumps(body.items) if body.items is not None else None,
|
|
||||||
note_type=body.note_type,
|
|
||||||
color=body.color,
|
|
||||||
label=body.label,
|
|
||||||
pinned=body.pinned,
|
|
||||||
due_date=body.due_date,
|
|
||||||
source=body.source,
|
|
||||||
session_id=body.session_id,
|
|
||||||
image_url=body.image_url,
|
|
||||||
repeat=body.repeat or "none",
|
|
||||||
sort_order=body.sort_order if body.sort_order is not None else 0,
|
|
||||||
)
|
|
||||||
db.add(note)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(note)
|
|
||||||
return _note_to_dict(note)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- GET ONE ---
|
|
||||||
@router.get("/{note_id}")
|
|
||||||
def get_note(request: Request, note_id: str):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
return _note_to_dict(note)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- UPDATE ---
|
|
||||||
@router.put("/{note_id}")
|
|
||||||
def update_note(request: Request, note_id: str, body: NoteUpdate):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
|
|
||||||
if body.title is not None:
|
|
||||||
note.title = body.title
|
|
||||||
if body.content is not None:
|
|
||||||
note.content = body.content
|
|
||||||
if body.items is not None:
|
|
||||||
note.items = json.dumps(body.items)
|
|
||||||
flag_modified(note, "items")
|
|
||||||
if body.note_type is not None:
|
|
||||||
note.note_type = body.note_type
|
|
||||||
if body.color is not None:
|
|
||||||
note.color = body.color
|
|
||||||
if body.label is not None:
|
|
||||||
note.label = body.label
|
|
||||||
if body.pinned is not None:
|
|
||||||
note.pinned = body.pinned
|
|
||||||
if body.archived is not None:
|
|
||||||
note.archived = body.archived
|
|
||||||
if body.due_date is not None:
|
|
||||||
note.due_date = body.due_date
|
|
||||||
if body.image_url is not None:
|
|
||||||
note.image_url = body.image_url
|
|
||||||
if body.repeat is not None:
|
|
||||||
note.repeat = body.repeat
|
|
||||||
if body.sort_order is not None:
|
|
||||||
note.sort_order = body.sort_order
|
|
||||||
if body.agent_session_id is not None:
|
|
||||||
note.agent_session_id = body.agent_session_id
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(note)
|
|
||||||
return _note_to_dict(note)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- DELETE ---
|
|
||||||
@router.delete("/{note_id}")
|
|
||||||
def delete_note(request: Request, note_id: str):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
db.delete(note)
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- TOGGLE PIN ---
|
|
||||||
@router.post("/{note_id}/pin")
|
|
||||||
def toggle_pin(request: Request, note_id: str):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
note.pinned = not note.pinned
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True, "pinned": note.pinned}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- TOGGLE ARCHIVE ---
|
|
||||||
@router.post("/{note_id}/archive")
|
|
||||||
def toggle_archive(request: Request, note_id: str):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
note.archived = not note.archived
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True, "archived": note.archived}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- TOGGLE CHECKLIST ITEM ---
|
|
||||||
@router.post("/{note_id}/items/{index}/toggle")
|
|
||||||
def toggle_item(request: Request, note_id: str, index: int):
|
|
||||||
user = _owner(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
|
|
||||||
# let any user touch a row whose owner field was null/empty.
|
|
||||||
if user is not None and note.owner != user:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
if not note.items:
|
|
||||||
raise HTTPException(400, "Note has no checklist items")
|
|
||||||
items = json.loads(note.items)
|
|
||||||
if index < 0 or index >= len(items):
|
|
||||||
raise HTTPException(400, f"Item index {index} out of range")
|
|
||||||
items[index]["done"] = not items[index].get("done", False)
|
|
||||||
note.items = json.dumps(items)
|
|
||||||
flag_modified(note, "items")
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True, "items": items}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# --- FIRE REMINDER ---
|
|
||||||
@router.post("/fire-reminder")
|
|
||||||
async def fire_reminder(request: Request):
|
|
||||||
"""Dispatch a reminder according to user settings.
|
|
||||||
|
|
||||||
Called by the frontend when a reminder fires. Optionally generates an
|
|
||||||
LLM synthesis line and/or sends an email through configured SMTP.
|
|
||||||
Returns {synthesis, email_sent}.
|
|
||||||
"""
|
|
||||||
# Gate against anonymous callers — LLM synthesis can burn tokens.
|
|
||||||
user = require_user(request)
|
|
||||||
body = await request.json()
|
|
||||||
note_id = str(body.get("note_id") or "").strip()
|
|
||||||
if not note_id:
|
|
||||||
raise HTTPException(400, "note_id required")
|
|
||||||
|
|
||||||
caller = _owner(request)
|
|
||||||
is_test = note_id.startswith("test-")
|
|
||||||
is_admin = _is_admin_or_single_user(request, user or caller)
|
|
||||||
_override: dict = {}
|
|
||||||
if is_test:
|
|
||||||
if not is_admin:
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
|
|
||||||
note_body = (body.get("body") or "").strip()
|
|
||||||
# Optional overrides let the admin settings test button pass the
|
|
||||||
# current UI values directly so it never races a pending save.
|
|
||||||
if body.get("channel"):
|
|
||||||
_override["reminder_channel"] = body["channel"]
|
|
||||||
if body.get("webhook_integration_id"):
|
|
||||||
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
|
|
||||||
if body.get("webhook_payload_template"):
|
|
||||||
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
|
|
||||||
# Mirror the in-UI AI Synthesis toggle + persona so the test
|
|
||||||
# actually exercises the synthesis path before/without a Save.
|
|
||||||
if "llm_synthesis" in body:
|
|
||||||
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
|
|
||||||
if "llm_persona" in body:
|
|
||||||
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
|
|
||||||
else:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
note = db.query(Note).filter(Note.id == note_id).first()
|
|
||||||
if not note:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
if caller is not None and note.owner != caller:
|
|
||||||
raise HTTPException(404, "Note not found")
|
|
||||||
title, note_body = _reminder_text_from_note(note)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return await dispatch_reminder(
|
|
||||||
title=title, note_body=note_body, note_id=note_id,
|
|
||||||
owner=caller or "",
|
|
||||||
queue_browser=False,
|
|
||||||
settings_override=_override or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- REORDER NOTES ---
|
|
||||||
@router.post("/reorder")
|
|
||||||
async def reorder_notes(request: Request):
|
|
||||||
"""Update sort_order for a list of note IDs in the order provided."""
|
|
||||||
user = _owner(request)
|
|
||||||
body = await request.json()
|
|
||||||
ids = body.get("ids", [])
|
|
||||||
if not isinstance(ids, list):
|
|
||||||
raise HTTPException(400, "ids must be a list")
|
|
||||||
# v2 review HIGH-12: drop the legacy `(owner == user) | (owner ==
|
|
||||||
# None)` OR which let an authenticated user silently reorder
|
|
||||||
# every legacy-null-owner note belonging to other accounts. In
|
|
||||||
# an unconfigured (single-user) auth deploy the OR is still safe
|
|
||||||
# because there's no second user to attack; we keep that branch
|
|
||||||
# explicit and gated on AuthManager.is_configured.
|
|
||||||
try:
|
|
||||||
from core.auth import AuthManager
|
|
||||||
_allow_null = not AuthManager().is_configured
|
|
||||||
except Exception:
|
|
||||||
_allow_null = False
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
for i, nid in enumerate(ids):
|
|
||||||
q = db.query(Note).filter(Note.id == nid)
|
|
||||||
if user is not None:
|
|
||||||
if _allow_null:
|
|
||||||
q = q.filter((Note.owner == user) | (Note.owner == None)) # noqa: E711
|
|
||||||
else:
|
|
||||||
q = q.filter(Note.owner == user)
|
|
||||||
note = q.first()
|
|
||||||
if note:
|
|
||||||
note.sort_order = i
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True, "count": len(ids)}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
+163
-92
@@ -1,11 +1,13 @@
|
|||||||
# routes/personal_routes.py
|
# routes/personal_routes.py
|
||||||
"""Routes for personal documents management."""
|
"""Routes for personal documents management."""
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from src.request_models import DirectoryRequest
|
from src.request_models import DirectoryRequest
|
||||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||||
from src.rag_singleton import get_rag_manager
|
from src.rag_singleton import get_rag_manager
|
||||||
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||||
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
"""
|
"""
|
||||||
router = APIRouter(prefix="/api/personal")
|
router = APIRouter(prefix="/api/personal")
|
||||||
|
|
||||||
|
# Serializes directory index jobs across requests. Indexing runs in the
|
||||||
|
# threadpool (#5558), so concurrent requests would otherwise run in parallel
|
||||||
|
# and race PersonalDocsManager's unsynchronized list mutations and file
|
||||||
|
# writes; before the threadpool move they serialized on the blocked event
|
||||||
|
# loop, so one-at-a-time is behavior parity.
|
||||||
|
#
|
||||||
|
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
|
||||||
|
# request parks on the event loop instead of pinning a threadpool worker (an
|
||||||
|
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
|
||||||
|
# tokens while blocked, starving every other run_in_threadpool caller).
|
||||||
|
# add/remove/reload all take this lock, so their mutations never interleave.
|
||||||
|
# Per-router (not module-global) so each app binds it to its own event loop.
|
||||||
|
# Scope is the single process: multi-worker deployments would need a shared
|
||||||
|
# lock (out of scope for #5558).
|
||||||
|
_index_job_lock = asyncio.Lock()
|
||||||
|
|
||||||
def _rag():
|
def _rag():
|
||||||
"""Get the current RAG manager, retrying init if needed."""
|
"""Get the current RAG manager, retrying init if needed."""
|
||||||
return get_rag_manager()
|
return get_rag_manager()
|
||||||
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
return {"files": files, "directories": directories}
|
return {"files": files, "directories": directories}
|
||||||
|
|
||||||
@router.post("/reload")
|
@router.post("/reload")
|
||||||
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||||
personal_docs_manager.refresh_index()
|
# refresh_index() re-extracts text across every tracked directory —
|
||||||
|
# blocking work. Take the shared job lock (so it cannot race an add /
|
||||||
|
# remove) and run it off the event loop.
|
||||||
|
async with _index_job_lock:
|
||||||
|
await run_in_threadpool(personal_docs_manager.refresh_index)
|
||||||
return {"ok": True, "count": len(personal_docs_manager.index)}
|
return {"ok": True, "count": len(personal_docs_manager.index)}
|
||||||
|
|
||||||
@router.post("/add_directory")
|
@router.post("/add_directory")
|
||||||
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
# Use the RAGManager to index the directory
|
# Use the RAGManager to index the directory
|
||||||
rag = _rag()
|
rag = _rag()
|
||||||
if rag:
|
if rag:
|
||||||
result = rag.index_personal_documents(directory, owner=owner)
|
def _index_directory():
|
||||||
|
result = rag.index_personal_documents(directory, owner=owner)
|
||||||
|
if result["success"]:
|
||||||
|
# Also update the personal_docs_manager to track this
|
||||||
|
# directory. Kept inside the offloaded call: it triggers
|
||||||
|
# refresh_index(), which re-extracts text across tracked
|
||||||
|
# directories.
|
||||||
|
personal_docs_manager.add_directory(directory, index=False)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Indexing walks, embeds, and stores the whole tree — minutes
|
||||||
|
# on a real directory. The handler is async, so calling it
|
||||||
|
# inline runs it on the event loop and every other request
|
||||||
|
# queues behind it until it finishes (#5558). Serialize on the
|
||||||
|
# async job lock BEFORE offloading so a queued request parks on
|
||||||
|
# the loop instead of pinning a threadpool worker.
|
||||||
|
async with _index_job_lock:
|
||||||
|
result = await run_in_threadpool(_index_directory)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
# Also update the personal_docs_manager to track this directory
|
|
||||||
personal_docs_manager.add_directory(directory, index=False)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
||||||
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
|
|
||||||
logger.info(f"Removing directory from RAG: {directory}")
|
logger.info(f"Removing directory from RAG: {directory}")
|
||||||
|
|
||||||
# Always remove from personal_docs_manager tracking
|
|
||||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
|
||||||
personal_docs_manager.remove_directory(directory)
|
|
||||||
|
|
||||||
# Remove from RAG vector store (best-effort)
|
|
||||||
rag = _rag()
|
rag = _rag()
|
||||||
if rag:
|
|
||||||
try:
|
def _remove_directory():
|
||||||
rag.remove_directory(directory)
|
# Always remove from personal_docs_manager tracking. This
|
||||||
except Exception as e:
|
# mutates the same unsynchronized list/index an add job touches
|
||||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
# and re-extracts text (refresh_index), so it is blocking work.
|
||||||
|
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||||
|
personal_docs_manager.remove_directory(directory)
|
||||||
|
# Remove from RAG vector store (best-effort).
|
||||||
|
if rag:
|
||||||
|
try:
|
||||||
|
rag.remove_directory(directory)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||||
|
|
||||||
|
# Same job lock as add/reload so remove cannot interleave with an
|
||||||
|
# in-flight add; offloaded off the event loop.
|
||||||
|
async with _index_job_lock:
|
||||||
|
await run_in_threadpool(_remove_directory)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
total_failed = 0
|
total_failed = 0
|
||||||
uploaded_files = []
|
uploaded_files = []
|
||||||
|
|
||||||
for upload in files:
|
# Chunking, embedding and the tracking update are blocking work over the
|
||||||
try:
|
# same vector/tracking state add_directory mutates (#5634). Take the
|
||||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
# shared job lock BEFORE offloading so a queued request parks on the loop
|
||||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
# instead of pinning a threadpool worker, matching add_directory.
|
||||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
# Read and process one capped payload at a time so a multi-file request
|
||||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
|
||||||
total_failed += 1
|
async with _index_job_lock:
|
||||||
continue
|
for upload in files:
|
||||||
with open(file_path, "wb") as f:
|
try:
|
||||||
f.write(content_bytes)
|
file_path, stored_name, safe_name = _unique_personal_upload_path(
|
||||||
|
upload_dir, upload.filename
|
||||||
ext = os.path.splitext(safe_name)[1].lower()
|
)
|
||||||
if ext == ".pdf":
|
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||||
from src.personal_docs import extract_pdf_text
|
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||||
text = extract_pdf_text(file_path)
|
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||||
else:
|
|
||||||
text = content_bytes.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if not text or not text.strip():
|
|
||||||
total_failed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Chunk and index
|
|
||||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
|
||||||
for i, chunk in enumerate(chunks):
|
|
||||||
metadata = {
|
|
||||||
"source": file_path,
|
|
||||||
"filename": safe_name,
|
|
||||||
"stored_filename": stored_name,
|
|
||||||
"directory": upload_dir,
|
|
||||||
"type": ext,
|
|
||||||
"chunk_id": i,
|
|
||||||
}
|
|
||||||
if user:
|
|
||||||
metadata["owner"] = user
|
|
||||||
if rag.add_document(chunk, metadata):
|
|
||||||
total_indexed += 1
|
|
||||||
else:
|
|
||||||
total_failed += 1
|
total_failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
uploaded_files.append(safe_name)
|
def _index_upload():
|
||||||
except Exception as e:
|
with open(file_path, "wb") as f:
|
||||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
f.write(content_bytes)
|
||||||
total_failed += 1
|
|
||||||
|
|
||||||
# Track uploads directory
|
ext = os.path.splitext(safe_name)[1].lower()
|
||||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
if ext == ".pdf":
|
||||||
personal_docs_manager.add_directory(upload_dir, index=False)
|
from src.personal_docs import extract_pdf_text
|
||||||
|
text = extract_pdf_text(file_path)
|
||||||
|
else:
|
||||||
|
text = content_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return 0, 1, None
|
||||||
|
|
||||||
|
indexed = 0
|
||||||
|
failed = 0
|
||||||
|
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
metadata = {
|
||||||
|
"source": file_path,
|
||||||
|
"filename": safe_name,
|
||||||
|
"stored_filename": stored_name,
|
||||||
|
"directory": upload_dir,
|
||||||
|
"type": ext,
|
||||||
|
"chunk_id": i,
|
||||||
|
}
|
||||||
|
if user:
|
||||||
|
metadata["owner"] = user
|
||||||
|
if rag.add_document(chunk, metadata):
|
||||||
|
indexed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
return indexed, failed, safe_name
|
||||||
|
|
||||||
|
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
|
||||||
|
total_indexed += indexed
|
||||||
|
total_failed += failed
|
||||||
|
if uploaded_name:
|
||||||
|
uploaded_files.append(uploaded_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
# Same transition, same lock: the tracking update must not land
|
||||||
|
# while another job is mid-write over the same state.
|
||||||
|
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||||
|
await run_in_threadpool(
|
||||||
|
personal_docs_manager.add_directory, upload_dir, index=False
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||||
"""Delete a specific file from RAG index and optionally from disk."""
|
"""Delete a specific file from RAG index and optionally from disk."""
|
||||||
try:
|
try:
|
||||||
# Remove chunks from RAG vector store (best-effort)
|
def _delete_file():
|
||||||
removed = 0
|
# Remove chunks from RAG vector store (best-effort)
|
||||||
rag = _rag()
|
removed = 0
|
||||||
if rag:
|
rag = _rag()
|
||||||
try:
|
if rag:
|
||||||
removed = rag.delete_by_source(filepath)
|
try:
|
||||||
except Exception as e:
|
removed = rag.delete_by_source(filepath)
|
||||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
except Exception as e:
|
||||||
|
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||||
|
|
||||||
# Delete file from disk if it's in the caller's own uploads dir.
|
# Delete file from disk if it's in the caller's own uploads dir.
|
||||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||||
# admin can't delete another user's personal files by path.
|
# admin can't delete another user's personal files by path.
|
||||||
deleted_from_disk = False
|
deleted_from_disk = False
|
||||||
try:
|
|
||||||
abs_target = os.path.realpath(filepath)
|
|
||||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
|
||||||
in_uploads = (
|
|
||||||
abs_target == base_abs
|
|
||||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# commonpath raises on mixed drives / non-comparable paths
|
|
||||||
in_uploads = False
|
|
||||||
if in_uploads and abs_target != base_abs:
|
|
||||||
try:
|
try:
|
||||||
os.remove(abs_target)
|
abs_target = os.path.realpath(filepath)
|
||||||
deleted_from_disk = True
|
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||||
except FileNotFoundError:
|
in_uploads = (
|
||||||
pass # already gone — race with another request or cleanup
|
abs_target == base_abs
|
||||||
|
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# commonpath raises on mixed drives / non-comparable paths
|
||||||
|
in_uploads = False
|
||||||
|
if in_uploads and abs_target != base_abs:
|
||||||
|
try:
|
||||||
|
os.remove(abs_target)
|
||||||
|
deleted_from_disk = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # already gone — race with another request or cleanup
|
||||||
|
|
||||||
# Exclude the file from the listing (persists across restarts)
|
# Exclude the file from the listing (persists across restarts)
|
||||||
personal_docs_manager.exclude_file(filepath)
|
personal_docs_manager.exclude_file(filepath)
|
||||||
|
return removed, deleted_from_disk
|
||||||
|
|
||||||
|
# Vector removal, the disk unlink and the exclusion write are one
|
||||||
|
# transition over the same state add_directory mutates (#5634), and
|
||||||
|
# all three block. Take the shared job lock BEFORE offloading, as
|
||||||
|
# add_directory does.
|
||||||
|
async with _index_job_lock:
|
||||||
|
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""User preferences API — per-user key/value store backed by a JSON file."""
|
"""User preferences API — per-user key/value store backed by a JSON file."""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
from core.atomic_io import atomic_write_json
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import USER_PREFS_FILE
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
@@ -20,13 +20,7 @@ def _load():
|
|||||||
|
|
||||||
|
|
||||||
def _save(prefs):
|
def _save(prefs):
|
||||||
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
|
atomic_write_json(PREFS_FILE, prefs, indent=2)
|
||||||
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(prefs, f, indent=2)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, PREFS_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Search route domain package (slice 2j, #4082/#4071).
|
||||||
|
|
||||||
|
Contains search_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/search_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Search routes — /api/search/config GET, /api/search POST."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||||
|
from services.search.core import _call_provider
|
||||||
|
from services.search.providers import _get_provider_key, _get_search_instance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_values(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Accept JSON, form data, or query params for search endpoints.
|
||||||
|
|
||||||
|
The browser UI posts FormData, while the agent's generic app_api tool
|
||||||
|
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
||||||
|
runs, which made the model think SearXNG was broken.
|
||||||
|
"""
|
||||||
|
values: Dict[str, Any] = dict(request.query_params)
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
try:
|
||||||
|
if "application/json" in content_type:
|
||||||
|
body = await request.json()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
values.update(body)
|
||||||
|
else:
|
||||||
|
form = await request.form()
|
||||||
|
values.update(dict(form))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def setup_search_routes(config) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["search"])
|
||||||
|
|
||||||
|
@router.get("/api/search/config")
|
||||||
|
async def get_search_settings() -> Dict[str, Any]:
|
||||||
|
return get_search_config()
|
||||||
|
|
||||||
|
@router.post("/api/search")
|
||||||
|
async def do_web_search(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Standalone web search — returns context string + source list.
|
||||||
|
|
||||||
|
Used by Compare mode to pre-search once and share results across panes.
|
||||||
|
"""
|
||||||
|
values = await _request_values(request)
|
||||||
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {"context": "", "sources": [], "error": "query is required"}
|
||||||
|
time_filter = values.get("time_filter") or values.get("freshness")
|
||||||
|
if time_filter is not None:
|
||||||
|
time_filter = str(time_filter).strip() or None
|
||||||
|
try:
|
||||||
|
context, sources = comprehensive_web_search(
|
||||||
|
query, return_sources=True, time_filter=time_filter,
|
||||||
|
)
|
||||||
|
return {"context": context, "sources": sources}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Standalone web search failed: {e}")
|
||||||
|
return {"context": "", "sources": [], "error": str(e)}
|
||||||
|
|
||||||
|
@router.get("/api/search/providers")
|
||||||
|
async def list_search_providers():
|
||||||
|
"""Return available search providers with config status."""
|
||||||
|
providers = []
|
||||||
|
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
||||||
|
if pid == "disabled":
|
||||||
|
continue
|
||||||
|
available = True
|
||||||
|
if needs_key and not _get_provider_key(pid):
|
||||||
|
available = False
|
||||||
|
if needs_url and pid == "searxng" and not _get_search_instance():
|
||||||
|
available = False
|
||||||
|
providers.append({
|
||||||
|
"id": pid,
|
||||||
|
"label": label,
|
||||||
|
"available": available,
|
||||||
|
})
|
||||||
|
return providers
|
||||||
|
|
||||||
|
@router.post("/api/search/query")
|
||||||
|
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Search using a specific provider. Used by compare search mode."""
|
||||||
|
values = await _request_values(request)
|
||||||
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
|
provider = str(values.get("provider") or "").strip()
|
||||||
|
try:
|
||||||
|
count = int(values.get("count") or values.get("limit") or 10)
|
||||||
|
except Exception:
|
||||||
|
count = 10
|
||||||
|
if not query:
|
||||||
|
return {"results": [], "provider": provider, "error": "query is required"}
|
||||||
|
if provider not in PROVIDER_INFO or provider == "disabled":
|
||||||
|
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
results = _call_provider(provider, query, min(count, 20))
|
||||||
|
elapsed = round(time.time() - t0, 2)
|
||||||
|
return {"results": results, "provider": provider, "time": elapsed}
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = round(time.time() - t0, 2)
|
||||||
|
logger.error(f"Search provider {provider} failed: {e}")
|
||||||
|
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
||||||
|
|
||||||
|
return router
|
||||||
+9
-107
@@ -1,111 +1,13 @@
|
|||||||
"""Search routes — /api/search/config GET, /api/search POST."""
|
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
|
||||||
|
|
||||||
import logging
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
from typing import Dict, Any
|
that ``import routes.search_routes`` and ``from routes.search_routes import X``
|
||||||
|
keep resolving to the canonical module. Keeps existing import paths working
|
||||||
|
after slice 2j (#4082/#4071).
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
import sys as _sys
|
||||||
|
|
||||||
import time
|
from routes.search import search_routes as _canonical # noqa: F401
|
||||||
|
|
||||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
_sys.modules[__name__] = _canonical
|
||||||
from services.search.core import _call_provider
|
|
||||||
from services.search.providers import _get_provider_key, _get_search_instance
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_values(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Accept JSON, form data, or query params for search endpoints.
|
|
||||||
|
|
||||||
The browser UI posts FormData, while the agent's generic app_api tool
|
|
||||||
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
|
||||||
runs, which made the model think SearXNG was broken.
|
|
||||||
"""
|
|
||||||
values: Dict[str, Any] = dict(request.query_params)
|
|
||||||
content_type = (request.headers.get("content-type") or "").lower()
|
|
||||||
try:
|
|
||||||
if "application/json" in content_type:
|
|
||||||
body = await request.json()
|
|
||||||
if isinstance(body, dict):
|
|
||||||
values.update(body)
|
|
||||||
else:
|
|
||||||
form = await request.form()
|
|
||||||
values.update(dict(form))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def setup_search_routes(config) -> APIRouter:
|
|
||||||
router = APIRouter(tags=["search"])
|
|
||||||
|
|
||||||
@router.get("/api/search/config")
|
|
||||||
async def get_search_settings() -> Dict[str, Any]:
|
|
||||||
return get_search_config()
|
|
||||||
|
|
||||||
@router.post("/api/search")
|
|
||||||
async def do_web_search(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Standalone web search — returns context string + source list.
|
|
||||||
|
|
||||||
Used by Compare mode to pre-search once and share results across panes.
|
|
||||||
"""
|
|
||||||
values = await _request_values(request)
|
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
|
||||||
if not query:
|
|
||||||
return {"context": "", "sources": [], "error": "query is required"}
|
|
||||||
time_filter = values.get("time_filter") or values.get("freshness")
|
|
||||||
if time_filter is not None:
|
|
||||||
time_filter = str(time_filter).strip() or None
|
|
||||||
try:
|
|
||||||
context, sources = comprehensive_web_search(
|
|
||||||
query, return_sources=True, time_filter=time_filter,
|
|
||||||
)
|
|
||||||
return {"context": context, "sources": sources}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Standalone web search failed: {e}")
|
|
||||||
return {"context": "", "sources": [], "error": str(e)}
|
|
||||||
|
|
||||||
@router.get("/api/search/providers")
|
|
||||||
async def list_search_providers():
|
|
||||||
"""Return available search providers with config status."""
|
|
||||||
providers = []
|
|
||||||
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
|
||||||
if pid == "disabled":
|
|
||||||
continue
|
|
||||||
available = True
|
|
||||||
if needs_key and not _get_provider_key(pid):
|
|
||||||
available = False
|
|
||||||
if needs_url and pid == "searxng" and not _get_search_instance():
|
|
||||||
available = False
|
|
||||||
providers.append({
|
|
||||||
"id": pid,
|
|
||||||
"label": label,
|
|
||||||
"available": available,
|
|
||||||
})
|
|
||||||
return providers
|
|
||||||
|
|
||||||
@router.post("/api/search/query")
|
|
||||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Search using a specific provider. Used by compare search mode."""
|
|
||||||
values = await _request_values(request)
|
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
|
||||||
provider = str(values.get("provider") or "").strip()
|
|
||||||
try:
|
|
||||||
count = int(values.get("count") or values.get("limit") or 10)
|
|
||||||
except Exception:
|
|
||||||
count = 10
|
|
||||||
if not query:
|
|
||||||
return {"results": [], "provider": provider, "error": "query is required"}
|
|
||||||
if provider not in PROVIDER_INFO or provider == "disabled":
|
|
||||||
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
results = _call_provider(provider, query, min(count, 20))
|
|
||||||
elapsed = round(time.time() - t0, 2)
|
|
||||||
return {"results": results, "provider": provider, "time": elapsed}
|
|
||||||
except Exception as e:
|
|
||||||
elapsed = round(time.time() - t0, 2)
|
|
||||||
logger.error(f"Search provider {provider} failed: {e}")
|
|
||||||
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
+59
-12
@@ -12,7 +12,9 @@ from core.models import ChatMessage
|
|||||||
from src.request_models import SessionResponse
|
from src.request_models import SessionResponse
|
||||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||||
|
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||||
from src.session_actions import is_session_recently_active
|
from src.session_actions import is_session_recently_active
|
||||||
|
from src.upload_handler import reserve_message_upload_references
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_export_filename(name: str) -> str:
|
def _sanitize_export_filename(name: str) -> str:
|
||||||
@@ -203,7 +205,12 @@ def _pick_endpoint_for_sort(owner=None):
|
|||||||
return url, model, headers
|
return url, model, headers
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
def setup_session_routes(session_manager: SessionManager, config: dict, webhook_manager=None):
|
def setup_session_routes(
|
||||||
|
session_manager: SessionManager,
|
||||||
|
config: dict,
|
||||||
|
webhook_manager=None,
|
||||||
|
upload_handler=None,
|
||||||
|
):
|
||||||
"""Setup session routes with the provided manager and config"""
|
"""Setup session routes with the provided manager and config"""
|
||||||
|
|
||||||
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
|
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
|
||||||
@@ -214,6 +221,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
@router.get("/sessions")
|
@router.get("/sessions")
|
||||||
def list_sessions(request: Request):
|
def list_sessions(request: Request):
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
|
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
|
||||||
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
||||||
# from the DB and session_manager so they vanish on the next page refresh.
|
# from the DB and session_manager so they vanish on the next page refresh.
|
||||||
# BUT: skip sessions that were created within the last 10 minutes.
|
# BUT: skip sessions that were created within the last 10 minutes.
|
||||||
@@ -234,6 +242,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
DbSession.created_at < _cutoff,
|
DbSession.created_at < _cutoff,
|
||||||
).all()
|
).all()
|
||||||
for _g in _ghosts:
|
for _g in _ghosts:
|
||||||
|
if active_incognito_id and _g.id == active_incognito_id:
|
||||||
|
continue
|
||||||
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
|
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
|
||||||
_purge_db.delete(_g)
|
_purge_db.delete(_g)
|
||||||
if hasattr(session_manager, "delete_session"):
|
if hasattr(session_manager, "delete_session"):
|
||||||
@@ -537,6 +547,22 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
messages = body.get("messages", [])
|
messages = body.get("messages", [])
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
|
owner = effective_user(request)
|
||||||
|
try:
|
||||||
|
for message in messages:
|
||||||
|
missing_id = reserve_message_upload_references(
|
||||||
|
upload_handler,
|
||||||
|
owner,
|
||||||
|
message.get("content"),
|
||||||
|
message.get("metadata"),
|
||||||
|
)
|
||||||
|
if missing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
)
|
||||||
|
except (AttributeError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||||
for m in messages:
|
for m in messages:
|
||||||
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
@@ -619,13 +645,43 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from core.database import ChatMessage as DbChatMessage
|
from core.database import ChatMessage as DbChatMessage
|
||||||
|
session_ids = [row[0] for row in db.query(DbSession.id).all()]
|
||||||
count = db.query(DbSession).count()
|
count = db.query(DbSession).count()
|
||||||
|
image_ids: set[str] = set()
|
||||||
|
filenames: set[str] = set()
|
||||||
|
for sid in session_ids:
|
||||||
|
ids, names = session_image_refs(db, sid)
|
||||||
|
image_ids.update(ids)
|
||||||
|
filenames.update(names)
|
||||||
|
image_query = db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)) if session_ids else db.query(GalleryImage).filter(False)
|
||||||
|
if image_ids or filenames:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
clauses = []
|
||||||
|
if session_ids:
|
||||||
|
clauses.append(GalleryImage.session_id.in_(session_ids))
|
||||||
|
if image_ids:
|
||||||
|
clauses.append(GalleryImage.id.in_(list(image_ids)))
|
||||||
|
if filenames:
|
||||||
|
clauses.append(GalleryImage.filename.in_(list(filenames)))
|
||||||
|
image_query = db.query(GalleryImage).filter(or_(*clauses))
|
||||||
|
images = image_query.all()
|
||||||
|
removed_images = 0
|
||||||
|
for img in images:
|
||||||
|
img.is_active = False
|
||||||
|
if img.filename:
|
||||||
|
path = _generated_image_path_for_cleanup(img.filename)
|
||||||
|
if path and path.exists():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
|
||||||
|
removed_images += 1
|
||||||
db.query(DbChatMessage).delete()
|
db.query(DbChatMessage).delete()
|
||||||
db.query(DbSession).delete()
|
db.query(DbSession).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
session_manager.sessions.clear()
|
session_manager.sessions.clear()
|
||||||
logger.info(f"Admin deleted all {count} sessions")
|
logger.info(f"Admin deleted all {count} sessions and {removed_images} linked images")
|
||||||
return {"status": "deleted", "count": count}
|
return {"status": "deleted", "count": count, "images_deleted": removed_images}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"Error deleting all sessions: {e}")
|
logger.error(f"Error deleting all sessions: {e}")
|
||||||
@@ -745,15 +801,6 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.get("/history/{sid}")
|
|
||||||
def get_history(request: Request, sid: str):
|
|
||||||
_verify_session_owner(request, sid)
|
|
||||||
try:
|
|
||||||
session = session_manager.get_session(sid)
|
|
||||||
except KeyError:
|
|
||||||
raise HTTPException(404, f"Session {sid} not found")
|
|
||||||
return {"history": [msg.to_dict() for msg in session.history]}
|
|
||||||
|
|
||||||
@router.get("/session/{sid}/export")
|
@router.get("/session/{sid}/export")
|
||||||
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
||||||
"""Export conversation history as a downloadable file.
|
"""Export conversation history as a downloadable file.
|
||||||
|
|||||||
+197
-4
@@ -157,6 +157,7 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
|||||||
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
||||||
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
||||||
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
||||||
|
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
|
||||||
|
|
||||||
if name == "vllm":
|
if name == "vllm":
|
||||||
return bool(binaries.get("vllm"))
|
return bool(binaries.get("vllm"))
|
||||||
@@ -166,11 +167,43 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
|||||||
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
|
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
|
||||||
if name == "mlx_lm":
|
if name == "mlx_lm":
|
||||||
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
|
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
|
||||||
|
if name == "mflux":
|
||||||
|
return bool(
|
||||||
|
dists.get("mflux")
|
||||||
|
or modules.get("mflux", {}).get("real_module")
|
||||||
|
or binaries.get("mflux-generate-qwen")
|
||||||
|
or binaries.get("mflux-generate")
|
||||||
|
)
|
||||||
|
if name == "boogu_image_mlx":
|
||||||
|
return bool(
|
||||||
|
dists.get("boogu-image-mlx")
|
||||||
|
or modules.get("boogu_image_mlx", {}).get("real_module")
|
||||||
|
)
|
||||||
|
if name == "mlx_lama_swift":
|
||||||
|
return bool(
|
||||||
|
(binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"))
|
||||||
|
and (files.get("mlx.metallib") or files.get("default.metallib"))
|
||||||
|
)
|
||||||
|
if name == "mlx_ddcolor_swift":
|
||||||
|
return bool(
|
||||||
|
(binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"))
|
||||||
|
and (files.get("mlx.metallib") or files.get("default.metallib"))
|
||||||
|
)
|
||||||
if name == "diffusers":
|
if name == "diffusers":
|
||||||
return bool(
|
return bool(
|
||||||
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||||
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||||
)
|
)
|
||||||
|
if name == "krea_diffusers":
|
||||||
|
return bool(
|
||||||
|
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||||
|
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||||
|
)
|
||||||
|
if name == "sam_mask":
|
||||||
|
return bool(
|
||||||
|
(dists.get("transformers") or modules.get("transformers", {}).get("real_module"))
|
||||||
|
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||||
|
)
|
||||||
if name == "hf_transfer":
|
if name == "hf_transfer":
|
||||||
return bool(
|
return bool(
|
||||||
dists.get("hf-transfer")
|
dists.get("hf-transfer")
|
||||||
@@ -183,6 +216,7 @@ def _package_status_note(name: str, probe: dict) -> str:
|
|||||||
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
||||||
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
||||||
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
||||||
|
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
|
||||||
module = modules.get(name) if isinstance(modules.get(name), dict) else {}
|
module = modules.get(name) if isinstance(modules.get(name), dict) else {}
|
||||||
locations = module.get("locations") or []
|
locations = module.get("locations") or []
|
||||||
if name == "vllm":
|
if name == "vllm":
|
||||||
@@ -212,10 +246,53 @@ def _package_status_note(name: str, probe: dict) -> str:
|
|||||||
if _package_installed_from_probe(name, probe):
|
if _package_installed_from_probe(name, probe):
|
||||||
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||||
return "Diffusers serving needs both diffusers and torch."
|
return "Diffusers serving needs both diffusers and torch."
|
||||||
|
if name == "krea_diffusers":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
return f"Latest Diffusers runtime: diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}. Use Update/Reinstall to pull latest Diffusers from Git."
|
||||||
|
return "Some newer image models need torch plus latest Diffusers from Git."
|
||||||
|
if name == "sam_mask":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
return f"SAM object masks: transformers {dists.get('transformers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||||
|
return "SAM click/object mask selection needs transformers and torch."
|
||||||
if name == "mlx_lm":
|
if name == "mlx_lm":
|
||||||
if _package_installed_from_probe(name, probe):
|
if _package_installed_from_probe(name, probe):
|
||||||
return f"MLX LM {dists.get('mlx-lm', 'available')}"
|
return f"MLX LM {dists.get('mlx-lm', 'available')}"
|
||||||
return "MLX serving needs mlx-lm on an Apple Silicon Mac."
|
return "MLX serving needs mlx-lm on an Apple Silicon Mac."
|
||||||
|
if name == "mflux":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
parts = []
|
||||||
|
if dists.get("mflux"):
|
||||||
|
parts.append(f"mflux {dists['mflux']}")
|
||||||
|
if binaries.get("mflux-generate-qwen"):
|
||||||
|
parts.append(f"Qwen CLI: {binaries['mflux-generate-qwen']}")
|
||||||
|
if binaries.get("mflux-generate"):
|
||||||
|
parts.append(f"Flux CLI: {binaries['mflux-generate']}")
|
||||||
|
return "; ".join(parts) if parts else "mflux available"
|
||||||
|
return "MLX image serving needs mflux on an Apple Silicon Mac."
|
||||||
|
if name == "boogu_image_mlx":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
return f"Boogu MLX pipeline {dists.get('boogu-image-mlx', 'available')}"
|
||||||
|
return "Boogu image models need boogu-image-mlx on an Apple Silicon Mac."
|
||||||
|
if name == "mlx_lama_swift":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
found = [
|
||||||
|
binaries.get("odysseus-mlx-inpaint"),
|
||||||
|
binaries.get("mlx-lama-serve"),
|
||||||
|
]
|
||||||
|
return f"LaMa/MI-GAN Swift MLX runner: {next((p for p in found if p), 'available')}"
|
||||||
|
if binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"):
|
||||||
|
return "LaMa/MI-GAN Swift runner is installed, but mlx.metallib is missing next to the runner."
|
||||||
|
return "LaMa/MI-GAN inpainting models need an Odysseus-compatible mlx-lama-swift bridge on an Apple Silicon Mac."
|
||||||
|
if name == "mlx_ddcolor_swift":
|
||||||
|
if _package_installed_from_probe(name, probe):
|
||||||
|
found = [
|
||||||
|
binaries.get("odysseus-mlx-colorize"),
|
||||||
|
binaries.get("mlx-ddcolor-serve"),
|
||||||
|
]
|
||||||
|
return f"DDColor Swift MLX runner: {next((p for p in found if p), 'available')}"
|
||||||
|
if binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"):
|
||||||
|
return "DDColor Swift runner is installed, but mlx.metallib is missing next to the runner."
|
||||||
|
return "DDColor colorization models need an Odysseus-compatible mlx-ddcolor-swift bridge on an Apple Silicon Mac."
|
||||||
if name in dists:
|
if name in dists:
|
||||||
return f"{name} {dists[name]}"
|
return f"{name} {dists[name]}"
|
||||||
return ""
|
return ""
|
||||||
@@ -314,12 +391,22 @@ dist_names={{
|
|||||||
'llama_cpp':['llama-cpp-python'],
|
'llama_cpp':['llama-cpp-python'],
|
||||||
'sglang':['sglang'],
|
'sglang':['sglang'],
|
||||||
'mlx_lm':['mlx-lm'],
|
'mlx_lm':['mlx-lm'],
|
||||||
|
'mlx_vlm':['mlx-vlm'],
|
||||||
|
'mflux':['mflux'],
|
||||||
|
'boogu_image_mlx':['boogu-image-mlx'],
|
||||||
|
'mlx_lama_swift':[],
|
||||||
|
'mlx_ddcolor_swift':[],
|
||||||
'diffusers':['diffusers','torch'],
|
'diffusers':['diffusers','torch'],
|
||||||
|
'krea_diffusers':['diffusers','torch'],
|
||||||
|
'sam_mask':['transformers','torch'],
|
||||||
'hf_transfer':['hf-transfer','hf_transfer'],
|
'hf_transfer':['hf-transfer','hf_transfer'],
|
||||||
}}
|
}}
|
||||||
bin_names={{
|
bin_names={{
|
||||||
'vllm':['vllm'],
|
'vllm':['vllm'],
|
||||||
'llama_cpp':['llama-server'],
|
'llama_cpp':['llama-server'],
|
||||||
|
'mflux':['mflux-generate-qwen', 'mflux-generate'],
|
||||||
|
'mlx_lama_swift':['odysseus-mlx-inpaint', 'mlx-lama-serve'],
|
||||||
|
'mlx_ddcolor_swift':['odysseus-mlx-colorize', 'mlx-ddcolor-serve'],
|
||||||
'tmux':['tmux'],
|
'tmux':['tmux'],
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -372,7 +459,19 @@ def probe(n):
|
|||||||
mods['torch'] = mod_status('torch')
|
mods['torch'] = mod_status('torch')
|
||||||
dists = dist_status(dist_names.get(n, [n]))
|
dists = dist_status(dist_names.get(n, [n]))
|
||||||
bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}}
|
bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}}
|
||||||
return {{'modules': mods, 'dists': dists, 'binaries': bins}}
|
files = {{}}
|
||||||
|
if n in ('mlx_lama_swift', 'mlx_ddcolor_swift'):
|
||||||
|
for key in ('mlx.metallib', 'default.metallib'):
|
||||||
|
found = None
|
||||||
|
for b in bins.values():
|
||||||
|
if not b:
|
||||||
|
continue
|
||||||
|
p = os.path.join(os.path.dirname(b), key)
|
||||||
|
if os.path.exists(p):
|
||||||
|
found = p
|
||||||
|
break
|
||||||
|
files[key] = found
|
||||||
|
return {{'modules': mods, 'dists': dists, 'binaries': bins, 'files': files}}
|
||||||
|
|
||||||
print(json.dumps({{n: probe(n) for n in names}}))
|
print(json.dumps({{n: probe(n) for n in names}}))
|
||||||
"""
|
"""
|
||||||
@@ -1088,6 +1187,8 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
ssh_port: str | None = None,
|
ssh_port: str | None = None,
|
||||||
venv: str | None = None,
|
venv: str | None = None,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
model_hint: str | None = None,
|
||||||
):
|
):
|
||||||
"""Check which optional packages are installed.
|
"""Check which optional packages are installed.
|
||||||
|
|
||||||
@@ -1104,6 +1205,14 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
import site
|
import site
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
platform_l = (platform or "").strip().lower()
|
||||||
|
model_hint_l = (model_hint or "").strip().lower()
|
||||||
|
has_krea_model = "krea" in model_hint_l
|
||||||
|
has_lama_mlx_model = any(
|
||||||
|
key in model_hint_l
|
||||||
|
for key in ("lama", "mi-gan", "migan", "inpainting-mlx")
|
||||||
|
)
|
||||||
|
has_ddcolor_mlx_model = "ddcolor" in model_hint_l
|
||||||
_prepend_user_install_bins_to_path()
|
_prepend_user_install_bins_to_path()
|
||||||
importlib.invalidate_caches()
|
importlib.invalidate_caches()
|
||||||
try:
|
try:
|
||||||
@@ -1158,7 +1267,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"name": "hf_transfer",
|
"name": "hf_transfer",
|
||||||
"pip": "hf_transfer",
|
"pip": "hf_transfer",
|
||||||
"desc": "Fast model downloads from HuggingFace",
|
"desc": "Fast model downloads from HuggingFace",
|
||||||
"category": "LLM",
|
"category": "Tools",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1210,8 +1319,52 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
# ── Image ── editor + diffusion model serving
|
# ── Image ── editor + diffusion model serving
|
||||||
{
|
{
|
||||||
"name": "diffusers",
|
"name": "diffusers",
|
||||||
"pip": "diffusers[torch]",
|
"pip": "diffusers[torch] torchvision accelerate scipy python-multipart",
|
||||||
"desc": "Image generation/editing pipelines (SD, Flux) with PyTorch",
|
"desc": "Image generation/editing pipelines with PyTorch and Diffusers",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "krea_diffusers",
|
||||||
|
"pip": "git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart",
|
||||||
|
"desc": "Latest Diffusers from Git for newly released image pipelines",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mflux",
|
||||||
|
"pip": "mflux",
|
||||||
|
"desc": "MLX image generation runtime for Apple Silicon models like Qwen Image",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boogu_image_mlx",
|
||||||
|
"pip": "git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||||
|
"desc": "MLX image generation pipeline for Boogu Image models on Apple Silicon",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mlx_lama_swift",
|
||||||
|
"pip": "",
|
||||||
|
"desc": "Swift MLX runtime for LaMa / MI-GAN inpainting and object removal",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
"install_hint": "Build an Odysseus-compatible mlx-lama-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-inpaint or mlx-lama-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable image-edit CLI.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mlx_ddcolor_swift",
|
||||||
|
"pip": "",
|
||||||
|
"desc": "Swift MLX runtime for DDColor automatic image colorization",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
"install_hint": "Build an Odysseus-compatible mlx-ddcolor-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable colorize CLI.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mlx_vlm",
|
||||||
|
"pip": "mlx-vlm",
|
||||||
|
"desc": "MLX-VLM backbone used by HiDream image models on Apple Silicon",
|
||||||
"category": "Image",
|
"category": "Image",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
@@ -1222,6 +1375,13 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"category": "Image",
|
"category": "Image",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sam_mask",
|
||||||
|
"pip": "torch torchvision transformers accelerate pillow",
|
||||||
|
"desc": "Neutral click/box/object segmentation masks for the image editor",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "local",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "rembg",
|
"name": "rembg",
|
||||||
"pip": "rembg[gpu]",
|
"pip": "rembg[gpu]",
|
||||||
@@ -1251,6 +1411,21 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
for pkg in packages:
|
for pkg in packages:
|
||||||
pkg.setdefault("install_cmd", None)
|
pkg.setdefault("install_cmd", None)
|
||||||
pkg.setdefault("update_cmd", None)
|
pkg.setdefault("update_cmd", None)
|
||||||
|
if not has_krea_model:
|
||||||
|
packages = [
|
||||||
|
p for p in packages
|
||||||
|
if p.get("name") not in {"krea_diffusers", "transformers"}
|
||||||
|
]
|
||||||
|
if not has_lama_mlx_model:
|
||||||
|
packages = [
|
||||||
|
p for p in packages
|
||||||
|
if p.get("name") != "mlx_lama_swift"
|
||||||
|
]
|
||||||
|
if not has_ddcolor_mlx_model:
|
||||||
|
packages = [
|
||||||
|
p for p in packages
|
||||||
|
if p.get("name") != "mlx_ddcolor_swift"
|
||||||
|
]
|
||||||
# Remote check: for remote-target packages, probe the selected server's
|
# Remote check: for remote-target packages, probe the selected server's
|
||||||
# venv over SSH so a remote `pip install` actually reflects here.
|
# venv over SSH so a remote `pip install` actually reflects here.
|
||||||
remote_status: dict = {}
|
remote_status: dict = {}
|
||||||
@@ -1381,8 +1556,22 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
target_os_id = ""
|
target_os_id = ""
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
target_os_id = "macos"
|
target_os_id = "macos"
|
||||||
|
if not target_os_id and platform_l in {"darwin", "macos", "mac"}:
|
||||||
|
target_os_id = "macos"
|
||||||
|
|
||||||
for pkg in packages:
|
for pkg in packages:
|
||||||
|
if pkg.get("name") in {"mflux", "boogu_image_mlx", "mlx_vlm", "mlx_lama_swift", "mlx_ddcolor_swift"}:
|
||||||
|
is_apple_target = target_os_id == "macos" or (
|
||||||
|
not host and IS_APPLE_SILICON
|
||||||
|
)
|
||||||
|
known_non_apple_target = bool(target_os_id and target_os_id != "macos") or (
|
||||||
|
not host and not IS_APPLE_SILICON
|
||||||
|
)
|
||||||
|
pkg["applicable"] = is_apple_target
|
||||||
|
if known_non_apple_target:
|
||||||
|
pkg["installed"] = None
|
||||||
|
pkg["status_note"] = "Only relevant for Apple Silicon / MLX image serving."
|
||||||
|
continue
|
||||||
on_remote = bool(host and pkg.get("target") == "remote")
|
on_remote = bool(host and pkg.get("target") == "remote")
|
||||||
probe = None
|
probe = None
|
||||||
if on_remote:
|
if on_remote:
|
||||||
@@ -1588,6 +1777,10 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"sglang[all]",
|
"sglang[all]",
|
||||||
"diffusers",
|
"diffusers",
|
||||||
"diffusers[torch]",
|
"diffusers[torch]",
|
||||||
|
"git+https://github.com/huggingface/diffusers.git",
|
||||||
|
"mflux",
|
||||||
|
"git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||||
|
"mlx-vlm",
|
||||||
"transformers",
|
"transformers",
|
||||||
"TTS",
|
"TTS",
|
||||||
"bark",
|
"bark",
|
||||||
|
|||||||
@@ -1409,7 +1409,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
|
|
||||||
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
||||||
# session's model. Fall back to the caller's session model only if unset.
|
# session's model. Fall back to the caller's session model only if unset.
|
||||||
url, model, headers = resolve_endpoint("default", owner=user)
|
url, model, headers = resolve_endpoint("utility", owner=user)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||||
model = model or ((body.get("model") or "").strip() or None)
|
model = model or ((body.get("model") or "").strip() or None)
|
||||||
|
|||||||
+145
-3
@@ -10,16 +10,140 @@ from fastapi import APIRouter, Request, File, UploadFile, HTTPException, Form
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import logging
|
import logging
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from core.database import SessionLocal, GalleryImage, Session as DbSession
|
from core.database import (
|
||||||
|
SessionLocal,
|
||||||
|
ChatMessage as DbChatMessage,
|
||||||
|
CalendarCal,
|
||||||
|
CalendarEvent,
|
||||||
|
Document,
|
||||||
|
DocumentVersion,
|
||||||
|
GalleryImage,
|
||||||
|
Note,
|
||||||
|
Session as DbSession,
|
||||||
|
)
|
||||||
from src.auth_helpers import effective_user
|
from src.auth_helpers import effective_user
|
||||||
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.upload_handler import count_recent_uploads
|
from src.upload_handler import (
|
||||||
|
UploadCleanupSafetyError,
|
||||||
|
count_recent_uploads,
|
||||||
|
extract_upload_ids,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||||
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
|
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
|
||||||
|
|
||||||
|
def _upload_ids_from_persisted_text(value: object) -> set[str]:
|
||||||
|
"""Return canonical upload IDs embedded in persisted text.
|
||||||
|
|
||||||
|
This covers attachment reference lines/URIs and the PDF source markers
|
||||||
|
stored by the document editor. False positives are intentionally
|
||||||
|
conservative: retaining an extra upload is safer than deleting referenced
|
||||||
|
bytes.
|
||||||
|
"""
|
||||||
|
return extract_upload_ids(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_ids_from_message_metadata(raw_metadata: object) -> set[str]:
|
||||||
|
"""Extract attachment IDs from a persisted chat metadata JSON value.
|
||||||
|
|
||||||
|
Malformed metadata raises instead of being treated as an empty reference
|
||||||
|
set. The admin cleanup route catches that failure and aborts cleanup.
|
||||||
|
"""
|
||||||
|
if raw_metadata in (None, ""):
|
||||||
|
return set()
|
||||||
|
if isinstance(raw_metadata, str):
|
||||||
|
metadata = json.loads(raw_metadata)
|
||||||
|
else:
|
||||||
|
metadata = raw_metadata
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("chat message metadata must be a JSON object")
|
||||||
|
|
||||||
|
attachments = metadata.get("attachments")
|
||||||
|
if attachments is not None:
|
||||||
|
if not isinstance(attachments, list) or any(
|
||||||
|
not isinstance(item, dict) for item in attachments
|
||||||
|
):
|
||||||
|
raise ValueError("chat message attachments metadata is malformed")
|
||||||
|
|
||||||
|
ids = {
|
||||||
|
str(ref["attachment_id"])
|
||||||
|
for ref in attachment_refs_from_metadata(metadata)
|
||||||
|
if ref.get("attachment_id")
|
||||||
|
}
|
||||||
|
# Preserve canonical IDs even in older metadata shapes not normalized by
|
||||||
|
# attachment_refs_from_metadata().
|
||||||
|
ids.update(_upload_ids_from_persisted_text(json.dumps(metadata)))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_persisted_upload_references() -> tuple[set[str], set[str]]:
|
||||||
|
"""Collect upload IDs/hashes still referenced by durable application data.
|
||||||
|
|
||||||
|
The caller must treat any exception as an incomplete scan and fail closed.
|
||||||
|
There is no distinct artifact table in the current schema; artifact-like
|
||||||
|
attachment references persisted in chat/document text are covered by the
|
||||||
|
canonical-ID scan.
|
||||||
|
"""
|
||||||
|
referenced_ids: set[str] = set()
|
||||||
|
referenced_hashes: set[str] = set()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for content, raw_metadata in db.query(
|
||||||
|
DbChatMessage.content,
|
||||||
|
DbChatMessage.meta_data,
|
||||||
|
).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
referenced_ids.update(_upload_ids_from_message_metadata(raw_metadata))
|
||||||
|
|
||||||
|
for (content,) in db.query(Document.current_content).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
|
||||||
|
for (content,) in db.query(DocumentVersion.content).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(content))
|
||||||
|
|
||||||
|
for filename, file_hash in db.query(
|
||||||
|
GalleryImage.filename,
|
||||||
|
GalleryImage.file_hash,
|
||||||
|
).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(filename))
|
||||||
|
if file_hash:
|
||||||
|
referenced_hashes.add(str(file_hash))
|
||||||
|
|
||||||
|
for image_url, color, content, items in db.query(
|
||||||
|
Note.image_url,
|
||||||
|
Note.color,
|
||||||
|
Note.content,
|
||||||
|
Note.items,
|
||||||
|
).yield_per(500):
|
||||||
|
for value in (image_url, color, content, items):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(value))
|
||||||
|
|
||||||
|
for (color,) in db.query(CalendarCal.color).yield_per(500):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(color))
|
||||||
|
|
||||||
|
for color, description, location in db.query(
|
||||||
|
CalendarEvent.color,
|
||||||
|
CalendarEvent.description,
|
||||||
|
CalendarEvent.location,
|
||||||
|
).yield_per(500):
|
||||||
|
for value in (color, description, location):
|
||||||
|
referenced_ids.update(_upload_ids_from_persisted_text(value))
|
||||||
|
|
||||||
|
return referenced_ids, referenced_hashes
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_reference_safe_cleanup(upload_handler) -> int:
|
||||||
|
referenced_ids, referenced_hashes = _collect_persisted_upload_references()
|
||||||
|
return upload_handler.cleanup_old_uploads(
|
||||||
|
referenced_upload_ids=referenced_ids,
|
||||||
|
referenced_upload_hashes=referenced_hashes,
|
||||||
|
)
|
||||||
|
|
||||||
def setup_upload_routes(upload_handler):
|
def setup_upload_routes(upload_handler):
|
||||||
"""Setup upload routes with the provided handler"""
|
"""Setup upload routes with the provided handler"""
|
||||||
|
|
||||||
@@ -172,7 +296,9 @@ def setup_upload_routes(upload_handler):
|
|||||||
"mime": meta["mime"],
|
"mime": meta["mime"],
|
||||||
"size": meta["size"],
|
"size": meta["size"],
|
||||||
"hash": meta["hash"],
|
"hash": meta["hash"],
|
||||||
|
"checksum_sha256": meta.get("checksum_sha256") or meta["hash"],
|
||||||
"uploaded_at": meta["uploaded_at"],
|
"uploaded_at": meta["uploaded_at"],
|
||||||
|
"created_at": meta.get("created_at") or meta["uploaded_at"],
|
||||||
"width": meta.get("width"),
|
"width": meta.get("width"),
|
||||||
"height": meta.get("height"),
|
"height": meta.get("height"),
|
||||||
"is_duplicate": meta.get("is_duplicate", False)
|
"is_duplicate": meta.get("is_duplicate", False)
|
||||||
@@ -195,7 +321,23 @@ def setup_upload_routes(upload_handler):
|
|||||||
async def manual_cleanup(request: Request):
|
async def manual_cleanup(request: Request):
|
||||||
"""Manually trigger cleanup of old uploads."""
|
"""Manually trigger cleanup of old uploads."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
cleaned_count = upload_handler.cleanup_old_uploads()
|
try:
|
||||||
|
cleaned_count = await asyncio.to_thread(
|
||||||
|
_run_reference_safe_cleanup,
|
||||||
|
upload_handler,
|
||||||
|
)
|
||||||
|
except UploadCleanupSafetyError:
|
||||||
|
logger.exception("Upload cleanup aborted because index safety checks failed")
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"Upload cleanup aborted because upload index integrity could not be verified",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Upload cleanup skipped because reference discovery failed")
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"Upload cleanup skipped because persisted references could not be verified",
|
||||||
|
)
|
||||||
return {"status": "success", "files_cleaned": cleaned_count}
|
return {"status": "success", "files_cleaned": cleaned_count}
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Vault route domain package (slice 2k, #4082/#4071).
|
||||||
|
|
||||||
|
Contains vault_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/vault_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
vault_routes.py
|
||||||
|
|
||||||
|
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
||||||
|
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.middleware import require_admin
|
||||||
|
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||||
|
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VAULT_FILE = Path(_VAULT_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_bw() -> str:
|
||||||
|
"""Locate the bw binary, checking PATH and common npm-global locations.
|
||||||
|
|
||||||
|
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
||||||
|
which_tool via PATHEXT.
|
||||||
|
"""
|
||||||
|
p = which_tool("bw")
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
if IS_WINDOWS:
|
||||||
|
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||||
|
for candidate in (
|
||||||
|
os.path.join(appdata, "npm", "bw.cmd"),
|
||||||
|
os.path.join(appdata, "npm", "bw.exe"),
|
||||||
|
):
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return "bw"
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
for candidate in (
|
||||||
|
f"{home}/.npm-global/bin/bw",
|
||||||
|
f"{home}/.nvm/versions/node/*/bin/bw",
|
||||||
|
"/usr/local/bin/bw",
|
||||||
|
"/opt/homebrew/bin/bw",
|
||||||
|
):
|
||||||
|
if "*" in candidate:
|
||||||
|
import glob
|
||||||
|
for m in glob.glob(candidate):
|
||||||
|
if os.path.isfile(m) and os.access(m, os.X_OK):
|
||||||
|
return m
|
||||||
|
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
if VAULT_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_config(cfg: dict):
|
||||||
|
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
||||||
|
# is ACL-restricted already).
|
||||||
|
safe_chmod(str(VAULT_FILE), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
||||||
|
bw_password: str = None) -> tuple:
|
||||||
|
env = {}
|
||||||
|
env.update(os.environ)
|
||||||
|
if session:
|
||||||
|
env["BW_SESSION"] = session
|
||||||
|
# Secrets must never be passed as argv — process arguments are world-readable
|
||||||
|
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
||||||
|
# support for bw commands that need it; unlock/login callers should prefer
|
||||||
|
# stdin so the master password is not left in the child environment either.
|
||||||
|
if bw_password is not None:
|
||||||
|
env["BW_PASSWORD"] = bw_password
|
||||||
|
bw_path = _find_bw()
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
bw_path, *args,
|
||||||
|
stdin=asyncio.subprocess.PIPE if input_text else None,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
||||||
|
except Exception as e:
|
||||||
|
return "", f"Failed to launch bw: {e}", 1
|
||||||
|
try:
|
||||||
|
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
||||||
|
except Exception as e:
|
||||||
|
return "", f"bw subprocess error: {e}", 1
|
||||||
|
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
class VaultConfig(BaseModel):
|
||||||
|
server_url: str = ""
|
||||||
|
email: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class VaultUnlockRequest(BaseModel):
|
||||||
|
master_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class VaultLoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
master_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vault_routes():
|
||||||
|
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config(request: Request):
|
||||||
|
"""Return vault config (no sensitive fields)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
return {
|
||||||
|
"server_url": cfg.get("server_url", ""),
|
||||||
|
"email": cfg.get("email", ""),
|
||||||
|
"unlocked": bool(cfg.get("session")),
|
||||||
|
"unlocked_at": cfg.get("unlocked_at", ""),
|
||||||
|
"bw_installed": await _check_bw_installed(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_config(req: VaultConfig, request: Request):
|
||||||
|
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
||||||
|
cfg["email"] = req.email.strip()
|
||||||
|
|
||||||
|
if cfg["server_url"]:
|
||||||
|
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
||||||
|
if rc != 0:
|
||||||
|
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
||||||
|
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(req: VaultLoginRequest, request: Request):
|
||||||
|
"""Log in to Vaultwarden (required once per account)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
# Update email
|
||||||
|
cfg["email"] = req.email
|
||||||
|
_save_config(cfg)
|
||||||
|
|
||||||
|
stdout, stderr, rc = await _run_bw(
|
||||||
|
["login", req.email, "--raw"],
|
||||||
|
input_text=req.master_password + "\n",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
# Already logged in is OK
|
||||||
|
if "already logged in" in stderr.lower():
|
||||||
|
return {"ok": True, "already": True}
|
||||||
|
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
||||||
|
# bw login --raw prints session key on success (when 2FA disabled)
|
||||||
|
if stdout:
|
||||||
|
cfg["session"] = stdout
|
||||||
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/unlock")
|
||||||
|
async def unlock(req: VaultUnlockRequest, request: Request):
|
||||||
|
"""Unlock the vault and save the session key."""
|
||||||
|
require_admin(request)
|
||||||
|
# Pass the master password on stdin, not argv. argv is visible through
|
||||||
|
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
||||||
|
# the child process environment.
|
||||||
|
stdout, stderr, rc = await _run_bw(
|
||||||
|
["unlock", "--raw"],
|
||||||
|
input_text=req.master_password + "\n",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
||||||
|
session = stdout.strip()
|
||||||
|
if not session:
|
||||||
|
return {"ok": False, "error": "bw returned empty session"}
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg["session"] = session
|
||||||
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True, "message": "Vault unlocked"}
|
||||||
|
|
||||||
|
@router.post("/lock")
|
||||||
|
async def lock(request: Request):
|
||||||
|
"""Lock the vault (clear session from config)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg.pop("session", None)
|
||||||
|
cfg.pop("unlocked_at", None)
|
||||||
|
_save_config(cfg)
|
||||||
|
# Also tell bw to lock
|
||||||
|
await _run_bw(["lock"])
|
||||||
|
return {"ok": True, "message": "Vault locked"}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Log out of the Bitwarden CLI completely."""
|
||||||
|
require_admin(request)
|
||||||
|
await _run_bw(["logout"])
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg.pop("session", None)
|
||||||
|
cfg.pop("email", None)
|
||||||
|
cfg.pop("unlocked_at", None)
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_bw_installed() -> bool:
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
_find_bw(), "--version",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
await proc.communicate()
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
+9
-237
@@ -1,242 +1,14 @@
|
|||||||
"""
|
"""Backward-compat shim — canonical location is routes/vault/vault_routes.py.
|
||||||
vault_routes.py
|
|
||||||
|
|
||||||
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
that ``import routes.vault_routes``, ``from routes.vault_routes import X``,
|
||||||
|
and the ``import ... as vr`` + ``monkeypatch.setattr(vr, ...)`` pattern used
|
||||||
|
by test_vault_password_not_in_argv.py all operate on the *same* object.
|
||||||
|
Keeps existing import paths working after slice 2k (#4082/#4071).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import sys as _sys
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from routes.vault import vault_routes as _canonical # noqa: F401
|
||||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
|
||||||
from src.constants import VAULT_FILE as _VAULT_FILE
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
VAULT_FILE = Path(_VAULT_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_bw() -> str:
|
|
||||||
"""Locate the bw binary, checking PATH and common npm-global locations.
|
|
||||||
|
|
||||||
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
|
||||||
which_tool via PATHEXT.
|
|
||||||
"""
|
|
||||||
p = which_tool("bw")
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
if IS_WINDOWS:
|
|
||||||
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
||||||
for candidate in (
|
|
||||||
os.path.join(appdata, "npm", "bw.cmd"),
|
|
||||||
os.path.join(appdata, "npm", "bw.exe"),
|
|
||||||
):
|
|
||||||
if os.path.isfile(candidate):
|
|
||||||
return candidate
|
|
||||||
return "bw"
|
|
||||||
home = os.path.expanduser("~")
|
|
||||||
for candidate in (
|
|
||||||
f"{home}/.npm-global/bin/bw",
|
|
||||||
f"{home}/.nvm/versions/node/*/bin/bw",
|
|
||||||
"/usr/local/bin/bw",
|
|
||||||
"/opt/homebrew/bin/bw",
|
|
||||||
):
|
|
||||||
if "*" in candidate:
|
|
||||||
import glob
|
|
||||||
for m in glob.glob(candidate):
|
|
||||||
if os.path.isfile(m) and os.access(m, os.X_OK):
|
|
||||||
return m
|
|
||||||
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
||||||
return candidate
|
|
||||||
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_config() -> dict:
|
|
||||||
if VAULT_FILE.exists():
|
|
||||||
try:
|
|
||||||
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_config(cfg: dict):
|
|
||||||
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
||||||
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
|
||||||
# is ACL-restricted already).
|
|
||||||
safe_chmod(str(VAULT_FILE), 0o600)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
|
||||||
bw_password: str = None) -> tuple:
|
|
||||||
env = {}
|
|
||||||
env.update(os.environ)
|
|
||||||
if session:
|
|
||||||
env["BW_SESSION"] = session
|
|
||||||
# Secrets must never be passed as argv — process arguments are world-readable
|
|
||||||
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
|
||||||
# support for bw commands that need it; unlock/login callers should prefer
|
|
||||||
# stdin so the master password is not left in the child environment either.
|
|
||||||
if bw_password is not None:
|
|
||||||
env["BW_PASSWORD"] = bw_password
|
|
||||||
bw_path = _find_bw()
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
bw_path, *args,
|
|
||||||
stdin=asyncio.subprocess.PIPE if input_text else None,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
|
||||||
except Exception as e:
|
|
||||||
return "", f"Failed to launch bw: {e}", 1
|
|
||||||
try:
|
|
||||||
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
|
||||||
except Exception as e:
|
|
||||||
return "", f"bw subprocess error: {e}", 1
|
|
||||||
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
|
||||||
|
|
||||||
|
|
||||||
class VaultConfig(BaseModel):
|
|
||||||
server_url: str = ""
|
|
||||||
email: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class VaultUnlockRequest(BaseModel):
|
|
||||||
master_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class VaultLoginRequest(BaseModel):
|
|
||||||
email: str
|
|
||||||
master_password: str
|
|
||||||
|
|
||||||
|
|
||||||
def setup_vault_routes():
|
|
||||||
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
|
||||||
|
|
||||||
@router.get("/config")
|
|
||||||
async def get_config(request: Request):
|
|
||||||
"""Return vault config (no sensitive fields)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
return {
|
|
||||||
"server_url": cfg.get("server_url", ""),
|
|
||||||
"email": cfg.get("email", ""),
|
|
||||||
"unlocked": bool(cfg.get("session")),
|
|
||||||
"unlocked_at": cfg.get("unlocked_at", ""),
|
|
||||||
"bw_installed": await _check_bw_installed(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/config")
|
|
||||||
async def save_config(req: VaultConfig, request: Request):
|
|
||||||
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
|
||||||
cfg["email"] = req.email.strip()
|
|
||||||
|
|
||||||
if cfg["server_url"]:
|
|
||||||
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
|
||||||
if rc != 0:
|
|
||||||
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
|
||||||
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login(req: VaultLoginRequest, request: Request):
|
|
||||||
"""Log in to Vaultwarden (required once per account)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
# Update email
|
|
||||||
cfg["email"] = req.email
|
|
||||||
_save_config(cfg)
|
|
||||||
|
|
||||||
stdout, stderr, rc = await _run_bw(
|
|
||||||
["login", req.email, "--raw"],
|
|
||||||
input_text=req.master_password + "\n",
|
|
||||||
)
|
|
||||||
if rc != 0:
|
|
||||||
# Already logged in is OK
|
|
||||||
if "already logged in" in stderr.lower():
|
|
||||||
return {"ok": True, "already": True}
|
|
||||||
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
|
||||||
# bw login --raw prints session key on success (when 2FA disabled)
|
|
||||||
if stdout:
|
|
||||||
cfg["session"] = stdout
|
|
||||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.post("/unlock")
|
|
||||||
async def unlock(req: VaultUnlockRequest, request: Request):
|
|
||||||
"""Unlock the vault and save the session key."""
|
|
||||||
require_admin(request)
|
|
||||||
# Pass the master password on stdin, not argv. argv is visible through
|
|
||||||
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
|
||||||
# the child process environment.
|
|
||||||
stdout, stderr, rc = await _run_bw(
|
|
||||||
["unlock", "--raw"],
|
|
||||||
input_text=req.master_password + "\n",
|
|
||||||
)
|
|
||||||
if rc != 0:
|
|
||||||
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
|
||||||
session = stdout.strip()
|
|
||||||
if not session:
|
|
||||||
return {"ok": False, "error": "bw returned empty session"}
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg["session"] = session
|
|
||||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True, "message": "Vault unlocked"}
|
|
||||||
|
|
||||||
@router.post("/lock")
|
|
||||||
async def lock(request: Request):
|
|
||||||
"""Lock the vault (clear session from config)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg.pop("session", None)
|
|
||||||
cfg.pop("unlocked_at", None)
|
|
||||||
_save_config(cfg)
|
|
||||||
# Also tell bw to lock
|
|
||||||
await _run_bw(["lock"])
|
|
||||||
return {"ok": True, "message": "Vault locked"}
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(request: Request):
|
|
||||||
"""Log out of the Bitwarden CLI completely."""
|
|
||||||
require_admin(request)
|
|
||||||
await _run_bw(["logout"])
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg.pop("session", None)
|
|
||||||
cfg.pop("email", None)
|
|
||||||
cfg.pop("unlocked_at", None)
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_bw_installed() -> bool:
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
_find_bw(), "--version",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
await proc.communicate()
|
|
||||||
return proc.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Webhook route domain package (slice 2l, #4082/#4071).
|
||||||
|
|
||||||
|
Contains webhook_routes.py, migrated from the flat routes/ directory.
|
||||||
|
Backward-compat shim at routes/webhook_routes.py re-exports from here.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Webhook, API Token, and sync chat routes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Form
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.url_security import validate_public_http_url
|
||||||
|
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["webhooks"])
|
||||||
|
|
||||||
|
# Input limits
|
||||||
|
MAX_NAME_LEN = 100
|
||||||
|
MAX_URL_LEN = 2048
|
||||||
|
MAX_SECRET_LEN = 256
|
||||||
|
MAX_MESSAGE_LEN = 32_000
|
||||||
|
|
||||||
|
|
||||||
|
from core.middleware import require_admin as _require_admin
|
||||||
|
|
||||||
|
|
||||||
|
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||||
|
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
||||||
|
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||||
|
let a chat-scoped token fall back onto another user's private endpoint and
|
||||||
|
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||||
|
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||||
|
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||||
|
"""
|
||||||
|
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||||
|
if token_owner:
|
||||||
|
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||||
|
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||||
|
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||||
|
"""Strict session-ownership gate for the token-authenticated sync-chat
|
||||||
|
endpoint (`POST /api/v1/chat`).
|
||||||
|
|
||||||
|
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
||||||
|
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
||||||
|
its owner matches them exactly. A null/empty session owner (legacy or
|
||||||
|
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
||||||
|
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
||||||
|
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
||||||
|
device) could resume such a session, inject a message, and read back its
|
||||||
|
history and reuse the owner's endpoint credentials. Fail closed: an
|
||||||
|
unresolvable caller also returns False.
|
||||||
|
"""
|
||||||
|
if not caller:
|
||||||
|
return False
|
||||||
|
return sess_owner == caller
|
||||||
|
|
||||||
|
|
||||||
|
def setup_webhook_routes(
|
||||||
|
webhook_manager: WebhookManager,
|
||||||
|
auth_manager,
|
||||||
|
session_manager=None,
|
||||||
|
api_key_manager=None,
|
||||||
|
) -> APIRouter:
|
||||||
|
|
||||||
|
@router.get("/webhooks")
|
||||||
|
def list_webhooks(request: Request):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
hooks = db.query(Webhook).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": w.id,
|
||||||
|
"name": w.name,
|
||||||
|
"url": w.url,
|
||||||
|
"has_secret": bool(w.secret),
|
||||||
|
"events": w.events.split(",") if w.events else [],
|
||||||
|
"is_active": w.is_active,
|
||||||
|
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
||||||
|
"last_status_code": w.last_status_code,
|
||||||
|
"last_error": w.last_error,
|
||||||
|
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||||
|
}
|
||||||
|
for w in hooks
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/webhooks")
|
||||||
|
def create_webhook(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(""),
|
||||||
|
url: str = Form(""),
|
||||||
|
secret: str = Form(""),
|
||||||
|
events: str = Form(""),
|
||||||
|
):
|
||||||
|
_require_admin(request)
|
||||||
|
name = name.strip()[:MAX_NAME_LEN]
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Webhook name is required")
|
||||||
|
try:
|
||||||
|
url = validate_webhook_url(url)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
try:
|
||||||
|
events = validate_events(events)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
||||||
|
# Encrypt the secret at rest using the same Fernet key as API keys
|
||||||
|
encrypted_secret = None
|
||||||
|
if secret_val and api_key_manager:
|
||||||
|
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
||||||
|
elif secret_val:
|
||||||
|
encrypted_secret = secret_val # Fallback if no encryption available
|
||||||
|
|
||||||
|
webhook_id = str(uuid.uuid4())[:8]
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(Webhook(
|
||||||
|
id=webhook_id,
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
secret=encrypted_secret,
|
||||||
|
events=events,
|
||||||
|
is_active=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"id": webhook_id, "name": name}
|
||||||
|
|
||||||
|
@router.post("/webhooks/{webhook_id}/test")
|
||||||
|
async def test_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||||
|
if not wh:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
url, secret = wh.url, wh.secret
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
await webhook_manager.deliver_test(webhook_id, url, secret)
|
||||||
|
return {"status": "sent"}
|
||||||
|
|
||||||
|
@router.patch("/webhooks/{webhook_id}")
|
||||||
|
def toggle_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||||
|
if not wh:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
wh.is_active = not wh.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"id": webhook_id, "is_active": wh.is_active}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.delete("/webhooks/{webhook_id}")
|
||||||
|
def delete_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
||||||
|
db.commit()
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
||||||
|
KNOWN_PROVIDERS = {
|
||||||
|
"deepseek": "https://api.deepseek.com/v1",
|
||||||
|
"openai": "https://api.openai.com/v1",
|
||||||
|
"mistral": "https://api.mistral.ai/v1",
|
||||||
|
"groq": "https://api.groq.com/openai/v1",
|
||||||
|
"together": "https://api.together.xyz/v1",
|
||||||
|
"openrouter": "https://openrouter.ai/api/v1",
|
||||||
|
"ollama": "https://ollama.com/api",
|
||||||
|
"opencode-zen": "https://opencode.ai/zen/v1",
|
||||||
|
"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
|
||||||
|
MODEL_PROVIDER_MAP = {
|
||||||
|
"deepseek": "deepseek",
|
||||||
|
"gpt-": "openai",
|
||||||
|
"o1": "openai",
|
||||||
|
"o3": "openai",
|
||||||
|
"o4": "openai",
|
||||||
|
"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]:
|
||||||
|
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
||||||
|
if provider and provider.lower() in KNOWN_PROVIDERS:
|
||||||
|
return KNOWN_PROVIDERS[provider.lower()]
|
||||||
|
if model:
|
||||||
|
model_lower = model.lower()
|
||||||
|
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
||||||
|
if model_lower.startswith(prefix):
|
||||||
|
return KNOWN_PROVIDERS[prov]
|
||||||
|
return None
|
||||||
|
|
||||||
|
class SyncChatRequest(BaseModel):
|
||||||
|
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
||||||
|
model: Optional[str] = Field(None, max_length=200)
|
||||||
|
session: Optional[str] = Field(None, max_length=100)
|
||||||
|
api_key: Optional[str] = Field(None, max_length=256)
|
||||||
|
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
||||||
|
provider: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
@router.post("/v1/chat")
|
||||||
|
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||||
|
if not getattr(request.state, "api_token", False):
|
||||||
|
raise HTTPException(403, "This endpoint requires an API token")
|
||||||
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
|
if "chat" not in scopes:
|
||||||
|
raise HTTPException(403, "API token is not scoped for chat")
|
||||||
|
token_owner = getattr(request.state, "api_token_owner", None)
|
||||||
|
|
||||||
|
from core.models import ChatMessage
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
|
|
||||||
|
message = body.message.strip()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(400, "Message is required")
|
||||||
|
|
||||||
|
session_id = body.session
|
||||||
|
sess = None
|
||||||
|
|
||||||
|
# --- Case 1: Resume an existing session ---
|
||||||
|
if session_id and session_manager:
|
||||||
|
try:
|
||||||
|
sess = session_manager.get_session(session_id)
|
||||||
|
except (KeyError, Exception):
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
# SECURITY: verify the API-token's user owns this session — without
|
||||||
|
# this any token holder could resume any user's chat by passing its
|
||||||
|
# ID. The token's user is on request.state.user (set by API-token
|
||||||
|
# middleware); fall back to require_user if not present.
|
||||||
|
try:
|
||||||
|
from src.auth_helpers import get_current_user as _gcu
|
||||||
|
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
||||||
|
except Exception:
|
||||||
|
_tok_user = None
|
||||||
|
# Strict ownership (see _caller_owns_session): fail closed so a
|
||||||
|
# null-owner / cross-owner session can't be resumed by an arbitrary
|
||||||
|
# chat-scoped token.
|
||||||
|
_sess_owner = getattr(sess, "owner", None)
|
||||||
|
if not _caller_owns_session(_sess_owner, _tok_user):
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
||||||
|
if not sess and body.api_key:
|
||||||
|
api_key = body.api_key.strip()
|
||||||
|
model = body.model or "deepseek-chat"
|
||||||
|
|
||||||
|
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
||||||
|
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
||||||
|
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
||||||
|
if direct_base_url:
|
||||||
|
try:
|
||||||
|
base_url = validate_public_http_url(direct_base_url)
|
||||||
|
except ValueError as e:
|
||||||
|
detail = str(e).replace("URL", "base_url", 1)
|
||||||
|
raise HTTPException(400, detail)
|
||||||
|
else:
|
||||||
|
base_url = _resolve_base_url(model, body.provider)
|
||||||
|
if not base_url:
|
||||||
|
raise HTTPException(400,
|
||||||
|
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||||
|
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||||
|
base_url = normalize_base(base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
|
||||||
|
if not session_manager:
|
||||||
|
raise HTTPException(500, "Session manager not available")
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sess = session_manager.create_session(
|
||||||
|
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||||
|
model=model, owner=token_owner,
|
||||||
|
)
|
||||||
|
sess.headers = build_headers(api_key, base_url)
|
||||||
|
session_manager.save_sessions()
|
||||||
|
session_id = sid
|
||||||
|
|
||||||
|
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
||||||
|
if not sess:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not ep:
|
||||||
|
raise HTTPException(400,
|
||||||
|
"No session, api_key, or configured endpoints. "
|
||||||
|
"Pass api_key + model, or configure an endpoint in Admin.")
|
||||||
|
|
||||||
|
base_url = normalize_base(ep.base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
model = body.model or "auto"
|
||||||
|
api_key = ep.api_key
|
||||||
|
if getattr(ep, "provider_auth_id", None):
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||||
|
|
||||||
|
if model == "auto":
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
models_url = build_models_url(base_url)
|
||||||
|
hdrs = build_headers(api_key, base_url)
|
||||||
|
if models_url:
|
||||||
|
resp = await client.get(models_url, headers=hdrs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||||
|
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||||
|
if not ids and isinstance(data, dict):
|
||||||
|
ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
import json as _json
|
||||||
|
ids = _json.loads(ep.cached_models or "[]")
|
||||||
|
model = ids[0] if ids else "auto"
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "Could not discover models from endpoint")
|
||||||
|
|
||||||
|
if not session_manager:
|
||||||
|
raise HTTPException(500, "Session manager not available")
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sess = session_manager.create_session(
|
||||||
|
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||||
|
model=model, owner=token_owner,
|
||||||
|
)
|
||||||
|
if api_key:
|
||||||
|
sess.headers = build_headers(api_key, base_url)
|
||||||
|
session_manager.save_sessions()
|
||||||
|
session_id = sid
|
||||||
|
|
||||||
|
# --- Send message and get response ---
|
||||||
|
sess.add_message(ChatMessage("user", message))
|
||||||
|
|
||||||
|
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
||||||
|
|
||||||
|
reply = await llm_call_async(
|
||||||
|
sess.endpoint_url, sess.model, messages,
|
||||||
|
headers=sess.headers, timeout=120,
|
||||||
|
)
|
||||||
|
sess.add_message(ChatMessage("assistant", reply))
|
||||||
|
session_manager.save_sessions()
|
||||||
|
|
||||||
|
webhook_manager.fire_and_forget("chat.completed", {
|
||||||
|
"session_id": session_id, "model": sess.model,
|
||||||
|
"user_message": message[:2000], "response": reply[:2000],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||||
|
|
||||||
|
return router
|
||||||
+12
-391
@@ -1,395 +1,16 @@
|
|||||||
"""Webhook, API Token, and sync chat routes."""
|
"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
|
||||||
|
|
||||||
import uuid
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
import logging
|
that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
|
||||||
from typing import Optional
|
``importlib.import_module("routes.webhook_routes")``, and the
|
||||||
|
``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
|
||||||
|
...)`` pattern used by test_null_owner_gates.py all operate on the *same*
|
||||||
|
object. Keeps existing import paths working after slice 2l (#4082/#4071).
|
||||||
|
Source-introspection tests read the canonical file by path.
|
||||||
|
"""
|
||||||
|
|
||||||
import httpx
|
import sys as _sys
|
||||||
from fastapi import APIRouter, HTTPException, Request, Form
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
from routes.webhook import webhook_routes as _canonical # noqa: F401
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
from src.url_security import validate_public_http_url
|
|
||||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["webhooks"])
|
|
||||||
|
|
||||||
# Input limits
|
|
||||||
MAX_NAME_LEN = 100
|
|
||||||
MAX_URL_LEN = 2048
|
|
||||||
MAX_SECRET_LEN = 256
|
|
||||||
MAX_MESSAGE_LEN = 32_000
|
|
||||||
|
|
||||||
|
|
||||||
from core.middleware import require_admin as _require_admin
|
|
||||||
|
|
||||||
|
|
||||||
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
|
||||||
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
|
||||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
|
||||||
let a chat-scoped token fall back onto another user's private endpoint and
|
|
||||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
|
||||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
|
||||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
|
||||||
"""
|
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
|
||||||
if token_owner:
|
|
||||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
|
||||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
|
||||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
|
||||||
|
|
||||||
|
|
||||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
|
||||||
"""Strict session-ownership gate for the token-authenticated sync-chat
|
|
||||||
endpoint (`POST /api/v1/chat`).
|
|
||||||
|
|
||||||
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
|
||||||
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
|
||||||
its owner matches them exactly. A null/empty session owner (legacy or
|
|
||||||
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
|
||||||
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
|
||||||
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
|
||||||
device) could resume such a session, inject a message, and read back its
|
|
||||||
history and reuse the owner's endpoint credentials. Fail closed: an
|
|
||||||
unresolvable caller also returns False.
|
|
||||||
"""
|
|
||||||
if not caller:
|
|
||||||
return False
|
|
||||||
return sess_owner == caller
|
|
||||||
|
|
||||||
|
|
||||||
def setup_webhook_routes(
|
|
||||||
webhook_manager: WebhookManager,
|
|
||||||
auth_manager,
|
|
||||||
session_manager=None,
|
|
||||||
api_key_manager=None,
|
|
||||||
) -> APIRouter:
|
|
||||||
|
|
||||||
@router.get("/webhooks")
|
|
||||||
def list_webhooks(request: Request):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
hooks = db.query(Webhook).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": w.id,
|
|
||||||
"name": w.name,
|
|
||||||
"url": w.url,
|
|
||||||
"has_secret": bool(w.secret),
|
|
||||||
"events": w.events.split(",") if w.events else [],
|
|
||||||
"is_active": w.is_active,
|
|
||||||
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
|
||||||
"last_status_code": w.last_status_code,
|
|
||||||
"last_error": w.last_error,
|
|
||||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
|
||||||
}
|
|
||||||
for w in hooks
|
|
||||||
]
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.post("/webhooks")
|
|
||||||
def create_webhook(
|
|
||||||
request: Request,
|
|
||||||
name: str = Form(""),
|
|
||||||
url: str = Form(""),
|
|
||||||
secret: str = Form(""),
|
|
||||||
events: str = Form(""),
|
|
||||||
):
|
|
||||||
_require_admin(request)
|
|
||||||
name = name.strip()[:MAX_NAME_LEN]
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(400, "Webhook name is required")
|
|
||||||
try:
|
|
||||||
url = validate_webhook_url(url)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
try:
|
|
||||||
events = validate_events(events)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
|
|
||||||
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
|
||||||
# Encrypt the secret at rest using the same Fernet key as API keys
|
|
||||||
encrypted_secret = None
|
|
||||||
if secret_val and api_key_manager:
|
|
||||||
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
|
||||||
elif secret_val:
|
|
||||||
encrypted_secret = secret_val # Fallback if no encryption available
|
|
||||||
|
|
||||||
webhook_id = str(uuid.uuid4())[:8]
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
db.add(Webhook(
|
|
||||||
id=webhook_id,
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
secret=encrypted_secret,
|
|
||||||
events=events,
|
|
||||||
is_active=True,
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return {"id": webhook_id, "name": name}
|
|
||||||
|
|
||||||
@router.post("/webhooks/{webhook_id}/test")
|
|
||||||
async def test_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
||||||
if not wh:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
url, secret = wh.url, wh.secret
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
await webhook_manager.deliver_test(webhook_id, url, secret)
|
|
||||||
return {"status": "sent"}
|
|
||||||
|
|
||||||
@router.patch("/webhooks/{webhook_id}")
|
|
||||||
def toggle_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
||||||
if not wh:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
wh.is_active = not wh.is_active
|
|
||||||
db.commit()
|
|
||||||
return {"id": webhook_id, "is_active": wh.is_active}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.delete("/webhooks/{webhook_id}")
|
|
||||||
def delete_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
|
||||||
db.commit()
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
return {"status": "deleted"}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
|
||||||
KNOWN_PROVIDERS = {
|
|
||||||
"deepseek": "https://api.deepseek.com/v1",
|
|
||||||
"openai": "https://api.openai.com/v1",
|
|
||||||
"mistral": "https://api.mistral.ai/v1",
|
|
||||||
"groq": "https://api.groq.com/openai/v1",
|
|
||||||
"together": "https://api.together.xyz/v1",
|
|
||||||
"openrouter": "https://openrouter.ai/api/v1",
|
|
||||||
"ollama": "https://ollama.com/api",
|
|
||||||
"opencode-zen": "https://opencode.ai/zen/v1",
|
|
||||||
"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
|
|
||||||
MODEL_PROVIDER_MAP = {
|
|
||||||
"deepseek": "deepseek",
|
|
||||||
"gpt-": "openai",
|
|
||||||
"o1": "openai",
|
|
||||||
"o3": "openai",
|
|
||||||
"o4": "openai",
|
|
||||||
"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]:
|
|
||||||
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
|
||||||
if provider and provider.lower() in KNOWN_PROVIDERS:
|
|
||||||
return KNOWN_PROVIDERS[provider.lower()]
|
|
||||||
if model:
|
|
||||||
model_lower = model.lower()
|
|
||||||
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
|
||||||
if model_lower.startswith(prefix):
|
|
||||||
return KNOWN_PROVIDERS[prov]
|
|
||||||
return None
|
|
||||||
|
|
||||||
class SyncChatRequest(BaseModel):
|
|
||||||
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
|
||||||
model: Optional[str] = Field(None, max_length=200)
|
|
||||||
session: Optional[str] = Field(None, max_length=100)
|
|
||||||
api_key: Optional[str] = Field(None, max_length=256)
|
|
||||||
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
|
||||||
provider: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
@router.post("/v1/chat")
|
|
||||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
|
||||||
if not getattr(request.state, "api_token", False):
|
|
||||||
raise HTTPException(403, "This endpoint requires an API token")
|
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
|
||||||
if "chat" not in scopes:
|
|
||||||
raise HTTPException(403, "API token is not scoped for chat")
|
|
||||||
token_owner = getattr(request.state, "api_token_owner", None)
|
|
||||||
|
|
||||||
from core.models import ChatMessage
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
|
||||||
|
|
||||||
message = body.message.strip()
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(400, "Message is required")
|
|
||||||
|
|
||||||
session_id = body.session
|
|
||||||
sess = None
|
|
||||||
|
|
||||||
# --- Case 1: Resume an existing session ---
|
|
||||||
if session_id and session_manager:
|
|
||||||
try:
|
|
||||||
sess = session_manager.get_session(session_id)
|
|
||||||
except (KeyError, Exception):
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
# SECURITY: verify the API-token's user owns this session — without
|
|
||||||
# this any token holder could resume any user's chat by passing its
|
|
||||||
# ID. The token's user is on request.state.user (set by API-token
|
|
||||||
# middleware); fall back to require_user if not present.
|
|
||||||
try:
|
|
||||||
from src.auth_helpers import get_current_user as _gcu
|
|
||||||
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
|
||||||
except Exception:
|
|
||||||
_tok_user = None
|
|
||||||
# Strict ownership (see _caller_owns_session): fail closed so a
|
|
||||||
# null-owner / cross-owner session can't be resumed by an arbitrary
|
|
||||||
# chat-scoped token.
|
|
||||||
_sess_owner = getattr(sess, "owner", None)
|
|
||||||
if not _caller_owns_session(_sess_owner, _tok_user):
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
|
|
||||||
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
|
||||||
if not sess and body.api_key:
|
|
||||||
api_key = body.api_key.strip()
|
|
||||||
model = body.model or "deepseek-chat"
|
|
||||||
|
|
||||||
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
|
||||||
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
|
||||||
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
|
||||||
if direct_base_url:
|
|
||||||
try:
|
|
||||||
base_url = validate_public_http_url(direct_base_url)
|
|
||||||
except ValueError as e:
|
|
||||||
detail = str(e).replace("URL", "base_url", 1)
|
|
||||||
raise HTTPException(400, detail)
|
|
||||||
else:
|
|
||||||
base_url = _resolve_base_url(model, body.provider)
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(400,
|
|
||||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
|
||||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
|
||||||
base_url = normalize_base(base_url)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
|
|
||||||
if not session_manager:
|
|
||||||
raise HTTPException(500, "Session manager not available")
|
|
||||||
|
|
||||||
sid = str(uuid.uuid4())
|
|
||||||
sess = session_manager.create_session(
|
|
||||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
|
||||||
model=model, owner=token_owner,
|
|
||||||
)
|
|
||||||
sess.headers = build_headers(api_key, base_url)
|
|
||||||
session_manager.save_sessions()
|
|
||||||
session_id = sid
|
|
||||||
|
|
||||||
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
|
||||||
if not sess:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if not ep:
|
|
||||||
raise HTTPException(400,
|
|
||||||
"No session, api_key, or configured endpoints. "
|
|
||||||
"Pass api_key + model, or configure an endpoint in Admin.")
|
|
||||||
|
|
||||||
base_url = normalize_base(ep.base_url)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
model = body.model or "auto"
|
|
||||||
api_key = ep.api_key
|
|
||||||
if getattr(ep, "provider_auth_id", None):
|
|
||||||
try:
|
|
||||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
|
||||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
|
||||||
|
|
||||||
if model == "auto":
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
|
||||||
models_url = build_models_url(base_url)
|
|
||||||
hdrs = build_headers(api_key, base_url)
|
|
||||||
if models_url:
|
|
||||||
resp = await client.get(models_url, headers=hdrs)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
|
||||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
|
||||||
if not ids and isinstance(data, dict):
|
|
||||||
ids = [
|
|
||||||
m.get("name") or m.get("model")
|
|
||||||
for m in (data.get("models") or [])
|
|
||||||
if m.get("name") or m.get("model")
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
import json as _json
|
|
||||||
ids = _json.loads(ep.cached_models or "[]")
|
|
||||||
model = ids[0] if ids else "auto"
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(500, "Could not discover models from endpoint")
|
|
||||||
|
|
||||||
if not session_manager:
|
|
||||||
raise HTTPException(500, "Session manager not available")
|
|
||||||
|
|
||||||
sid = str(uuid.uuid4())
|
|
||||||
sess = session_manager.create_session(
|
|
||||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
|
||||||
model=model, owner=token_owner,
|
|
||||||
)
|
|
||||||
if api_key:
|
|
||||||
sess.headers = build_headers(api_key, base_url)
|
|
||||||
session_manager.save_sessions()
|
|
||||||
session_id = sid
|
|
||||||
|
|
||||||
# --- Send message and get response ---
|
|
||||||
sess.add_message(ChatMessage("user", message))
|
|
||||||
|
|
||||||
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
|
||||||
|
|
||||||
reply = await llm_call_async(
|
|
||||||
sess.endpoint_url, sess.model, messages,
|
|
||||||
headers=sess.headers, timeout=120,
|
|
||||||
)
|
|
||||||
sess.add_message(ChatMessage("assistant", reply))
|
|
||||||
session_manager.save_sessions()
|
|
||||||
|
|
||||||
webhook_manager.fire_and_forget("chat.completed", {
|
|
||||||
"session_id": session_id, "model": sess.model,
|
|
||||||
"user_message": message[:2000], "response": reply[:2000],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|||||||
@@ -214,6 +214,22 @@ _check_nvidia_smi() {
|
|||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WSL2 snap Docker cannot see /usr/lib/wsl/lib/libdxcore.so from its confined
|
||||||
|
# namespace, so NVIDIA passthrough fails until the user switches to non-snap
|
||||||
|
# Docker. DockerRootDir identifies snap installs more reliably than snap(8).
|
||||||
|
_is_wsl() {
|
||||||
|
grep -qi microsoft /proc/version 2>/dev/null && return 0
|
||||||
|
[ -d /usr/lib/wsl ] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_is_docker_snap() {
|
||||||
|
case "$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)" in
|
||||||
|
*/snap/docker/*|*/snap.docker/*) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Returns 1 if Docker is unavailable (callers should stop further GPU checks).
|
# Returns 1 if Docker is unavailable (callers should stop further GPU checks).
|
||||||
_check_docker() {
|
_check_docker() {
|
||||||
_info "Checking Docker..."
|
_info "Checking Docker..."
|
||||||
@@ -258,6 +274,26 @@ _check_gpu_passthrough() {
|
|||||||
echo
|
echo
|
||||||
_fail "GPU passthrough failed. Check these steps in order:"
|
_fail "GPU passthrough failed. Check these steps in order:"
|
||||||
echo
|
echo
|
||||||
|
if _is_wsl && _is_docker_snap; then
|
||||||
|
_warn "Detected: Docker installed via snap, running on WSL2."
|
||||||
|
_warn "This is a known incompatibility, not a toolkit/config problem:"
|
||||||
|
_warn " snap confines Docker's mount namespace, so it cannot see the"
|
||||||
|
_warn " WSL2-injected GPU library at /usr/lib/wsl/lib/libdxcore.so even"
|
||||||
|
_warn " though the file exists on the host. Installing/reconfiguring"
|
||||||
|
_warn " nvidia-container-toolkit will NOT fix this — the numbered"
|
||||||
|
_warn " steps below will not help until Docker itself is replaced."
|
||||||
|
echo
|
||||||
|
_info "Fix: remove snap Docker and install the official apt-based Docker"
|
||||||
|
_info "Engine instead (unsandboxed, can see /usr/lib/wsl/lib):"
|
||||||
|
echo
|
||||||
|
echo " sudo snap remove docker"
|
||||||
|
echo " # then follow: https://docs.docker.com/engine/install/ubuntu/"
|
||||||
|
echo " sudo nvidia-ctk runtime configure --runtime=docker"
|
||||||
|
echo " sudo systemctl restart docker"
|
||||||
|
echo
|
||||||
|
_info "Re-run this script afterward to confirm passthrough works."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
echo " 1. Install NVIDIA Container Toolkit (if not already installed):"
|
echo " 1. Install NVIDIA Container Toolkit (if not already installed):"
|
||||||
echo " Arch: sudo pacman -S nvidia-container-toolkit"
|
echo " Arch: sudo pacman -S nvidia-container-toolkit"
|
||||||
echo " Debian: sudo apt install nvidia-container-toolkit"
|
echo " Debian: sudo apt install nvidia-container-toolkit"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Create/remove the switchable, non-default 'Demo' EmailAccount in Odysseus.
|
"""Create/remove the switchable 'Demo' EmailAccount in Odysseus.
|
||||||
|
|
||||||
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
|
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
|
||||||
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
|
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
|
||||||
@@ -20,7 +20,14 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from core.database import SessionLocal, EmailAccount, Base, engine # noqa: E402
|
from core.database import ( # noqa: E402
|
||||||
|
Base,
|
||||||
|
EmailAccount,
|
||||||
|
SessionLocal,
|
||||||
|
engine,
|
||||||
|
lock_email_account_owner_mutations,
|
||||||
|
)
|
||||||
|
from sqlalchemy import or_ # noqa: E402
|
||||||
from src.secret_storage import encrypt # noqa: E402
|
from src.secret_storage import encrypt # noqa: E402
|
||||||
|
|
||||||
NAME = "Demo"
|
NAME = "Demo"
|
||||||
@@ -31,18 +38,98 @@ IMAP_PASSWORD = "demodemo"
|
|||||||
OWNER = ""
|
OWNER = ""
|
||||||
|
|
||||||
|
|
||||||
def setup() -> int:
|
def _owner_scope(query, owner: str):
|
||||||
Base.metadata.create_all(bind=engine)
|
if owner:
|
||||||
|
return query.filter(EmailAccount.owner == owner)
|
||||||
|
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_demo_scopes() -> set[str]:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
acct = db.query(EmailAccount).filter(
|
return {
|
||||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
row.owner or ""
|
||||||
).first()
|
for row in db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.name == NAME,
|
||||||
|
EmailAccount.imap_user == IMAP_USER,
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_and_load_demo_rows(db, scopes: set[str]):
|
||||||
|
"""Reload Demo rows under every observed owner lock."""
|
||||||
|
scopes = set(scopes) or {OWNER}
|
||||||
|
while True:
|
||||||
|
lock_email_account_owner_mutations(db, *scopes)
|
||||||
|
rows = (
|
||||||
|
db.query(EmailAccount)
|
||||||
|
.filter(
|
||||||
|
EmailAccount.name == NAME,
|
||||||
|
EmailAccount.imap_user == IMAP_USER,
|
||||||
|
)
|
||||||
|
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
current_scopes = {row.owner or "" for row in rows}
|
||||||
|
if current_scopes.issubset(scopes) or db.get_bind().dialect.name == "sqlite":
|
||||||
|
return rows
|
||||||
|
db.rollback()
|
||||||
|
scopes.update(current_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_oldest_enabled(db, owner: str, excluded_ids: list[str]) -> None:
|
||||||
|
remaining = _owner_scope(
|
||||||
|
db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.enabled == True, # noqa: E712
|
||||||
|
~EmailAccount.id.in_(excluded_ids),
|
||||||
|
),
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
if remaining.filter(EmailAccount.is_default == True).first() is not None: # noqa: E712
|
||||||
|
return
|
||||||
|
promote = remaining.order_by(
|
||||||
|
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
||||||
|
).first()
|
||||||
|
if promote is not None:
|
||||||
|
promote.is_default = True
|
||||||
|
|
||||||
|
|
||||||
|
def setup() -> int:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
scopes = _discover_demo_scopes() | {OWNER}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = _lock_and_load_demo_rows(db, scopes)
|
||||||
|
acct = rows[0] if rows else None
|
||||||
if acct is None:
|
if acct is None:
|
||||||
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
|
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
|
||||||
db.add(acct)
|
db.add(acct)
|
||||||
|
old_scope = acct.owner or ""
|
||||||
|
was_default = bool(acct.is_default)
|
||||||
|
if old_scope != OWNER:
|
||||||
|
# Move a non-default row first so the unique index cannot see two
|
||||||
|
# defaults transiently while SQLAlchemy flushes the owner move and
|
||||||
|
# old-scope promotion in separate UPDATE statements.
|
||||||
|
acct.is_default = False
|
||||||
|
acct.owner = OWNER
|
||||||
|
db.flush()
|
||||||
|
if was_default:
|
||||||
|
_promote_oldest_enabled(db, old_scope, [acct.id])
|
||||||
|
|
||||||
|
target_default = _owner_scope(
|
||||||
|
db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.id != acct.id,
|
||||||
|
EmailAccount.is_default == True, # noqa: E712
|
||||||
|
),
|
||||||
|
OWNER,
|
||||||
|
).first()
|
||||||
acct.owner = OWNER
|
acct.owner = OWNER
|
||||||
acct.is_default = False # never default — user switches to it
|
# Keep Demo non-default when a real default exists. If it is the only
|
||||||
|
# enabled account, it must be default to preserve normal create
|
||||||
|
# semantics and avoid leaving the owner partition without one.
|
||||||
|
acct.is_default = target_default is None
|
||||||
acct.enabled = True
|
acct.enabled = True
|
||||||
acct.imap_host = "localhost"
|
acct.imap_host = "localhost"
|
||||||
acct.imap_port = 31143
|
acct.imap_port = 31143
|
||||||
@@ -57,20 +144,27 @@ def setup() -> int:
|
|||||||
acct.smtp_password = encrypt(IMAP_PASSWORD)
|
acct.smtp_password = encrypt(IMAP_PASSWORD)
|
||||||
acct.from_address = IMAP_USER
|
acct.from_address = IMAP_USER
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"'{NAME}' account ready (id={acct.id}, non-default, switchable).")
|
state = "default" if acct.is_default else "non-default"
|
||||||
|
print(f"'{NAME}' account ready (id={acct.id}, {state}, switchable).")
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def teardown() -> int:
|
def teardown() -> int:
|
||||||
|
scopes = _discover_demo_scopes()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.query(EmailAccount).filter(
|
rows = _lock_and_load_demo_rows(db, scopes)
|
||||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
deleted_ids = [row.id for row in rows]
|
||||||
).all()
|
default_scopes = {row.owner or "" for row in rows if row.is_default}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
db.delete(r)
|
db.delete(r)
|
||||||
|
# Ensure the old default DELETE reaches the database before a
|
||||||
|
# replacement UPDATE; the unique index is enforced per statement.
|
||||||
|
db.flush()
|
||||||
|
for owner in default_scopes:
|
||||||
|
_promote_oldest_enabled(db, owner, deleted_ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"removed {len(rows)} '{NAME}' account row(s).")
|
print(f"removed {len(rows)} '{NAME}' account row(s).")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+436
-100
@@ -26,13 +26,14 @@ import io
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -44,6 +45,7 @@ _pipe = None
|
|||||||
_model_id = ""
|
_model_id = ""
|
||||||
DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
||||||
_args = None
|
_args = None
|
||||||
|
_PROGRESS = {}
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -112,6 +114,77 @@ def _configure_security_middleware(application, allowed_hosts, allowed_origins):
|
|||||||
_configure_security_middleware(app, _DEFAULT_ALLOWED_HOSTS, _DEFAULT_CORS_ORIGINS)
|
_configure_security_middleware(app, _DEFAULT_ALLOWED_HOSTS, _DEFAULT_CORS_ORIGINS)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_progress(request_id: str, total_steps: int, prompt: str, kind: str) -> str:
|
||||||
|
rid = (request_id or "").strip() or uuid.uuid4().hex
|
||||||
|
_PROGRESS[rid] = {
|
||||||
|
"id": rid,
|
||||||
|
"kind": kind,
|
||||||
|
"status": "running",
|
||||||
|
"step": 0,
|
||||||
|
"total": max(1, int(total_steps or 1)),
|
||||||
|
"percent": 0,
|
||||||
|
"prompt": (prompt or "")[:120],
|
||||||
|
"started_at": time.time(),
|
||||||
|
"updated_at": time.time(),
|
||||||
|
}
|
||||||
|
return rid
|
||||||
|
|
||||||
|
|
||||||
|
def _update_progress(request_id: str, step: int, total_steps: int | None = None, status: str = "running"):
|
||||||
|
if not request_id:
|
||||||
|
return
|
||||||
|
item = _PROGRESS.get(request_id)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
total = max(1, int(total_steps or item.get("total") or 1))
|
||||||
|
current = max(0, min(int(step or 0), total))
|
||||||
|
item.update({
|
||||||
|
"status": status,
|
||||||
|
"step": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": round((current / total) * 100, 1),
|
||||||
|
"updated_at": time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_progress(request_id: str, status: str = "done", error: str = ""):
|
||||||
|
if not request_id:
|
||||||
|
return
|
||||||
|
item = _PROGRESS.get(request_id)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
total = max(1, int(item.get("total") or 1))
|
||||||
|
item.update({
|
||||||
|
"status": status,
|
||||||
|
"step": total if status == "done" else item.get("step", 0),
|
||||||
|
"total": total,
|
||||||
|
"percent": 100 if status == "done" else item.get("percent", 0),
|
||||||
|
"error": error,
|
||||||
|
"updated_at": time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipeline_with_progress(pipe, request_id: str, total_steps: int, **kwargs):
|
||||||
|
def step_end_callback(_pipe, step, timestep, callback_kwargs):
|
||||||
|
_update_progress(request_id, int(step) + 1, total_steps)
|
||||||
|
return callback_kwargs
|
||||||
|
|
||||||
|
def legacy_callback(step, timestep, latents):
|
||||||
|
_update_progress(request_id, int(step) + 1, total_steps)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return pipe(callback_on_step_end=step_end_callback, **kwargs)
|
||||||
|
except TypeError as exc:
|
||||||
|
if "callback_on_step_end" not in str(exc):
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return pipe(callback=legacy_callback, callback_steps=1, **kwargs)
|
||||||
|
except TypeError as exc:
|
||||||
|
if "callback" not in str(exc) and "callback_steps" not in str(exc):
|
||||||
|
raise
|
||||||
|
return pipe(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ImageRequest(BaseModel):
|
class ImageRequest(BaseModel):
|
||||||
model: str = ""
|
model: str = ""
|
||||||
prompt: str
|
prompt: str
|
||||||
@@ -119,10 +192,71 @@ class ImageRequest(BaseModel):
|
|||||||
size: str = "1024x1024"
|
size: str = "1024x1024"
|
||||||
quality: str = "medium"
|
quality: str = "medium"
|
||||||
response_format: str = "b64_json"
|
response_format: str = "b64_json"
|
||||||
|
request_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_size(size: str) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
w, h = (size or "").split("x")
|
||||||
|
return int(w), int(h)
|
||||||
|
except Exception:
|
||||||
|
return _args.width, _args.height
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_steps(quality: str) -> int:
|
||||||
|
default_steps = _args.steps or 8
|
||||||
|
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
|
||||||
|
return steps_map.get(quality, default_steps)
|
||||||
|
|
||||||
|
|
||||||
|
def _guidance_scale() -> float:
|
||||||
|
return float(_args.guidance_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_negative_prompt() -> str | None:
|
||||||
|
value = (_args.negative_prompt or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_accepts_arg(name: str) -> bool:
|
||||||
|
try:
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(_pipe.__call__)
|
||||||
|
return name in sig.parameters
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_call_kwargs(**kwargs) -> dict:
|
||||||
|
"""Filter kwargs to the active pipeline signature.
|
||||||
|
|
||||||
|
Diffusers/community pipelines are not consistent: ordinary img2img
|
||||||
|
usually accepts `image`, while instruction-edit models such as OmniGen2
|
||||||
|
accept `input_images` plus model-specific guidance fields. Filtering keeps
|
||||||
|
the server generic and avoids hardcoding repo IDs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(_pipe.__call__)
|
||||||
|
params = sig.parameters
|
||||||
|
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||||
|
return {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
return {k: v for k, v in kwargs.items() if v is not None and k in params}
|
||||||
|
except Exception:
|
||||||
|
return {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_response(images) -> dict:
|
||||||
|
data = []
|
||||||
|
for img in images:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
data.append({"b64_json": base64.b64encode(buf.getvalue()).decode()})
|
||||||
|
return {"created": int(time.time()), "data": data}
|
||||||
|
|
||||||
|
|
||||||
def _fix_meta_tensors(pipe, dtype):
|
def _fix_meta_tensors(pipe, dtype):
|
||||||
"""Replace any meta tensors with real zero tensors on CPU so .to(cuda) works."""
|
"""Replace any meta tensors with real zero tensors on CPU so .to(device) works."""
|
||||||
for name, component in pipe.components.items():
|
for name, component in pipe.components.items():
|
||||||
if not hasattr(component, 'parameters'):
|
if not hasattr(component, 'parameters'):
|
||||||
continue
|
continue
|
||||||
@@ -142,6 +276,69 @@ def _fix_meta_tensors(pipe, dtype):
|
|||||||
logger.info(f" Fixed {fixed} meta tensors in {name}")
|
logger.info(f" Fixed {fixed} meta tensors in {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _target_device() -> str:
|
||||||
|
"""Best available torch device for Diffusers on this host."""
|
||||||
|
try:
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def _can_cpu_offload(device: str) -> bool:
|
||||||
|
# Diffusers CPU offload helpers are CUDA/accelerate-oriented. On Apple
|
||||||
|
# Silicon they either no-op poorly or fail; MPS should use direct .to("mps").
|
||||||
|
return device == "cuda"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_omnigen2_pipeline(model_path: str, torch_dtype, target_device: str, use_offload: bool) -> bool:
|
||||||
|
"""Load OmniGen2 from the official repo package.
|
||||||
|
|
||||||
|
OmniGen2 publishes a Diffusers-style model_index.json, but current
|
||||||
|
public diffusers builds do not expose OmniGen2Pipeline as a stock class.
|
||||||
|
The official examples import the pipeline from the cloned `omnigen2`
|
||||||
|
package, so support that path without hardcoding any private model.
|
||||||
|
"""
|
||||||
|
global _pipe
|
||||||
|
try:
|
||||||
|
from omnigen2.pipelines.omnigen2.pipeline_omnigen2 import OmniGen2Pipeline
|
||||||
|
from omnigen2.models.transformers.transformer_omnigen2 import OmniGen2Transformer2DModel
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("OmniGen2 package import failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Loading OmniGen2 pipeline via official omnigen2 package")
|
||||||
|
pipe = OmniGen2Pipeline.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch_dtype,
|
||||||
|
trust_remote_code=True,
|
||||||
|
)
|
||||||
|
pipe.transformer = OmniGen2Transformer2DModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
subfolder="transformer",
|
||||||
|
torch_dtype=torch_dtype,
|
||||||
|
)
|
||||||
|
if use_offload and _can_cpu_offload(target_device):
|
||||||
|
pipe.enable_model_cpu_offload()
|
||||||
|
logger.info("Loaded OmniGen2 with CPU offload")
|
||||||
|
else:
|
||||||
|
pipe = pipe.to(target_device)
|
||||||
|
logger.info("Loaded OmniGen2 on %s", target_device)
|
||||||
|
_pipe = pipe
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Official OmniGen2 loader failed: %s", exc)
|
||||||
|
_pipe = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def load_model():
|
def load_model():
|
||||||
global _pipe, _model_id
|
global _pipe, _model_id
|
||||||
import diffusers
|
import diffusers
|
||||||
@@ -151,8 +348,12 @@ def load_model():
|
|||||||
dtype_map = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
dtype_map = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
||||||
torch_dtype = dtype_map.get(_args.dtype, torch.bfloat16)
|
torch_dtype = dtype_map.get(_args.dtype, torch.bfloat16)
|
||||||
use_offload = _args.cpu_offload
|
use_offload = _args.cpu_offload
|
||||||
|
target_device = _target_device()
|
||||||
|
if target_device != "cuda" and use_offload:
|
||||||
|
logger.warning("CPU offload requested but %s is the active device; using direct device placement instead", target_device)
|
||||||
|
use_offload = False
|
||||||
|
|
||||||
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload})...")
|
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload}, device={target_device})...")
|
||||||
|
|
||||||
# Ensure HF token is available for gated repos
|
# Ensure HF token is available for gated repos
|
||||||
_hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
_hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
||||||
@@ -207,23 +408,45 @@ def load_model():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"GPU cache clear failed: {e}")
|
logger.debug(f"GPU cache clear failed: {e}")
|
||||||
|
|
||||||
|
loaded = False
|
||||||
|
if cls_name_from_index == "OmniGen2Pipeline" or "omnigen2" in str(model_path).lower():
|
||||||
|
loaded = _load_omnigen2_pipeline(model_path, torch_dtype, target_device, use_offload)
|
||||||
|
|
||||||
def _load_pipe(cls, name):
|
def _load_pipe(cls, name):
|
||||||
"""Try loading pipeline, handling meta tensor issues."""
|
"""Try loading pipeline, handling meta tensor issues."""
|
||||||
global _pipe
|
global _pipe
|
||||||
|
|
||||||
# First try normal load
|
# First try normal load
|
||||||
try:
|
try:
|
||||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
kwargs = {"torch_dtype": torch_dtype}
|
||||||
|
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||||
|
kwargs["trust_remote_code"] = True
|
||||||
|
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"{name} from_pretrained failed: {e}")
|
logger.warning(f"{name} from_pretrained failed: {e}")
|
||||||
_pipe = None
|
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||||
_cleanup()
|
try:
|
||||||
return False
|
logger.info(f"Retrying {name} with custom_pipeline={model_path}")
|
||||||
|
_pipe = cls.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch_dtype,
|
||||||
|
custom_pipeline=model_path,
|
||||||
|
trust_remote_code=True,
|
||||||
|
)
|
||||||
|
except Exception as e2:
|
||||||
|
logger.warning(f"{name} custom_pipeline retry failed: {e2}")
|
||||||
|
_pipe = None
|
||||||
|
_cleanup()
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
_pipe = None
|
||||||
|
_cleanup()
|
||||||
|
return False
|
||||||
|
|
||||||
# Materialize any meta tensors before moving to device
|
# Materialize any meta tensors before moving to device
|
||||||
_fix_meta_tensors(_pipe, torch_dtype)
|
_fix_meta_tensors(_pipe, torch_dtype)
|
||||||
|
|
||||||
if use_offload:
|
if use_offload and _can_cpu_offload(target_device):
|
||||||
try:
|
try:
|
||||||
_pipe.enable_model_cpu_offload()
|
_pipe.enable_model_cpu_offload()
|
||||||
logger.info(f"Loaded as {name} with CPU offload")
|
logger.info(f"Loaded as {name} with CPU offload")
|
||||||
@@ -234,24 +457,27 @@ def load_model():
|
|||||||
_cleanup()
|
_cleanup()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Try full CUDA
|
# Try direct device placement
|
||||||
try:
|
try:
|
||||||
_pipe = _pipe.to("cuda")
|
_pipe = _pipe.to(target_device)
|
||||||
logger.info(f"Loaded as {name} on CUDA")
|
logger.info(f"Loaded as {name} on {target_device}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"{name} + .to(cuda) failed: {e}")
|
logger.warning(f"{name} + .to({target_device}) failed: {e}")
|
||||||
_pipe = None
|
_pipe = None
|
||||||
_cleanup()
|
_cleanup()
|
||||||
|
|
||||||
if not use_offload:
|
if not use_offload:
|
||||||
logger.error(f"{name} doesn't fit in VRAM. Use --cpu-offload to enable offloading.")
|
logger.error(f"{name} could not be placed on {target_device}. On CUDA, try --cpu-offload; on Apple Silicon try a smaller model or lower resolution.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# OOM — reload and try with CPU offload
|
# OOM — reload and try with CPU offload
|
||||||
try:
|
try:
|
||||||
logger.info(f"Reloading {name} with CPU offload...")
|
logger.info(f"Reloading {name} with CPU offload...")
|
||||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
kwargs = {"torch_dtype": torch_dtype}
|
||||||
|
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||||
|
kwargs["trust_remote_code"] = True
|
||||||
|
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||||
_fix_meta_tensors(_pipe, torch_dtype)
|
_fix_meta_tensors(_pipe, torch_dtype)
|
||||||
_pipe.enable_model_cpu_offload()
|
_pipe.enable_model_cpu_offload()
|
||||||
logger.info(f"Loaded as {name} with CPU offload")
|
logger.info(f"Loaded as {name} with CPU offload")
|
||||||
@@ -264,7 +490,10 @@ def load_model():
|
|||||||
# Last resort — sequential offload
|
# Last resort — sequential offload
|
||||||
try:
|
try:
|
||||||
logger.info(f"Reloading {name} with sequential CPU offload...")
|
logger.info(f"Reloading {name} with sequential CPU offload...")
|
||||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
kwargs = {"torch_dtype": torch_dtype}
|
||||||
|
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||||
|
kwargs["trust_remote_code"] = True
|
||||||
|
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||||
_fix_meta_tensors(_pipe, torch_dtype)
|
_fix_meta_tensors(_pipe, torch_dtype)
|
||||||
_pipe.enable_sequential_cpu_offload()
|
_pipe.enable_sequential_cpu_offload()
|
||||||
logger.info(f"Loaded as {name} with sequential CPU offload")
|
logger.info(f"Loaded as {name} with sequential CPU offload")
|
||||||
@@ -276,11 +505,11 @@ def load_model():
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
loaded = False
|
if not loaded:
|
||||||
for cls, name in candidates:
|
for cls, name in candidates:
|
||||||
if _load_pipe(cls, name):
|
if _load_pipe(cls, name):
|
||||||
loaded = True
|
loaded = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# Last resort: override unknown pipeline class
|
# Last resort: override unknown pipeline class
|
||||||
if not loaded and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
if not loaded and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||||
@@ -322,36 +551,19 @@ def load_model():
|
|||||||
|
|
||||||
if single_file:
|
if single_file:
|
||||||
logger.info(f"Trying from_single_file with: {single_file}")
|
logger.info(f"Trying from_single_file with: {single_file}")
|
||||||
# Detect model family from path/filename to prioritize the right pipeline + config
|
explicit_configs = [
|
||||||
_path_lower = (model_path + "/" + (single_file or "")).lower()
|
c.strip()
|
||||||
_SD35_CONFIGS = ["stabilityai/stable-diffusion-3.5-large", "stabilityai/stable-diffusion-3.5-medium"]
|
for c in str(_args.single_file_config or "").replace("\n", ",").split(",")
|
||||||
_SD3_CONFIGS = ["stabilityai/stable-diffusion-3-medium-diffusers"]
|
if c.strip()
|
||||||
_FLUX2_CONFIGS = ["black-forest-labs/FLUX.2-dev"]
|
]
|
||||||
_FLUX_CONFIGS = ["black-forest-labs/FLUX.1-schnell", "black-forest-labs/FLUX.1-dev"]
|
config_candidates = explicit_configs or [None]
|
||||||
_SDXL_CONFIGS = ["stabilityai/stable-diffusion-xl-base-1.0"]
|
_pipeline_configs = [
|
||||||
|
("Flux2Pipeline", config_candidates),
|
||||||
# Build ordered pipeline candidates based on model name hints
|
("StableDiffusion3Pipeline", config_candidates),
|
||||||
_pipeline_configs = []
|
("FluxPipeline", config_candidates),
|
||||||
if "sd3.5" in _path_lower or "stable-diffusion-3.5" in _path_lower:
|
("StableDiffusionXLPipeline", config_candidates),
|
||||||
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD35_CONFIGS))
|
("StableDiffusionPipeline", config_candidates),
|
||||||
elif "sd3" in _path_lower or "stable-diffusion-3" in _path_lower:
|
]
|
||||||
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD3_CONFIGS + _SD35_CONFIGS))
|
|
||||||
elif "flux.2" in _path_lower or "flux2" in _path_lower:
|
|
||||||
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
|
|
||||||
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
|
|
||||||
elif "flux" in _path_lower:
|
|
||||||
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
|
|
||||||
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
|
|
||||||
elif "sdxl" in _path_lower or "xl" in _path_lower:
|
|
||||||
_pipeline_configs.append(("StableDiffusionXLPipeline", _SDXL_CONFIGS))
|
|
||||||
# Always add all pipelines as fallbacks
|
|
||||||
_pipeline_configs.extend([
|
|
||||||
("Flux2Pipeline", _FLUX2_CONFIGS),
|
|
||||||
("StableDiffusion3Pipeline", _SD35_CONFIGS + _SD3_CONFIGS),
|
|
||||||
("FluxPipeline", _FLUX_CONFIGS),
|
|
||||||
("StableDiffusionXLPipeline", _SDXL_CONFIGS + [None]),
|
|
||||||
("StableDiffusionPipeline", [None]),
|
|
||||||
])
|
|
||||||
# Deduplicate while preserving order
|
# Deduplicate while preserving order
|
||||||
_seen = set()
|
_seen = set()
|
||||||
_deduped = []
|
_deduped = []
|
||||||
@@ -409,12 +621,12 @@ def load_model():
|
|||||||
logger.info(f"Trying {cls_name}.from_single_file with config={config}")
|
logger.info(f"Trying {cls_name}.from_single_file with config={config}")
|
||||||
_pipe = cls.from_single_file(single_file, **kwargs)
|
_pipe = cls.from_single_file(single_file, **kwargs)
|
||||||
_fix_meta_tensors(_pipe, torch_dtype)
|
_fix_meta_tensors(_pipe, torch_dtype)
|
||||||
if use_offload:
|
if use_offload and _can_cpu_offload(target_device):
|
||||||
_pipe.enable_model_cpu_offload()
|
_pipe.enable_model_cpu_offload()
|
||||||
logger.info(f"Loaded as {cls_name} (single file, config={config}) with CPU offload")
|
logger.info(f"Loaded as {cls_name} (single file, config={config}) with CPU offload")
|
||||||
else:
|
else:
|
||||||
_pipe = _pipe.to("cuda")
|
_pipe = _pipe.to(target_device)
|
||||||
logger.info(f"Loaded as {cls_name} (single file, config={config}) on CUDA")
|
logger.info(f"Loaded as {cls_name} (single file, config={config}) on {target_device}")
|
||||||
loaded = True
|
loaded = True
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -480,17 +692,9 @@ def generate_image(req: ImageRequest):
|
|||||||
if _pipe is None:
|
if _pipe is None:
|
||||||
return {"error": "Model not loaded"}
|
return {"error": "Model not loaded"}
|
||||||
|
|
||||||
# Parse size
|
width, height = _parse_size(req.size)
|
||||||
try:
|
steps = _quality_steps(req.quality)
|
||||||
w, h = req.size.split("x")
|
request_id = _start_progress(req.request_id, steps * max(1, int(req.n or 1)), req.prompt, "generation")
|
||||||
width, height = int(w), int(h)
|
|
||||||
except Exception:
|
|
||||||
width, height = _args.width, _args.height
|
|
||||||
|
|
||||||
# Map quality to num_inference_steps
|
|
||||||
default_steps = _args.steps or 8
|
|
||||||
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
|
|
||||||
steps = steps_map.get(req.quality, default_steps)
|
|
||||||
|
|
||||||
logger.info(f"Generating: {req.prompt[:80]}... ({width}x{height}, {steps} steps)")
|
logger.info(f"Generating: {req.prompt[:80]}... ({width}x{height}, {steps} steps)")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -499,44 +703,172 @@ def generate_image(req: ImageRequest):
|
|||||||
_is_inpaint_pipe = 'inpaint' in type(_pipe).__name__.lower()
|
_is_inpaint_pipe = 'inpaint' in type(_pipe).__name__.lower()
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
for _ in range(req.n):
|
try:
|
||||||
if _is_inpaint_pipe:
|
for image_index in range(req.n):
|
||||||
# Inpaint pipelines need an image + mask — create blank ones for txt2img
|
progress_offset = image_index * steps
|
||||||
from PIL import Image as _PILGen
|
negative_prompt = _default_negative_prompt() if _pipeline_accepts_arg("negative_prompt") else None
|
||||||
_blank = _PILGen.new('RGB', (width, height), (128, 128, 128))
|
if _is_inpaint_pipe:
|
||||||
_mask = _PILGen.new('L', (width, height), 255) # full white = regenerate everything
|
# Inpaint pipelines need an image + mask — create blank ones for txt2img
|
||||||
result = _pipe(
|
from PIL import Image as _PILGen
|
||||||
prompt=req.prompt,
|
_blank = _PILGen.new('RGB', (width, height), (128, 128, 128))
|
||||||
image=_blank,
|
_mask = _PILGen.new('L', (width, height), 255) # full white = regenerate everything
|
||||||
mask_image=_mask,
|
kwargs = {
|
||||||
width=width,
|
"prompt": req.prompt,
|
||||||
height=height,
|
"image": _blank,
|
||||||
num_inference_steps=steps,
|
"mask_image": _mask,
|
||||||
guidance_scale=3.5,
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"num_inference_steps": steps,
|
||||||
|
"guidance_scale": _guidance_scale(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
kwargs = {
|
||||||
|
"prompt": req.prompt,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"num_inference_steps": steps,
|
||||||
|
"guidance_scale": _guidance_scale(),
|
||||||
|
}
|
||||||
|
if negative_prompt:
|
||||||
|
kwargs["negative_prompt"] = negative_prompt
|
||||||
|
result = _run_pipeline_with_progress(
|
||||||
|
_pipe,
|
||||||
|
request_id,
|
||||||
|
steps * max(1, int(req.n or 1)),
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
else:
|
_update_progress(request_id, progress_offset + steps, steps * max(1, int(req.n or 1)))
|
||||||
result = _pipe(
|
img = result.images[0]
|
||||||
prompt=req.prompt,
|
images.append(img)
|
||||||
width=width,
|
except Exception as e:
|
||||||
height=height,
|
_finish_progress(request_id, "error", str(e))
|
||||||
num_inference_steps=steps,
|
raise
|
||||||
guidance_scale=3.5,
|
|
||||||
)
|
|
||||||
img = result.images[0]
|
|
||||||
|
|
||||||
# Convert to base64
|
|
||||||
buf = io.BytesIO()
|
|
||||||
img.save(buf, format="PNG")
|
|
||||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
||||||
images.append({"b64_json": b64})
|
|
||||||
|
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
logger.info(f"Generated {req.n} image(s) in {elapsed:.1f}s")
|
logger.info(f"Generated {req.n} image(s) in {elapsed:.1f}s")
|
||||||
|
_finish_progress(request_id)
|
||||||
|
|
||||||
return {
|
return _image_response(images)
|
||||||
"created": int(time.time()),
|
|
||||||
"data": images,
|
|
||||||
}
|
@app.get("/v1/images/progress/{request_id}")
|
||||||
|
def image_progress(request_id: str):
|
||||||
|
item = _PROGRESS.get(request_id)
|
||||||
|
if not item:
|
||||||
|
return {"id": request_id, "status": "unknown", "step": 0, "total": 0, "percent": 0}
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/images/edits")
|
||||||
|
async def edit_image(
|
||||||
|
prompt: str = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
model: str = Form(""),
|
||||||
|
n: int = Form(1),
|
||||||
|
size: str = Form("1024x1024"),
|
||||||
|
quality: str = Form("medium"),
|
||||||
|
response_format: str = Form("b64_json"),
|
||||||
|
request_id: str = Form(""),
|
||||||
|
):
|
||||||
|
if _pipe is None:
|
||||||
|
return {"error": "Model not loaded"}
|
||||||
|
accepts_image = _pipeline_accepts_arg("image")
|
||||||
|
accepts_input_images = _pipeline_accepts_arg("input_images")
|
||||||
|
if not accepts_image and not accepts_input_images:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"{type(_pipe).__name__} does not support image edits. Use /v1/images/generations with this model.",
|
||||||
|
)
|
||||||
|
|
||||||
|
from PIL import Image as PILImage, ImageOps
|
||||||
|
|
||||||
|
width, height = _parse_size(size)
|
||||||
|
steps = _quality_steps(quality)
|
||||||
|
request_id = _start_progress(request_id, steps * max(1, min(int(n or 1), 4)), prompt, "edit")
|
||||||
|
raw = await image.read()
|
||||||
|
init_image = PILImage.open(io.BytesIO(raw)).convert("RGB")
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
init_image = ImageOps.fit(init_image, (width, height), method=PILImage.LANCZOS, centering=(0.5, 0.5))
|
||||||
|
|
||||||
|
logger.info(f"Editing image: {prompt[:80]}... ({width}x{height}, {steps} steps)")
|
||||||
|
start = time.time()
|
||||||
|
images = []
|
||||||
|
total_images = max(1, min(int(n or 1), 4))
|
||||||
|
for image_index in range(total_images):
|
||||||
|
progress_offset = image_index * steps
|
||||||
|
try:
|
||||||
|
if accepts_input_images and not accepts_image:
|
||||||
|
negative_prompt = _default_negative_prompt()
|
||||||
|
kwargs = _pipeline_call_kwargs(
|
||||||
|
prompt=prompt,
|
||||||
|
input_images=[init_image],
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
max_sequence_length=1024,
|
||||||
|
text_guidance_scale=_guidance_scale(),
|
||||||
|
image_guidance_scale=2.0,
|
||||||
|
cfg_range=(0.0, 1.0),
|
||||||
|
negative_prompt=negative_prompt,
|
||||||
|
num_images_per_prompt=1,
|
||||||
|
output_type="pil",
|
||||||
|
max_pixels=width * height if width > 0 and height > 0 else None,
|
||||||
|
max_input_image_side_length=max(width, height) if width > 0 and height > 0 else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kwargs = _pipeline_call_kwargs(
|
||||||
|
image=init_image,
|
||||||
|
prompt=prompt,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=3.5,
|
||||||
|
true_cfg_scale=4.0,
|
||||||
|
negative_prompt=_default_negative_prompt(),
|
||||||
|
output_type="pil",
|
||||||
|
)
|
||||||
|
result = _run_pipeline_with_progress(
|
||||||
|
_pipe,
|
||||||
|
request_id,
|
||||||
|
steps * total_images,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
if accepts_input_images and not accepts_image:
|
||||||
|
kwargs = _pipeline_call_kwargs(
|
||||||
|
prompt=prompt,
|
||||||
|
input_images=[init_image],
|
||||||
|
num_inference_steps=steps,
|
||||||
|
text_guidance_scale=_guidance_scale(),
|
||||||
|
image_guidance_scale=2.0,
|
||||||
|
negative_prompt=_default_negative_prompt(),
|
||||||
|
output_type="pil",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kwargs = _pipeline_call_kwargs(
|
||||||
|
image=init_image,
|
||||||
|
prompt=prompt,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=3.5,
|
||||||
|
negative_prompt=_default_negative_prompt(),
|
||||||
|
output_type="pil",
|
||||||
|
)
|
||||||
|
result = _run_pipeline_with_progress(
|
||||||
|
_pipe,
|
||||||
|
request_id,
|
||||||
|
steps * total_images,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_finish_progress(request_id, "error", str(e))
|
||||||
|
raise
|
||||||
|
_update_progress(request_id, progress_offset + steps, steps * total_images)
|
||||||
|
images.append(result.images[0])
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
logger.info(f"Edited {len(images)} image(s) in {elapsed:.1f}s")
|
||||||
|
_finish_progress(request_id)
|
||||||
|
return _image_response(images)
|
||||||
|
|
||||||
|
|
||||||
class InpaintRequest(BaseModel):
|
class InpaintRequest(BaseModel):
|
||||||
@@ -607,11 +939,12 @@ def _get_inpaint_pipe():
|
|||||||
]
|
]
|
||||||
torch_dtype = DTYPE_MAP.get(_args.dtype, torch.bfloat16)
|
torch_dtype = DTYPE_MAP.get(_args.dtype, torch.bfloat16)
|
||||||
harmonize_gpu = _args.harmonize_gpu
|
harmonize_gpu = _args.harmonize_gpu
|
||||||
|
target_device = _target_device()
|
||||||
for name in img2img_names:
|
for name in img2img_names:
|
||||||
cls = getattr(diffusers, name, None)
|
cls = getattr(diffusers, name, None)
|
||||||
if cls:
|
if cls:
|
||||||
try:
|
try:
|
||||||
if harmonize_gpu is not None:
|
if harmonize_gpu is not None and target_device == "cuda":
|
||||||
# Load fresh on separate GPU
|
# Load fresh on separate GPU
|
||||||
logger.info(f"Loading {name} on cuda:{harmonize_gpu}...")
|
logger.info(f"Loading {name} on cuda:{harmonize_gpu}...")
|
||||||
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
||||||
@@ -625,10 +958,10 @@ def _get_inpaint_pipe():
|
|||||||
try:
|
try:
|
||||||
# Some pipelines need from_pretrained instead of from_pipe
|
# Some pipelines need from_pretrained instead of from_pipe
|
||||||
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
||||||
if _args.cpu_offload:
|
if _args.cpu_offload and _can_cpu_offload(target_device):
|
||||||
_img2img_pipe.enable_model_cpu_offload()
|
_img2img_pipe.enable_model_cpu_offload()
|
||||||
else:
|
else:
|
||||||
_img2img_pipe = _img2img_pipe.to("cuda")
|
_img2img_pipe = _img2img_pipe.to(target_device)
|
||||||
logger.info(f"Loaded img2img pipeline (from_pretrained): {name}")
|
logger.info(f"Loaded img2img pipeline (from_pretrained): {name}")
|
||||||
return _img2img_pipe, 'img2img'
|
return _img2img_pipe, 'img2img'
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
@@ -1140,6 +1473,9 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
|
parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
|
||||||
parser.add_argument("--device-map", default=None, help="Device map strategy (unused, kept for compat)")
|
parser.add_argument("--device-map", default=None, help="Device map strategy (unused, kept for compat)")
|
||||||
parser.add_argument("--steps", type=int, default=0, help="Default inference steps (0=auto)")
|
parser.add_argument("--steps", type=int, default=0, help="Default inference steps (0=auto)")
|
||||||
|
parser.add_argument("--guidance-scale", type=float, default=3.5, help="Default classifier-free guidance scale")
|
||||||
|
parser.add_argument("--negative-prompt", default="", help="Default negative prompt for pipelines that support it")
|
||||||
|
parser.add_argument("--single-file-config", default="", help="Base Diffusers repo/path for single-file checkpoints that need missing components. Comma-separated values are tried in order.")
|
||||||
parser.add_argument("--width", type=int, default=1024, help="Default output width")
|
parser.add_argument("--width", type=int, default=1024, help="Default output width")
|
||||||
parser.add_argument("--height", type=int, default=1024, help="Default output height")
|
parser.add_argument("--height", type=int, default=1024, help="Default output height")
|
||||||
parser.add_argument("--cpu-offload", action="store_true", help="Enable model CPU offload")
|
parser.add_argument("--cpu-offload", action="store_true", help="Enable model CPU offload")
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OpenAI-compatible image API wrapper for MLX image models.
|
||||||
|
|
||||||
|
This is intentionally small: it exposes the same `/v1/images/generations`
|
||||||
|
shape Odysseus already uses for local image endpoints, then delegates to the
|
||||||
|
MLX image CLI for the actual generation. Text MLX models still use
|
||||||
|
`mlx_lm.server`; image MLX models should use this wrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("mlx_image_server")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageRequest(BaseModel):
|
||||||
|
model: str = ""
|
||||||
|
prompt: str
|
||||||
|
n: int = 1
|
||||||
|
size: str = "1024x1024"
|
||||||
|
quality: str = "medium"
|
||||||
|
response_format: str = "b64_json"
|
||||||
|
|
||||||
|
|
||||||
|
class HarmonizeRequest(BaseModel):
|
||||||
|
image: str
|
||||||
|
prompt: str = ""
|
||||||
|
mask: str | None = None
|
||||||
|
body_mask: str | None = None
|
||||||
|
seam_mask: str | None = None
|
||||||
|
strength: float = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Odysseus MLX Image Server")
|
||||||
|
_args: argparse.Namespace
|
||||||
|
|
||||||
|
|
||||||
|
def _steps(quality: str) -> int:
|
||||||
|
if _args.steps:
|
||||||
|
return int(_args.steps)
|
||||||
|
return {"low": 8, "medium": 20, "high": 32, "auto": 20}.get((quality or "medium").lower(), 20)
|
||||||
|
|
||||||
|
|
||||||
|
def _size(size: str) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
w, h = str(size or "").lower().split("x", 1)
|
||||||
|
return max(64, int(w)), max(64, int(h))
|
||||||
|
except Exception:
|
||||||
|
return int(_args.width), int(_args.height)
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_for_model(model: str) -> str:
|
||||||
|
lower = model.lower()
|
||||||
|
if "qwen" in lower:
|
||||||
|
return "mflux-generate-qwen"
|
||||||
|
if "flux" in lower:
|
||||||
|
return "mflux-generate"
|
||||||
|
return "mflux-generate"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli(name: str) -> str:
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
local = Path(sys.executable).resolve().parent / name
|
||||||
|
if local.exists():
|
||||||
|
return str(local)
|
||||||
|
prefix_local = Path(sys.prefix).resolve() / "bin" / name
|
||||||
|
if prefix_local.exists():
|
||||||
|
return str(prefix_local)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_numbers(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values or []:
|
||||||
|
s = str(value).strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
float(s)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hidream(model: str) -> bool:
|
||||||
|
return "hidream" in (model or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_boogu(model: str) -> bool:
|
||||||
|
return "boogu" in (model or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lama_inpaint(model: str) -> bool:
|
||||||
|
lower = (model or "").lower()
|
||||||
|
return "mi-gan" in lower or "migan" in lower or "lama" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ddcolor(model: str) -> bool:
|
||||||
|
return "ddcolor" in (model or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _unsupported_swift_mlx_runtime(model: str) -> HTTPException:
|
||||||
|
if _is_ddcolor(model):
|
||||||
|
return HTTPException(
|
||||||
|
503,
|
||||||
|
"DDColor MLX models require an Odysseus-compatible mlx-ddcolor-swift bridge. "
|
||||||
|
"Build/install a bridge binary named odysseus-mlx-colorize or mlx-ddcolor-serve "
|
||||||
|
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
|
||||||
|
"smoke executables, not a stable colorize CLI.",
|
||||||
|
)
|
||||||
|
return HTTPException(
|
||||||
|
503,
|
||||||
|
"LaMa / MI-GAN MLX inpainting models require an Odysseus-compatible mlx-lama-swift bridge. "
|
||||||
|
"Build/install a bridge binary named odysseus-mlx-inpaint or mlx-lama-serve "
|
||||||
|
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
|
||||||
|
"smoke executables, not a stable image-edit CLI.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bridge(names: list[str]) -> str:
|
||||||
|
for name in names:
|
||||||
|
found = _resolve_cli(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_path(model: str) -> Path:
|
||||||
|
p = Path(model).expanduser()
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
try:
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"huggingface_hub is required to download MLX image model snapshots. "
|
||||||
|
"Install the model requirements in the selected Python environment.",
|
||||||
|
) from e
|
||||||
|
return Path(snapshot_download(model))
|
||||||
|
|
||||||
|
|
||||||
|
def _weights_path(model: str) -> Path:
|
||||||
|
p = Path(model).expanduser()
|
||||||
|
if p.is_file():
|
||||||
|
return p
|
||||||
|
snap = _snapshot_path(model)
|
||||||
|
if snap.is_file():
|
||||||
|
return snap
|
||||||
|
candidates = sorted(snap.rglob("*.safetensors"))
|
||||||
|
if not candidates:
|
||||||
|
raise HTTPException(500, f"No safetensors weights found for {model} in {snap}")
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bridge_input_image(raw: bytes, out_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(503, "Pillow is required for MLX image edit bridge inputs.") from e
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Invalid input image: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bridge_mask(raw: bytes, out_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(503, "Pillow is required for MLX image edit bridge masks.") from e
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
if img.mode == "RGBA":
|
||||||
|
# OpenAI edits mask convention: transparent = regenerate.
|
||||||
|
alpha = img.getchannel("A")
|
||||||
|
mask = alpha.point(lambda p: 255 if p < 128 else 0)
|
||||||
|
else:
|
||||||
|
mask = img.convert("L")
|
||||||
|
mask.save(out_path, format="PNG")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Invalid mask image: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _run_bridge(cmd: list[str]) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or "MLX Swift bridge failed").strip()
|
||||||
|
logger.error("MLX Swift bridge failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
|
||||||
|
raise HTTPException(500, detail[-4000:])
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ddcolor_bridge(model: str, image_raw: bytes, out_path: Path) -> None:
|
||||||
|
bridge = _resolve_bridge(["odysseus-mlx-colorize", "mlx-ddcolor-serve"])
|
||||||
|
if not bridge:
|
||||||
|
raise _unsupported_swift_mlx_runtime(model)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="odysseus-ddcolor-") as td:
|
||||||
|
inp = Path(td) / "input.png"
|
||||||
|
_write_bridge_input_image(image_raw, inp)
|
||||||
|
weights = _weights_path(model)
|
||||||
|
tier = "tiny" if "tiny" in model.lower() else "large"
|
||||||
|
_run_bridge([
|
||||||
|
bridge,
|
||||||
|
"--model", str(weights),
|
||||||
|
"--image", str(inp),
|
||||||
|
"--output", str(out_path),
|
||||||
|
"--tier", tier,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _run_inpaint_bridge(model: str, image_raw: bytes, mask_raw: bytes | None, out_path: Path) -> None:
|
||||||
|
if not mask_raw:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"LaMa / MI-GAN inpainting requires an image mask. Use the editor inpaint/object-removal tool so Odysseus can send the mask.",
|
||||||
|
)
|
||||||
|
bridge = _resolve_bridge(["odysseus-mlx-inpaint", "mlx-lama-serve"])
|
||||||
|
if not bridge:
|
||||||
|
raise _unsupported_swift_mlx_runtime(model)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-inpaint-") as td:
|
||||||
|
inp = Path(td) / "input.png"
|
||||||
|
mask = Path(td) / "mask.png"
|
||||||
|
_write_bridge_input_image(image_raw, inp)
|
||||||
|
_write_bridge_mask(mask_raw, mask)
|
||||||
|
weights = _weights_path(model)
|
||||||
|
mode = "fast" if ("mi-gan" in model.lower() or "migan" in model.lower()) else "best"
|
||||||
|
_run_bridge([
|
||||||
|
bridge,
|
||||||
|
"--model", str(weights),
|
||||||
|
"--image", str(inp),
|
||||||
|
"--mask", str(mask),
|
||||||
|
"--output", str(out_path),
|
||||||
|
"--mode", mode,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_hidream(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
|
||||||
|
model_path = _snapshot_path(model)
|
||||||
|
script = model_path / "scripts" / "hidream_o1" / "generate_hidream_o1_mlx.py"
|
||||||
|
if not script.exists():
|
||||||
|
raise HTTPException(500, f"HiDream generator script not found in snapshot: {script}")
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(script),
|
||||||
|
"--model-path",
|
||||||
|
str(model_path),
|
||||||
|
"--prompt",
|
||||||
|
prompt,
|
||||||
|
"--output",
|
||||||
|
str(out_path),
|
||||||
|
"--width",
|
||||||
|
str(width),
|
||||||
|
"--height",
|
||||||
|
str(height),
|
||||||
|
"--num-inference-steps",
|
||||||
|
str(steps),
|
||||||
|
"--no-snap-resolution",
|
||||||
|
]
|
||||||
|
env = os.environ.copy()
|
||||||
|
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or "HiDream generator failed").strip()
|
||||||
|
raise HTTPException(500, detail[-4000:])
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_boogu(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
|
||||||
|
try:
|
||||||
|
from boogu_image_mlx.pipeline_mlx import BooguImagePipeline
|
||||||
|
from PIL import Image
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"Boogu MLX serving requires boogu-image-mlx in the launch Python. "
|
||||||
|
"Install with: python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
model_path = _snapshot_path(model)
|
||||||
|
vlm_model = (_args.vlm_model or os.environ.get("ODYSSEUS_MLX_IMAGE_VLM_MODEL") or "").strip()
|
||||||
|
if not vlm_model:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"This MLX image pipeline requires a companion vision-language model. "
|
||||||
|
"Relaunch with --vlm-model <repo_or_path> or set ODYSSEUS_MLX_IMAGE_VLM_MODEL.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pipe = BooguImagePipeline.from_pretrained(
|
||||||
|
str(model_path),
|
||||||
|
vlm_model,
|
||||||
|
)
|
||||||
|
img = pipe.generate(
|
||||||
|
prompt,
|
||||||
|
height=height,
|
||||||
|
width=width,
|
||||||
|
steps=steps,
|
||||||
|
guidance=3.5,
|
||||||
|
)
|
||||||
|
Image.fromarray(img).save(out_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Boogu MLX generation failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
def list_models():
|
||||||
|
return {"data": [{"id": _args.model, "object": "model", "owned_by": "local"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/images/generations")
|
||||||
|
def generate(req: ImageRequest):
|
||||||
|
model = req.model or _args.model
|
||||||
|
width, height = _size(req.size)
|
||||||
|
out_images = []
|
||||||
|
count = max(1, min(int(req.n or 1), 4))
|
||||||
|
for _ in range(count):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-image-") as td:
|
||||||
|
out_path = Path(td) / "image.png"
|
||||||
|
if _is_hidream(model):
|
||||||
|
_generate_hidream(model, req.prompt, out_path, width, height, _steps(req.quality))
|
||||||
|
elif _is_boogu(model):
|
||||||
|
_generate_boogu(model, req.prompt, out_path, width, height, _steps(req.quality))
|
||||||
|
elif _is_lama_inpaint(model) or _is_ddcolor(model):
|
||||||
|
raise _unsupported_swift_mlx_runtime(model)
|
||||||
|
else:
|
||||||
|
cli = _cli_for_model(model)
|
||||||
|
cli_path = _resolve_cli(cli)
|
||||||
|
if not cli_path:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
f"{cli} not found in PATH or next to {sys.executable}. Install the MLX image runtime with: python3 -m pip install -U mflux",
|
||||||
|
)
|
||||||
|
cmd = [
|
||||||
|
cli_path,
|
||||||
|
"--model",
|
||||||
|
model,
|
||||||
|
"--prompt",
|
||||||
|
req.prompt,
|
||||||
|
"--steps",
|
||||||
|
str(_steps(req.quality)),
|
||||||
|
"--output",
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
if _args.base_model:
|
||||||
|
cmd += ["--base-model", _args.base_model]
|
||||||
|
if _args.lora_style:
|
||||||
|
cmd += ["--lora-style", _args.lora_style]
|
||||||
|
if _args.lora_paths:
|
||||||
|
cmd += ["--lora-paths", *_args.lora_paths]
|
||||||
|
lora_scales = _valid_numbers(_args.lora_scales)
|
||||||
|
if lora_scales:
|
||||||
|
cmd += ["--lora-scales", *lora_scales]
|
||||||
|
if "qwen" not in model.lower():
|
||||||
|
cmd += ["--width", str(width), "--height", str(height)]
|
||||||
|
env = os.environ.copy()
|
||||||
|
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = (proc.stderr or proc.stdout or f"{cli} failed").strip()
|
||||||
|
logger.error("MLX image command failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
|
||||||
|
raise HTTPException(500, detail[-4000:])
|
||||||
|
if not out_path.exists():
|
||||||
|
raise HTTPException(500, f"MLX image generator completed but did not write {out_path}")
|
||||||
|
b64 = base64.b64encode(out_path.read_bytes()).decode("ascii")
|
||||||
|
out_images.append({"b64_json": b64})
|
||||||
|
return {"created": 0, "data": out_images}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/images/edits")
|
||||||
|
async def edit_image(
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
mask: UploadFile | None = File(None),
|
||||||
|
prompt: str = Form(""),
|
||||||
|
model: str = Form(""),
|
||||||
|
n: int = Form(1),
|
||||||
|
size: str = Form("1024x1024"),
|
||||||
|
response_format: str = Form("b64_json"),
|
||||||
|
):
|
||||||
|
active_model = model or _args.model
|
||||||
|
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||||
|
image_raw = await image.read()
|
||||||
|
mask_raw = await mask.read() if mask is not None else None
|
||||||
|
out_images = []
|
||||||
|
count = max(1, min(int(n or 1), 4))
|
||||||
|
for _ in range(count):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-edit-") as td:
|
||||||
|
out_path = Path(td) / "image.png"
|
||||||
|
if _is_ddcolor(active_model):
|
||||||
|
_run_ddcolor_bridge(active_model, image_raw, out_path)
|
||||||
|
else:
|
||||||
|
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
|
||||||
|
if not out_path.exists():
|
||||||
|
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
|
||||||
|
out_images.append({"b64_json": base64.b64encode(out_path.read_bytes()).decode("ascii")})
|
||||||
|
return {"created": 0, "data": out_images}
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"This MLX image endpoint supports text-to-image generation only. "
|
||||||
|
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/images/harmonize")
|
||||||
|
def harmonize_image(req: HarmonizeRequest):
|
||||||
|
active_model = _args.model
|
||||||
|
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||||
|
try:
|
||||||
|
image_raw = base64.b64decode(req.image.split(",", 1)[-1])
|
||||||
|
mask_b64 = req.body_mask or req.mask
|
||||||
|
mask_raw = base64.b64decode(mask_b64.split(",", 1)[-1]) if mask_b64 else None
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Invalid base64 image payload: {e}") from e
|
||||||
|
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-harmonize-") as td:
|
||||||
|
out_path = Path(td) / "image.png"
|
||||||
|
if _is_ddcolor(active_model):
|
||||||
|
_run_ddcolor_bridge(active_model, image_raw, out_path)
|
||||||
|
else:
|
||||||
|
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
|
||||||
|
if not out_path.exists():
|
||||||
|
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
|
||||||
|
return {"image": base64.b64encode(out_path.read_bytes()).decode("ascii")}
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"This MLX image endpoint supports text-to-image generation only. "
|
||||||
|
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
global _args
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--model", required=True)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8100)
|
||||||
|
parser.add_argument("--steps", type=int, default=0)
|
||||||
|
parser.add_argument("--width", type=int, default=1024)
|
||||||
|
parser.add_argument("--height", type=int, default=1024)
|
||||||
|
parser.add_argument("--base-model", default="")
|
||||||
|
parser.add_argument("--lora-style", default="")
|
||||||
|
parser.add_argument("--lora-paths", nargs="*", default=[])
|
||||||
|
parser.add_argument("--lora-scales", nargs="*", default=[])
|
||||||
|
parser.add_argument("--vlm-model", default="")
|
||||||
|
_args = parser.parse_args()
|
||||||
|
uvicorn.run(app, host=_args.host, port=_args.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+23
-9
@@ -133,18 +133,32 @@ def cmd_list(args):
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
entries = []
|
entries = []
|
||||||
for p in sorted(_BACKUP_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
for p in _BACKUP_DIR.iterdir():
|
||||||
if not p.is_file():
|
entry = _backup_entry(p)
|
||||||
continue
|
if entry is not None:
|
||||||
entries.append({
|
entries.append(entry)
|
||||||
"path": str(p),
|
entries.sort(key=lambda entry: entry["_mtime"], reverse=True)
|
||||||
"name": p.name,
|
for entry in entries:
|
||||||
"bytes": p.stat().st_size,
|
entry.pop("_mtime", None)
|
||||||
"modified": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
|
|
||||||
})
|
|
||||||
emit(entries, args)
|
emit(entries, args)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_entry(p):
|
||||||
|
try:
|
||||||
|
if not p.is_file():
|
||||||
|
return None
|
||||||
|
st = p.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"path": str(p),
|
||||||
|
"name": p.name,
|
||||||
|
"bytes": st.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
||||||
|
"_mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args):
|
def cmd_verify(args):
|
||||||
"""Open the tarball read-only and walk its members — confirms
|
"""Open the tarball read-only and walk its members — confirms
|
||||||
integrity without extracting anything."""
|
integrity without extracting anything."""
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def cmd_add(args):
|
|||||||
# add_entry doesn't save by default — the call in chat does it
|
# add_entry doesn't save by default — the call in chat does it
|
||||||
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
# after dedup checks. Persist here so a one-shot CLI add sticks.
|
||||||
all_entries = _manager().load_all()
|
all_entries = _manager().load_all()
|
||||||
if not any(e.get("id") == entry.get("id") for e in all_entries):
|
if not any(isinstance(e, dict) and e.get("id") == entry.get("id") for e in all_entries):
|
||||||
all_entries.append(entry)
|
all_entries.append(entry)
|
||||||
_manager().save(all_entries)
|
_manager().save(all_entries)
|
||||||
emit(entry, args)
|
emit(entry, args)
|
||||||
|
|||||||
+334
-275
@@ -1,278 +1,328 @@
|
|||||||
"""Image generation model registry and VRAM fitting for Cookbook."""
|
"""Image generation model registry and VRAM fitting for Cookbook."""
|
||||||
|
|
||||||
# Curated registry of image generation models supported by diffusers.
|
from __future__ import annotations
|
||||||
# ONLY verified HuggingFace repo IDs.
|
|
||||||
# VRAM estimates are for inference (single image generation).
|
import json
|
||||||
IMAGE_MODEL_REGISTRY = [
|
import re
|
||||||
# ── Z-Image (Alibaba Tongyi) ──
|
import time
|
||||||
{
|
import urllib.parse
|
||||||
"id": "Tongyi-MAI/Z-Image-Turbo",
|
import urllib.request
|
||||||
"name": "Z-Image Turbo",
|
from typing import Any
|
||||||
"provider": "Tongyi",
|
|
||||||
"params_b": 6.0,
|
# Image models are discovered from HuggingFace collections/search and local cache.
|
||||||
"vram_bf16": 19.0,
|
# Keep this empty: source-coded repo IDs become hidden recommendations.
|
||||||
"vram_fp8": 10.0,
|
IMAGE_MODEL_REGISTRY: list[dict[str, Any]] = []
|
||||||
"vram_q4": 6.0,
|
|
||||||
"default_quant": "BF16",
|
HF_IMAGE_COLLECTIONS = [
|
||||||
"quant_repos": {
|
"stabilityai/image",
|
||||||
"FP8": "drbaph/Z-Image-Turbo-FP8",
|
"stabilityai/stable-diffusion-35",
|
||||||
},
|
"black-forest-labs/flux2",
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "6B distilled, 8-step. Sub-second on H800. Apache 2.0.",
|
|
||||||
"quality": 92,
|
|
||||||
"speed": 95,
|
|
||||||
"released": "2025-12",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Tongyi-MAI/Z-Image",
|
|
||||||
"name": "Z-Image",
|
|
||||||
"provider": "Tongyi",
|
|
||||||
"params_b": 6.0,
|
|
||||||
"vram_bf16": 19.0,
|
|
||||||
"vram_fp8": 10.0,
|
|
||||||
"vram_q4": 6.0,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "drbaph/Z-Image-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "Full undistilled model. Highest creative freedom. Apache 2.0.",
|
|
||||||
"quality": 93,
|
|
||||||
"speed": 70,
|
|
||||||
"released": "2025-12",
|
|
||||||
},
|
|
||||||
# ── Qwen Image ──
|
|
||||||
{
|
|
||||||
"id": "Qwen/Qwen-Image-2512",
|
|
||||||
"name": "Qwen Image 2512",
|
|
||||||
"provider": "Qwen",
|
|
||||||
"params_b": 20.0,
|
|
||||||
"vram_bf16": 42.0,
|
|
||||||
"vram_fp8": 22.0,
|
|
||||||
"vram_q4": 14.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["text-to-image", "text-rendering"],
|
|
||||||
"description": "Dec 2025 update. Better humans, finer detail, strong text. Apache 2.0.",
|
|
||||||
"quality": 95,
|
|
||||||
"speed": 50,
|
|
||||||
"released": "2025-12",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Qwen/Qwen-Image",
|
|
||||||
"name": "Qwen Image",
|
|
||||||
"provider": "Qwen",
|
|
||||||
"params_b": 20.0,
|
|
||||||
"vram_bf16": 42.0,
|
|
||||||
"vram_fp8": 22.0,
|
|
||||||
"vram_q4": 14.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["text-to-image", "text-rendering"],
|
|
||||||
"description": "20B foundation. Best text rendering in images. Apache 2.0.",
|
|
||||||
"quality": 94,
|
|
||||||
"speed": 50,
|
|
||||||
"released": "2025-08",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Qwen/Qwen-Image-Edit-2511",
|
|
||||||
"name": "Qwen Image Edit",
|
|
||||||
"provider": "Qwen",
|
|
||||||
"params_b": 20.0,
|
|
||||||
"vram_bf16": 42.0,
|
|
||||||
"vram_fp8": 22.0,
|
|
||||||
"vram_q4": 14.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["image-editing", "inpainting"],
|
|
||||||
"description": "Dedicated editing. Style transfer, object removal. Apache 2.0.",
|
|
||||||
"quality": 92,
|
|
||||||
"speed": 50,
|
|
||||||
"released": "2025-11",
|
|
||||||
},
|
|
||||||
# ── Stable Diffusion (dedicated inpainting) ──
|
|
||||||
{
|
|
||||||
"id": "diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
|
|
||||||
"name": "SDXL Inpainting",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 3.5,
|
|
||||||
"vram_bf16": 12.0,
|
|
||||||
"vram_fp8": 8.0,
|
|
||||||
"vram_q4": 6.0,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["inpainting", "image-editing"],
|
|
||||||
"description": "SDXL fine-tuned for inpainting (9-channel UNet). Best SD-family fill quality; fits a 24GB card comfortably.",
|
|
||||||
"quality": 86,
|
|
||||||
"speed": 68,
|
|
||||||
"released": "2023-11",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
|
||||||
"name": "SD 1.5 Inpainting",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 1.1,
|
|
||||||
"vram_bf16": 4.0,
|
|
||||||
"vram_fp8": 3.0,
|
|
||||||
"vram_q4": 2.5,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["inpainting"],
|
|
||||||
"description": "Classic SD 1.5 inpaint. Very light and fast; lower fidelity than SDXL.",
|
|
||||||
"quality": 70,
|
|
||||||
"speed": 92,
|
|
||||||
"released": "2022-10",
|
|
||||||
},
|
|
||||||
# ── FLUX ──
|
|
||||||
{
|
|
||||||
"id": "black-forest-labs/FLUX.1-dev",
|
|
||||||
"name": "FLUX.1 Dev",
|
|
||||||
"provider": "Black Forest Labs",
|
|
||||||
"params_b": 12.0,
|
|
||||||
"vram_bf16": 33.0,
|
|
||||||
"vram_fp8": 17.0,
|
|
||||||
"vram_q4": 10.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "diffusers/FLUX.1-dev-torchao-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "High quality, detailed. Popular community model. Non-commercial.",
|
|
||||||
"quality": 92,
|
|
||||||
"speed": 55,
|
|
||||||
"released": "2024-08",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "black-forest-labs/FLUX.1-schnell",
|
|
||||||
"name": "FLUX.1 Schnell",
|
|
||||||
"provider": "Black Forest Labs",
|
|
||||||
"params_b": 12.0,
|
|
||||||
"vram_bf16": 33.0,
|
|
||||||
"vram_fp8": 17.0,
|
|
||||||
"vram_q4": 10.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "Kijai/flux-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "Fast 4-step variant. Apache 2.0 license.",
|
|
||||||
"quality": 85,
|
|
||||||
"speed": 90,
|
|
||||||
"released": "2024-08",
|
|
||||||
},
|
|
||||||
# ── Stable Diffusion ──
|
|
||||||
{
|
|
||||||
"id": "stabilityai/stable-diffusion-3.5-medium",
|
|
||||||
"name": "SD 3.5 Medium",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 2.5,
|
|
||||||
"vram_bf16": 12.0,
|
|
||||||
"vram_fp8": 7.0,
|
|
||||||
"vram_q4": None,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "2.5B lightweight, fast. Fits almost any GPU.",
|
|
||||||
"quality": 75,
|
|
||||||
"speed": 95,
|
|
||||||
"released": "2024-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "stabilityai/stable-diffusion-3.5-large",
|
|
||||||
"name": "SD 3.5 Large",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 8.1,
|
|
||||||
"vram_bf16": 22.0,
|
|
||||||
"vram_fp8": 12.0,
|
|
||||||
"vram_q4": None,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "8B high quality. Good balance of speed and quality.",
|
|
||||||
"quality": 85,
|
|
||||||
"speed": 70,
|
|
||||||
"released": "2024-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "stabilityai/stable-diffusion-3.5-large-turbo",
|
|
||||||
"name": "SD 3.5 Large Turbo",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 8.1,
|
|
||||||
"vram_bf16": 22.0,
|
|
||||||
"vram_fp8": 12.0,
|
|
||||||
"vram_q4": None,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {
|
|
||||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "Distilled for few-step inference. Fastest large SD.",
|
|
||||||
"quality": 80,
|
|
||||||
"speed": 92,
|
|
||||||
"released": "2024-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "stabilityai/stable-diffusion-xl-base-1.0",
|
|
||||||
"name": "SDXL",
|
|
||||||
"provider": "Stability AI",
|
|
||||||
"params_b": 3.5,
|
|
||||||
"vram_bf16": 10.0,
|
|
||||||
"vram_fp8": 6.0,
|
|
||||||
"vram_q4": None,
|
|
||||||
"default_quant": "BF16",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["text-to-image"],
|
|
||||||
"description": "Classic workhorse. Huge LoRA ecosystem. Fits 8GB+.",
|
|
||||||
"quality": 72,
|
|
||||||
"speed": 90,
|
|
||||||
"released": "2023-07",
|
|
||||||
},
|
|
||||||
# ── Hunyuan ──
|
|
||||||
{
|
|
||||||
"id": "tencent/HunyuanImage-3.0",
|
|
||||||
"name": "HunyuanImage 3.0",
|
|
||||||
"provider": "Tencent",
|
|
||||||
"params_b": 13.0,
|
|
||||||
"vram_bf16": 30.0,
|
|
||||||
"vram_fp8": 16.0,
|
|
||||||
"vram_q4": 9.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {
|
|
||||||
"Q4": "wikeeyang/Hunyuan-Image-30-Qint4",
|
|
||||||
"NF4": "EricRollei/HunyuanImage-3.0-Instruct-NF4",
|
|
||||||
},
|
|
||||||
"capabilities": ["text-to-image", "text-rendering"],
|
|
||||||
"description": "Strong text rendering. Bilingual Chinese/English. 13B activated per token.",
|
|
||||||
"quality": 88,
|
|
||||||
"speed": 60,
|
|
||||||
"released": "2025-09",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tencent/HunyuanImage-3.0-Instruct-Distil",
|
|
||||||
"name": "HunyuanImage 3.0 Distil",
|
|
||||||
"provider": "Tencent",
|
|
||||||
"params_b": 13.0,
|
|
||||||
"vram_bf16": 30.0,
|
|
||||||
"vram_fp8": 16.0,
|
|
||||||
"vram_q4": 9.0,
|
|
||||||
"default_quant": "FP8",
|
|
||||||
"quant_repos": {},
|
|
||||||
"capabilities": ["text-to-image", "text-rendering"],
|
|
||||||
"description": "Distilled variant, fewer steps. Faster with comparable quality.",
|
|
||||||
"quality": 85,
|
|
||||||
"speed": 80,
|
|
||||||
"released": "2026-01",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
HF_MLX_IMAGE_COLLECTIONS = [
|
||||||
|
"mlx-community/flux2-klein-mlx",
|
||||||
|
"mlx-community/inpainting-mlx",
|
||||||
|
"mlx-community/ddcolor-mlx",
|
||||||
|
"mlx-community/boogu-image-01-mlx",
|
||||||
|
]
|
||||||
|
|
||||||
|
HF_MLX_IMAGE_REPO_SEEDS: list[str] = []
|
||||||
|
HF_IMAGE_REPO_SEEDS: list[str] = []
|
||||||
|
|
||||||
|
_HF_COLLECTION_CACHE = {"ts": 0.0, "models": []}
|
||||||
|
_HF_COLLECTION_TTL = 30 * 60
|
||||||
|
_HF_VARIANT_CACHE: dict[str, dict[str, str]] = {}
|
||||||
|
_HF_SEARCH_DISABLED_UNTIL = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_display_name(repo_id: str) -> str:
|
||||||
|
name = str(repo_id or "").split("/")[-1]
|
||||||
|
return name.replace("-", " ").replace("_", " ").strip() or repo_id
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_from_repo(repo_id: str) -> str:
|
||||||
|
owner = str(repo_id or "").split("/", 1)[0].lower()
|
||||||
|
return {
|
||||||
|
"stabilityai": "Stability AI",
|
||||||
|
"black-forest-labs": "Black Forest Labs",
|
||||||
|
"tongyi-mai": "Tongyi",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"mlx-community": "mlx-community",
|
||||||
|
}.get(owner, owner.replace("-", " ").title() if owner else "HuggingFace")
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_capabilities(item: dict[str, Any], repo_id: str) -> list[str]:
|
||||||
|
tasks = set()
|
||||||
|
pipeline = str(item.get("pipeline_tag") or "").strip().lower()
|
||||||
|
if pipeline:
|
||||||
|
tasks.add(pipeline)
|
||||||
|
for provider in item.get("availableInferenceProviders") or []:
|
||||||
|
if isinstance(provider, dict) and provider.get("task"):
|
||||||
|
tasks.add(str(provider["task"]).strip().lower())
|
||||||
|
text = f"{repo_id} {' '.join(tasks)}".lower()
|
||||||
|
caps = []
|
||||||
|
if "image-to-image" in tasks or "edit" in text or "inpaint" in text:
|
||||||
|
caps.append("image-editing")
|
||||||
|
if "inpaint" in text:
|
||||||
|
caps.append("inpainting")
|
||||||
|
if "text-to-image" in tasks or not caps:
|
||||||
|
caps.append("text-to-image")
|
||||||
|
return caps
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_image_model(repo_id: str) -> dict[str, Any]:
|
||||||
|
text = str(repo_id or "").lower()
|
||||||
|
params_b = 8.0
|
||||||
|
param_match = re.search(r"(?<![\d.])(\d+(?:\.\d+)?)\s*b(?:\b|[-_])", text)
|
||||||
|
if param_match:
|
||||||
|
params_b = max(0.01, float(param_match.group(1)))
|
||||||
|
if any(k in text for k in ("mi-gan", "big-lama", "lama-")):
|
||||||
|
return {"params_b": 0.01, "bf16": 1.0, "fp8": 0.7, "q4": 0.5, "quality": 65, "speed": 98, "quant": "BF16"}
|
||||||
|
quant = "BF16"
|
||||||
|
if any(k in text for k in ("4bit", "q4", "nf4")):
|
||||||
|
quant = "Q4"
|
||||||
|
elif "fp8" in text or "8bit" in text:
|
||||||
|
quant = "FP8"
|
||||||
|
bf16 = max(1.0, round(params_b * 2.6 + 3.0, 1))
|
||||||
|
fp8 = max(0.7, round(params_b * 1.35 + 2.0, 1))
|
||||||
|
q4 = max(0.5, round(params_b * 0.8 + 1.5, 1))
|
||||||
|
speed = max(35, min(95, int(98 - params_b * 3)))
|
||||||
|
quality = max(60, min(88, int(70 + min(params_b, 18) * 0.8)))
|
||||||
|
return {"params_b": params_b, "bf16": bf16, "fp8": fp8, "q4": q4, "quality": quality, "speed": speed, "quant": quant}
|
||||||
|
|
||||||
|
|
||||||
|
def _params_b_from_item(item: dict[str, Any]) -> float | None:
|
||||||
|
raw = item.get("numParameters")
|
||||||
|
if isinstance(raw, (int, float)) and raw > 0:
|
||||||
|
return max(0.01, round(float(raw) / 1_000_000_000.0, 3))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mlx_quantize_estimate(repo_id: str, est: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = str(repo_id or "").lower()
|
||||||
|
out = dict(est)
|
||||||
|
if "3bit" in text or "4bit" in text or "q4" in text:
|
||||||
|
out["quant"] = "Q4"
|
||||||
|
out["bf16"] = None
|
||||||
|
out["fp8"] = None
|
||||||
|
elif "8bit" in text:
|
||||||
|
out["quant"] = "FP8"
|
||||||
|
out["bf16"] = None
|
||||||
|
elif "6bit" in text or "5bit" in text:
|
||||||
|
out["quant"] = "Q4"
|
||||||
|
out["bf16"] = None
|
||||||
|
out["fp8"] = out.get("fp8") or out.get("q4")
|
||||||
|
elif "bf16" in text or "fp16" in text:
|
||||||
|
out["quant"] = "BF16"
|
||||||
|
out["fp8"] = None
|
||||||
|
out["q4"] = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_item_to_model(item: dict[str, Any], collection_title: str = "", mlx_only: bool = False) -> dict[str, Any] | None:
|
||||||
|
repo_id = str(item.get("id") or "").strip()
|
||||||
|
if "/" not in repo_id:
|
||||||
|
return None
|
||||||
|
typ = str(item.get("type") or item.get("itemType") or "model").lower()
|
||||||
|
if typ not in {"", "model"}:
|
||||||
|
return None
|
||||||
|
est = _estimate_image_model(repo_id)
|
||||||
|
item_params_b = _params_b_from_item(item)
|
||||||
|
if item_params_b is not None:
|
||||||
|
est = {
|
||||||
|
**est,
|
||||||
|
"params_b": item_params_b,
|
||||||
|
"bf16": max(0.5, round(item_params_b * 2.4 + 0.8, 1)),
|
||||||
|
"fp8": max(0.5, round(item_params_b * 1.3 + 0.5, 1)),
|
||||||
|
"q4": max(0.4, round(item_params_b * 0.8 + 0.4, 1)),
|
||||||
|
}
|
||||||
|
if mlx_only:
|
||||||
|
est = _mlx_quantize_estimate(repo_id, est)
|
||||||
|
caps = _infer_capabilities(item, repo_id)
|
||||||
|
gated = item.get("gated")
|
||||||
|
desc_bits = []
|
||||||
|
if collection_title:
|
||||||
|
desc_bits.append(f"HF collection: {collection_title}.")
|
||||||
|
if gated:
|
||||||
|
desc_bits.append("Gated on HuggingFace.")
|
||||||
|
out = {
|
||||||
|
"id": repo_id,
|
||||||
|
"name": _repo_display_name(repo_id),
|
||||||
|
"provider": _provider_from_repo(repo_id),
|
||||||
|
"params_b": est["params_b"],
|
||||||
|
"vram_bf16": est["bf16"],
|
||||||
|
"vram_fp8": est["fp8"],
|
||||||
|
"vram_q4": est["q4"],
|
||||||
|
"default_quant": est["quant"],
|
||||||
|
"quant_repos": {},
|
||||||
|
"capabilities": caps,
|
||||||
|
"description": " ".join(desc_bits).strip() or "Imported from HuggingFace collection.",
|
||||||
|
"quality": est["quality"],
|
||||||
|
"speed": est["speed"],
|
||||||
|
"released": "",
|
||||||
|
}
|
||||||
|
if mlx_only:
|
||||||
|
out["mlx_only"] = True
|
||||||
|
out["description"] = (out["description"] + " Apple Silicon / MLX only.").strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_hf_image_collection_models() -> list[dict[str, Any]]:
|
||||||
|
now = time.time()
|
||||||
|
if now - float(_HF_COLLECTION_CACHE.get("ts") or 0) < _HF_COLLECTION_TTL:
|
||||||
|
return list(_HF_COLLECTION_CACHE.get("models") or [])
|
||||||
|
models: list[dict[str, Any]] = []
|
||||||
|
for slug, mlx_only in [(slug, False) for slug in HF_IMAGE_COLLECTIONS] + [(slug, True) for slug in HF_MLX_IMAGE_COLLECTIONS]:
|
||||||
|
url = f"https://huggingface.co/api/collections/{slug}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
title = str(data.get("title") or slug)
|
||||||
|
for item in data.get("items") or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
model = _collection_item_to_model(item, title, mlx_only=mlx_only)
|
||||||
|
if model:
|
||||||
|
models.append(model)
|
||||||
|
_HF_COLLECTION_CACHE["ts"] = now
|
||||||
|
_HF_COLLECTION_CACHE["models"] = models
|
||||||
|
return list(models)
|
||||||
|
|
||||||
|
|
||||||
|
def _hf_model_search(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
global _HF_SEARCH_DISABLED_UNTIL
|
||||||
|
now = time.time()
|
||||||
|
if now < _HF_SEARCH_DISABLED_UNTIL:
|
||||||
|
return []
|
||||||
|
url = "https://huggingface.co/api/models?" + urllib.parse.urlencode({
|
||||||
|
"search": query,
|
||||||
|
"limit": str(limit),
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
_HF_SEARCH_DISABLED_UNTIL = now + 10 * 60
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _variant_score(candidate: dict[str, Any], base_repo: str, want: str) -> float:
|
||||||
|
rid = str(candidate.get("id") or candidate.get("modelId") or "")
|
||||||
|
text = " ".join([
|
||||||
|
rid,
|
||||||
|
str(candidate.get("library_name") or ""),
|
||||||
|
str(candidate.get("pipeline_tag") or ""),
|
||||||
|
" ".join(str(t) for t in candidate.get("tags") or []),
|
||||||
|
]).lower()
|
||||||
|
base = base_repo.lower()
|
||||||
|
base_short = base_repo.rsplit("/", 1)[-1].lower()
|
||||||
|
if want == "gguf" and "gguf" not in text:
|
||||||
|
return -1
|
||||||
|
if want == "fp8" and not any(k in text for k in ("fp8", "nvfp4", "mxfp8", "mxfp4")):
|
||||||
|
return -1
|
||||||
|
score = float(candidate.get("downloads") or 0) / 1000.0 + float(candidate.get("likes") or 0)
|
||||||
|
if f"base_model:{base}" in text or f"base_model:quantized:{base}" in text:
|
||||||
|
score += 10000
|
||||||
|
elif base_short and base_short in rid.lower():
|
||||||
|
score += 1000
|
||||||
|
else:
|
||||||
|
score -= 200
|
||||||
|
if "diffusers" in text:
|
||||||
|
score += 50
|
||||||
|
if str(candidate.get("private")).lower() == "true":
|
||||||
|
score -= 10000
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def _best_variant_repo(base_repo: str, want: str) -> str:
|
||||||
|
base_short = str(base_repo or "").rsplit("/", 1)[-1]
|
||||||
|
candidates = _hf_model_search(f"{base_short} {want}", limit=12)
|
||||||
|
scored = []
|
||||||
|
for item in candidates:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
rid = str(item.get("id") or item.get("modelId") or "").strip()
|
||||||
|
if "/" not in rid or rid.lower() == base_repo.lower():
|
||||||
|
continue
|
||||||
|
score = _variant_score(item, base_repo, want)
|
||||||
|
if score >= 0:
|
||||||
|
scored.append((score, rid))
|
||||||
|
scored.sort(reverse=True)
|
||||||
|
return scored[0][1] if scored else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _should_discover_variants(repo_id: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_quant_repos(repo_id: str, need_fp8: bool = True, need_gguf: bool = True) -> dict[str, str]:
|
||||||
|
key = str(repo_id or "").strip()
|
||||||
|
if not key:
|
||||||
|
return {}
|
||||||
|
cache_key = f"{key.lower()}|fp8={int(need_fp8)}|gguf={int(need_gguf)}"
|
||||||
|
if cache_key in _HF_VARIANT_CACHE:
|
||||||
|
return dict(_HF_VARIANT_CACHE[cache_key])
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
if need_fp8:
|
||||||
|
fp8 = _best_variant_repo(key, "fp8")
|
||||||
|
if fp8:
|
||||||
|
found["FP8"] = fp8
|
||||||
|
if need_gguf:
|
||||||
|
gguf = _best_variant_repo(key, "gguf")
|
||||||
|
if gguf:
|
||||||
|
# The image-model fitter's smallest bucket is Q4; most HF image GGUF
|
||||||
|
# repos expose Q4/Q5/Q8 files under one repo, so use it as the low-VRAM
|
||||||
|
# download source while preserving the explicit GGUF label for callers.
|
||||||
|
found["Q4"] = gguf
|
||||||
|
found["GGUF"] = gguf
|
||||||
|
_HF_VARIANT_CACHE[cache_key] = found
|
||||||
|
return dict(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_quant_repos(model: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(model)
|
||||||
|
existing = dict(out.get("quant_repos") or {})
|
||||||
|
repo_id = str(out.get("id") or "")
|
||||||
|
if _should_discover_variants(repo_id):
|
||||||
|
discovered = _discover_quant_repos(
|
||||||
|
repo_id,
|
||||||
|
need_fp8="FP8" not in existing,
|
||||||
|
need_gguf="Q4" not in existing and "GGUF" not in existing,
|
||||||
|
)
|
||||||
|
for k, v in discovered.items():
|
||||||
|
existing.setdefault(k, v)
|
||||||
|
out["quant_repos"] = existing
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_image_models():
|
def get_image_models():
|
||||||
"""Return the image model registry."""
|
"""Return the image model registry."""
|
||||||
return IMAGE_MODEL_REGISTRY
|
merged = [_merge_quant_repos(m) for m in IMAGE_MODEL_REGISTRY]
|
||||||
|
seen = {str(m.get("id") or "").lower() for m in merged if isinstance(m, dict)}
|
||||||
|
for model in _fetch_hf_image_collection_models():
|
||||||
|
key = str(model.get("id") or "").lower()
|
||||||
|
if key and key not in seen:
|
||||||
|
merged.append(_merge_quant_repos(model))
|
||||||
|
seen.add(key)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _is_apple_image_system(system: dict[str, Any]) -> bool:
|
||||||
|
backend = str(system.get("backend") or "").lower()
|
||||||
|
gpu_name = str(system.get("gpu_name") or "").lower()
|
||||||
|
cpu_name = str(system.get("cpu_name") or "").lower()
|
||||||
|
platform = str(system.get("platform") or "").lower()
|
||||||
|
return (
|
||||||
|
bool(system.get("unified_memory"))
|
||||||
|
or backend in {"metal", "mps", "apple"}
|
||||||
|
or "apple" in gpu_name
|
||||||
|
or "apple" in cpu_name
|
||||||
|
or platform == "darwin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def rank_image_models(system, search=None, sort="fit"):
|
def rank_image_models(system, search=None, sort="fit"):
|
||||||
@@ -284,9 +334,17 @@ def rank_image_models(system, search=None, sort="fit"):
|
|||||||
system = {}
|
system = {}
|
||||||
gpu_vram = system.get("gpu_vram_gb", 0) or 0
|
gpu_vram = system.get("gpu_vram_gb", 0) or 0
|
||||||
has_gpu = system.get("has_gpu", False)
|
has_gpu = system.get("has_gpu", False)
|
||||||
|
ram_gb = system.get("available_ram_gb") or system.get("total_ram_gb") or 0
|
||||||
|
budget_gb = gpu_vram if has_gpu and gpu_vram > 0 else ram_gb
|
||||||
|
budget_kind = "gpu" if has_gpu and gpu_vram > 0 else "ram"
|
||||||
|
apple_system = _is_apple_image_system(system)
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for model in IMAGE_MODEL_REGISTRY:
|
for model in get_image_models():
|
||||||
|
if apple_system and not (model.get("mlx_only") or model.get("apple_ok")):
|
||||||
|
continue
|
||||||
|
if model.get("mlx_only") and not apple_system:
|
||||||
|
continue
|
||||||
# Filter by search
|
# Filter by search
|
||||||
if isinstance(search, str) and search:
|
if isinstance(search, str) and search:
|
||||||
s = search.lower()
|
s = search.lower()
|
||||||
@@ -299,11 +357,11 @@ def rank_image_models(system, search=None, sort="fit"):
|
|||||||
fits = False
|
fits = False
|
||||||
quant_repo = None
|
quant_repo = None
|
||||||
|
|
||||||
if has_gpu and gpu_vram > 0:
|
if budget_gb > 0:
|
||||||
# Try BF16 first, then FP8, then Q4
|
# Try BF16 first, then FP8, then Q4
|
||||||
for q, vram_key in [("BF16", "vram_bf16"), ("FP8", "vram_fp8"), ("Q4", "vram_q4")]:
|
for q, vram_key in [("BF16", "vram_bf16"), ("FP8", "vram_fp8"), ("Q4", "vram_q4")]:
|
||||||
v = model.get(vram_key)
|
v = model.get(vram_key)
|
||||||
if v is not None and v <= gpu_vram * 0.90: # 10% headroom
|
if v is not None and v <= budget_gb * 0.90: # 10% headroom
|
||||||
quant = q
|
quant = q
|
||||||
vram_needed = v
|
vram_needed = v
|
||||||
fits = True
|
fits = True
|
||||||
@@ -315,15 +373,15 @@ def rank_image_models(system, search=None, sort="fit"):
|
|||||||
vram_needed = model.get("vram_bf16", 0)
|
vram_needed = model.get("vram_bf16", 0)
|
||||||
|
|
||||||
# Fit label
|
# Fit label
|
||||||
if not has_gpu:
|
if budget_gb <= 0:
|
||||||
fit = "no_gpu"
|
fit = "no_gpu"
|
||||||
fit_label = "No GPU"
|
fit_label = "No GPU"
|
||||||
elif fits:
|
elif fits:
|
||||||
headroom = gpu_vram - vram_needed
|
headroom = budget_gb - vram_needed
|
||||||
if headroom > gpu_vram * 0.3:
|
if headroom > budget_gb * 0.3:
|
||||||
fit = "perfect"
|
fit = "perfect"
|
||||||
fit_label = "Perfect"
|
fit_label = "Perfect"
|
||||||
elif headroom > gpu_vram * 0.1:
|
elif headroom > budget_gb * 0.1:
|
||||||
fit = "good"
|
fit = "good"
|
||||||
fit_label = "Good"
|
fit_label = "Good"
|
||||||
else:
|
else:
|
||||||
@@ -355,6 +413,7 @@ def rank_image_models(system, search=None, sort="fit"):
|
|||||||
"fits": fits,
|
"fits": fits,
|
||||||
"fit": fit,
|
"fit": fit,
|
||||||
"fit_label": fit_label,
|
"fit_label": fit_label,
|
||||||
|
"fit_budget": budget_kind,
|
||||||
"quality": model["quality"],
|
"quality": model["quality"],
|
||||||
"speed": model["speed"],
|
"speed": model["speed"],
|
||||||
"score": round(score, 1),
|
"score": round(score, 1),
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def is_prequantized(model):
|
|||||||
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
or re.search(r"(^|[-_/])fp8($|[-_/\s])", text) is not None
|
||||||
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
or (not (model.get("is_gguf") or model.get("gguf_sources")) and re.search(r"(^|[-_/])(?:int)?8bit($|[-_/\s])", text) is not None)
|
||||||
or any(x in text for x in ("awq", "gptq", "mlx"))
|
or any(x in text for x in ("awq", "gptq", "mlx"))
|
||||||
or any(q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
or any(isinstance(q, str) and q.startswith(p) for p in PREQUANTIZED_PREFIXES)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ def params_b(model):
|
|||||||
return raw / 1_000_000_000.0
|
return raw / 1_000_000_000.0
|
||||||
|
|
||||||
pc = model.get("parameter_count", "")
|
pc = model.get("parameter_count", "")
|
||||||
if pc:
|
if isinstance(pc, str) and pc:
|
||||||
pc = pc.strip().upper()
|
pc = pc.strip().upper()
|
||||||
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
m = re.match(r"^([\d.]+)\s*([BKMGT]?)$", pc)
|
||||||
if m:
|
if m:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Memory service — persistent memory storage and retrieval."""
|
"""Memory service — persistent memory storage and retrieval."""
|
||||||
|
|
||||||
from .service import MemoryService, Memory, MemorySearchResult
|
from .service import MemoryService, Memory, MemorySearchResult
|
||||||
from .memory import MemoryManager
|
from .memory import MemoryManager, MemoryStoreUnreadable
|
||||||
from .memory_vector import MemoryVectorStore
|
from .memory_vector import MemoryVectorStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -10,5 +10,6 @@ __all__ = [
|
|||||||
"Memory",
|
"Memory",
|
||||||
"MemorySearchResult",
|
"MemorySearchResult",
|
||||||
"MemoryManager",
|
"MemoryManager",
|
||||||
|
"MemoryStoreUnreadable",
|
||||||
"MemoryVectorStore",
|
"MemoryVectorStore",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
|||||||
parallel implementation here risks silent drift between import paths.
|
parallel implementation here risks silent drift between import paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.memory import MemoryManager, get_text_similarity, tokenize
|
from src.memory import (
|
||||||
|
MemoryManager,
|
||||||
|
MemoryStoreUnreadable,
|
||||||
|
get_text_similarity,
|
||||||
|
tokenize,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
__all__ = [
|
||||||
|
"MemoryManager",
|
||||||
|
"MemoryStoreUnreadable",
|
||||||
|
"get_text_similarity",
|
||||||
|
"tokenize",
|
||||||
|
]
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.memory import MemoryStoreUnreadable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -387,7 +389,13 @@ async def extract_and_store(
|
|||||||
# Get owner from session
|
# Get owner from session
|
||||||
_owner = getattr(session, 'owner', None)
|
_owner = getattr(session, 'owner', None)
|
||||||
|
|
||||||
existing = memory_manager.load_all()
|
# Strict load: this is a read-modify-write. Degrading to [] here would
|
||||||
|
# save only the newly extracted facts and drop the entire store.
|
||||||
|
try:
|
||||||
|
existing = memory_manager.load_all_for_update()
|
||||||
|
except MemoryStoreUnreadable as e:
|
||||||
|
logger.error("Skipping auto memory extraction, store unreadable: %s", e)
|
||||||
|
return
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
for fact in facts:
|
for fact in facts:
|
||||||
@@ -626,7 +634,18 @@ async def audit_memories(
|
|||||||
|
|
||||||
# Merge audited entries back with other users' entries
|
# Merge audited entries back with other users' entries
|
||||||
if owner:
|
if owner:
|
||||||
all_entries = memory_manager.load_all()
|
# Strict load: the merge below reconstructs the whole file. If this
|
||||||
|
# degraded to [] we would save only this owner's audited slice and
|
||||||
|
# destroy every other tenant's memories.
|
||||||
|
try:
|
||||||
|
all_entries = memory_manager.load_all_for_update()
|
||||||
|
except MemoryStoreUnreadable as e:
|
||||||
|
logger.error("Aborting memory audit save, store unreadable: %s", e)
|
||||||
|
return {
|
||||||
|
"before": before_count,
|
||||||
|
"after": before_count,
|
||||||
|
"error": "store_unreadable",
|
||||||
|
}
|
||||||
audited_ids = {e["id"] for e in final_entries}
|
audited_ids = {e["id"] for e in final_entries}
|
||||||
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
||||||
# Also keep legacy entries that weren't part of this audit
|
# Also keep legacy entries that weren't part of this audit
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -100,6 +100,18 @@ def _parse_scalar(raw: str) -> Any:
|
|||||||
if raw.lower() in ("null", "none", "~"):
|
if raw.lower() in ("null", "none", "~"):
|
||||||
return None
|
return None
|
||||||
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
|
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
|
||||||
|
if raw[0] == '"':
|
||||||
|
# _emit_scalar writes double-quoted scalars with json.dumps, so
|
||||||
|
# decode the escapes instead of only stripping the quotes. Without
|
||||||
|
# this, `\"` / `\\` / `\uXXXX` stayed verbatim in the value and the
|
||||||
|
# next save escaped their backslashes again, doubling them on every
|
||||||
|
# load/save cycle (issue #5210).
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
# Hand-written file using escapes JSON rejects (e.g. a bare
|
||||||
|
# Windows path). Keep the previous literal reading.
|
||||||
|
pass
|
||||||
return raw[1:-1]
|
return raw[1:-1]
|
||||||
# Try number
|
# Try number
|
||||||
try:
|
try:
|
||||||
@@ -171,6 +183,26 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|||||||
return fm, body
|
return fm, body
|
||||||
|
|
||||||
|
|
||||||
|
# Characters that force a quoted scalar. The punctuation would otherwise change
|
||||||
|
# how the value reads back; the second row is every character str.splitlines()
|
||||||
|
# treats as a line break, and parse_frontmatter() reads one scalar per line, so
|
||||||
|
# emitting one of those bare would split the value across lines.
|
||||||
|
_FM_MUST_QUOTE = (
|
||||||
|
":", "#", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@",
|
||||||
|
"\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029",
|
||||||
|
)
|
||||||
|
|
||||||
|
# json.dumps escapes every C0 control character, but with ensure_ascii=False it
|
||||||
|
# passes NEL / LINE SEPARATOR / PARAGRAPH SEPARATOR through literally, and
|
||||||
|
# str.splitlines() still breaks on all three. Re-escape exactly those, which
|
||||||
|
# json.loads decodes again on the way in, so the pair stays symmetric.
|
||||||
|
_FM_POST_DUMPS_ESCAPES = (
|
||||||
|
("\x85", "\\u0085"),
|
||||||
|
("\u2028", "\\u2028"),
|
||||||
|
("\u2029", "\\u2029"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _emit_scalar(v: Any) -> str:
|
def _emit_scalar(v: Any) -> str:
|
||||||
if v is None:
|
if v is None:
|
||||||
return "null"
|
return "null"
|
||||||
@@ -181,8 +213,15 @@ def _emit_scalar(v: Any) -> str:
|
|||||||
if isinstance(v, list):
|
if isinstance(v, list):
|
||||||
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
||||||
s = str(v)
|
s = str(v)
|
||||||
if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")):
|
if any(c in s for c in _FM_MUST_QUOTE):
|
||||||
return json.dumps(s)
|
# ensure_ascii=False keeps non-ASCII text as itself. SKILL.md is UTF-8 at
|
||||||
|
# both ends (skills.py reads it, atomic_write_text writes it), so the
|
||||||
|
# \uXXXX form bought nothing and leaked into the parsed value (#5210).
|
||||||
|
out = json.dumps(s, ensure_ascii=False)
|
||||||
|
for ch, esc in _FM_POST_DUMPS_ESCAPES:
|
||||||
|
if ch in out:
|
||||||
|
out = out.replace(ch, esc)
|
||||||
|
return out
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -441,4 +480,4 @@ class Skill:
|
|||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -68,6 +68,52 @@ def _is_text_file(name: str) -> bool:
|
|||||||
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
|
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
# Max redirect hops to follow manually while re-validating each one.
|
||||||
|
_MAX_FETCH_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _check_fetch_url(url: str) -> None:
|
||||||
|
"""SSRF guard for skill-import fetches (defense-in-depth).
|
||||||
|
|
||||||
|
Skill bundles only ever come from public GitHub, never an internal
|
||||||
|
address, so block private/loopback/link-local targets on every hop —
|
||||||
|
matching the hardened web-fetch path in
|
||||||
|
``services/search/content.py:_get_public_url`` rather than the lenient
|
||||||
|
default used for admin-configured model endpoints.
|
||||||
|
"""
|
||||||
|
ok, reason = check_outbound_url(url, block_private=True)
|
||||||
|
if not ok:
|
||||||
|
raise SkillImportError(reason)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_checked(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""GET that follows redirects manually, re-running the SSRF guard per hop.
|
||||||
|
|
||||||
|
``httpx``'s ``follow_redirects=True`` validates only the initial URL, so a
|
||||||
|
``3xx`` to an internal address (``169.254.169.254``, ``127.0.0.1``, …) would
|
||||||
|
still be connected to before any post-hoc host check. Following redirects by
|
||||||
|
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||||
|
"""
|
||||||
|
current = url
|
||||||
|
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
||||||
|
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||||
|
_check_fetch_url(current)
|
||||||
|
r = client.get(current, headers=headers)
|
||||||
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
|
location = r.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
return r
|
||||||
|
current = urljoin(str(r.url), location)
|
||||||
|
continue
|
||||||
|
return r
|
||||||
|
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_source(url: str) -> ResolvedSource:
|
def parse_skill_source(url: str) -> ResolvedSource:
|
||||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||||
raw = (url or "").strip()
|
raw = (url or "").strip()
|
||||||
@@ -76,22 +122,18 @@ def parse_skill_source(url: str) -> ResolvedSource:
|
|||||||
|
|
||||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
||||||
if "skills.sh" in raw and "github.com" not in raw:
|
if "skills.sh" in raw and "github.com" not in raw:
|
||||||
ok, reason = check_outbound_url(raw)
|
r = _get_checked(raw, timeout=20.0)
|
||||||
if not ok:
|
if r.status_code >= 400:
|
||||||
raise SkillImportError(reason)
|
raise _github_response_error(r)
|
||||||
with httpx.Client(follow_redirects=True, timeout=20.0) as client:
|
final = str(r.url)
|
||||||
r = client.get(raw)
|
_assert_github_url(final, context="redirect target")
|
||||||
if r.status_code >= 400:
|
# Page may embed a github link; prefer final URL if redirected.
|
||||||
raise _github_response_error(r)
|
if "github.com" in final:
|
||||||
final = str(r.url)
|
raw = final
|
||||||
_assert_github_url(final, context="redirect target")
|
else:
|
||||||
# Page may embed a github link; prefer final URL if redirected.
|
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
||||||
if "github.com" in final:
|
if m:
|
||||||
raw = final
|
raw = m.group(0).rstrip(".,)")
|
||||||
else:
|
|
||||||
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
|
||||||
if m:
|
|
||||||
raw = m.group(0).rstrip(".,)")
|
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
parsed = urlparse(raw)
|
||||||
host = _github_host(raw)
|
host = _github_host(raw)
|
||||||
@@ -165,17 +207,13 @@ def _github_response_error(response: httpx.Response) -> SkillImportError:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_bytes(url: str) -> bytes:
|
def _fetch_bytes(url: str) -> bytes:
|
||||||
ok, reason = check_outbound_url(url)
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
||||||
if not ok:
|
if r.status_code >= 400:
|
||||||
raise SkillImportError(reason)
|
raise _github_response_error(r)
|
||||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
_assert_github_url(str(r.url), context="redirect target")
|
||||||
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
|
if len(r.content) > MAX_FILE_BYTES:
|
||||||
if r.status_code >= 400:
|
raise SkillImportError(f"file too large: {url}")
|
||||||
raise _github_response_error(r)
|
return r.content
|
||||||
_assert_github_url(str(r.url), context="redirect target")
|
|
||||||
if len(r.content) > MAX_FILE_BYTES:
|
|
||||||
raise SkillImportError(f"file too large: {url}")
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_text(url: str) -> str:
|
def _fetch_text(url: str) -> str:
|
||||||
@@ -190,15 +228,11 @@ def _list_github_dir(src: ResolvedSource, rel_dir: str, out: Dict[str, str], *,
|
|||||||
if depth > 4 or len(out) >= MAX_FILES:
|
if depth > 4 or len(out) >= MAX_FILES:
|
||||||
return
|
return
|
||||||
url = _api_contents_url(src, rel_dir)
|
url = _api_contents_url(src, rel_dir)
|
||||||
ok, reason = check_outbound_url(url)
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
||||||
if not ok:
|
if r.status_code >= 400:
|
||||||
raise SkillImportError(reason)
|
raise _github_response_error(r)
|
||||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
_assert_github_url(str(r.url), context="redirect target")
|
||||||
r = client.get(url, headers={"Accept": "application/vnd.github+json"})
|
entries = r.json()
|
||||||
if r.status_code >= 400:
|
|
||||||
raise _github_response_error(r)
|
|
||||||
_assert_github_url(str(r.url), context="redirect target")
|
|
||||||
entries = r.json()
|
|
||||||
if not isinstance(entries, list):
|
if not isinstance(entries, list):
|
||||||
raise SkillImportError("expected a directory on GitHub")
|
raise SkillImportError("expected a directory on GitHub")
|
||||||
total = sum(len(v.encode("utf-8")) for v in out.values())
|
total = sum(len(v.encode("utf-8")) for v in out.values())
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ class ResearchHandler:
|
|||||||
seen = set()
|
seen = set()
|
||||||
sources = []
|
sources = []
|
||||||
for f in findings:
|
for f in findings:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
url = f.get("url", "")
|
url = f.get("url", "")
|
||||||
title = f.get("title", "") or url
|
title = f.get("title", "") or url
|
||||||
summary = f.get("summary", "") or f.get("evidence", "")
|
summary = f.get("summary", "") or f.get("evidence", "")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import wave
|
import wave
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -41,6 +42,11 @@ class TTSService:
|
|||||||
self.cache_dir = Path(cache_dir)
|
self.cache_dir = Path(cache_dir)
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._kokoro = None # lazy-init
|
self._kokoro = None # lazy-init
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
|
||||||
|
except ValueError:
|
||||||
|
self.max_cache_bytes = 500 * 1024 * 1024
|
||||||
|
|
||||||
# ── Settings ──
|
# ── Settings ──
|
||||||
|
|
||||||
@@ -68,7 +74,7 @@ class TTSService:
|
|||||||
if provider == "local":
|
if provider == "local":
|
||||||
kokoro = self._get_kokoro()
|
kokoro = self._get_kokoro()
|
||||||
return kokoro is not None and kokoro.available
|
return kokoro is not None and kokoro.available
|
||||||
if provider.startswith("endpoint:"):
|
if isinstance(provider, str) and provider.startswith("endpoint:"):
|
||||||
return True # assume reachable; errors surface at synthesis time
|
return True # assume reachable; errors surface at synthesis time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -89,6 +95,53 @@ class TTSService:
|
|||||||
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
||||||
(self.cache_dir / f"{key}{ext}").write_bytes(data)
|
(self.cache_dir / f"{key}{ext}").write_bytes(data)
|
||||||
|
|
||||||
|
self._enforce_cache_limit()
|
||||||
|
|
||||||
|
def _enforce_cache_limit(self):
|
||||||
|
"""Evicts oldest files if the cache exceeds the configured byte limit."""
|
||||||
|
if self.max_cache_bytes <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = []
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
# Safely scan files and sum sizes, ignoring files deleted mid-scan
|
||||||
|
for f in self.cache_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
|
||||||
|
files.append(f)
|
||||||
|
total_size += f.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if total_size > self.max_cache_bytes:
|
||||||
|
logger.info(
|
||||||
|
f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort files by modification time (oldest first)
|
||||||
|
try:
|
||||||
|
files.sort(key=lambda f: f.stat().st_mtime)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to sort cache files by mtime: {e}")
|
||||||
|
|
||||||
|
# Trim down to 80% of max capacity
|
||||||
|
target_size = self.max_cache_bytes * 0.8
|
||||||
|
|
||||||
|
while files and total_size > target_size:
|
||||||
|
f = files.pop(0)
|
||||||
|
try:
|
||||||
|
size = f.stat().st_size
|
||||||
|
f.unlink()
|
||||||
|
total_size -= size
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to evict cache file {f}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
count = 0
|
count = 0
|
||||||
for f in self.cache_dir.glob("*.*"):
|
for f in self.cache_dir.glob("*.*"):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Architecture Runtime Inventory
|
# Architecture Runtime Inventory
|
||||||
|
|
||||||
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
|
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
|
||||||
> **Parent issue**: [#4082](https://github.com/pewdiepie-archdaemon/odysseus/issues/4082)
|
> **Parent issue**: [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
|
||||||
> **Last updated**: dev@b58af42 | 2026-06-16
|
> **Last updated**: dev@b58af42 | 2026-06-16
|
||||||
> **Status**: Draft — to be reviewed before follow-up slices open.
|
> **Status**: Draft — to be reviewed before follow-up slices open.
|
||||||
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
|
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
|||||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
("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+.+"),
|
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
|
|
||||||
|
# Workspace / coding-agent intent. These should promote to the agent
|
||||||
|
# workspace with shell/file tools available, not the "light" typed-tool
|
||||||
|
# path used for notes/calendar/email.
|
||||||
|
("workspace", "repo implementation request", rf"{_PLEASE}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||||
|
("workspace", "assistant repo implementation request", rf"{_ACTION_QUESTION}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||||
|
("workspace", "test/build command request", rf"{_PLEASE}(?:run|execute|start|launch)\b.{{0,80}}\b(?:tests?|pytest|npm\s+test|pnpm\s+test|yarn\s+test|build|lint|typecheck|benchmark|eval|terminal[- ]bench|tbench)\b"),
|
||||||
|
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
||||||
|
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
|
||||||
|
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
|
||||||
|
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
||||||
|
("workspace", "terminal workspace request", r"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|benchmark|terminal[- ]bench|tbench)\b"),
|
||||||
|
|
||||||
# Shell / remote-host intent.
|
# Shell / remote-host intent.
|
||||||
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
||||||
("shell", "ssh target request", r"\bssh\s+\w+"),
|
("shell", "ssh target request", r"\bssh\s+\w+"),
|
||||||
|
|||||||
+833
-60
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
from .subprocess_tools import BashTool, PythonTool
|
from .subprocess_tools import BashTool, PythonTool
|
||||||
from .web_tools import WebSearchTool, WebFetchTool
|
from .web_tools import WebSearchTool, WebFetchTool
|
||||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, ApplyPatchTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||||
|
from .coding_tools import TodoWriteTool
|
||||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||||
from .interaction_tools import AskUserTool, UpdatePlanTool
|
from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
@@ -41,6 +42,8 @@ TOOL_HANDLERS = {
|
|||||||
"read_file": ReadFileTool().execute,
|
"read_file": ReadFileTool().execute,
|
||||||
"write_file": WriteFileTool().execute,
|
"write_file": WriteFileTool().execute,
|
||||||
"edit_file": EditFileTool().execute,
|
"edit_file": EditFileTool().execute,
|
||||||
|
"apply_patch": ApplyPatchTool().execute,
|
||||||
|
"todowrite": TodoWriteTool().execute,
|
||||||
"ls": LsTool().execute,
|
"ls": LsTool().execute,
|
||||||
"glob": GlobTool().execute,
|
"glob": GlobTool().execute,
|
||||||
"grep": GrepTool().execute,
|
"grep": GrepTool().execute,
|
||||||
@@ -74,6 +77,7 @@ PYTHON_TIMEOUT = 30
|
|||||||
|
|
||||||
# Tool types that trigger execution
|
# Tool types that trigger execution
|
||||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||||
|
"apply_patch", "todowrite",
|
||||||
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
||||||
"create_document", "update_document", "edit_document",
|
"create_document", "update_document", "edit_document",
|
||||||
"search_chats",
|
"search_chats",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
_TODO_DIR = os.path.join(DATA_DIR, "agent_todos")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_session_id(value: str) -> str:
|
||||||
|
value = value or "current"
|
||||||
|
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:120] or "current"
|
||||||
|
|
||||||
|
|
||||||
|
class TodoWriteTool:
|
||||||
|
async def execute(self, content: str, ctx: dict) -> dict:
|
||||||
|
try:
|
||||||
|
args = json.loads(content) if (content or "").strip().startswith("{") else {"todos": []}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {"error": "todowrite: JSON object required", "exit_code": 1}
|
||||||
|
todos = args.get("todos")
|
||||||
|
if not isinstance(todos, list):
|
||||||
|
return {"error": "todowrite: todos must be a list", "exit_code": 1}
|
||||||
|
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
allowed_statuses = {"pending", "in_progress", "completed"}
|
||||||
|
allowed_priorities = {"low", "medium", "high"}
|
||||||
|
active_count = 0
|
||||||
|
for item in todos:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return {"error": "todowrite: each todo must be an object", "exit_code": 1}
|
||||||
|
content_text = str(item.get("content") or item.get("text") or "").strip()
|
||||||
|
if not content_text:
|
||||||
|
return {"error": "todowrite: todo content required", "exit_code": 1}
|
||||||
|
status = str(item.get("status") or "pending").strip()
|
||||||
|
if status not in allowed_statuses:
|
||||||
|
return {"error": f"todowrite: invalid status {status!r}", "exit_code": 1}
|
||||||
|
if status == "in_progress":
|
||||||
|
active_count += 1
|
||||||
|
priority = str(item.get("priority") or "medium").strip()
|
||||||
|
if priority not in allowed_priorities:
|
||||||
|
priority = "medium"
|
||||||
|
normalized.append({
|
||||||
|
"content": content_text,
|
||||||
|
"status": status,
|
||||||
|
"priority": priority,
|
||||||
|
})
|
||||||
|
if active_count > 1:
|
||||||
|
return {"error": "todowrite: only one todo can be in_progress", "exit_code": 1}
|
||||||
|
|
||||||
|
session_id = _safe_session_id(str(ctx.get("session_id") or args.get("session_id") or "current"))
|
||||||
|
os.makedirs(_TODO_DIR, exist_ok=True)
|
||||||
|
path = os.path.join(_TODO_DIR, f"{session_id}.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"todos": normalized}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for item in normalized:
|
||||||
|
marker = {"pending": " ", "in_progress": ">", "completed": "x"}[item["status"]]
|
||||||
|
lines.append(f"[{marker}] {item['content']} ({item['priority']})")
|
||||||
|
return {
|
||||||
|
"output": "Updated todo list:\n" + ("\n".join(lines) if lines else "(empty)"),
|
||||||
|
"exit_code": 0,
|
||||||
|
"todos": normalized,
|
||||||
|
}
|
||||||
@@ -2,10 +2,16 @@ from typing import Any, Dict, List, Optional
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from src.constants import MAX_READ_CHARS
|
from src.constants import MAX_READ_CHARS
|
||||||
from src.tool_utils import _parse_tool_args
|
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||||
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_document_upload(owner: Optional[str], content: Any) -> Optional[str]:
|
||||||
|
"""Reserve explicit upload URLs before an agent persists document text."""
|
||||||
|
return reserve_upload_references(get_upload_handler(), owner, content)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Active document state
|
# Active document state
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -384,6 +390,13 @@ class CreateDocumentTool:
|
|||||||
return {"error": "Cannot create document in another user's session"}
|
return {"error": "Cannot create document in another user's session"}
|
||||||
_owner = _sess.owner if _sess else None
|
_owner = _sess.owner if _sess else None
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(_owner, content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
doc = Document(
|
doc = Document(
|
||||||
id=doc_id,
|
id=doc_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -455,6 +468,13 @@ class UpdateDocumentTool:
|
|||||||
if is_email_doc:
|
if is_email_doc:
|
||||||
doc.language = "email"
|
doc.language = "email"
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(owner, new_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
if not is_email_doc and _pdf_source_upload_id(doc.current_content or ""):
|
if not is_email_doc and _pdf_source_upload_id(doc.current_content or ""):
|
||||||
return _create_pdf_text_derivative(
|
return _create_pdf_text_derivative(
|
||||||
db,
|
db,
|
||||||
@@ -532,6 +552,12 @@ class EditDocumentTool:
|
|||||||
applied = 1
|
applied = 1
|
||||||
skipped = max(0, len(edits) - 1)
|
skipped = max(0, len(edits) - 1)
|
||||||
doc.language = "email"
|
doc.language = "email"
|
||||||
|
missing_id = _missing_document_upload(owner, updated_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
new_ver = doc.version_count + 1
|
new_ver = doc.version_count + 1
|
||||||
ver = DocumentVersion(
|
ver = DocumentVersion(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -584,6 +610,13 @@ class EditDocumentTool:
|
|||||||
if applied == 0:
|
if applied == 0:
|
||||||
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
|
return {"error": f"No edits applied — none of the FIND blocks matched the document content (skipped {skipped})"}
|
||||||
|
|
||||||
|
missing_id = _missing_document_upload(owner, updated_content)
|
||||||
|
if missing_id:
|
||||||
|
return {
|
||||||
|
"error": f"Referenced upload is no longer available: {missing_id}",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
if _pdf_source_upload_id(doc.current_content or ""):
|
if _pdf_source_upload_id(doc.current_content or ""):
|
||||||
return _create_pdf_text_derivative(
|
return _create_pdf_text_derivative(
|
||||||
db,
|
db,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user