mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-11 23:52:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716e7f1e28 | ||
|
|
a130611340 | ||
|
|
0276e15f6e | ||
|
|
34e4666f35 | ||
|
|
1c15090949 | ||
|
|
37454f9d34 | ||
|
|
ae03bb8363 | ||
|
|
e4cc8efa19 | ||
|
|
1844b084bc | ||
|
|
c21980f5a6 | ||
|
|
7fdc97880a | ||
|
|
9974841c49 | ||
|
|
631c05603c | ||
|
|
f814018098 | ||
|
|
d47d85d8a4 | ||
|
|
77964b0025 | ||
|
|
682662d36f | ||
|
|
b7ab0a37c1 | ||
|
|
3efbecd50a | ||
|
|
06c1e71fa0 | ||
|
|
1e34f938cc | ||
|
|
596e1421e4 | ||
|
|
cad14f81b9 | ||
|
|
8532f4606b | ||
|
|
5d4e8d57bb | ||
|
|
ed531cfc61 | ||
|
|
f00ae350cd | ||
|
|
c1f959ae61 | ||
|
|
2790708b19 | ||
|
|
4126bea113 | ||
|
|
f7f6abf82b | ||
|
|
2a413814c5 | ||
|
|
1de3c72140 | ||
|
|
8450793fe6 | ||
|
|
c4fdd7ad5b | ||
|
|
6fced41220 | ||
|
|
6f2b58c896 | ||
|
|
39303f19cd | ||
|
|
5353241743 | ||
|
|
285d05e72e | ||
|
|
dc0fbbf7aa | ||
|
|
12b9c5f2ad | ||
|
|
cb26ccd9a7 | ||
|
|
23a480571a | ||
|
|
6a43cf23ea | ||
|
|
64b457dc0a | ||
|
|
ef3467e8d4 | ||
|
|
72e8520fa3 | ||
|
|
9276db9c11 | ||
|
|
a797cee0b3 | ||
|
|
76061498c8 | ||
|
|
04f8546d9d | ||
|
|
9835c1a7f9 | ||
|
|
239f09cbc4 | ||
|
|
a1a7f8fc9a | ||
|
|
87193d23f5 | ||
|
|
516f046fef | ||
|
|
48c3a7fc36 | ||
|
|
70fc58225c | ||
|
|
9efedc8b07 | ||
|
|
1d251ef867 | ||
|
|
bf5bf57bc1 | ||
|
|
2019a97339 | ||
|
|
9a1fdb192e | ||
|
|
bebf53be3c | ||
|
|
a8e0ff1c8e | ||
|
|
31685b050a | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
f510b1973a | ||
|
|
fe81c491cd | ||
|
|
1339efa856 | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
8b8fc62449 | ||
|
|
0f996f58da | ||
|
|
18329268b0 | ||
|
|
6cc98f34b8 | ||
|
|
c493a66101 | ||
|
|
d5949c0286 | ||
|
|
9ec897d947 | ||
|
|
9257f0e6d0 | ||
|
|
a334a21d57 | ||
|
|
87ef52f7cc | ||
|
|
25617a8c10 | ||
|
|
4992cc620f | ||
|
|
59cca6b915 | ||
|
|
1ddda10716 | ||
|
|
958848f9f8 | ||
|
|
571ac09cea | ||
|
|
f979e5602f | ||
|
|
0f95032177 | ||
|
|
b3e2e628ab | ||
|
|
f1a45e85c5 | ||
|
|
f843a3dc01 | ||
|
|
72ba37af6b | ||
|
|
5bf4524618 | ||
|
|
1a7c79946b | ||
|
|
6d102a213f | ||
|
|
de8f151632 | ||
|
|
cce5892eeb | ||
|
|
f4e3ae6e68 | ||
|
|
671cbd79ac | ||
|
|
2e1c2a343a | ||
|
|
0116f0a62b | ||
|
|
c237a1cb9c | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
57a1859f08 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
a646b59110 | ||
|
|
4079f12ae6 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
091364f7e6 | ||
|
|
91922d854c | ||
|
|
0133de6f0e | ||
|
|
641752387b | ||
|
|
339c6886cb | ||
|
|
25c2fad3e1 | ||
|
|
d105fbde95 | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
76abce349b | ||
|
|
4954cc2e8c | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -105,6 +105,17 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
# trusted single-user setups that accept injected content reaching side-effect tools.
|
||||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
||||||
|
|
||||||
|
# Comma-separated list of exact qualified MCP read and write tool names
|
||||||
|
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
|
||||||
|
# Declarations do not override built-in tool effects. Declared reads remain
|
||||||
|
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
|
||||||
|
# or network tools, but allow continuation to declared non-destructive write tools
|
||||||
|
# on the exact same stdio server without repeated approvals. Stdio transport does
|
||||||
|
# not guarantee network isolation. Authorization must still originate from the
|
||||||
|
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
|
||||||
|
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
|
||||||
|
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
|
||||||
|
|
||||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||||
# force it on, or false to force it off while you still serve plain HTTP.
|
# force it on, or false to force it off while you still serve plain HTTP.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ body:
|
|||||||
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/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
||||||
|
|
||||||
|
How a proposal is triaged and decided: [product and feature proposals](https://github.com/odysseus-dev/odysseus/blob/dev/docs/maintainers/product-and-features.md#feature-proposal-workflow).
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
@@ -79,6 +81,32 @@ body:
|
|||||||
label: Prior Art / Related Issues
|
label: Prior Art / Related Issues
|
||||||
description: Link any related issues, discussions, or external references that informed this proposal.
|
description: Link any related issues, discussions, or external references that informed this proposal.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Size and reversibility
|
||||||
|
description: Your best guess; the triager decides. Definitions are in the maintainer handbook (product-and-features.md).
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- "Small, reversible change within ROADMAP"
|
||||||
|
- "Big (more than one PR, or changes existing default behaviour)"
|
||||||
|
- "New direction (new area, multi-user, data leaving the machine, ...)"
|
||||||
|
- "Hard to undo (migration, deleted data, changed contract or permissions)"
|
||||||
|
- "Not sure"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: decision_questions
|
||||||
|
attributes:
|
||||||
|
label: Questions for the Product Owner
|
||||||
|
description: If this needs a product decision, the exact yes/no questions — one per independent part.
|
||||||
|
placeholder: "1. Should notes be shareable between accounts on one instance? (yes/no)"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: evidence
|
||||||
|
attributes:
|
||||||
|
label: Screenshots, recording or demo
|
||||||
|
description: For anything that renders, attach screenshots or a short recording. Link a demo branch if you have one.
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: willing_to_implement
|
id: willing_to_implement
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
|
|
||||||
Private maintainer-preview PRs may instead use:
|
Private maintainer-preview PRs may instead use:
|
||||||
N/A — maintainer integration work
|
N/A — maintainer integration work
|
||||||
|
|
||||||
|
New features: the linked issue carries `product: approved` or
|
||||||
|
`product: maintainer decision` (docs/maintainers/product-and-features.md).
|
||||||
-->
|
-->
|
||||||
|
|
||||||
Fixes #
|
Fixes #
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ jobs:
|
|||||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||||
python scripts/check_ca_trust.py --python
|
python scripts/check_ca_trust.py --python
|
||||||
uv pip install --system -r requirements.txt
|
uv pip install --system -r requirements.txt
|
||||||
|
uv pip install --system "playwright==1.62.0"
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
@@ -256,3 +257,27 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTEST_SHARD: ${{ matrix.shard }}
|
PYTEST_SHARD: ${{ matrix.shard }}
|
||||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||||
|
|
||||||
|
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||||
|
run: |
|
||||||
|
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||||
|
--config tests/e2e/ios-layout.config.cjs \
|
||||||
|
--reporter=line
|
||||||
|
|
||||||
|
benchmark-gfm-security:
|
||||||
|
name: Benchmark GFM rendering security
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
# Test-only renderer, pinned independently of application requirements.
|
||||||
|
- name: Install GitHub Flavored Markdown renderer
|
||||||
|
run: |
|
||||||
|
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
|
||||||
|
- name: Verify rendered benchmark report security
|
||||||
|
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ services/data/
|
|||||||
|
|
||||||
# Test capture artifacts (browser session dumps may contain personal content)
|
# Test capture artifacts (browser session dumps may contain personal content)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ regexTarget = "secret"
|
|||||||
regexes = [
|
regexes = [
|
||||||
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
|
||||||
|
targetRules = ["generic-api-key"]
|
||||||
|
condition = "AND"
|
||||||
|
paths = [
|
||||||
|
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
|
||||||
|
]
|
||||||
|
regexTarget = "secret"
|
||||||
|
regexes = [
|
||||||
|
'''^sk-123456789$''',
|
||||||
|
]
|
||||||
|
|||||||
+107
-63
@@ -1,28 +1,50 @@
|
|||||||
# Contributing to Odysseus
|
# Contributing to Odysseus
|
||||||
|
|
||||||
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
|
Thanks for helping improve Odysseus. The best contributions are **focused, reviewable, and backed by meaningful validation**.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Public pull requests target `dev`, not `main`.** Search existing work before implementing, and follow the current [PR template](.github/pull_request_template.md). Runtime changes require relevant execution evidence, not just a green CI badge.
|
||||||
|
|
||||||
|
## At a glance
|
||||||
|
|
||||||
|
| Topic | What contributors should do |
|
||||||
|
| --- | --- |
|
||||||
|
| **Branch** | Target `dev`; `main` is the curated release branch |
|
||||||
|
| **Scope** | One coherent change per PR; coordinate larger modularization work through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||||
|
| **Validation** | Document commands, results, runtime checks, and anything not tested |
|
||||||
|
| **Visual changes** | Run the UI, match the existing visual language, and attach screenshots or clips |
|
||||||
|
| **Review** | Follow the [risk-based maintainer policy](docs/maintainers/changes-and-review.md) and applicable repository protections |
|
||||||
|
| **Security** | Report vulnerabilities privately using [SECURITY.md](SECURITY.md) |
|
||||||
|
|
||||||
|
**Jump to:** [Branches](#branch-model) · [Getting started](#before-you-start) · [Setup](#setup) · [Checks](#running-checks) · [Pull requests](#pull-requests) · [Modularization](#structural-changes-and-modularization) · [Visual changes](#style-and-visual-changes) · [Code conventions](#code-conventions) · [Issue reports](#issue-reports)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Branch model
|
## Branch model
|
||||||
|
|
||||||
Odysseus has two branches:
|
| Branch | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| **`dev`** | Default integration branch and target for every public PR. Work can be in flux. |
|
||||||
|
| **`main`** | Curated release branch. A [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership) promotes a verified `dev` commit. |
|
||||||
|
|
||||||
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
|
If your PR accidentally targets `main`, use **Edit** on GitHub to change its base to `dev`. Changing the base does not itself require a rebase.
|
||||||
- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release.
|
|
||||||
|
|
||||||
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
|
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
|
||||||
|
|
||||||
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
|
|
||||||
|
|
||||||
## Before You Start
|
## Before You Start
|
||||||
|
|
||||||
- Search existing issues and pull requests before opening a new one.
|
1. Search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls) to avoid duplicate or conflicting work.
|
||||||
- Prefer one bug fix or feature per pull request.
|
2. Prefer one bug fix, feature, or coherent refactor per PR. Avoid broad rewrites, formatting-only churn, and unrelated file moves.
|
||||||
- Avoid broad rewrites, formatting-only changes, or moving many files unless the issue is specifically about structure.
|
3. For a feature, open a **Feature Request** first. Big, new-direction, or difficult-to-reverse changes require the documented [product decision process](docs/maintainers/product-and-features.md#feature-proposal-workflow).
|
||||||
- If you want to work on a large feature, open an issue first and describe the approach.
|
4. For structural work, coordinate the intended module boundary with maintainers and the [modularization tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Coding agents:** If you use an LLM coding agent, open an issue describing the problem before submitting its PR. Bulk agent-generated submissions that ignore the project's contribution format or visual style may be closed without review, even when the underlying fix is correct.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Docker is the recommended path for normal testing:
|
<details open>
|
||||||
|
<summary><strong>Docker (recommended for normal testing)</strong></summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
git clone https://github.com/odysseus-dev/odysseus.git
|
||||||
@@ -31,7 +53,10 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual development uses Python 3.11+:
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
@@ -40,11 +65,14 @@ pip install -r requirements.txt
|
|||||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
</details>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Native Windows is not actively tested. Docker on Linux or a Linux/macOS manual installation is the safer path for now. Document the environment actually used for testing.
|
||||||
|
|
||||||
## Running Checks
|
## Running Checks
|
||||||
|
|
||||||
Run the smallest relevant checks for your change:
|
Run relevant checks for the affected area. Examples from the existing contributor workflow:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pytest
|
python -m pytest
|
||||||
@@ -60,78 +88,94 @@ docker compose up -d --build
|
|||||||
docker compose logs --tail=120 odysseus
|
docker compose logs --tail=120 odysseus
|
||||||
```
|
```
|
||||||
|
|
||||||
Mention what you ran in the pull request description. If you could not run a check, say so.
|
> [!IMPORTANT]
|
||||||
|
> **Report what actually ran.** Include commands, results, and relevant manual/runtime verification in the PR description. If a check was not run, mark it as **not run** and explain the limitation. Passing syntax checks and unit tests do not automatically prove changed behavior works in the running application.
|
||||||
|
|
||||||
|
See the [testing and releases handbook](docs/maintainers/testing-and-releases.md) for risk-based validation and isolation requirements.
|
||||||
|
|
||||||
## Maintainer governance and review guidance
|
## Maintainer governance and review guidance
|
||||||
|
|
||||||
For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template.
|
The [Odysseus maintainer handbook](docs/maintainers/README.md) is authoritative for repository roles, product decisions, risk-based approvals, issue triage, security review, CodeQL findings, and release handoffs.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The handbook complements this contributor guide. It does not override the branch model, live GitHub protection rules, or the PR template.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
Good pull requests usually include:
|
Keep PRs **cohesive**. Small, focused changes are the default. A larger, coordinated module decomposition may be appropriate under the criteria below, but unrelated cleanup, formatting, refactors, and behavior changes must not be mixed.
|
||||||
|
|
||||||
- A short explanation of the bug or feature.
|
| Include in the PR | Why it matters |
|
||||||
- The files or areas changed.
|
| --- | --- |
|
||||||
- Manual test steps or automated test results from running the actual app, not just the test suite.
|
| **Summary and scope** | Explain the problem, intended behavior, and areas changed |
|
||||||
- Screenshots or short recordings for UI changes.
|
| **Linked issue** | Connect the PR to the existing report or approved work, for example `Fixes #123` or `Part of #4071` |
|
||||||
- Links to related issues, for example `Fixes #123`.
|
| **Validation evidence** | Show manual test steps or automated results that exercise the actual behavior |
|
||||||
|
| **UI evidence, when applicable** | Attach screenshots or short recordings from the running application |
|
||||||
|
| **Limitations** | Clearly identify tests or environments that were not exercised |
|
||||||
|
|
||||||
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
|
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
|
||||||
|
|
||||||
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
|
### Structural changes and modularization
|
||||||
|
|
||||||
|
**One coherent responsibility is the unit of review.** A larger PR can be appropriate when extracting that responsibility from an oversized module is safer or simpler than splitting the work into many conflicting or interdependent PRs.
|
||||||
|
|
||||||
|
Before implementation and review:
|
||||||
|
|
||||||
|
- **Coordinate:** Read existing issues/PRs and agree on the boundary with maintainers. Use [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) for codebase modularization.
|
||||||
|
- **Constrain scope:** Keep one subsystem or responsibility per PR. Avoid unrelated features, formatting, and opportunistic cleanup.
|
||||||
|
- **Preserve contracts:** Maintain observable behavior, imports, public interfaces, persistence contracts, and runtime invariants unless an approved issue explicitly scopes a change.
|
||||||
|
- **Validate behavior:** Include targeted regression tests and runtime checks. Frontend work needs browser validation and screenshots or recordings when rendering may change.
|
||||||
|
- **Explain tradeoffs:** Describe extraction boundaries, compatibility, validation results, and known limitations in the PR.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Large does not mean broad.** There is no arbitrary line-count limit for a well-scoped extraction. Correctness, reviewability, and integration risk determine whether the scope is acceptable.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Temporary Harness V2 coordination boundary:** Unrelated changes to `src/agent_loop.py` and closely related execution, authorization, orchestration, and context contracts are temporarily frozen while Harness V2 is developed and validated. Coordinate such work with maintainers. Other modules remain open, and urgent bug or security reports are welcome.
|
||||||
|
|
||||||
|
These contributions remain subject to the [risk-based review policy](docs/maintainers/changes-and-review.md), current repository protections, and PR template.
|
||||||
|
|
||||||
## Style and visual changes
|
## Style and visual changes
|
||||||
|
|
||||||
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
|
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
|
||||||
|
|
||||||
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
|
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
|
||||||
|
|
||||||
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
|
1. **Run the application locally** and inspect the change in a browser. Type checks and unit tests alone are insufficient.
|
||||||
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
|
2. **Attach a screenshot or short clip** from the running app, plus a mobile screenshot when mobile behavior changes.
|
||||||
3. **Match the existing visual language.** Specifically:
|
3. **Reuse the existing design system:**
|
||||||
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
|
- Prefer existing CSS tokens (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.). Do not introduce parallel colors, type scales, or spacing systems.
|
||||||
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
|
- Reuse existing button, input, card, and border classes rather than creating duplicate patterns.
|
||||||
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
|
- Do **not** add Unicode emoji in UI or code. Use inline SVG consistent with `static/index.html` or plain text.
|
||||||
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
|
- Preserve the existing primary monospaced font (`Fira Code`).
|
||||||
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
|
- Keep dark mode as the default; route any light-mode work through the existing theme system.
|
||||||
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
|
4. **Extend existing components** instead of introducing a parallel component for the same purpose.
|
||||||
|
|
||||||
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
|
> [!IMPORTANT]
|
||||||
|
> If you are unsure whether a change is visual, assume that it is and provide a screenshot. Visual evidence does not replace behavioral validation.
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
|
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
|
||||||
|
|
||||||
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
|
- **Filesystem paths:** Do not derive writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Persisted files and directories have named constants in `src/constants.py`, such as `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, and `CHROMA_DIR`. Import those names rather than deriving paths with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` reads `ODYSSEUS_DATA_DIR` and is intended directly only for dynamic paths without fixed names, such as per-owner files. Add a constant when a fixed path does not yet have one. The source tree is read-only in Docker, and `/app/...` does not exist on native runs. Guard directory creation so unwritable paths degrade gracefully rather than crashing at import.
|
||||||
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
|
- **Internal API and loopback URLs:** Do not hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants`, which honors `ODYSSEUS_INTERNAL_BASE` and `APP_PORT`.
|
||||||
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
|
- **Ports, limits, and model lists:** Reuse existing constants. When a value is shared by multiple locations and has no constant, add one in `src/constants.py` instead of duplicating a literal. `core/constants.py` is a compatibility re-export, not another source of truth.
|
||||||
|
|
||||||
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
|
**Commits:** Use [Conventional Commits](https://www.conventionalcommits.org) with `type(scope): summary`, for example `fix(search): ...`, `feat(notes): ...`, or `docs(contributing): ...`. Common types include `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, and `ci`. Keep the subject short and imperative, with the motivation in the body when useful.
|
||||||
|
|
||||||
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
|
|
||||||
|
|
||||||
## Issue Reports
|
## Issue Reports
|
||||||
|
|
||||||
For bugs, include:
|
| Bug reports should include | Model-serving reports should additionally include |
|
||||||
|
| --- | --- |
|
||||||
|
| Installation method (Docker, Python, WSL, etc.) | Backend (Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.) |
|
||||||
|
| OS, browser, and device when relevant | Model name |
|
||||||
|
| Exact reproduction steps | GPU/CPU and operating system |
|
||||||
|
| Expected versus actual behavior | Cookbook task logs or backend/server logs |
|
||||||
|
| Relevant logs, screenshots, or terminal output | Relevant backend configuration |
|
||||||
|
|
||||||
- Install method: Docker, manual Python, WSL, etc.
|
Issues containing only "help", "does not work", or an unexplained screenshot may be closed as not actionable. Start with a reproducible problem or use [Discussions](https://github.com/odysseus-dev/odysseus/discussions) for setup questions.
|
||||||
- OS, browser, and device if relevant.
|
|
||||||
- Exact steps to reproduce.
|
|
||||||
- Expected behavior and actual behavior.
|
|
||||||
- Logs, screenshots, or terminal output.
|
|
||||||
|
|
||||||
For model-serving issues, include:
|
|
||||||
|
|
||||||
- Backend: Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.
|
|
||||||
- Model name.
|
|
||||||
- GPU/CPU and operating system.
|
|
||||||
- Cookbook task logs or server logs.
|
|
||||||
|
|
||||||
Issues with only "help", "does not work", or a screenshot without context may be closed as not actionable.
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Do not post secrets, API keys, private logs, personal documents, or public IPs in issues or pull requests.
|
> [!CAUTION]
|
||||||
|
> **Do not publish secrets.** Keep API keys, sensitive logs, personal documents, and public IP addresses out of public issues and PRs. Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
|
||||||
For security reports, follow [SECURITY.md](SECURITY.md).
|
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -1,9 +1,22 @@
|
|||||||
|
# Both stages need the fixed native decoder, including curl in the builder and
|
||||||
|
# distro Node in the runtime. A cached Python base may predate the Debian fix.
|
||||||
|
FROM python:3.14.9-slim AS brotli-base
|
||||||
|
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
|
||||||
|
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||||
|
# Check inherited trust before the first HTTPS package request, retaining Debian's
|
||||||
|
# signed archive verification. Refresh only the explicit native dependency.
|
||||||
|
RUN python /usr/local/bin/check-ca-trust.py --system \
|
||||||
|
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
|
||||||
|
&& python /usr/local/bin/check-brotli.py \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||||
# the final image / Cookbook never has to compile the broken sdists. See
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
FROM python:3.14.9-slim AS realesrgan-wheels
|
FROM brotli-base AS realesrgan-wheels
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||||
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
|
|||||||
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM python:3.14.9-slim
|
FROM brotli-base
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||||
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libglib2.0-0t64 \
|
libglib2.0-0t64 \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
|
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Refresh both Python trust bundles before installing application dependencies.
|
# Refresh both Python trust bundles before installing application dependencies.
|
||||||
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
|||||||
# Check gRPC's effective defaults after all Python dependencies are installed.
|
# Check gRPC's effective defaults after all Python dependencies are installed.
|
||||||
# The application minimum fixes its bundled roots; the checked override also
|
# The application minimum fixes its bundled roots; the checked override also
|
||||||
# keeps the effective Docker selection consistent with system trust.
|
# keeps the effective Docker selection consistent with system trust.
|
||||||
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
|
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
|
||||||
|
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
+104
-74
@@ -1,87 +1,117 @@
|
|||||||
# Roadmap / Help Wanted
|
# Roadmap / Help Wanted
|
||||||
|
|
||||||
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
|
Odysseus is an evolving, self-hosted AI workspace. This roadmap highlights where improvements and contributions are most useful, from reliability and maintainability to product capabilities.
|
||||||
|
|
||||||
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
|
> [!NOTE]
|
||||||
the codebase, you are probably right to stay away.
|
> **Direction, not a release schedule.** This page is not a complete issue backlog or a promise to deliver every proposal. Implementation scope and priority are coordinated through existing issues, pull requests, and the project's [feature proposal process](docs/maintainers/product-and-features.md).
|
||||||
|
|
||||||
## High Priority
|
## At a glance
|
||||||
|
|
||||||
- SQUASH BUGS
|
| Area | Direction | Coordination |
|
||||||
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
|
| --- | --- | --- |
|
||||||
and WSL all need coverage.
|
| **Stabilization** | Reliability, CI, fresh installs, security, and integrations | [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) |
|
||||||
|
| **Modularization** | Clearer module ownership, safer changes, and contributor onboarding | [Canonical tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||||
|
| **Agent runtime** | Active Harness V2 development, with a narrow temporary change freeze | [Coordination in #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||||
|
| **Product improvements** | Local models, research, frontend experience, and operations | [Issues](https://github.com/odysseus-dev/odysseus/issues) and [feature proposals](docs/maintainers/product-and-features.md) |
|
||||||
|
|
||||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
**Jump to:** [Stabilization](#stabilization-and-reliability) · [Modularization](#modularization-and-maintainability) · [AI and local models](#ai-local-models-and-research) · [Frontend](#frontend-and-user-experience) · [Backend](#backend-and-operations) · [Contributing](#contributing)
|
||||||
- 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
|
|
||||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
|
||||||
NVIDIA/AMD hardware paths.
|
|
||||||
- Deep Research model presets by hardware. Recommend approved model/parameter
|
|
||||||
profiles for small, medium, and large local setups so people with different
|
|
||||||
hardware can use Deep Research without guessing. Surface this either in Deep
|
|
||||||
Research settings or as a Cookbook scan/dropdown suggestion.
|
|
||||||
- Cookbook model scan/download ranking. Prioritize newer architectures and
|
|
||||||
better hardware-fit models instead of scoring everything almost the same.
|
|
||||||
Ranking should account for architecture age, quant format, VRAM/RAM fit,
|
|
||||||
backend support, vision/mmproj requirements, and likely serve reliability.
|
|
||||||
- Cookbook error feedback and logging. Failed downloads, dependency installs,
|
|
||||||
preflights, and serve jobs should show the actual command/output/error in the
|
|
||||||
UI, with copyable logs and clear next steps instead of just "crashed".
|
|
||||||
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
|
|
||||||
tool schemas, skills, memory, documents, and instructions can eat the context
|
|
||||||
before the user request really starts. We need slimmer prompts, better tool
|
|
||||||
selection, smaller default tool sets, and clearer guidance for models with
|
|
||||||
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,
|
|
||||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
|
||||||
whether models follow malicious instructions from those surfaces.
|
|
||||||
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
|
|
||||||
- Email performance audit. Fetching, searching, opening, deleting, and sending
|
|
||||||
email can feel slow, especially over IMAP/SMTP providers with high latency.
|
|
||||||
Need someone who knows mail performance to profile the current flow, identify
|
|
||||||
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
|
|
||||||
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
|
|
||||||
propose safer caching/prefetch/batching without breaking multi-account state.
|
|
||||||
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
|
|
||||||
|
|
||||||
## Refactor Targets
|
---
|
||||||
- CSS cleanup. `static/style.css` basically Calypso's island atm.
|
|
||||||
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
|
|
||||||
- Modal/window positioning cleanup. Some window controls have improved, but the
|
|
||||||
underlying popup/dropdown/fixed-position behavior is still too fragile.
|
|
||||||
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
|
|
||||||
- Dead code pass for old routes, stale feature flags, and unused UI states.
|
|
||||||
|
|
||||||
## Frontend
|
## Stabilization and reliability
|
||||||
|
|
||||||
- Expand the Editor for quicker, more robust everyday use. Better file/document
|
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
|
||||||
handling, smoother window behavior, clearer save/export flows, stronger image
|
|
||||||
editing affordances, and fewer brittle edge cases.
|
|
||||||
- Better AI integration for Notes and Todos. Notes should be easier for the
|
|
||||||
agent to read, update, summarize, and turn into actions. Todos should be
|
|
||||||
assignable to an agent from the UI, possibly through a button, task action,
|
|
||||||
or dedicated skill/tool flow.
|
|
||||||
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
|
|
||||||
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
|
|
||||||
- Improve empty states and error messages on fresh installs.
|
|
||||||
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
|
|
||||||
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
|
|
||||||
|
|
||||||
## Backend
|
| Workstream | What needs improvement | Tracking |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **CI and validation** | Exact-revision validation, meaningful regression coverage, and release confidence | [#5932](https://github.com/odysseus-dev/odysseus/issues/5932), [#7109](https://github.com/odysseus-dev/odysseus/issues/7109) |
|
||||||
|
| **Test quality** | Deterministic tests, isolation, focused execution, and maintainable test structure | [#2523](https://github.com/odysseus-dev/odysseus/issues/2523), [testing standard](tests/TESTING_STANDARD.md) |
|
||||||
|
| **Fresh installations** | Smoke tests for Linux, macOS, Windows, WSL, and Docker; baseline startup and graceful degradation | [#3968](https://github.com/odysseus-dev/odysseus/issues/3968) |
|
||||||
|
| **Email integration** | Remaining Google OAuth regressions, account isolation, and cross-provider reliability | [#5637](https://github.com/odysseus-dev/odysseus/issues/5637) |
|
||||||
|
| **Storage integrity** | Upload payload growth, indexing, and attachment/file lifecycle | [#4889](https://github.com/odysseus-dev/odysseus/issues/4889) |
|
||||||
|
|
||||||
- More tests around endpoint probing and provider setup.
|
Additional reliability priorities:
|
||||||
- Better task scheduler defaults and visibility.
|
|
||||||
- Backup/restore guide and helper flow for `data/`.
|
|
||||||
- Security hardening around admin-only tools and clear docs for their risk.
|
|
||||||
|
|
||||||
## Not The Focus Right Now
|
- **Cookbook:** Improve model installation, dependency management, hardware detection, and model serving across operating systems, GPUs, drivers, shells, Python environments, and supported backends.
|
||||||
|
- **Security:** Keep strengthening authorization, administrative tools, agent tool execution, prompt-injection boundaries, filesystem access, and untrusted-content handling.
|
||||||
|
- **Integrations and degraded states:** Audit supported providers and optional services, including ChromaDB, SearXNG, email, and ntfy. Make failures observable and nonessential outages predictable.
|
||||||
|
|
||||||
I prob shouldnt add more themes.
|
> [!TIP]
|
||||||
|
> Start with an existing, reproducible issue. The [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) is a curated work queue, not an invitation to implement every item in this roadmap without coordination.
|
||||||
|
|
||||||
|
## Modularization and maintainability
|
||||||
|
|
||||||
|
The goal is **clearer responsibility boundaries and safer development**, not smaller files for their own sake. Changes should preserve behavior, compatibility, and relevant runtime contracts.
|
||||||
|
|
||||||
|
| Area | Current foundation | Remaining opportunities |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **CSS** | The former `static/style.css` has been split into ordered files under `static/css/` | Improve ownership while preserving the cascade, themes, and responsive layouts |
|
||||||
|
| **Tools** | Substantial tool-domain extraction has landed | Clarify remaining ownership and preserve compatibility through the existing tool interfaces |
|
||||||
|
| **Frontend** | Existing feature modules remain in use | Decompose large document, gallery, settings, calendar, and other UI modules around coherent behavior |
|
||||||
|
| **Backend** | Domain-specific routes and services already exist | Identify safe boundaries in Cookbook routes, persistence, and other tightly coupled modules |
|
||||||
|
| **Tests** | Taxonomy, focused execution, and structural improvements exist | Align fixtures, helpers, and regression coverage with implementation boundaries |
|
||||||
|
| **UI infrastructure** | Targeted cleanup is underway | Consolidate tour scaffolding, modal/window positioning, responsive overrides, and obsolete paths |
|
||||||
|
|
||||||
|
**Canonical coordination:** [Codebase modularization and contributor onboarding, #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Cohesive work is more important than a small line count.** A larger PR may be appropriate for one well-defined decomposition when several smaller PRs would create avoidable conflicts or integration overhead. Coordinate the boundary, avoid unrelated work, and supply regression and runtime evidence. See [CONTRIBUTING.md](CONTRIBUTING.md#structural-changes-and-modularization).
|
||||||
|
|
||||||
|
### Agent runtime and Harness V2
|
||||||
|
|
||||||
|
Harness V2 is under active development.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Temporary agent-runtime freeze.** Unrelated changes to `src/agent_loop.py` and closely related execution, orchestration, authorization, and context contracts must be coordinated with maintainers while Harness V2 is developed and validated.
|
||||||
|
>
|
||||||
|
> This does **not** freeze unrelated application modules. Urgent bug reports and security issues remain welcome. No release date is implied.
|
||||||
|
|
||||||
|
## AI, local models, and research
|
||||||
|
|
||||||
|
These are **areas of product interest**, not commitments to a specific implementation or delivery order. Follow normal issue triage and product-decision requirements.
|
||||||
|
|
||||||
|
| Direction | Opportunity |
|
||||||
|
| --- | --- |
|
||||||
|
| **Smaller-model agents** | Reduce unnecessary prompt, tool-schema, skill, memory, and document context overhead; improve tool selection for constrained context windows |
|
||||||
|
| **Cookbook and SGLang** | Improve installation and serving across relevant hardware, operating systems, and deployment methods |
|
||||||
|
| **Model discovery** | Rank models by architecture, quantization, RAM/VRAM fit, backend support, multimodal requirements, and expected reliability |
|
||||||
|
| **Deep Research presets** | Explore suitable model and parameter configurations for small, medium, and large local systems |
|
||||||
|
| **Actionable diagnostics** | Expose command output, logs, and next steps for failed downloads, dependencies, preflights, and serving jobs |
|
||||||
|
| **Speculative decoding** | Investigate compatible draft-model configurations and evaluate end-to-end correctness and time to success |
|
||||||
|
| **Provider setup** | Improve endpoint discovery, capability checks, and setup for supported local and hosted providers |
|
||||||
|
|
||||||
|
## Frontend and user experience
|
||||||
|
|
||||||
|
- **Editors and files:** Improve document/file editing, window behavior, save/export flows, and image-editing reliability.
|
||||||
|
- **Notes and Todos:** Expand agent-assisted reading, updates, summarization, and turning information into tasks.
|
||||||
|
- **Mobile:** Improve gallery/editor usability, model setup, and responsive behavior.
|
||||||
|
- **Accessibility:** Strengthen keyboard navigation, focus states, contrast, and reduced-motion support.
|
||||||
|
- **First run:** Improve setup, hints, empty states, error messages, and onboarding tours without duplicate guidance.
|
||||||
|
- **Self-hosting:** Reduce unnecessary external asset dependencies where practical for offline operation.
|
||||||
|
|
||||||
|
## Backend and operations
|
||||||
|
|
||||||
|
- **Email performance:** Measure fetching, searching, body and attachment loading, caching, sending, and frontend refresh behavior before optimizing.
|
||||||
|
- **Provider reliability:** Expand regression coverage for setup, endpoint probing, and degraded-service behavior.
|
||||||
|
- **Task scheduling:** Improve defaults, reliability, and operational visibility.
|
||||||
|
- **Data recovery:** Improve backup and restore guidance and workflows for persistent data.
|
||||||
|
- **Administrative security:** Review privileged capabilities and sensitive integrations against their authorization boundaries.
|
||||||
|
|
||||||
|
## Not a current priority
|
||||||
|
|
||||||
|
- Additional themes without a demonstrated product need.
|
||||||
|
- Broad rewrites or framework migrations without clear, measurable value.
|
||||||
|
- Changes undertaken solely to reduce file counts.
|
||||||
|
- Uncoordinated changes to temporarily frozen agent-runtime contracts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) and search [existing issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls).
|
||||||
|
2. Coordinate modularization through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and active stabilization work through the [milestone](https://github.com/odysseus-dev/odysseus/milestone/1).
|
||||||
|
3. Use the repository's issue and PR templates and target **`dev`**.
|
||||||
|
4. Include relevant tests, runtime evidence, and screenshots or recordings for visual changes.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Product-direction or difficult-to-reverse changes follow the [feature proposal process](docs/maintainers/product-and-features.md). Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), never in a public issue.
|
||||||
|
|||||||
+3
-2
@@ -64,12 +64,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
|||||||
|
|
||||||
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
|
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
|
||||||
|
|
||||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||||
|
|
||||||
Two exemptions apply even when the gate is on, both deliberate:
|
Three exemptions apply even when the gate is on, all deliberate:
|
||||||
|
|
||||||
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
||||||
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
||||||
|
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
|
||||||
|
|
||||||
## Security Headers
|
## Security Headers
|
||||||
|
|
||||||
|
|||||||
+39
-1
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|||||||
# backwards for this sentinel.
|
# backwards for this sentinel.
|
||||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||||
|
|
||||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
|
||||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
|||||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def username_error(username: str | None) -> Optional[str]:
|
||||||
|
"""Why a new username is not acceptable, or None when it is.
|
||||||
|
|
||||||
|
Admin routes carry the name as a URL path segment, where "/" never
|
||||||
|
reaches the handler, so such an account could not be managed afterwards.
|
||||||
|
"""
|
||||||
|
name = (username or "").strip()
|
||||||
|
if not name:
|
||||||
|
return "Username is required"
|
||||||
|
if len(name) > USERNAME_MAX_LENGTH:
|
||||||
|
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
|
||||||
|
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
|
||||||
|
return "Username must not contain slashes or control characters"
|
||||||
|
if name in (".", ".."):
|
||||||
|
return "Username is not allowed"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||||
"""Return a normalized username only when it exists in the auth user map."""
|
"""Return a normalized username only when it exists in the auth user map."""
|
||||||
key = str(username or "").strip().lower()
|
key = str(username or "").strip().lower()
|
||||||
@@ -396,6 +414,15 @@ class AuthManager:
|
|||||||
current = self.get_privileges(username)
|
current = self.get_privileges(username)
|
||||||
for k, v in privileges.items():
|
for k, v in privileges.items():
|
||||||
if k in DEFAULT_PRIVILEGES:
|
if k in DEFAULT_PRIVILEGES:
|
||||||
|
default = DEFAULT_PRIVILEGES[k]
|
||||||
|
if isinstance(default, bool):
|
||||||
|
ok = isinstance(v, bool)
|
||||||
|
elif isinstance(default, int):
|
||||||
|
ok = isinstance(v, int) and not isinstance(v, bool)
|
||||||
|
else:
|
||||||
|
ok = isinstance(v, list)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Invalid value for privilege '{k}'")
|
||||||
current[k] = v
|
current[k] = v
|
||||||
self._config["users"][username]["privileges"] = current
|
self._config["users"][username]["privileges"] = current
|
||||||
self._save()
|
self._save()
|
||||||
@@ -436,6 +463,17 @@ class AuthManager:
|
|||||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||||
if admin_count <= 1:
|
if admin_count <= 1:
|
||||||
return SetAdminResult.LAST_ADMIN
|
return SetAdminResult.LAST_ADMIN
|
||||||
|
# Token creation is admin-only and a demoted user can neither
|
||||||
|
# list nor revoke their tokens, so purge them with the role.
|
||||||
|
# Done before the flag flips: if the token store fails the
|
||||||
|
# exception propagates and the user is still an admin.
|
||||||
|
from core.database import get_db_session, ApiToken
|
||||||
|
with get_db_session() as db:
|
||||||
|
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||||
|
if removed_tokens:
|
||||||
|
logger.info(
|
||||||
|
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
|
||||||
|
)
|
||||||
# Write order matters for lock-free readers: get_privileges()
|
# Write order matters for lock-free readers: get_privileges()
|
||||||
# reads without _config_lock and trusts is_admin, so the admin
|
# reads without _config_lock and trusts is_admin, so the admin
|
||||||
# flag must be flipped while the stored map is safe to expose —
|
# flag must be flipped while the stored map is safe to expose —
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ class ModelEndpoint(TimestampMixin, Base):
|
|||||||
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
|
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
|
||||||
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
|
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
|
||||||
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
|
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
|
||||||
|
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
|
||||||
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
|
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
|
||||||
# auto = classify by URL; local = self-hosted server; api/proxy = external
|
# auto = classify by URL; local = self-hosted server; api/proxy = external
|
||||||
# OpenAI-compatible API even when reachable through a private/tailnet IP.
|
# OpenAI-compatible API even when reachable through a private/tailnet IP.
|
||||||
@@ -1378,6 +1379,26 @@ def _migrate_add_pinned_models_column():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_cookbook_serve_session_id_column():
|
||||||
|
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
columns = {
|
||||||
|
column["name"]
|
||||||
|
for column in inspect(conn).get_columns("model_endpoints")
|
||||||
|
}
|
||||||
|
|
||||||
|
if "cookbook_serve_session_id" not in columns:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE model_endpoints "
|
||||||
|
"ADD COLUMN cookbook_serve_session_id VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_notes_sort_order():
|
def _migrate_add_notes_sort_order():
|
||||||
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -2390,6 +2411,7 @@ def init_db():
|
|||||||
_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()
|
||||||
|
_migrate_add_cookbook_serve_session_id_column()
|
||||||
_migrate_add_notes_sort_order()
|
_migrate_add_notes_sort_order()
|
||||||
_migrate_add_model_type_column()
|
_migrate_add_model_type_column()
|
||||||
_migrate_add_model_endpoint_refresh_columns()
|
_migrate_add_model_endpoint_refresh_columns()
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ services:
|
|||||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
@@ -82,13 +84,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- 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}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- 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:-}
|
||||||
@@ -165,7 +169,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ services:
|
|||||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
@@ -81,13 +83,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- 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}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- 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:-}
|
||||||
@@ -168,7 +172,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
+6
-2
@@ -61,6 +61,8 @@ services:
|
|||||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||||
|
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||||
@@ -70,13 +72,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- 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}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- 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:-}
|
||||||
@@ -146,7 +150,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
|||||||
name="${spec%%==*}"
|
name="${spec%%==*}"
|
||||||
ver="${spec##*==}"
|
ver="${spec##*==}"
|
||||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
# sdist URL from the PyPI JSON API instead.
|
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||||
url="$(python - "$name" "$ver" <<'PY'
|
# survive transient TLS / network failures that urllib cannot recover from.
|
||||||
import json, sys, urllib.request
|
metadata="${name}-${ver}.json"
|
||||||
name, ver = sys.argv[1], sys.argv[2]
|
curl --fail --silent --show-error --location \
|
||||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
--retry 8 \
|
||||||
for f in data["urls"]:
|
--retry-delay 3 \
|
||||||
if f["packagetype"] == "sdist":
|
--retry-max-time 120 \
|
||||||
print(f["url"]); break
|
--retry-all-errors \
|
||||||
|
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||||
|
-o "$metadata"
|
||||||
|
|
||||||
|
url="$(python - "$metadata" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for item in data["urls"]:
|
||||||
|
if item["packagetype"] == "sdist":
|
||||||
|
print(item["url"])
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
sys.exit(f"no sdist found for {name}=={ver}")
|
raise SystemExit("no sdist found")
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
rm -f "$metadata"
|
||||||
|
|
||||||
echo ">> fetching ${name} ${ver}: ${url}"
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
curl --fail --silent --show-error --location \
|
||||||
|
--retry 8 \
|
||||||
|
--retry-delay 3 \
|
||||||
|
--retry-max-time 120 \
|
||||||
|
--retry-all-errors \
|
||||||
|
"$url" \
|
||||||
|
-o "${name}.tar.gz"
|
||||||
tar xzf "${name}.tar.gz"
|
tar xzf "${name}.tar.gz"
|
||||||
|
rm -f "${name}.tar.gz"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ">> patching get_version()"
|
echo ">> patching get_version()"
|
||||||
|
|||||||
@@ -119,19 +119,26 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
|||||||
- The page runs the op via the control API and replies:
|
- The page runs the op via the control API and replies:
|
||||||
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
||||||
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||||
With `wait:true` the call blocks and returns the editor's result.
|
With `wait:true` and one step the call blocks and returns the editor's result.
|
||||||
|
Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id.
|
||||||
|
Timeouts are seconds, greater than zero and at most 900. Pending results expire
|
||||||
|
after the command timeout; an unknown or another owner's id returns 404.
|
||||||
|
|
||||||
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||||
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
|
||||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
Session scoping uses the authenticated owner and session as separate values. A
|
||||||
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
bound editor-tool turn uses its server-owned session, not a model-selected one.
|
||||||
|
The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||||
|
For `/api/editor/agent`, clients must send `incognito: true` for private image
|
||||||
|
results; omitted or false requests use normal gallery persistence. These
|
||||||
|
owner-scoped routes do not accept delegated API tokens without a scope contract.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -9,6 +9,7 @@ This handbook records the project's maintainer governance and gives reviewers a
|
|||||||
| Area | Working rule |
|
| Area | Working rule |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product direction | Pewds is the Product Owner |
|
| Product direction | Pewds is the Product Owner |
|
||||||
|
| New feature | Feature Request issue; big, new-direction or hard-to-undo features need a recorded Product Owner decision ([workflow](product-and-features.md#feature-proposal-workflow)) |
|
||||||
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
|
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
|
||||||
| External contribution | Maintainer review required |
|
| External contribution | Maintainer review required |
|
||||||
| High-risk change | At least one **independent, qualified** maintainer review |
|
| High-risk change | At least one **independent, qualified** maintainer review |
|
||||||
@@ -22,7 +23,7 @@ These are governance rules, not guarantees that a GitHub permission or branch-pr
|
|||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
|
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
|
||||||
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
|
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership, and the feature proposal workflow.
|
||||||
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
|
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
|
||||||
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
|
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
|
||||||
- [Triage](triage.md): turning incoming reports into actionable work.
|
- [Triage](triage.md): turning incoming reports into actionable work.
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
|
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
|
||||||
|
|
||||||
|
This page has two parts: **which changes need product approval**, and **the workflow every feature proposal follows**, from issue to merged PR. The terms in bold are defined in [Definitions](#definitions); when a case is unclear, the definitions decide, not intuition.
|
||||||
|
|
||||||
## Which changes require product approval?
|
## Which changes require product approval?
|
||||||
|
|
||||||
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
|
**Product Owner approval before implementation:** features that are **big**, set a **new direction**, or are **hard to undo**.
|
||||||
|
|
||||||
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
|
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and features that are **small**, **reversible**, and **fit ROADMAP**.
|
||||||
|
|
||||||
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
|
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
|
||||||
|
|
||||||
### Urgent security and reliability fixes
|
### Urgent security and reliability fixes
|
||||||
|
|
||||||
@@ -25,19 +27,171 @@ Product Owner proposal or implementation
|
|||||||
|
|
||||||
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
|
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
|
||||||
|
|
||||||
## Community or maintainer initiated
|
## Feature proposal workflow
|
||||||
|
|
||||||
Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas).
|
Every community or maintainer feature proposal follows these steps. The issue is the record at every step: labels show where a proposal stands, and comments hold the decisions.
|
||||||
|
|
||||||
```text
|
```mermaid
|
||||||
Feature proposal
|
flowchart TD
|
||||||
-> Product Owner accepts / rejects, where approval is required
|
A["0. Idea (optional)<br/>Discussions › Ideas"] -.-> B["1. Feature Request issue"]
|
||||||
-> technical feasibility
|
B --> C{"2. Triage<br/>within 7 days"}
|
||||||
-> plan and implementation
|
C -- "duplicate / not actionable" --> X["Closed with a reason"]
|
||||||
-> risk-based review and validation
|
C -- "small + reversible + fits ROADMAP" --> M["product: maintainer decision"]
|
||||||
|
C -- "big, new direction or hard to undo" --> N["product: needs decision"]
|
||||||
|
N --> D["3. Decision brief"]
|
||||||
|
D --> E{"4. Product Owner decides"}
|
||||||
|
E -- yes --> P["product: approved"]
|
||||||
|
E -- no --> R["product: declined<br/>closed as not planned"]
|
||||||
|
E -- "no answer in 14 days" --> L{"Only reason was size?"}
|
||||||
|
L -- yes --> M
|
||||||
|
L -- "no: new direction or hard to undo" --> N
|
||||||
|
M --> F["5. Implementation PRs<br/>Part of #N"]
|
||||||
|
P --> F
|
||||||
|
F --> G["6. Review and merge"]
|
||||||
|
G --> H["7. Close the issue"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision.
|
### Step 0 — Idea (optional)
|
||||||
|
|
||||||
|
Half-formed ideas go to [Discussions › Ideas](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) to gauge interest. Nothing is decided there. A proposal is ready for step 1 when it is **actionable**.
|
||||||
|
|
||||||
|
### Step 1 — Feature Request issue
|
||||||
|
|
||||||
|
**Who:** the proposer.
|
||||||
|
|
||||||
|
Open a [Feature Request](https://github.com/odysseus-dev/odysseus/issues/new?template=feature_request.yml) after checking [ROADMAP.md](../../ROADMAP.md) and open issues. Besides the required fields, the form asks three optional questions that make triage faster:
|
||||||
|
|
||||||
|
- whether the proposer thinks it is big, a new direction, or hard to undo;
|
||||||
|
- the yes/no questions they want the Product Owner to answer;
|
||||||
|
- screenshots, a recording, or a demo branch.
|
||||||
|
|
||||||
|
A proposal with several independent parts may be one issue, provided each part is listed separately so it can be decided separately.
|
||||||
|
|
||||||
|
### Step 2 — Triage
|
||||||
|
|
||||||
|
**Who:** a **triager**. **When:** within 7 days of the issue being opened.
|
||||||
|
|
||||||
|
The triager does one of three things and records why in a comment:
|
||||||
|
|
||||||
|
| Outcome | When | Label | Next |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Close | **Duplicate** or not **actionable** | — (closed as duplicate or not planned, with a link) | — |
|
||||||
|
| Maintainer decision | **Small** *and* **reversible** *and* **fits ROADMAP** | `product: maintainer decision` | Step 5 |
|
||||||
|
| Needs the Product Owner | **Big**, *or* **new direction**, *or* **hard to undo** | `product: needs decision` | Step 3 |
|
||||||
|
|
||||||
|
The triage comment names the ROADMAP line the proposal fits, or says that it fits none, and which of big / new direction / hard to undo applies. When the triager is unsure, the proposal goes to the Product Owner.
|
||||||
|
|
||||||
|
### Step 3 — Decision brief
|
||||||
|
|
||||||
|
**Who:** the proposer writes it; the triager checks it is complete before the clock starts.
|
||||||
|
|
||||||
|
The brief goes at the top of the issue body (edit the issue, or post it as the first comment and link it from the body) and contains:
|
||||||
|
|
||||||
|
1. **Summary:** what changes for users, in three to five lines.
|
||||||
|
2. **Questions:** the exact yes/no questions, one per independent part.
|
||||||
|
3. **Evidence:** screenshots or a recording for anything that renders; a demo branch if one exists.
|
||||||
|
4. **What cannot be undone:** migrations, deleted data, changed contracts, or "nothing".
|
||||||
|
5. **For big designs only:** architecture, migration and rollback, and the planned PR slices. A long design may live in a proposal document under `/design` and be linked from the brief.
|
||||||
|
|
||||||
|
When the brief is complete, the triager mentions the Product Owner on the issue. This is **day 0** for the timeout.
|
||||||
|
|
||||||
|
### Step 4 — Product Owner decision
|
||||||
|
|
||||||
|
**Who:** the Product Owner. **When:** on the issue at any time, or at the next **queue review**.
|
||||||
|
|
||||||
|
| Answer | Label | Recorded as |
|
||||||
|
|---|---|---|
|
||||||
|
| Yes, possibly with conditions | `product: approved` | A **recorded decision** listing any conditions |
|
||||||
|
| No | `product: declined` | A recorded decision; the issue is closed as not planned |
|
||||||
|
| A question | stays `product: needs decision` | The clock pauses until the proposer answers, then restarts at day 0 |
|
||||||
|
| Different answers per part | the label for the approved part(s) | The recorded decision lists each part and its answer |
|
||||||
|
|
||||||
|
A decision given outside GitHub (Matrix, a call) counts once a maintainer has recorded it on the issue. A declined feature can be maintained in a contributor's fork, and the issue can be reopened only with **materially new** information.
|
||||||
|
|
||||||
|
#### When there is no answer: lazy consensus
|
||||||
|
|
||||||
|
| Day | What happens |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Brief complete; Product Owner mentioned on the issue |
|
||||||
|
| 7 | One reminder from a maintainer, and the issue is put on the next queue review |
|
||||||
|
| 14 | If there has been no **objection**: |
|
||||||
|
|
||||||
|
- If the **only** reason the proposal needed the Product Owner was that it is **big** — it is **reversible** and **fits ROADMAP** — a maintainer may decide. They record "decided by lazy consensus on YYYY-MM-DD", apply `product: maintainer decision`, and own any rollback Pewds later asks for.
|
||||||
|
- If it is a **new direction** or **hard to undo**, it keeps waiting. Silence never approves a one-way change or a change of direction. It is raised at each queue review.
|
||||||
|
- A proposal still waiting after **90 days** is closed as not planned with a comment saying it expired without a decision. It can be reopened by anyone with the same brief.
|
||||||
|
|
||||||
|
### Step 5 — Implementation
|
||||||
|
|
||||||
|
**Who:** the proposer or any contributor.
|
||||||
|
|
||||||
|
Feature PRs link the issue with `Part of #N` (or `Fixes #N` for the last slice) and stay within what was decided. A maintainer does not merge a feature PR whose issue carries neither `product: approved` nor `product: maintainer decision`, and checks any conditions in the recorded decision.
|
||||||
|
|
||||||
|
If the work grows beyond what was decided — a new part, or a part that becomes hard to undo — that part goes back to step 2.
|
||||||
|
|
||||||
|
A maintainer's own small, reversible feature that fits ROADMAP may skip the separate issue: the PR description states why it qualifies, and the PR is the record.
|
||||||
|
|
||||||
|
### Step 6 — Review and merge
|
||||||
|
|
||||||
|
Unchanged: [changes and review](changes-and-review.md). Product approval is not technical approval; high-risk changes still need an independent qualified review.
|
||||||
|
|
||||||
|
### Step 7 — Close
|
||||||
|
|
||||||
|
The issue is closed when the last PR lands. If the feature was a ROADMAP item, the same PR or a follow-up updates ROADMAP.md.
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
| Label | Meaning | Applied by |
|
||||||
|
|---|---|---|
|
||||||
|
| `product: needs decision` | Waiting for the Product Owner | Triager |
|
||||||
|
| `product: approved` | The Product Owner said yes (see the recorded decision for scope and conditions) | Product Owner or the maintainer recording the decision |
|
||||||
|
| `product: maintainer decision` | Small, reversible and on ROADMAP, or decided by lazy consensus | Triager, or the maintainer applying lazy consensus |
|
||||||
|
| `product: declined` | The Product Owner said no | Product Owner or the maintainer recording the decision |
|
||||||
|
|
||||||
|
Only maintainers and the Product Owner set or change these labels. Exactly one of them is on a triaged feature issue at any time.
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
**Feature.** A change that adds or alters user-visible behavior, a setting, or a public contract (HTTP API, MCP tool, plugin interface, file format). Restoring documented or clearly intended behavior is a bug fix, not a feature.
|
||||||
|
|
||||||
|
**Small.** All of the following are true:
|
||||||
|
|
||||||
|
- it ships as one PR of ordinary size — as a guide, no more than about 600 changed lines excluding tests, generated files and lockfiles;
|
||||||
|
- it touches one area of the app;
|
||||||
|
- it adds an option or a capability without changing what existing users see or get by default;
|
||||||
|
- it adds no new dependency, service, or background process.
|
||||||
|
|
||||||
|
Anything else is **big**.
|
||||||
|
|
||||||
|
**Reversible.** Reverting the PR(s) fully restores the previous state for every user, with nothing left behind: no data migrated, rewritten or deleted; no stored format that other code or users now depend on; no public contract that integrations have started to use; no security boundary moved. If any of those is not true, the change is **hard to undo**.
|
||||||
|
|
||||||
|
**Hard to undo.** Not reversible. Typical cases: data migrations, deleting or purging data, new persisted formats, new or changed authentication, permission or sharing models, and removing or renaming a public API, MCP tool, setting or file location.
|
||||||
|
|
||||||
|
**New direction.** The change takes Odysseus somewhere it has not committed to. Any of:
|
||||||
|
|
||||||
|
- a new top-level area of the app (a new panel, tab or module);
|
||||||
|
- multi-user or collaboration features where the app was single-user;
|
||||||
|
- data or requests leaving the user's machine by default, including telemetry and new external services;
|
||||||
|
- dropping or adding a supported platform, install method or model backend;
|
||||||
|
- anything listed under "Not The Focus Right Now" in ROADMAP.md;
|
||||||
|
- reversing a previous Product Owner decision.
|
||||||
|
|
||||||
|
**Fits ROADMAP.** The triager can point to a specific ROADMAP.md item that the proposal implements or directly extends, and the proposal is not listed under "Not The Focus Right Now". "It is in the same area as an item" is not enough.
|
||||||
|
|
||||||
|
**Actionable.** The issue states a concrete problem and a concrete change in behavior, specific enough to write acceptance criteria from.
|
||||||
|
|
||||||
|
**Duplicate.** Same user-facing outcome as an open issue or open PR, or a request for a ROADMAP item that adds nothing to it. Close it with a link, and move any new information to the original. A proposal that offers to *implement* a ROADMAP item, with a concrete design, is not a duplicate: it is triaged normally and usually **fits ROADMAP**.
|
||||||
|
|
||||||
|
**Triager.** Any maintainer other than the proposer. A maintainer's own proposal is triaged by another maintainer.
|
||||||
|
|
||||||
|
**Objection.** A comment on the issue — or one recorded there from another channel — by the Product Owner or a maintainer, asking to stop, wait, or change the scope. A maintainer's objection stops lazy consensus for that proposal; it then waits for the Product Owner like a new direction does. Questions and suggestions that do not ask to stop are not objections.
|
||||||
|
|
||||||
|
**Recorded decision.** A comment on the issue that states the decision, who made it, the date, where it was made if not on GitHub, the parts it covers, and any conditions — together with the matching label change.
|
||||||
|
|
||||||
|
**Queue review.** Every 14 days, a maintainer brings the [`product: needs decision` list](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen+label%3A%22product%3A+needs+decision%22) to the Product Owner and records each answer on its issue. The maintainer who runs it is agreed among maintainers and noted here: *to be decided*.
|
||||||
|
|
||||||
|
**Days.** Calendar days, counted from the timestamp of the event on the issue (label applied, brief completed, reminder posted).
|
||||||
|
|
||||||
|
**Materially new.** Information the original decision did not have: a different design, new evidence of demand, or a changed ROADMAP.
|
||||||
|
|
||||||
## Decision boundaries
|
## Decision boundaries
|
||||||
|
|
||||||
@@ -45,4 +199,4 @@ The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how
|
|||||||
|
|
||||||
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
|
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
|
||||||
|
|
||||||
If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above.
|
If the Product Owner is unavailable, work that needs product approval follows the timeout above. It never proceeds on silence when it is a new direction or hard to undo. Smaller decisions and urgent fixes follow the rules above.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Record what is known and distinguish it from what remains unverified:
|
|||||||
- Priority, ownership, and relevant domain expertise.
|
- Priority, ownership, and relevant domain expertise.
|
||||||
- Remaining unknowns, risks, and safe recovery or workaround when available.
|
- Remaining unknowns, risks, and safe recovery or workaround when available.
|
||||||
|
|
||||||
|
Feature requests follow the [feature proposal workflow](product-and-features.md#feature-proposal-workflow): triage within 7 days, and a product label on every triaged feature issue.
|
||||||
|
|
||||||
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
|
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
|
||||||
|
|
||||||
## Practical priority language
|
## Practical priority language
|
||||||
|
|||||||
+12
-15
@@ -33,6 +33,7 @@ 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.email_mime import decode_text_part as _decode_text_part
|
||||||
server = Server("email")
|
server = Server("email")
|
||||||
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
||||||
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
||||||
@@ -923,29 +924,25 @@ 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():
|
||||||
text_parts = []
|
text_parts = []
|
||||||
|
html_parts = []
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
ct = part.get_content_type()
|
ct = part.get_content_type()
|
||||||
cd = str(part.get("Content-Disposition", ""))
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
if ct == "text/plain" and "attachment" not in cd:
|
if ct == "text/plain" and "attachment" not in cd:
|
||||||
payload = part.get_payload(decode=True)
|
text = _decode_text_part(part)
|
||||||
if payload:
|
if text:
|
||||||
charset = part.get_content_charset() or "utf-8"
|
text_parts.append(text)
|
||||||
text_parts.append(payload.decode(charset, errors="replace"))
|
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
raw_html = _decode_text_part(part)
|
||||||
payload = part.get_payload(decode=True)
|
if raw_html:
|
||||||
if payload:
|
|
||||||
charset = part.get_content_charset() or "utf-8"
|
|
||||||
raw_html = payload.decode(charset, errors="replace")
|
|
||||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||||
text = re.sub(r"<[^>]+>", "", text)
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
text = html.unescape(text)
|
text = html.unescape(text)
|
||||||
text_parts.append(text.strip())
|
html_parts.append(text.strip())
|
||||||
return "\n".join(text_parts)
|
return "\n".join(text_parts or html_parts)
|
||||||
else:
|
else:
|
||||||
payload = msg.get_payload(decode=True)
|
text = _decode_text_part(msg)
|
||||||
if payload:
|
if text:
|
||||||
charset = msg.get_content_charset() or "utf-8"
|
|
||||||
text = payload.decode(charset, errors="replace")
|
|
||||||
if msg.get_content_type() == "text/html":
|
if msg.get_content_type() == "text/html":
|
||||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
|||||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||||
},
|
},
|
||||||
"required": ["prompt"],
|
"required": ["prompt"],
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = _settings.get("image_model", "")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if "quality" not in arguments and _settings.get("image_quality"):
|
||||||
quality = _settings["image_quality"]
|
quality = _settings["image_quality"]
|
||||||
|
|
||||||
# Auto-detect best available image model
|
# Auto-detect best available image model
|
||||||
|
|||||||
+5
-1
@@ -9,7 +9,11 @@
|
|||||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||||
|
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||||
|
"test:ios-layout:install": "playwright install chromium webkit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.7.0",
|
"@antithesishq/bombadil": "^0.7.0",
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||||
pytest-xdist[psutil]>=3.8,<4
|
pytest-xdist[psutil]>=3.8,<4
|
||||||
|
playwright==1.62.0
|
||||||
|
|||||||
+57
-10
@@ -13,11 +13,12 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||||
from src.auth_helpers import _auth_disabled
|
from src.auth_helpers import _auth_disabled
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
|
from src import tool_capabilities
|
||||||
from src.settings import (
|
from src.settings import (
|
||||||
load_settings as _load_settings,
|
load_settings as _load_settings,
|
||||||
save_settings as _save_settings,
|
save_settings as _save_settings,
|
||||||
@@ -154,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(400, "Already configured")
|
raise HTTPException(400, "Already configured")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
if len(body.username.strip()) < 1:
|
name_error = username_error(body.username)
|
||||||
raise HTTPException(400, "Username is required")
|
if name_error:
|
||||||
|
raise HTTPException(400, name_error)
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
raise HTTPException(403, "Username is reserved")
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||||
@@ -174,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
if len(body.username.strip()) < 1:
|
name_error = username_error(body.username)
|
||||||
raise HTTPException(400, "Username is required")
|
if name_error:
|
||||||
|
raise HTTPException(400, name_error)
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
raise HTTPException(403, "Username is reserved")
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||||
@@ -333,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
if len(body.username.strip()) < 1:
|
name_error = username_error(body.username)
|
||||||
raise HTTPException(400, "Username is required")
|
if name_error:
|
||||||
|
raise HTTPException(400, name_error)
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
raise HTTPException(403, "Username is reserved")
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||||
@@ -361,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
body = await request.json()
|
try:
|
||||||
ok = auth_manager.set_privileges(username, body)
|
body = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
|
try:
|
||||||
|
ok = auth_manager.set_privileges(username, body)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "User not found or is admin")
|
raise HTTPException(404, "User not found or is admin")
|
||||||
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
|
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
|
||||||
@@ -376,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
new_username = (body.username or "").strip().lower()
|
new_username = (body.username or "").strip().lower()
|
||||||
if not new_username:
|
if not new_username:
|
||||||
raise HTTPException(400, "Username required")
|
raise HTTPException(400, "Username required")
|
||||||
|
name_error = username_error(new_username)
|
||||||
|
if name_error:
|
||||||
|
raise HTTPException(400, name_error)
|
||||||
if old_username == new_username:
|
if old_username == new_username:
|
||||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||||
if old_username not in auth_manager.users:
|
if old_username not in auth_manager.users:
|
||||||
@@ -653,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
try:
|
||||||
|
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||||
|
finally:
|
||||||
|
# A demotion purges the user's ApiToken rows; dirty the bearer
|
||||||
|
# cache so already-cached tokens stop authenticating.
|
||||||
|
if not body.is_admin:
|
||||||
|
invalidator = getattr(
|
||||||
|
getattr(getattr(request, "app", None), "state", None),
|
||||||
|
"invalidate_token_cache", None,
|
||||||
|
)
|
||||||
|
if callable(invalidator):
|
||||||
|
invalidator()
|
||||||
if result is SetAdminResult.USER_NOT_FOUND:
|
if result is SetAdminResult.USER_NOT_FOUND:
|
||||||
raise HTTPException(404, "User not found")
|
raise HTTPException(404, "User not found")
|
||||||
if result is SetAdminResult.NOT_AUTHORIZED:
|
if result is SetAdminResult.NOT_AUTHORIZED:
|
||||||
@@ -753,6 +779,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
settings = without_retired_settings(_load_settings())
|
settings = without_retired_settings(_load_settings())
|
||||||
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
||||||
|
# Deployment-level truth for the Tool approval row: a falsy
|
||||||
|
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
|
||||||
|
# run regardless of the stored tool_approval_mode, so surface it to
|
||||||
|
# admins instead of letting the row mislead them the other way.
|
||||||
|
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
|
||||||
return settings
|
return settings
|
||||||
return scrub_settings(settings)
|
return scrub_settings(settings)
|
||||||
|
|
||||||
@@ -769,6 +800,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||||
"auto_compact_threshold_percent": (50, 95),
|
"auto_compact_threshold_percent": (50, 95),
|
||||||
}
|
}
|
||||||
|
_VALID_VALUES = {
|
||||||
|
"tool_approval_mode": {"ask", "off"},
|
||||||
|
}
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
if key in RETIRED_SETTING_KEYS:
|
if key in RETIRED_SETTING_KEYS:
|
||||||
continue
|
continue
|
||||||
@@ -782,6 +816,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(400, f"{key} must be an integer")
|
raise HTTPException(400, f"{key} must be an integer")
|
||||||
val = max(lo, min(val, hi))
|
val = max(lo, min(val, hi))
|
||||||
|
|
||||||
|
if key in _VALID_VALUES:
|
||||||
|
allowed = _VALID_VALUES[key]
|
||||||
|
if not isinstance(val, str) or val not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"{key} must be one of {sorted(allowed)}",
|
||||||
|
)
|
||||||
|
|
||||||
current[key] = val
|
current[key] = val
|
||||||
_save_settings(current)
|
_save_settings(current)
|
||||||
return without_retired_settings(current)
|
return without_retired_settings(current)
|
||||||
@@ -887,6 +930,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"""Create a new integration (admin only)."""
|
"""Create a new integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
item = add_integration(body)
|
item = add_integration(body)
|
||||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||||
|
|
||||||
@@ -895,6 +940,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
"""Update an existing integration (admin only)."""
|
"""Update an existing integration (admin only)."""
|
||||||
_require_admin_user(request)
|
_require_admin_user(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
item = update_integration(integration_id, body)
|
item = update_integration(integration_id, body)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(404, "Integration not found")
|
raise HTTPException(404, "Integration not found")
|
||||||
|
|||||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
|||||||
return FALLBACK_OWNER
|
return FALLBACK_OWNER
|
||||||
|
|
||||||
|
|
||||||
|
async def _json_object_body(request: Request) -> dict:
|
||||||
|
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||||
|
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||||
if not cal:
|
if not cal:
|
||||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
async def save_config(request: Request):
|
async def save_config(request: Request):
|
||||||
"""Legacy single-account endpoint — upserts the first account."""
|
"""Legacy single-account endpoint — upserts the first account."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
if not (body.get("url") or "").strip():
|
if not (body.get("url") or "").strip():
|
||||||
_save_caldav_accounts(owner, [])
|
_save_caldav_accounts(owner, [])
|
||||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
"""Add a new CalDAV account."""
|
"""Add a new CalDAV account."""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
from src.caldav_sync import validate_caldav_url
|
from src.caldav_sync import validate_caldav_url
|
||||||
try:
|
try:
|
||||||
url = validate_caldav_url(body.get("url", ""))
|
url = validate_caldav_url(body.get("url", ""))
|
||||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
async def update_caldav_account(account_id: str, request: Request):
|
async def update_caldav_account(account_id: str, request: Request):
|
||||||
"""Update an existing CalDAV account by id."""
|
"""Update an existing CalDAV account by id."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
accounts = _get_caldav_accounts(owner)
|
||||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
test an already-saved account. Falls back to the first saved account
|
test an already-saved account. Falls back to the first saved account
|
||||||
when nothing is provided."""
|
when nothing is provided."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
body = await _json_object_body(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
url = (body.get("url") or "").strip()
|
url = (body.get("url") or "").strip()
|
||||||
user = (body.get("username") or "").strip()
|
user = (body.get("username") or "").strip()
|
||||||
pw = body.get("password") or ""
|
pw = body.get("password") or ""
|
||||||
|
|||||||
+95
-28
@@ -569,6 +569,16 @@ async def auto_name_session(session_manager, sess):
|
|||||||
if not first_msg:
|
if not first_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
from src.owner_identity import is_request_sentinel_owner
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
|
invalid_owner = (
|
||||||
|
not isinstance(owner, str) or not owner.strip()
|
||||||
|
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
||||||
|
)
|
||||||
|
if invalid_owner:
|
||||||
|
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
||||||
|
return
|
||||||
|
|
||||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
model_name = str(getattr(sess, "model", "") or "")
|
model_name = str(getattr(sess, "model", "") or "")
|
||||||
|
|
||||||
@@ -583,15 +593,25 @@ async def auto_name_session(session_manager, sess):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_is_spec_model = False
|
_is_spec_model = False
|
||||||
if _is_spec_model and endpoint_url:
|
if _is_spec_model and endpoint_url:
|
||||||
_headers = getattr(sess, "headers", None)
|
|
||||||
if isinstance(_headers, str):
|
|
||||||
try:
|
|
||||||
_headers = json.loads(_headers)
|
|
||||||
except Exception:
|
|
||||||
_headers = None
|
|
||||||
try:
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (build_chat_url, build_headers,
|
||||||
|
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
|
if not owner and not auth_disabled():
|
||||||
|
raise ValueError("Session has no owner")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||||
|
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||||
|
title_url = build_chat_url(base)
|
||||||
|
title_headers = build_headers(key, base)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
title = await llm_call_async(
|
title = await llm_call_async(
|
||||||
endpoint_url,
|
title_url,
|
||||||
model_name,
|
model_name,
|
||||||
[
|
[
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||||
@@ -599,7 +619,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=32,
|
max_tokens=32,
|
||||||
headers=_headers or None,
|
headers=title_headers,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
thinking_mode="off",
|
thinking_mode="off",
|
||||||
)
|
)
|
||||||
@@ -625,6 +645,14 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
if not owner and not auth_disabled():
|
||||||
|
title = fallback_session_title(first_msg)
|
||||||
|
session_manager.update_session_name(sess.id, title)
|
||||||
|
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||||
)
|
)
|
||||||
@@ -632,24 +660,64 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
# max_tokens big enough that reasoning models (Minimax M2,
|
sess_url = getattr(sess, "endpoint_url", None)
|
||||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
if t_url and sess_url and t_url == sess_url:
|
||||||
# plus the actual title — 200 used to clip them mid-reasoning
|
from src.database import SessionLocal
|
||||||
# so strip_think left an empty string and no rename happened.
|
from src.endpoint_resolver import (
|
||||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
build_chat_url,
|
||||||
title = await llm_call_async(
|
build_headers,
|
||||||
t_url,
|
resolve_endpoint,
|
||||||
t_model,
|
resolve_endpoint_runtime,
|
||||||
[
|
resolve_owner_registered_endpoint,
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
)
|
||||||
{"role": "user", "content": first_msg},
|
db = SessionLocal()
|
||||||
],
|
try:
|
||||||
temperature=0.3,
|
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
||||||
max_tokens=64,
|
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
||||||
headers=t_headers,
|
t_url = build_chat_url(base)
|
||||||
timeout=15,
|
t_headers = build_headers(key, base)
|
||||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
except Exception as e:
|
||||||
)
|
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
||||||
|
t_url, t_model, t_headers = None, None, None
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
u, m, h = resolve_endpoint(prefix, owner=owner)
|
||||||
|
if u and m:
|
||||||
|
t_url, t_model, t_headers = u, m, h
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not t_url or not t_model:
|
||||||
|
title = fallback_session_title(first_msg)
|
||||||
|
session_manager.update_session_name(sess.id, title)
|
||||||
|
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# A configured effort is the user's explicit choice. Otherwise thinking
|
||||||
|
# stays off, because reasoning would use up this small token budget and
|
||||||
|
# leave no title.
|
||||||
|
title_effort = effort_for_call(t_url, t_model, owner, sess)
|
||||||
|
try:
|
||||||
|
title = await llm_call_async(
|
||||||
|
t_url,
|
||||||
|
t_model,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
||||||
|
{"role": "user", "content": first_msg},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=64,
|
||||||
|
headers=t_headers,
|
||||||
|
timeout=15,
|
||||||
|
reasoning_effort=title_effort,
|
||||||
|
thinking_mode=None if title_effort else "off",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||||
|
title = ""
|
||||||
|
|
||||||
title = title.strip().strip('"\'').strip()
|
title = title.strip().strip('"\'').strip()
|
||||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||||
@@ -1208,6 +1276,7 @@ async def build_chat_context(
|
|||||||
message=_ctx_msg,
|
message=_ctx_msg,
|
||||||
session=sess,
|
session=sess,
|
||||||
use_web=use_web_val and not skip_web,
|
use_web=use_web_val and not skip_web,
|
||||||
|
use_rag=use_rag_val,
|
||||||
use_memory=mem_enabled,
|
use_memory=mem_enabled,
|
||||||
time_filter=time_filter,
|
time_filter=time_filter,
|
||||||
preset_system_prompt=preset.system_prompt,
|
preset_system_prompt=preset.system_prompt,
|
||||||
@@ -1218,8 +1287,6 @@ async def build_chat_context(
|
|||||||
incognito=incognito,
|
incognito=incognito,
|
||||||
use_skills=skills_enabled,
|
use_skills=skills_enabled,
|
||||||
)
|
)
|
||||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
|
||||||
_preface_kwargs["use_rag"] = use_rag_val
|
|
||||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||||
|
|
||||||
# Capture used memories immediately
|
# Capture used memories immediately
|
||||||
|
|||||||
+24
-3
@@ -331,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
|
|||||||
|
|
||||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||||
|
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
||||||
|
|
||||||
|
|
||||||
def _mentions_pdf_url(value: str) -> bool:
|
def _mentions_pdf_url(value: str) -> bool:
|
||||||
@@ -1900,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
|||||||
|
|
||||||
|
|
||||||
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.
|
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||||
|
|
||||||
Covers the window between endpoint setup and the first chat send: the
|
Covers the window between endpoint setup and the first chat send: the
|
||||||
picker showed a model in the dropdown but the session record never got
|
picker showed a model in the dropdown but the session record never got
|
||||||
@@ -1964,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
except Exception:
|
except Exception:
|
||||||
visible = cached
|
visible = cached
|
||||||
|
if not is_chatgpt_subscription:
|
||||||
|
from src.endpoint_resolver import _endpoint_enabled_models
|
||||||
|
visible = _endpoint_enabled_models(ep)
|
||||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
return False
|
return False
|
||||||
if is_chatgpt_subscription:
|
if is_chatgpt_subscription:
|
||||||
@@ -2693,11 +2697,15 @@ def setup_chat_routes(
|
|||||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||||
# image-edit chat turn) may carry the open image-editor session id and a
|
# image-edit chat turn) may carry the open image-editor session id and a
|
||||||
# canvas snapshot so the editor tool family can drive it in-process.
|
# canvas snapshot so the editor tool family can drive it in-process.
|
||||||
editor_session = (
|
# The id is quoted into a system message, so accept only an
|
||||||
|
# identifier; any other value is treated as no open editor.
|
||||||
|
editor_session = str(
|
||||||
form_data.get("editor_session")
|
form_data.get("editor_session")
|
||||||
or form_data.get("editor_session_id")
|
or form_data.get("editor_session_id")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
|
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
||||||
|
editor_session = ""
|
||||||
photo_editor_requested = (
|
photo_editor_requested = (
|
||||||
str(form_data.get("photo_editor") or "").strip().lower()
|
str(form_data.get("photo_editor") or "").strip().lower()
|
||||||
in {"1", "true", "yes", "on"}
|
in {"1", "true", "yes", "on"}
|
||||||
@@ -3521,7 +3529,7 @@ def setup_chat_routes(
|
|||||||
if not _privs.get("can_use_documents", True):
|
if not _privs.get("can_use_documents", True):
|
||||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||||
if not _privs.get("can_generate_images", True):
|
if not _privs.get("can_generate_images", True):
|
||||||
disabled_tools.update({"generate_image", "edit_image"})
|
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||||
if not _privs.get("can_manage_memory", True):
|
if not _privs.get("can_manage_memory", True):
|
||||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||||
if not _privs.get("can_use_research", True):
|
if not _privs.get("can_use_research", True):
|
||||||
@@ -3918,6 +3926,19 @@ def setup_chat_routes(
|
|||||||
if ctx.preprocessed.attachment_meta:
|
if ctx.preprocessed.attachment_meta:
|
||||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||||
|
|
||||||
|
# Give the page the saved user message's id. Only the reply's id
|
||||||
|
# was sent back, so a message sent in the open page had none and
|
||||||
|
# a later regenerate/edit of it could not cut the chat by id.
|
||||||
|
try:
|
||||||
|
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||||
|
if getattr(m, "role", None) == "user"), None)
|
||||||
|
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||||
|
if _last_user_msg is not None else None)
|
||||||
|
if _user_db_id:
|
||||||
|
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||||
# PDF → editable markdown) so the editor pane switches to them
|
# PDF → editable markdown) so the editor pane switches to them
|
||||||
# before the model starts streaming.
|
# before the model starts streaming.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cookbook route domain package (#4071)."""
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""Auto-register Cookbook-served models as model endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from routes.cookbook_helpers import ServeRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_register_image_endpoint(
|
||||||
|
req: ServeRequest,
|
||||||
|
remote: str | None,
|
||||||
|
serve_session_id: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||||
|
import re
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from routes.model_routes import _normalize_model_ids
|
||||||
|
from src.settings import load_settings, save_settings
|
||||||
|
|
||||||
|
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||||
|
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||||
|
port = int(port_match.group(1)) if port_match else 8100
|
||||||
|
|
||||||
|
# Determine host
|
||||||
|
if remote:
|
||||||
|
# SSH alias — use as hostname (Tailscale resolves it later)
|
||||||
|
host = remote.split("@")[-1] if "@" in remote else remote
|
||||||
|
else:
|
||||||
|
host = "localhost"
|
||||||
|
|
||||||
|
base_url = f"http://{host}:{port}/v1"
|
||||||
|
|
||||||
|
# Friendly display name from repo_id
|
||||||
|
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||||
|
display_name = f"{short_name} (image)"
|
||||||
|
pinned_models = [req.repo_id] if req.repo_id else []
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Check for existing endpoint with same base_url — update it
|
||||||
|
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||||
|
if existing:
|
||||||
|
existing.is_enabled = True
|
||||||
|
existing.model_type = "image"
|
||||||
|
# Follow the newly served model unless the user renamed the endpoint.
|
||||||
|
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||||
|
if existing.name in pinned_names:
|
||||||
|
existing.name = display_name
|
||||||
|
existing.endpoint_kind = "local"
|
||||||
|
existing.model_refresh_mode = "manual"
|
||||||
|
existing.cookbook_serve_session_id = serve_session_id
|
||||||
|
if pinned_models:
|
||||||
|
existing.cached_models = json.dumps(pinned_models)
|
||||||
|
existing.pinned_models = json.dumps(pinned_models)
|
||||||
|
db.commit()
|
||||||
|
settings = load_settings()
|
||||||
|
if settings.get("image_gen_enabled") is not True:
|
||||||
|
settings["image_gen_enabled"] = True
|
||||||
|
save_settings(settings)
|
||||||
|
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||||
|
return existing.id
|
||||||
|
|
||||||
|
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||||
|
ep = ModelEndpoint(
|
||||||
|
id=ep_id,
|
||||||
|
name=display_name,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=None,
|
||||||
|
is_enabled=True,
|
||||||
|
model_type="image",
|
||||||
|
endpoint_kind="local",
|
||||||
|
model_refresh_mode="manual",
|
||||||
|
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
|
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
|
cookbook_serve_session_id=serve_session_id,
|
||||||
|
)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
settings = load_settings()
|
||||||
|
settings["image_gen_enabled"] = True
|
||||||
|
if not settings.get("image_model"):
|
||||||
|
settings["image_model"] = req.repo_id
|
||||||
|
save_settings(settings)
|
||||||
|
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||||
|
return ep_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to auto-register image endpoint: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_register_llm_endpoint(
|
||||||
|
req: ServeRequest,
|
||||||
|
remote: str | None,
|
||||||
|
serve_session_id: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||||
|
model picker without a manual /setup step — the text-model sibling of
|
||||||
|
_auto_register_image_endpoint.
|
||||||
|
|
||||||
|
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
||||||
|
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
||||||
|
endpoint at that server's /v1; the picker auto-discovers the model id by
|
||||||
|
probing /v1/models and dims the endpoint until the server is reachable,
|
||||||
|
so registering immediately (before the server finishes loading) is safe.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
||||||
|
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
||||||
|
)
|
||||||
|
import re
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from routes.model_routes import _normalize_model_ids
|
||||||
|
|
||||||
|
# Port: ordered fallbacks so we match whatever the user actually
|
||||||
|
# asked for, not a hardcoded default:
|
||||||
|
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||||
|
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||||
|
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||||
|
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||||
|
# registered every Ollama endpoint at 11434 — even if the user
|
||||||
|
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||||
|
# existing systemd Ollama, the registered endpoint pointed at
|
||||||
|
# the OLD port and showed as offline.
|
||||||
|
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||||
|
# Host length is capped at the longest DNS name; unbounded, repeated
|
||||||
|
# assignments make this scan quadratic and stall the event loop.
|
||||||
|
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
|
||||||
|
if port_match:
|
||||||
|
port = int(port_match.group(1))
|
||||||
|
elif ollama_host_match:
|
||||||
|
port = int(ollama_host_match.group(1))
|
||||||
|
elif "ollama" in req.cmd:
|
||||||
|
port = 11434
|
||||||
|
else:
|
||||||
|
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||||
|
|
||||||
|
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
||||||
|
# the odysseus container — so the right URL for the in-container
|
||||||
|
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
||||||
|
# (the latter points at the docker HOST, which doesn't have a server
|
||||||
|
# on that port). The previous host.docker.internal fallback only made
|
||||||
|
# sense for /setup-added external services like systemd Ollama on the
|
||||||
|
# host — and those go through manual setup, not this auto-register
|
||||||
|
# code path. For remote serves we still use the SSH host alias.
|
||||||
|
if remote:
|
||||||
|
host = remote.split("@")[-1] if "@" in remote else remote
|
||||||
|
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
||||||
|
host = "host.docker.internal"
|
||||||
|
else:
|
||||||
|
host = "localhost"
|
||||||
|
|
||||||
|
base_url = f"http://{host}:{port}/v1"
|
||||||
|
|
||||||
|
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||||
|
display_name = short_name or "Local model"
|
||||||
|
is_mlx_deepseek_v4 = (
|
||||||
|
"mlx_lm.server" in (req.cmd or "")
|
||||||
|
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||||
|
)
|
||||||
|
mlx_shim_model_id = ""
|
||||||
|
if is_mlx_deepseek_v4 and short_name:
|
||||||
|
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||||
|
remote_home = home_match.group(1) if home_match else ""
|
||||||
|
if remote_home:
|
||||||
|
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||||
|
|
||||||
|
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||||
|
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||||
|
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||||
|
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||||
|
# Pin the model the user launched for every Cookbook-created LLM
|
||||||
|
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||||
|
# deployment alias from /v1/models, and a stale server can answer on the
|
||||||
|
# same port while the new launch failed. Keeping the requested model id
|
||||||
|
# pinned makes the picker reflect the actual launch intent.
|
||||||
|
launched_model = mlx_shim_model_id or req.repo_id
|
||||||
|
pinned_models = [launched_model] if launched_model else []
|
||||||
|
|
||||||
|
# Launch registration must not delete other endpoints.
|
||||||
|
# Matching names, model pins, and failed probes do not prove that
|
||||||
|
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||||
|
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||||
|
if existing:
|
||||||
|
existing.is_enabled = True
|
||||||
|
existing.model_type = "llm"
|
||||||
|
# Follow the newly served model unless the user renamed the endpoint.
|
||||||
|
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||||
|
if existing.name in pinned_names:
|
||||||
|
existing.name = display_name
|
||||||
|
existing.endpoint_kind = "local"
|
||||||
|
existing.model_refresh_mode = "auto"
|
||||||
|
# A reused endpoint belongs to the newest serve session.
|
||||||
|
existing.cookbook_serve_session_id = serve_session_id
|
||||||
|
if launched_model:
|
||||||
|
# Pins represent model selection, not launch history.
|
||||||
|
existing_pins = _normalize_model_ids(
|
||||||
|
existing.pinned_models
|
||||||
|
)
|
||||||
|
if launched_model not in existing_pins:
|
||||||
|
existing_pins.append(launched_model)
|
||||||
|
existing.pinned_models = json.dumps(existing_pins)
|
||||||
|
if is_ollama_endpoint:
|
||||||
|
existing.endpoint_kind = "ollama"
|
||||||
|
if pinned_models:
|
||||||
|
existing.cached_models = json.dumps(pinned_models)
|
||||||
|
if supports_tools is not None:
|
||||||
|
existing.supports_tools = supports_tools
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Updated existing local model endpoint: {base_url}")
|
||||||
|
# Re-probe so cached_models matches what the server actually
|
||||||
|
# serves right now (the URL may have stayed the same but the
|
||||||
|
# model behind it changed across launches).
|
||||||
|
try:
|
||||||
|
if mlx_shim_model_id:
|
||||||
|
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||||
|
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
from routes.model_routes import _probe_endpoint
|
||||||
|
import json as _json2
|
||||||
|
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||||
|
if probed:
|
||||||
|
existing.cached_models = _json2.dumps(probed)
|
||||||
|
db.commit()
|
||||||
|
except Exception as _pe:
|
||||||
|
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||||
|
return existing.id
|
||||||
|
|
||||||
|
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||||
|
ep = ModelEndpoint(
|
||||||
|
id=ep_id,
|
||||||
|
name=display_name,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=None,
|
||||||
|
is_enabled=True,
|
||||||
|
model_type="llm",
|
||||||
|
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
||||||
|
model_refresh_mode="auto",
|
||||||
|
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
|
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||||
|
supports_tools=supports_tools,
|
||||||
|
cookbook_serve_session_id=serve_session_id,
|
||||||
|
)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||||
|
# Probe /v1/models NOW and write cached_models so the chat
|
||||||
|
# picker actually shows the model on the next /api/models
|
||||||
|
# call. Without this immediate probe, the endpoint has empty
|
||||||
|
# cached_models until the next background refresh fires (up
|
||||||
|
# to a minute later) and the picker shows nothing — even
|
||||||
|
# though the endpoint is in the DB and the server is up.
|
||||||
|
try:
|
||||||
|
if mlx_shim_model_id:
|
||||||
|
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||||
|
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||||
|
else:
|
||||||
|
from routes.model_routes import _probe_endpoint
|
||||||
|
import json as _json2
|
||||||
|
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||||
|
if probed:
|
||||||
|
ep.cached_models = _json2.dumps(probed)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||||
|
except Exception as _pe:
|
||||||
|
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||||
|
return ep_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+185
-2
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from routes._validators import validate_remote_host, validate_ssh_port
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||||
from core.platform_compat import _ssh_exec_argv
|
from core.platform_compat import _ssh_exec_argv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
"""
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
"import json, os, re, shutil, subprocess, urllib.request",
|
"import json, os, re, shutil, subprocess, urllib.request",
|
||||||
|
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||||
"models = []",
|
"models = []",
|
||||||
"seen = set()",
|
"seen = set()",
|
||||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
|||||||
" sz, nf, ic = 0, 0, False",
|
" sz, nf, ic = 0, 0, False",
|
||||||
" if os.path.isdir(blobs):",
|
" if os.path.isdir(blobs):",
|
||||||
" for f in os.scandir(blobs):",
|
" for f in os.scandir(blobs):",
|
||||||
|
" if f.name.endswith('.incomplete'):",
|
||||||
|
" if not is_partial_blob(f.path): continue",
|
||||||
|
" ic = True",
|
||||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||||
" if f.name.endswith('.incomplete'): ic = True",
|
|
||||||
" snap = os.path.join(cache, d, 'snapshots')",
|
" snap = os.path.join(cache, d, 'snapshots')",
|
||||||
" def snapshot_size():",
|
" def snapshot_size():",
|
||||||
" total, count, incomplete = 0, 0, False",
|
" total, count, incomplete = 0, 0, False",
|
||||||
@@ -724,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
|
|||||||
if not tokens:
|
if not tokens:
|
||||||
return
|
return
|
||||||
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
first = next((t for t in tokens if not env_re.match(t)), "")
|
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
|
||||||
|
first = tokens[idx] if idx < len(tokens) else ""
|
||||||
base = os.path.basename(first)
|
base = os.path.basename(first)
|
||||||
|
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
|
||||||
|
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
|
||||||
|
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
|
||||||
|
return
|
||||||
if base not in _SERVE_CMD_ALLOWLIST:
|
if base not in _SERVE_CMD_ALLOWLIST:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
400,
|
400,
|
||||||
@@ -1030,6 +1039,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
|||||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||||
|
|
||||||
|
|
||||||
|
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||||
|
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||||
|
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin=""')
|
||||||
|
runner_lines.append(f' local _marker={marker}')
|
||||||
|
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||||
|
runner_lines.append(" IFS=':'")
|
||||||
|
runner_lines.append(' local -a _parts')
|
||||||
|
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||||
|
runner_lines.append(' IFS="$_saved_ifs"')
|
||||||
|
runner_lines.append(' local _dir _cand')
|
||||||
|
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||||
|
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||||
|
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||||
|
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||||
|
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' continue')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||||
|
runner_lines.append(' break')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' done')
|
||||||
|
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||||
|
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||||
|
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(' local _cand_dir')
|
||||||
|
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||||
|
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||||
|
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' return 0')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append('}')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||||
|
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||||
|
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||||
|
runner_lines.append(f' local _marker={marker}')
|
||||||
|
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||||
|
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||||
|
runner_lines.append(' else')
|
||||||
|
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||||
|
runner_lines.append(' local _tmp')
|
||||||
|
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||||
|
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' }')
|
||||||
|
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||||
|
runner_lines.append('#!/usr/bin/env bash')
|
||||||
|
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||||
|
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||||
|
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||||
|
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||||
|
runner_lines.append('ARGS=()')
|
||||||
|
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||||
|
runner_lines.append(' case "$1" in')
|
||||||
|
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||||
|
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||||
|
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||||
|
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||||
|
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||||
|
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||||
|
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||||
|
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||||
|
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||||
|
runner_lines.append(' esac')
|
||||||
|
runner_lines.append('done')
|
||||||
|
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||||
|
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||||
|
runner_lines.append(' then')
|
||||||
|
runner_lines.append(' rm -f -- "$_tmp"')
|
||||||
|
runner_lines.append(' return 1')
|
||||||
|
runner_lines.append(' fi')
|
||||||
|
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||||
|
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||||
|
runner_lines.append(' hash -r')
|
||||||
|
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||||
|
runner_lines.append('}')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||||
|
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- Never touch an arbitrary user-owned executable or symlink.
|
||||||
|
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||||
|
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||||
|
so the native binary is used.
|
||||||
|
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||||
|
and hash -r so the fallback/build path is triggered.
|
||||||
|
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||||
|
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||||
|
ensure the launched executable is the validated candidate.
|
||||||
|
"""
|
||||||
|
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||||
|
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||||
|
|
||||||
|
|
||||||
|
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||||
|
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||||
|
|
||||||
|
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||||
|
command is unresolvable, the compatibility launcher is created. If no usable
|
||||||
|
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||||
|
"""
|
||||||
|
err_msg = (
|
||||||
|
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||||
|
if platform_hint
|
||||||
|
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||||
|
)
|
||||||
|
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||||
|
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
|
runner_lines.append(' _odysseus_create_llama_shim')
|
||||||
|
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||||
|
runner_lines.append(f' echo "{err_msg}"')
|
||||||
|
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||||
|
runner_lines.append('fi')
|
||||||
|
|
||||||
|
|
||||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||||
|
|
||||||
@@ -1327,6 +1496,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"Port is already in use.",
|
"Port is already in use.",
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||||
|
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||||
|
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||||
|
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||||
|
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||||
|
"op": "manual",
|
||||||
|
},
|
||||||
|
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||||
"No GPUs are visible to the serve process.",
|
"No GPUs are visible to the serve process.",
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ import re
|
|||||||
|
|
||||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||||
|
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||||
|
# a blank marker alone may be a download that has not received its first byte.
|
||||||
|
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||||
|
if not path.endswith('.incomplete'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
completed = path[:-len('.incomplete')]
|
||||||
|
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||||
|
or os.path.getsize(completed) == 0)
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
"""
|
||||||
|
|
||||||
# Probe scripts for the dead-session download check, run as
|
# Probe scripts for the dead-session download check, run as
|
||||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
|||||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||||
# the probe process's own environment knows nothing about it.
|
# the probe process's own environment knows nothing about it.
|
||||||
HF_CACHE_COMPLETE_PROBE = (
|
HF_CACHE_COMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
|||||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||||
"inc=False;"
|
"inc=False;"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"blobs=os.path.join(d,'blobs');"
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||||
"sys.exit(0 if ok and not inc else 1)"
|
"sys.exit(0 if ok and not inc else 1)"
|
||||||
)
|
)
|
||||||
|
|
||||||
HF_CACHE_INCOMPLETE_PROBE = (
|
HF_CACHE_INCOMPLETE_PROBE = (
|
||||||
"import os,sys;"
|
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||||
"repo=sys.argv[1];"
|
"repo=sys.argv[1];"
|
||||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||||
"blobs=os.path.join(d,'blobs');"
|
"blobs=os.path.join(d,'blobs');"
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||||
"sys.exit(0 if inc else 1)"
|
"sys.exit(0 if inc else 1)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+82
-530
@@ -43,6 +43,9 @@ from routes.cookbook_output import (
|
|||||||
error_aware_output_tail, classify_dead_download,
|
error_aware_output_tail, classify_dead_download,
|
||||||
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
||||||
)
|
)
|
||||||
|
from routes.cookbook.endpoint_registration import (
|
||||||
|
_auto_register_image_endpoint, _auto_register_llm_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,9 +60,11 @@ from routes.cookbook_helpers import (
|
|||||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||||
_diagnose_serve_output, run_ssh_command_async,
|
_diagnose_serve_output, run_ssh_command_async,
|
||||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
|
||||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||||
_normalize_llama_cpp_python_cache_types,
|
_normalize_llama_cpp_python_cache_types,
|
||||||
|
_append_llama_server_shim_definition_lines,
|
||||||
|
_append_llama_server_shim_reconciliation_lines,
|
||||||
|
_append_llama_server_finalization_lines,
|
||||||
ModelDownloadRequest, ServeRequest,
|
ModelDownloadRequest, ServeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -466,175 +471,6 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
task["payload"].pop("hf_token", None)
|
task["payload"].pop("hf_token", None)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _diagnose_serve_output(text: str) -> dict | None:
|
|
||||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
|
||||||
|
|
||||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
|
||||||
the agent/tool path the same structured signal so it can retry with an
|
|
||||||
adjusted command instead of guessing from raw tmux output.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
tail = text[-6000:]
|
|
||||||
patterns = [
|
|
||||||
(
|
|
||||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
|
||||||
"No GPU memory left for KV cache after loading model.",
|
|
||||||
[
|
|
||||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
|
||||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
|
||||||
"GPU ran out of memory during startup or warmup.",
|
|
||||||
[
|
|
||||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
|
||||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
|
||||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"not divisib|must be divisible|attention heads.*divisible",
|
|
||||||
"Tensor parallel size is incompatible with the model.",
|
|
||||||
[
|
|
||||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
|
||||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
|
||||||
"Context length is too large for available GPU memory.",
|
|
||||||
[
|
|
||||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
|
||||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
|
||||||
"Auto tool choice requires an explicit tool call parser.",
|
|
||||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
|
||||||
"Model requires custom code or newer model support.",
|
|
||||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
|
||||||
"vLLM/Transformers kernel package mismatch.",
|
|
||||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Address already in use|bind.*address.*in use",
|
|
||||||
"Port is already in use.",
|
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
|
||||||
"No GPUs are visible to the serve process.",
|
|
||||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
|
||||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
|
||||||
"This machine may have integrated or unsupported graphics only.",
|
|
||||||
[
|
|
||||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
|
||||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
|
||||||
"vLLM is not installed or not in PATH on this server.",
|
|
||||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
|
||||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
|
||||||
r"Could not load any common_ops library|"
|
|
||||||
r"Please ensure sgl_kernel is properly installed",
|
|
||||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
|
||||||
[
|
|
||||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
|
||||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
|
||||||
"SGLang is not installed or not in PATH on this server.",
|
|
||||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
|
||||||
"MLX LM is not installed on this server.",
|
|
||||||
[{"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",
|
|
||||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
|
||||||
[
|
|
||||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
|
||||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
|
||||||
# so cmake / build-essential / git missing → a specific OS-package
|
|
||||||
# remediation instead of "install llama-cpp-python[server]" (which
|
|
||||||
# itself fails to compile when cmake is absent).
|
|
||||||
(
|
|
||||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
|
||||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
|
||||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
|
||||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
|
||||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"^git: command not found",
|
|
||||||
"git is required to clone the llama.cpp source tree.",
|
|
||||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
|
||||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
|
||||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
|
||||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
|
||||||
[{"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 '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, Torchvision, Diffusers, Accelerate, and SciPy.",
|
|
||||||
[{"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",
|
|
||||||
"Model access is gated or unauthorized.",
|
|
||||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for pattern, message, suggestions in patterns:
|
|
||||||
if re.search(pattern, tail, re.I):
|
|
||||||
return {"message": message, "suggestions": suggestions}
|
|
||||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
|
||||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
|
||||||
):
|
|
||||||
return {
|
|
||||||
"message": "Python traceback detected during serve startup.",
|
|
||||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _state_for_client(state):
|
def _state_for_client(state):
|
||||||
"""Return cookbook state without raw secrets for browser clients."""
|
"""Return cookbook state without raw secrets for browser clients."""
|
||||||
_strip_task_secrets(state)
|
_strip_task_secrets(state)
|
||||||
@@ -1575,80 +1411,6 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
|
|
||||||
return {"models": models, "host": host or "local"}
|
return {"models": models, "host": host or "local"}
|
||||||
|
|
||||||
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
|
||||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
|
||||||
import re
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
|
||||||
from src.settings import load_settings, save_settings
|
|
||||||
|
|
||||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
|
||||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
|
||||||
port = int(port_match.group(1)) if port_match else 8100
|
|
||||||
|
|
||||||
# Determine host
|
|
||||||
if remote:
|
|
||||||
# SSH alias — use as hostname (Tailscale resolves it later)
|
|
||||||
host = remote.split("@")[-1] if "@" in remote else remote
|
|
||||||
else:
|
|
||||||
host = "localhost"
|
|
||||||
|
|
||||||
base_url = f"http://{host}:{port}/v1"
|
|
||||||
|
|
||||||
# Friendly display name from repo_id
|
|
||||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
|
||||||
display_name = f"{short_name} (image)"
|
|
||||||
pinned_models = [req.repo_id] if req.repo_id else []
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Check for existing endpoint with same base_url — update it
|
|
||||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
|
||||||
if existing:
|
|
||||||
existing.is_enabled = True
|
|
||||||
existing.model_type = "image"
|
|
||||||
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()
|
|
||||||
settings = load_settings()
|
|
||||||
if settings.get("image_gen_enabled") is not True:
|
|
||||||
settings["image_gen_enabled"] = True
|
|
||||||
save_settings(settings)
|
|
||||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
|
||||||
return existing.id
|
|
||||||
|
|
||||||
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
|
||||||
ep = ModelEndpoint(
|
|
||||||
id=ep_id,
|
|
||||||
name=display_name,
|
|
||||||
base_url=base_url,
|
|
||||||
api_key=None,
|
|
||||||
is_enabled=True,
|
|
||||||
model_type="image",
|
|
||||||
endpoint_kind="local",
|
|
||||||
model_refresh_mode="manual",
|
|
||||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
|
||||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
|
||||||
)
|
|
||||||
db.add(ep)
|
|
||||||
db.commit()
|
|
||||||
settings = load_settings()
|
|
||||||
settings["image_gen_enabled"] = True
|
|
||||||
if not settings.get("image_model"):
|
|
||||||
settings["image_model"] = req.repo_id
|
|
||||||
save_settings(settings)
|
|
||||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
|
||||||
return ep_id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to auto-register image endpoint: {e}")
|
|
||||||
db.rollback()
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def _pick_free_port_for_ollama(
|
def _pick_free_port_for_ollama(
|
||||||
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
|
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -1777,244 +1539,66 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# let the probe layer mark it offline if nothing's listening.
|
# let the probe layer mark it offline if nothing's listening.
|
||||||
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
|
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
|
||||||
return
|
return
|
||||||
# Non-zero exit — drop the endpoint.
|
# A nonzero exit may delete only the endpoint owned by this serve.
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
||||||
|
from core.constants import internal_api_base as _internal_api_base
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
from urllib.parse import quote
|
||||||
|
import httpx
|
||||||
|
|
||||||
db = _SL()
|
db = _SL()
|
||||||
try:
|
try:
|
||||||
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
|
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
|
||||||
if ep:
|
if not ep or ep.cookbook_serve_session_id != session_id:
|
||||||
# A scheduled serve can leave old non-zero exit markers
|
|
||||||
# in tmux scrollback while the current OpenAI endpoint is
|
|
||||||
# actually alive. Verify reachability before deleting the
|
|
||||||
# endpoint row; otherwise chats fall back even though the
|
|
||||||
# served model is ready.
|
|
||||||
try:
|
|
||||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
|
||||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
|
||||||
if 200 <= getattr(resp, "status", 0) < 300:
|
|
||||||
logger.info(
|
|
||||||
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
|
|
||||||
f"but endpoint {ep.id} is reachable; leaving it registered"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"crash-watchdog: dropping endpoint {endpoint_id} "
|
"crash-watchdog: endpoint %s no longer owned by %s",
|
||||||
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
|
endpoint_id, session_id,
|
||||||
)
|
)
|
||||||
db.delete(ep)
|
return
|
||||||
db.commit()
|
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Preserve an endpoint that is already healthy, including
|
||||||
|
# after an old runner emits a stale failure marker.
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||||
|
if 200 <= getattr(resp, "status", 0) < 300:
|
||||||
|
logger.info(
|
||||||
|
"crash-watchdog: endpoint %s is reachable; keeping it",
|
||||||
|
endpoint_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=8) as client:
|
||||||
|
response = await client.delete(
|
||||||
|
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||||
|
params={"expected_cookbook_serve_session_id": session_id},
|
||||||
|
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info(
|
||||||
|
"crash-watchdog: deleted endpoint %s after serve %s exited %s",
|
||||||
|
endpoint_id, session_id, exit_code,
|
||||||
|
)
|
||||||
|
elif response.status_code in (404, 409):
|
||||||
|
logger.info(
|
||||||
|
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
|
||||||
|
endpoint_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"crash-watchdog: endpoint %s deletion returned HTTP %s",
|
||||||
|
endpoint_id, response.status_code,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}")
|
logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
|
||||||
return
|
return
|
||||||
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
||||||
|
|
||||||
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
|
||||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
|
||||||
model picker without a manual /setup step — the text-model sibling of
|
|
||||||
_auto_register_image_endpoint.
|
|
||||||
|
|
||||||
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
|
||||||
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
|
||||||
endpoint at that server's /v1; the picker auto-discovers the model id by
|
|
||||||
probing /v1/models and dims the endpoint until the server is reachable,
|
|
||||||
so registering immediately (before the server finishes loading) is safe.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
|
||||||
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
|
||||||
)
|
|
||||||
import re
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
|
||||||
|
|
||||||
# Port: ordered fallbacks so we match whatever the user actually
|
|
||||||
# asked for, not a hardcoded default:
|
|
||||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
|
||||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
|
||||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
|
||||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
|
||||||
# registered every Ollama endpoint at 11434 — even if the user
|
|
||||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
|
||||||
# existing systemd Ollama, the registered endpoint pointed at
|
|
||||||
# the OLD port and showed as offline.
|
|
||||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
|
||||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
|
|
||||||
if port_match:
|
|
||||||
port = int(port_match.group(1))
|
|
||||||
elif ollama_host_match:
|
|
||||||
port = int(ollama_host_match.group(1))
|
|
||||||
elif "ollama" in req.cmd:
|
|
||||||
port = 11434
|
|
||||||
else:
|
|
||||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
|
||||||
|
|
||||||
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
|
||||||
# the odysseus container — so the right URL for the in-container
|
|
||||||
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
|
||||||
# (the latter points at the docker HOST, which doesn't have a server
|
|
||||||
# on that port). The previous host.docker.internal fallback only made
|
|
||||||
# sense for /setup-added external services like systemd Ollama on the
|
|
||||||
# host — and those go through manual setup, not this auto-register
|
|
||||||
# code path. For remote serves we still use the SSH host alias.
|
|
||||||
if remote:
|
|
||||||
host = remote.split("@")[-1] if "@" in remote else remote
|
|
||||||
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
|
||||||
host = "host.docker.internal"
|
|
||||||
else:
|
|
||||||
host = "localhost"
|
|
||||||
|
|
||||||
base_url = f"http://{host}:{port}/v1"
|
|
||||||
|
|
||||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
|
||||||
display_name = short_name or "Local model"
|
|
||||||
is_mlx_deepseek_v4 = (
|
|
||||||
"mlx_lm.server" in (req.cmd or "")
|
|
||||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
|
||||||
)
|
|
||||||
mlx_shim_model_id = ""
|
|
||||||
if is_mlx_deepseek_v4 and short_name:
|
|
||||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
|
||||||
remote_home = home_match.group(1) if home_match else ""
|
|
||||||
if remote_home:
|
|
||||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
|
||||||
|
|
||||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
|
||||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
|
||||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
|
||||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
|
||||||
# Pin the model the user launched for every Cookbook-created LLM
|
|
||||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
|
||||||
# deployment alias from /v1/models, and a stale server can answer on the
|
|
||||||
# same port while the new launch failed. Keeping the requested model id
|
|
||||||
# pinned makes the picker reflect the actual launch intent.
|
|
||||||
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
|
||||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
|
||||||
if existing:
|
|
||||||
existing.is_enabled = True
|
|
||||||
existing.model_type = "llm"
|
|
||||||
existing.name = display_name
|
|
||||||
existing.endpoint_kind = "local"
|
|
||||||
existing.model_refresh_mode = "auto"
|
|
||||||
if pinned_models:
|
|
||||||
try:
|
|
||||||
existing_pinned = json.loads(existing.pinned_models or "[]")
|
|
||||||
except Exception:
|
|
||||||
existing_pinned = []
|
|
||||||
merged_pinned = []
|
|
||||||
for mid in [*existing_pinned, *pinned_models]:
|
|
||||||
if mid and mid not in merged_pinned:
|
|
||||||
merged_pinned.append(mid)
|
|
||||||
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
|
|
||||||
if is_ollama_endpoint:
|
|
||||||
existing.endpoint_kind = "ollama"
|
|
||||||
if pinned_models:
|
|
||||||
existing.cached_models = json.dumps(pinned_models)
|
|
||||||
if supports_tools is not None:
|
|
||||||
existing.supports_tools = supports_tools
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Updated existing local model endpoint: {base_url}")
|
|
||||||
# Re-probe so cached_models matches what the server actually
|
|
||||||
# serves right now (the URL may have stayed the same but the
|
|
||||||
# model behind it changed across launches).
|
|
||||||
try:
|
|
||||||
if mlx_shim_model_id:
|
|
||||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
|
||||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
|
||||||
db.commit()
|
|
||||||
else:
|
|
||||||
from routes.model_routes import _probe_endpoint
|
|
||||||
import json as _json2
|
|
||||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
|
||||||
if probed:
|
|
||||||
existing.cached_models = _json2.dumps(probed)
|
|
||||||
db.commit()
|
|
||||||
except Exception as _pe:
|
|
||||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
|
||||||
# Sweep stale dupes: other endpoints with the same display name
|
|
||||||
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
|
|
||||||
# deleted so the picker doesn't show an offline ghost next to
|
|
||||||
# the working one. Only sweeps endpoints whose id starts with
|
|
||||||
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
|
|
||||||
# etc. entry with a coincidentally matching name.
|
|
||||||
stale = (db.query(ModelEndpoint)
|
|
||||||
.filter(ModelEndpoint.name == display_name)
|
|
||||||
.filter(ModelEndpoint.base_url != base_url)
|
|
||||||
.filter(ModelEndpoint.id.like("local-%"))
|
|
||||||
.all())
|
|
||||||
for s in stale:
|
|
||||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
|
||||||
db.delete(s)
|
|
||||||
if stale:
|
|
||||||
db.commit()
|
|
||||||
return existing.id
|
|
||||||
|
|
||||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
|
||||||
ep = ModelEndpoint(
|
|
||||||
id=ep_id,
|
|
||||||
name=display_name,
|
|
||||||
base_url=base_url,
|
|
||||||
api_key=None,
|
|
||||||
is_enabled=True,
|
|
||||||
model_type="llm",
|
|
||||||
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
|
||||||
model_refresh_mode="auto",
|
|
||||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
|
||||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
|
||||||
supports_tools=supports_tools,
|
|
||||||
)
|
|
||||||
db.add(ep)
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
|
||||||
# Same sweep on first-register path: drop any pre-existing local-*
|
|
||||||
# endpoints with this display name pointed elsewhere.
|
|
||||||
stale = (db.query(ModelEndpoint)
|
|
||||||
.filter(ModelEndpoint.name == display_name)
|
|
||||||
.filter(ModelEndpoint.id != ep_id)
|
|
||||||
.filter(ModelEndpoint.id.like("local-%"))
|
|
||||||
.all())
|
|
||||||
for s in stale:
|
|
||||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
|
||||||
db.delete(s)
|
|
||||||
if stale:
|
|
||||||
db.commit()
|
|
||||||
# Probe /v1/models NOW and write cached_models so the chat
|
|
||||||
# picker actually shows the model on the next /api/models
|
|
||||||
# call. Without this immediate probe, the endpoint has empty
|
|
||||||
# cached_models until the next background refresh fires (up
|
|
||||||
# to a minute later) and the picker shows nothing — even
|
|
||||||
# though the endpoint is in the DB and the server is up.
|
|
||||||
try:
|
|
||||||
if mlx_shim_model_id:
|
|
||||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
|
||||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
|
||||||
else:
|
|
||||||
from routes.model_routes import _probe_endpoint
|
|
||||||
import json as _json2
|
|
||||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
|
||||||
if probed:
|
|
||||||
ep.cached_models = _json2.dumps(probed)
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
|
||||||
except Exception as _pe:
|
|
||||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
|
||||||
return ep_id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
|
||||||
db.rollback()
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.post("/api/model/serve")
|
@router.post("/api/model/serve")
|
||||||
async def model_serve(request: Request, req: ServeRequest):
|
async def model_serve(request: Request, req: ServeRequest):
|
||||||
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
|
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
|
||||||
@@ -2161,7 +1745,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
ps_lines.append('}')
|
ps_lines.append('}')
|
||||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||||
@@ -2247,14 +1831,17 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||||
|
_append_llama_server_shim_definition_lines(runner_lines)
|
||||||
|
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||||
|
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||||
runner_lines.append(' mkdir -p ~/bin')
|
runner_lines.append(' mkdir -p ~/bin')
|
||||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||||
@@ -2271,8 +1858,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
|
||||||
runner_lines.append(' else')
|
runner_lines.append(' else')
|
||||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
@@ -2296,58 +1882,13 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||||
# native binary is missing but llama_cpp Python is already
|
# If the native build failed, fall back to the Python bindings.
|
||||||
# installed, drop a wrapper at ~/bin/llama-server that
|
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||||
# translates llama-server CLI args to llama_cpp.server's
|
|
||||||
# underscore-style flags. The user's serve command stays
|
|
||||||
# `llama-server ...` and "just works" — no build, no cmake,
|
|
||||||
# no second install. This is the path that unblocks every
|
|
||||||
# remote where pip-installed llama-cpp-python is already
|
|
||||||
# working but Cookbook used to insist on a native binary.
|
|
||||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
|
||||||
runner_lines.append(' mkdir -p ~/bin')
|
|
||||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
|
||||||
runner_lines.append('#!/usr/bin/env bash')
|
|
||||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
|
||||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
|
||||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
|
||||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
|
||||||
runner_lines.append('ARGS=()')
|
|
||||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
|
||||||
runner_lines.append(' case "$1" in')
|
|
||||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
|
||||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
|
||||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
|
||||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
|
||||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
|
||||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
|
||||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
|
||||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
|
||||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
|
||||||
runner_lines.append(' esac')
|
|
||||||
runner_lines.append('done')
|
|
||||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
|
||||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
|
||||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
|
||||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
|
||||||
runner_lines.append(' fi')
|
|
||||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
|
||||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
|
||||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||||
runner_lines.append(' fi')
|
runner_lines.append(' fi')
|
||||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
_append_llama_server_finalization_lines(runner_lines)
|
||||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
|
||||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
|
||||||
runner_lines.append(' fi')
|
|
||||||
runner_lines.append('fi')
|
runner_lines.append('fi')
|
||||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||||
handled_ollama_serve = True
|
handled_ollama_serve = True
|
||||||
@@ -2849,9 +2390,17 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
endpoint_id = None
|
endpoint_id = None
|
||||||
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||||
if is_image_endpoint:
|
if is_image_endpoint:
|
||||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
endpoint_id = _auto_register_image_endpoint(
|
||||||
|
req,
|
||||||
|
remote,
|
||||||
|
serve_session_id=session_id,
|
||||||
|
)
|
||||||
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,
|
||||||
|
serve_session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Crash watchdog: the auto-register above writes the endpoint row
|
# Crash watchdog: the auto-register above writes the endpoint row
|
||||||
# IMMEDIATELY (before the server has even bound its port) so the
|
# IMMEDIATELY (before the server has even bound its port) so the
|
||||||
@@ -3971,7 +3520,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||||
files = [
|
files = [
|
||||||
str(s.get("rfilename") or "")
|
str(s.get("rfilename") or "")
|
||||||
@@ -4343,7 +3892,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
tmux output can stop at a stale progress line if the pane/session
|
tmux output can stop at a stale progress line if the pane/session
|
||||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||||
In that case, trust the cache shape: a snapshot directory with files
|
In that case, trust the cache shape: a snapshot directory with files
|
||||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
and no unfinished blobs means HuggingFace finished materializing the
|
||||||
model. cache_root is the task's custom download dir — the runner
|
model. cache_root is the task's custom download dir — the runner
|
||||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||||
not wherever this probe's environment says.
|
not wherever this probe's environment says.
|
||||||
@@ -4368,7 +3917,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""Best-effort check for resumable HF partial blobs.
|
"""Best-effort check for resumable HF partial blobs.
|
||||||
|
|
||||||
A lost SSH/tmux session can leave a real download still incomplete.
|
A lost SSH/tmux session can leave a real download still incomplete.
|
||||||
Treat any *.incomplete blob as stronger evidence than stale
|
Treat unfinished blobs (excluding empty markers paired with a
|
||||||
|
completed blob) as stronger evidence than stale
|
||||||
"100%" lines in the captured pane output.
|
"100%" lines in the captured pane output.
|
||||||
"""
|
"""
|
||||||
if not repo_id or "/" not in repo_id:
|
if not repo_id or "/" not in repo_id:
|
||||||
@@ -4486,7 +4036,9 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# fills the visible tail. Without this, output_tail ends up
|
# fills the visible tail. Without this, output_tail ends up
|
||||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||||
# can't diagnose the actual error.
|
# can't diagnose the actual error.
|
||||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
# -J joins lines tmux wrapped at the pane width, so an error
|
||||||
|
# split mid-word still matches the serve diagnosis patterns.
|
||||||
|
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||||
elif IS_WINDOWS:
|
elif IS_WINDOWS:
|
||||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||||
# Liveness comes from the <session>.pid file, output from the
|
# Liveness comes from the <session>.pid file, output from the
|
||||||
@@ -4495,7 +4047,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
capture_cmd = None
|
capture_cmd = None
|
||||||
else:
|
else:
|
||||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||||
|
|
||||||
local_win_task = (not remote) and IS_WINDOWS
|
local_win_task = (not remote) and IS_WINDOWS
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
|||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, _auth_disabled
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
from src.session_search import _escape_like
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# match docs containing both words regardless of position/order.
|
# match docs containing both words regardless of position/order.
|
||||||
if search:
|
if search:
|
||||||
for tok in search.split():
|
for tok in search.split():
|
||||||
term = f"%{tok}%"
|
term = f"%{_escape_like(tok)}%"
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
Document.title.ilike(term, escape="\\")
|
||||||
|
| Document.current_content.ilike(term, escape="\\")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Language filter. "pdf" is a display language derived from the
|
# Language filter. "pdf" is a display language derived from the
|
||||||
@@ -781,9 +783,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||||
data = {}
|
data = {}
|
||||||
ids = data.get("ids") or []
|
ids = data.get("ids") if isinstance(data, dict) else None
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "No documents specified")
|
raise HTTPException(400, "No documents specified")
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||||
|
raise HTTPException(400, "ids must be a list of document ids")
|
||||||
_ext = {
|
_ext = {
|
||||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||||
@@ -810,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
ext = _ext.get(doc.language or "text", ".txt")
|
ext = _ext.get(doc.language or "text", ".txt")
|
||||||
base = (doc.title or "document").strip() or "document"
|
base = (doc.title or "document").strip() or "document"
|
||||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||||
name = base if "." in base else base + ext
|
has_ext = base.lower().endswith(ext)
|
||||||
|
stem = base[:-len(ext)] if has_ext else base
|
||||||
|
suffix = base[-len(ext):] if has_ext else ext
|
||||||
|
name = stem + suffix
|
||||||
i = 1
|
i = 1
|
||||||
while name in used:
|
while name in used:
|
||||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
name = f"{stem}-{i}{suffix}"
|
||||||
i += 1
|
i += 1
|
||||||
used.add(name)
|
used.add(name)
|
||||||
content = doc.current_content or ""
|
content = doc.current_content or ""
|
||||||
|
|||||||
+373
-93
@@ -21,11 +21,12 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from src import editor_style_guide as style_guide
|
from src import editor_style_guide as style_guide
|
||||||
@@ -845,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_background_removal_intent(prompt: str) -> bool:
|
||||||
|
"""Linear-time, bounded intent parser for background removal commands.
|
||||||
|
|
||||||
|
Replaces the previous backtracking regular expression flagged by CodeQL
|
||||||
|
(#1212) while preserving the exact command grammar.
|
||||||
|
"""
|
||||||
|
if not prompt or not isinstance(prompt, str):
|
||||||
|
return False
|
||||||
|
i = len(prompt)
|
||||||
|
while i > 0 and prompt[i - 1] in ".! \t\r\n":
|
||||||
|
i -= 1
|
||||||
|
cleaned = prompt[:i].strip()
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
|
||||||
|
tokens = cleaned.split()
|
||||||
|
if not tokens:
|
||||||
|
return False
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
if tokens[idx].lower() == "please":
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
|
||||||
|
return False
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() == "the":
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
|
||||||
|
return False
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx == len(tokens):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if tokens[idx].lower() in {"from", "of"}:
|
||||||
|
idx += 1
|
||||||
|
if idx < len(tokens) and tokens[idx].lower() == "the":
|
||||||
|
idx += 1
|
||||||
|
if (
|
||||||
|
idx + 1 < len(tokens)
|
||||||
|
and tokens[idx].lower() in {"active", "current", "selected"}
|
||||||
|
and tokens[idx + 1].lower() == "layer"
|
||||||
|
and idx + 2 == len(tokens)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _plan_special(
|
def _plan_special(
|
||||||
prompt: str, state: Any, provider_available: bool,
|
prompt: str, state: Any, provider_available: bool,
|
||||||
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
||||||
@@ -854,6 +913,8 @@ def _plan_special(
|
|||||||
left to the model. Returns (steps, reply, note) or None. These never need an
|
left to the model. Returns (steps, reply, note) or None. These never need an
|
||||||
image provider except generative border fill, used only when one exists.
|
image provider except generative border fill, used only when one exists.
|
||||||
"""
|
"""
|
||||||
|
if _is_background_removal_intent(prompt):
|
||||||
|
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||||
# and must never be left to the model, which sometimes substitutes a
|
# and must never be left to the model, which sometimes substitutes a
|
||||||
# generative placeImage. Highest priority so it can never vary.
|
# generative placeImage. Highest priority so it can never vary.
|
||||||
@@ -1169,16 +1230,111 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
op = str(step.get("op") or "")
|
op = str(step.get("op") or "")
|
||||||
if op not in _OP_SET:
|
if op not in _OP_SET:
|
||||||
continue
|
continue
|
||||||
out.append({"op": op, "params": step.get("params") or {}})
|
params = step.get("params")
|
||||||
|
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _user(request: Request) -> Optional[str]:
|
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import get_current_user
|
body = await request.json()
|
||||||
return get_current_user(request)
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
raise HTTPException(400, "Expected a JSON object")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Expected a JSON object")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if len(text) > 4096:
|
||||||
|
raise HTTPException(413, "Editor prompt is too long")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_timeout(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
raise HTTPException(400, "Invalid bridge timeout")
|
||||||
|
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
||||||
|
raise HTTPException(400, "Invalid bridge timeout")
|
||||||
|
return timeout
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(request: Request) -> bool:
|
||||||
|
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
|
||||||
|
|
||||||
|
Delegated credentials (bearer API tokens) are explicitly rejected:
|
||||||
|
per src/tool_security.py, bearer tokens are long-lived credentials handed to
|
||||||
|
third parties/scripts and must remain capped at non-admin policies even
|
||||||
|
when minted by an administrator.
|
||||||
|
"""
|
||||||
|
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. In-process internal tool bypass (loopback agent tools)
|
||||||
|
try:
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||||
|
import secrets
|
||||||
|
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||||
|
return True
|
||||||
|
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
|
||||||
|
try:
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
if _auth_disabled():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Middleware require_admin
|
||||||
|
try:
|
||||||
|
from core.middleware import require_admin
|
||||||
|
require_admin(request)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Direct session/user check when request.state.current_user is set
|
||||||
|
try:
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
if auth_mgr and getattr(auth_mgr, "is_configured", False):
|
||||||
|
from src.auth_helpers import get_current_user
|
||||||
|
user = get_current_user(request) or getattr(request.state, "current_user", None)
|
||||||
|
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _user(request: Request) -> Optional[str]:
|
||||||
|
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
from src.auth_helpers import require_api_token_scope
|
||||||
|
return require_api_token_scope(request, "chat")
|
||||||
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
|
u = get_current_user(request)
|
||||||
|
if u:
|
||||||
|
return u
|
||||||
|
if _auth_disabled():
|
||||||
return None
|
return None
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
client = getattr(request, "client", None)
|
||||||
|
host = (client.host if client else "") or ""
|
||||||
|
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
|
||||||
|
return None
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
|
||||||
|
|
||||||
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
||||||
@@ -1206,17 +1362,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if _EDITOR_PLANNER is None:
|
if _EDITOR_PLANNER is None:
|
||||||
set_editor_planner(_default_editor_planner)
|
set_editor_planner(_default_editor_planner)
|
||||||
|
|
||||||
# Probe the local editor-agent model server (non-blocking) so the planner
|
|
||||||
# can use it for vision planning when reachable.
|
|
||||||
editor_agent_model.startup_probe()
|
|
||||||
|
|
||||||
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
# scoped session -> list of that session's live subscriber queues; id -> result future.
|
||||||
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
|
||||||
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
# single shared queue: EventSource reconnects overlap, and with one shared queue a
|
||||||
# command could be consumed by a stale subscriber while the active page got nothing.
|
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||||
backlog: Dict[str, List[dict]] = {}
|
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||||
pending: Dict[str, asyncio.Future] = {}
|
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||||
|
max_pending = 1024
|
||||||
|
|
||||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||||
@@ -1253,17 +1406,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
del bl[0: len(bl) - _QUEUE_MAX]
|
del bl[0: len(bl) - _QUEUE_MAX]
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
def _expire_command(session, cid, fut):
|
||||||
return f"{owner or 'anon'}:{session or 'default'}"
|
entry = pending.get(cid)
|
||||||
|
if entry is not None and entry[1] is fut:
|
||||||
|
pending.pop(cid, None)
|
||||||
|
if not fut.done():
|
||||||
|
fut.cancel()
|
||||||
|
queued = backlog.get(session)
|
||||||
|
if queued:
|
||||||
|
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
|
||||||
|
if not backlog[session]:
|
||||||
|
backlog.pop(session, None)
|
||||||
|
|
||||||
|
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
|
||||||
|
# Usernames and session ids may both contain colons.
|
||||||
|
return (owner, str(session or 'default'))
|
||||||
|
|
||||||
|
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
|
||||||
|
return f"{scope[0] or 'anon'}:{scope[1]}"
|
||||||
|
|
||||||
@router.post("/api/editor/plan")
|
@router.post("/api/editor/plan")
|
||||||
async def plan(request: Request) -> JSONResponse:
|
async def plan(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
prompt = _prompt(body.get("prompt"))
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
|
||||||
state = body.get("state") or {}
|
state = body.get("state") or {}
|
||||||
mode = str(body.get("mode") or "plan").strip().lower()
|
mode = str(body.get("mode") or "plan").strip().lower()
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
@@ -1297,9 +1463,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
model_used = (_used[0] if _used and _used[0]
|
|
||||||
else (agent_model or editor_agent_model.local_model()))
|
|
||||||
parsed = _parse_plan(raw)
|
parsed = _parse_plan(raw)
|
||||||
|
if _used and _used[0]:
|
||||||
|
model_used = _used[0]
|
||||||
|
elif parsed:
|
||||||
|
model_used = agent_model or editor_agent_model.local_model()
|
||||||
|
else:
|
||||||
|
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
parsed["agent_model"] = model_used
|
parsed["agent_model"] = model_used
|
||||||
if mode != "check":
|
if mode != "check":
|
||||||
@@ -1363,31 +1533,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
@router.post("/api/editor/bridge/command")
|
@router.post("/api/editor/bridge/command")
|
||||||
async def bridge_command(request: Request) -> JSONResponse:
|
async def bridge_command(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
session = _scoped(body.get("session"), owner)
|
session = _scoped(body.get("session"), owner)
|
||||||
steps = _normalize_steps(body)
|
steps = _normalize_steps(body)
|
||||||
wait = bool(body.get("wait"))
|
wait = bool(body.get("wait"))
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
wait_ids: List[str] = []
|
if len(pending) + len(steps) > max_pending:
|
||||||
|
raise HTTPException(429, "Too many pending editor commands")
|
||||||
for step in steps:
|
for step in steps:
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
ids.append(cid)
|
ids.append(cid)
|
||||||
if wait:
|
fut = asyncio.get_event_loop().create_future()
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
pending[cid] = (owner, fut)
|
||||||
wait_ids.append(cid)
|
|
||||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||||
if wait and len(wait_ids) == 1:
|
|
||||||
|
if wait and len(steps) == 1:
|
||||||
|
cid = ids[0]
|
||||||
|
entry = pending.get(cid)
|
||||||
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||||
return JSONResponse(result)
|
if isinstance(res, dict):
|
||||||
|
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||||
|
res.setdefault("completed", bool(res.get("ok")))
|
||||||
|
return JSONResponse(res)
|
||||||
|
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(wait_ids[0], None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
finally:
|
||||||
return JSONResponse({"queued": ids, "session": session})
|
_expire_command(session, cid, fut)
|
||||||
|
else:
|
||||||
|
for cid in ids:
|
||||||
|
entry = pending.get(cid)
|
||||||
|
if entry is not None:
|
||||||
|
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"queued": ids,
|
||||||
|
"session": _scope_label(session),
|
||||||
|
"status": "queued",
|
||||||
|
"completed": False,
|
||||||
|
"ok": False,
|
||||||
|
})
|
||||||
|
|
||||||
@router.get("/api/editor/bridge/events")
|
@router.get("/api/editor/bridge/events")
|
||||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||||
@@ -1412,38 +1600,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
|
|
||||||
@router.post("/api/editor/bridge/result")
|
@router.post("/api/editor/bridge/result")
|
||||||
async def bridge_result(request: Request) -> JSONResponse:
|
async def bridge_result(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
fut = pending.pop(cid, None)
|
entry = pending.get(cid)
|
||||||
|
if entry is None:
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
if entry[0] != owner:
|
||||||
|
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
|
||||||
|
_, fut = entry
|
||||||
if fut is not None and not fut.done():
|
if fut is not None and not fut.done():
|
||||||
|
is_ok = bool(body.get("ok"))
|
||||||
fut.set_result({
|
fut.set_result({
|
||||||
"id": cid, "ok": bool(body.get("ok")),
|
"id": cid,
|
||||||
"result": body.get("result"), "error": body.get("error"),
|
"ok": is_ok,
|
||||||
|
"status": "completed" if is_ok else "failed",
|
||||||
|
"completed": is_ok,
|
||||||
|
"result": body.get("result"),
|
||||||
|
"error": body.get("error"),
|
||||||
"screenshot": body.get("screenshot"),
|
"screenshot": body.get("screenshot"),
|
||||||
})
|
})
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
@router.post("/api/editor/bridge/await")
|
@router.post("/api/editor/bridge/await")
|
||||||
async def bridge_await(request: Request) -> JSONResponse:
|
async def bridge_await(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
fut = pending.get(cid)
|
entry = pending.get(cid)
|
||||||
if fut is None:
|
if entry is None or entry[0] != owner:
|
||||||
fut = asyncio.get_event_loop().create_future()
|
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||||
pending[cid] = fut
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||||
return JSONResponse(result)
|
if pending.get(cid) == (owner, fut):
|
||||||
|
pending.pop(cid, None)
|
||||||
|
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
||||||
|
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
||||||
|
res["completed"] = bool(res.get("ok"))
|
||||||
|
return JSONResponse(res)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
except asyncio.CancelledError:
|
||||||
|
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||||
|
|
||||||
_AGENT_SYSTEM_PROMPT = (
|
_AGENT_SYSTEM_PROMPT = (
|
||||||
"You are the photo editor agent. You drive the open image editor and "
|
"You are the photo editor agent. You drive the open image editor and "
|
||||||
@@ -1494,22 +1694,32 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
overwritten.
|
overwritten.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
from src.settings import get_setting
|
||||||
except Exception:
|
disabled = get_setting("disabled_tools", []) or []
|
||||||
body = {}
|
if not isinstance(disabled, (list, tuple, set)):
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
disabled = []
|
||||||
|
if "plan_edit" in disabled:
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
|
prompt = _prompt(body.get("prompt"))
|
||||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
# Optional user-picked image model (from the editor model selector).
|
# Optional user-picked image model (from the editor model selector).
|
||||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||||
|
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||||
|
use_image_settings = body.get("_use_image_settings") is True
|
||||||
|
if use_image_settings:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
image_models = editor_image_models(owner=owner)
|
||||||
|
model_choice = image_models["edit"]
|
||||||
|
generation_model_choice = image_models["generation"]
|
||||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||||
agent_model = str(body.get("agent_model") or "").strip()
|
agent_model = str(body.get("agent_model") or "").strip()
|
||||||
tools_offered = _scoped_editor_tools()
|
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||||
scoped = _scoped(session, owner)
|
scoped = _scoped(session, owner)
|
||||||
trace: List[Dict[str, Any]] = [
|
trace: List[Dict[str, Any]] = [
|
||||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||||
]
|
]
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1540,9 +1750,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
model_used = (_used[0] if _used and _used[0]
|
parsed = _parse_plan(raw)
|
||||||
else (agent_model or editor_agent_model.local_model()))
|
if _used and _used[0]:
|
||||||
plan = _parse_plan(raw) or _fallback_plan(prompt)
|
model_used = _used[0]
|
||||||
|
elif parsed:
|
||||||
|
model_used = agent_model or editor_agent_model.local_model()
|
||||||
|
else:
|
||||||
|
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
||||||
|
plan = parsed or _fallback_plan(prompt)
|
||||||
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
||||||
if _strengthened != plan.get("steps"):
|
if _strengthened != plan.get("steps"):
|
||||||
plan = {**plan, "steps": _strengthened,
|
plan = {**plan, "steps": _strengthened,
|
||||||
@@ -1601,8 +1816,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"agent_model": model_used,
|
||||||
})
|
})
|
||||||
|
# This route dispatches tools directly, outside execute_tool_block.
|
||||||
|
# Apply the same administrator tool switches before any step runs.
|
||||||
|
needed = {"plan_edit"}
|
||||||
|
for step in steps:
|
||||||
|
op = step.get("op")
|
||||||
|
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||||
|
if needed.intersection(disabled):
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
# No image provider: stop before any edit runs and tell the user plainly
|
# No image provider: stop before any edit runs and tell the user plainly
|
||||||
# (zero canvas changes; nothing to roll back).
|
# (zero canvas changes; nothing to roll back).
|
||||||
|
if _plan_uses_ai_content(steps):
|
||||||
|
from src.auth_helpers import require_privilege
|
||||||
|
require_privilege(request, "can_generate_images")
|
||||||
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1618,6 +1844,12 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"agent_model": model_used,
|
||||||
})
|
})
|
||||||
|
needed = {"plan_edit"}
|
||||||
|
for step in steps:
|
||||||
|
op = step.get("op")
|
||||||
|
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
||||||
|
if needed.intersection(disabled):
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for step in steps:
|
for step in steps:
|
||||||
op = step.get("op")
|
op = step.get("op")
|
||||||
@@ -1629,24 +1861,43 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||||
break
|
break
|
||||||
tool_payload = dict(params)
|
tool_payload = dict(params)
|
||||||
tool_payload.setdefault("session", session)
|
# Planner output is untrusted: it cannot redirect this turn to
|
||||||
|
# another editor session or override an explicit model choice.
|
||||||
|
tool_payload["session"] = session
|
||||||
|
if use_image_settings:
|
||||||
|
tool_payload["_use_image_settings"] = True
|
||||||
|
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||||
|
try:
|
||||||
|
width, height = int(state["width"]), int(state["height"])
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
tool_payload["size"] = f"{width}x{height}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
# Route to the user-picked model when one is selected.
|
# Route to the user-picked model when one is selected.
|
||||||
if model_choice:
|
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||||
tool_payload.setdefault("model", model_choice)
|
if selected_model:
|
||||||
|
tool_payload["model"] = selected_model
|
||||||
|
elif op == "generate_image" or use_image_settings:
|
||||||
|
# Let server settings resolve Auto, not a planner-invented model.
|
||||||
|
tool_payload.pop("model", None)
|
||||||
# Hand the AI step the canvas image the client already captured
|
# Hand the AI step the canvas image the client already captured
|
||||||
# for this request. Without it the tool falls back to a separate
|
# for this request. Without it the tool falls back to a separate
|
||||||
# bridge screenshot, which is not reliably answered right after a
|
# bridge screenshot, which is not reliably answered right after a
|
||||||
# selection step (the masked path was losing the canvas image).
|
# selection step (the masked path was losing the canvas image).
|
||||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||||
tool_payload.setdefault("image", screenshot)
|
tool_payload["image"] = screenshot
|
||||||
|
else:
|
||||||
|
tool_payload.pop("image", None)
|
||||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||||
try:
|
try:
|
||||||
_desc, res = await tool_cls().execute(
|
_desc, res = await tool_cls().execute(
|
||||||
json.dumps(tool_payload),
|
json.dumps(tool_payload),
|
||||||
{"owner": owner, "session_id": scoped},
|
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||||
|
"incognito": body.get("incognito") is True},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
res = {"error": str(exc), "exit_code": 1}
|
logger.exception("Editor image operation failed")
|
||||||
|
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||||
res = res if isinstance(res, dict) else {}
|
res = res if isinstance(res, dict) else {}
|
||||||
ok = res.get("exit_code", 0) == 0
|
ok = res.get("exit_code", 0) == 0
|
||||||
step_result = {
|
step_result = {
|
||||||
@@ -1664,15 +1915,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[cid] = (owner, fut)
|
||||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||||
try:
|
try:
|
||||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
|
||||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, cid, fut)
|
||||||
results.append(res)
|
results.append(res)
|
||||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||||
if res.get("ok") is False:
|
if res.get("ok") is False:
|
||||||
@@ -1683,13 +1936,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if steps and not any(r.get("ok") is False for r in results):
|
if steps and not any(r.get("ok") is False for r in results):
|
||||||
try:
|
try:
|
||||||
_info_id = uuid.uuid4().hex
|
_info_id = uuid.uuid4().hex
|
||||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
_info_fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[_info_id] = (owner, _info_fut)
|
||||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||||
final_state = _info["result"]
|
final_state = _info["result"]
|
||||||
except Exception:
|
except Exception:
|
||||||
final_state = {}
|
final_state = {}
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, _info_id, _info_fut)
|
||||||
planned_reply = reply
|
planned_reply = reply
|
||||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||||
if special_note and special_note.strip() not in reply:
|
if special_note and special_note.strip() not in reply:
|
||||||
@@ -1726,6 +1982,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
models: List[Dict[str, Any]] = []
|
models: List[Dict[str, Any]] = []
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
try:
|
try:
|
||||||
@@ -1748,22 +2005,32 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
# recognisably image-capable — the same test the request
|
# recognisably image-capable — the same test the request
|
||||||
# router uses.
|
# router uses.
|
||||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||||
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
|
endpoint_id = getattr(ep, "id", None)
|
||||||
|
provider = getattr(ep, "name", None)
|
||||||
|
if not provider:
|
||||||
|
provider = (
|
||||||
|
getattr(ep, "base_url", "")
|
||||||
|
if is_admin
|
||||||
|
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
|
||||||
|
)
|
||||||
|
provider = provider or "image"
|
||||||
for name in _endpoint_enabled_models(ep):
|
for name in _endpoint_enabled_models(ep):
|
||||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||||
continue
|
continue
|
||||||
key = (provider, name)
|
key = (str(endpoint_id or provider), name)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
models.append({
|
entry = {
|
||||||
"id": name,
|
"id": name,
|
||||||
"label": name,
|
"label": name,
|
||||||
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"endpoint_id": getattr(ep, "id", None),
|
"endpoint_id": endpoint_id,
|
||||||
"base_url": getattr(ep, "base_url", "") or "",
|
}
|
||||||
})
|
if is_admin:
|
||||||
|
entry["base_url"] = getattr(ep, "base_url", "") or ""
|
||||||
|
models.append(entry)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1774,42 +2041,55 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
async def editor_agent_models(request: Request) -> JSONResponse:
|
async def editor_agent_models(request: Request) -> JSONResponse:
|
||||||
"""List the models the editor agent/planner can run on.
|
"""List the models the editor agent/planner can run on.
|
||||||
|
|
||||||
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
|
Each entry is ``{id, label, source, vision}`` where ``id`` is
|
||||||
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
||||||
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
||||||
``hosted`` (the configured default/utility chat model that the planner
|
``hosted`` (the configured default/utility chat model that the planner
|
||||||
falls back to).
|
falls back to).
|
||||||
|
For non-admin users, private URLs and internal probe status are omitted.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
try:
|
try:
|
||||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
models = await editor_agent_model.list_editor_agent_models(owner)
|
||||||
|
if not is_admin:
|
||||||
|
sanitized = []
|
||||||
|
for m in models:
|
||||||
|
m_copy = dict(m)
|
||||||
|
m_copy.pop("base_url", None)
|
||||||
|
sanitized.append(m_copy)
|
||||||
|
models = sanitized
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||||
models = []
|
models = []
|
||||||
|
|
||||||
|
probe_info = {}
|
||||||
|
if is_admin:
|
||||||
|
probe_info = editor_agent_model.probe_status()
|
||||||
|
if not probe_info:
|
||||||
|
editor_agent_model.startup_probe()
|
||||||
|
probe_info = editor_agent_model.probe_status()
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"models": models,
|
"models": models,
|
||||||
"probe": editor_agent_model.probe_status(),
|
"probe": probe_info,
|
||||||
})
|
})
|
||||||
|
|
||||||
@router.post("/api/editor/self-check")
|
@router.post("/api/editor/self-check")
|
||||||
async def editor_self_check(request: Request) -> JSONResponse:
|
async def editor_self_check(request: Request) -> JSONResponse:
|
||||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||||
|
|
||||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
A configured vision server may not provide browser CORS headers. This route
|
||||||
cannot call it directly and the self-check silently "passed". This route
|
|
||||||
proxies the two images + intent to the agent VLM and returns
|
proxies the two images + intent to the agent VLM and returns
|
||||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||||
``reason="unavailable"`` on any failure so the client never loops).
|
``reason="unavailable"`` on any failure so the client never loops).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
result = await editor_agent_model.self_check(
|
result = await editor_agent_model.self_check(
|
||||||
body.get("before"),
|
body.get("before"),
|
||||||
body.get("after"),
|
body.get("after"),
|
||||||
str(body.get("prompt") or body.get("intent") or ""),
|
_prompt(body.get("prompt") or body.get("intent")),
|
||||||
model=str(body.get("model") or "").strip() or None,
|
model=str(body.get("model") or "").strip() or None,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from src.email_mime import decode_text_part as _decode_text_part
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
from src.secret_storage import decrypt as _decrypt
|
from src.secret_storage import decrypt as _decrypt
|
||||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
||||||
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
|
|||||||
ct = part.get_content_type()
|
ct = part.get_content_type()
|
||||||
cd = str(part.get("Content-Disposition", ""))
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
if ct == "text/html" and "attachment" not in cd:
|
if ct == "text/html" and "attachment" not in cd:
|
||||||
payload = part.get_payload(decode=True)
|
text = _decode_text_part(part)
|
||||||
if payload:
|
if text:
|
||||||
charset = part.get_content_charset() or "utf-8"
|
return text
|
||||||
return payload.decode(charset, errors="replace")
|
|
||||||
elif msg.get_content_type() == "text/html":
|
elif msg.get_content_type() == "text/html":
|
||||||
payload = msg.get_payload(decode=True)
|
text = _decode_text_part(msg)
|
||||||
if payload:
|
if text:
|
||||||
charset = msg.get_content_charset() or "utf-8"
|
return text
|
||||||
return payload.decode(charset, errors="replace")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_text(msg):
|
def _extract_text(msg):
|
||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
text_parts = []
|
text_parts = []
|
||||||
|
html_parts = []
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
ct = part.get_content_type()
|
ct = part.get_content_type()
|
||||||
cd = str(part.get("Content-Disposition", ""))
|
cd = str(part.get("Content-Disposition", ""))
|
||||||
if ct == "text/plain" and "attachment" not in cd:
|
if ct == "text/plain" and "attachment" not in cd:
|
||||||
payload = part.get_payload(decode=True)
|
text = _decode_text_part(part)
|
||||||
if payload:
|
if text:
|
||||||
charset = part.get_content_charset() or "utf-8"
|
text_parts.append(text)
|
||||||
text_parts.append(payload.decode(charset, errors="replace"))
|
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
raw_html = _decode_text_part(part)
|
||||||
payload = part.get_payload(decode=True)
|
if raw_html:
|
||||||
if payload:
|
|
||||||
charset = part.get_content_charset() or "utf-8"
|
|
||||||
raw_html = payload.decode(charset, errors="replace")
|
|
||||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||||
text = re.sub(r"<[^>]+>", "", text)
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
text = html.unescape(text)
|
text = html.unescape(text)
|
||||||
text_parts.append(text.strip())
|
html_parts.append(text.strip())
|
||||||
return "\n".join(text_parts)
|
return "\n".join(text_parts or html_parts)
|
||||||
else:
|
else:
|
||||||
payload = msg.get_payload(decode=True)
|
text = _decode_text_part(msg)
|
||||||
if payload:
|
if text:
|
||||||
charset = msg.get_content_charset() or "utf-8"
|
|
||||||
text = payload.decode(charset, errors="replace")
|
|
||||||
if msg.get_content_type() == "text/html":
|
if msg.get_content_type() == "text/html":
|
||||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||||
|
|||||||
+133
-73
@@ -1827,18 +1827,25 @@ def setup_email_routes():
|
|||||||
# a cached message body.
|
# a cached message body.
|
||||||
return (account_id or "", folder, str(uid), owner)
|
return (account_id or "", folder, str(uid), owner)
|
||||||
|
|
||||||
def _list_cache_get(key):
|
|
||||||
v = _LIST_CACHE.get(key)
|
|
||||||
if not v: return None
|
|
||||||
if v[0] < _time.monotonic():
|
|
||||||
_LIST_CACHE.pop(key, None)
|
|
||||||
return None
|
|
||||||
return v[1]
|
|
||||||
|
|
||||||
_LIST_STALE = {}
|
_LIST_STALE = {}
|
||||||
|
_LIST_LOCK = _threading.RLock()
|
||||||
|
_LIST_CACHE_LOCK = _LIST_LOCK
|
||||||
|
# Invalidation fences reads already in flight, including background refresh.
|
||||||
|
_LIST_EPOCH = [0]
|
||||||
|
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
||||||
|
|
||||||
|
def _list_cache_get(key):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
v = _LIST_CACHE.get(key)
|
||||||
|
if not v: return None
|
||||||
|
if v[0] < _time.monotonic():
|
||||||
|
_LIST_CACHE.pop(key, None)
|
||||||
|
return None
|
||||||
|
return v[1]
|
||||||
|
|
||||||
def _list_stale_get(key):
|
def _list_stale_get(key):
|
||||||
return _LIST_STALE.get(key)
|
with _LIST_LOCK:
|
||||||
|
return _LIST_STALE.get(key)
|
||||||
|
|
||||||
def _list_stale_put(key, value):
|
def _list_stale_put(key, value):
|
||||||
_LIST_STALE[key] = value
|
_LIST_STALE[key] = value
|
||||||
@@ -1849,13 +1856,13 @@ def setup_email_routes():
|
|||||||
_LIST_REFRESHING = set()
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
with _LIST_LOCK:
|
||||||
# Also snapshot the last-good response for stale-while-revalidate.
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
_list_stale_put(key, value)
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
# Cap size
|
_list_stale_put(key, value)
|
||||||
if len(_LIST_CACHE) > 64:
|
if len(_LIST_CACHE) > 64:
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||||
_LIST_CACHE.pop(k, None)
|
_LIST_CACHE.pop(k, None)
|
||||||
|
|
||||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
||||||
"""Refresh a list snapshot off the event loop, at most once per key.
|
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||||
@@ -1863,15 +1870,20 @@ def setup_email_routes():
|
|||||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
||||||
the TTL cache and the stale snapshot so the next list view is warm.
|
the TTL cache and the stale snapshot so the next list view is warm.
|
||||||
"""
|
"""
|
||||||
if cache_key in _LIST_REFRESHING:
|
with _LIST_LOCK:
|
||||||
return
|
if cache_key in _LIST_REFRESHING:
|
||||||
_LIST_REFRESHING.add(cache_key)
|
return
|
||||||
|
_LIST_REFRESHING.add(cache_key)
|
||||||
|
epoch = _LIST_EPOCH[0]
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
refreshed = _list_emails_sync(*sync_args)
|
refreshed = _list_emails_sync(*sync_args)
|
||||||
if refreshed and not refreshed.get("error"):
|
if refreshed and not refreshed.get("error"):
|
||||||
_list_cache_put(cache_key, refreshed)
|
with _LIST_LOCK:
|
||||||
|
if epoch != _LIST_EPOCH[0]:
|
||||||
|
return
|
||||||
|
_list_cache_put(cache_key, refreshed)
|
||||||
try:
|
try:
|
||||||
_schedule_recent_email_warm(
|
_schedule_recent_email_warm(
|
||||||
refreshed.get("emails") or [], folder, account_id, owner)
|
refreshed.get("emails") or [], folder, account_id, owner)
|
||||||
@@ -1880,7 +1892,8 @@ def setup_email_routes():
|
|||||||
except Exception as exc: # pragma: no cover - background best effort
|
except Exception as exc: # pragma: no cover - background best effort
|
||||||
logger.debug("email list background refresh failed: %s", exc)
|
logger.debug("email list background refresh failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
_LIST_REFRESHING.discard(cache_key)
|
with _LIST_LOCK:
|
||||||
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||||
|
|
||||||
@@ -1917,37 +1930,74 @@ def setup_email_routes():
|
|||||||
hook(account_id, folder)
|
hook(account_id, folder)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if account_id is None and folder is None:
|
with _LIST_LOCK:
|
||||||
_LIST_CACHE.clear()
|
_LIST_EPOCH[0] += 1
|
||||||
return
|
if account_id is None and folder is None:
|
||||||
for k in list(_LIST_CACHE.keys()):
|
_LIST_CACHE.clear()
|
||||||
k_acct = k[0] if len(k) > 0 else ""
|
_LIST_STALE.clear()
|
||||||
k_folder = k[1] if len(k) > 1 else ""
|
return
|
||||||
if (account_id is None or k_acct == (account_id or "")) and \
|
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||||
(folder is None or k_folder == folder):
|
for k in list(cache.keys()):
|
||||||
_LIST_CACHE.pop(k, None)
|
k_acct = k[0] if len(k) > 0 else ""
|
||||||
|
k_folder = k[1] if len(k) > 1 else ""
|
||||||
|
if (account_id is None or k_acct == (account_id or "")) and \
|
||||||
|
(folder is None or k_folder == folder):
|
||||||
|
cache.pop(k, None)
|
||||||
|
|
||||||
|
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
||||||
|
_invalidate_list_cache(account_id, folder)
|
||||||
|
|
||||||
|
def _index_delete(owner, account_id, folder, uid):
|
||||||
|
with _LIST_LOCK:
|
||||||
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
|
_invalidate_list_cache(account_id, folder)
|
||||||
|
|
||||||
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||||
uid_s = str(uid)
|
uid_s = str(uid)
|
||||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
with _LIST_LOCK:
|
||||||
if key[0] != (account_id or "") or key[1] != folder:
|
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||||
continue
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
emails = list((value or {}).get("emails") or [])
|
continue
|
||||||
changed = False
|
emails = list((value or {}).get("emails") or [])
|
||||||
if seen and key[2] == "unread":
|
changed = False
|
||||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
if seen and key[2] == "unread":
|
||||||
if len(kept) != len(emails):
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
value = dict(value)
|
if len(kept) != len(emails):
|
||||||
value["emails"] = kept
|
value = dict(value)
|
||||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
value["emails"] = kept
|
||||||
changed = True
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
else:
|
|
||||||
for e in emails:
|
|
||||||
if str((e or {}).get("uid") or "") == uid_s:
|
|
||||||
e["is_read"] = bool(seen)
|
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
else:
|
||||||
_LIST_CACHE[key] = (expires_at, value)
|
for e in emails:
|
||||||
|
if str((e or {}).get("uid") or "") == uid_s:
|
||||||
|
e["is_read"] = bool(seen)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
_LIST_CACHE[key] = (expires_at, value)
|
||||||
|
if key in _LIST_STALE:
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
|
||||||
|
for key, value in list(_LIST_STALE.items()):
|
||||||
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
|
continue
|
||||||
|
emails = list((value or {}).get("emails") or [])
|
||||||
|
changed = False
|
||||||
|
if seen and key[2] == "unread":
|
||||||
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
|
if len(kept) != len(emails):
|
||||||
|
value = dict(value)
|
||||||
|
value["emails"] = kept
|
||||||
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
for e in emails:
|
||||||
|
if str((e or {}).get("uid") or "") == uid_s:
|
||||||
|
e["is_read"] = bool(seen)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
_LIST_STALE[key] = value
|
||||||
|
|
||||||
def _read_cache_get(key):
|
def _read_cache_get(key):
|
||||||
v = _READ_CACHE.get(key)
|
v = _READ_CACHE.get(key)
|
||||||
@@ -2327,6 +2377,8 @@ def setup_email_routes():
|
|||||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||||
the fallback config lookup is scoped to this user's accounts only.
|
the fallback config lookup is scoped to this user's accounts only.
|
||||||
"""
|
"""
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
index_generation = _LIST_CACHE_GENERATION[0]
|
||||||
conn = None
|
conn = None
|
||||||
conn_ok = False
|
conn_ok = False
|
||||||
try:
|
try:
|
||||||
@@ -2621,7 +2673,9 @@ def setup_email_routes():
|
|||||||
if parsed:
|
if parsed:
|
||||||
fetched_emails.append(parsed)
|
fetched_emails.append(parsed)
|
||||||
cached_rows[parsed["uid"]] = parsed
|
cached_rows[parsed["uid"]] = parsed
|
||||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||||
for uid_num in uid_order:
|
for uid_num in uid_order:
|
||||||
row = cached_rows.get(uid_num)
|
row = cached_rows.get(uid_num)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -2915,7 +2969,7 @@ def setup_email_routes():
|
|||||||
# with a `stale` marker and refresh in the background so the next view
|
# with a `stale` marker and refresh in the background so the next view
|
||||||
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
# is warm. Blocking IMAP only remains for a genuine cold start with no
|
||||||
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||||
if not from_addr and not has_attachments and not (date_from or date_to):
|
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
snapshot = _list_stale_get(ck)
|
snapshot = _list_stale_get(ck)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
try:
|
try:
|
||||||
@@ -2950,6 +3004,8 @@ def setup_email_routes():
|
|||||||
ck, folder, account_id, owner,
|
ck, folder, account_id, owner,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
cache_generation = _LIST_CACHE_GENERATION[0]
|
||||||
result = await _asyncio.to_thread(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -2958,7 +3014,9 @@ def setup_email_routes():
|
|||||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||||
_list_cache_put(ck, result)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_list_cache_put(ck, result)
|
||||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||||
if elapsed_ms > 1500:
|
if elapsed_ms > 1500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -3306,7 +3364,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||||
if moved:
|
if moved:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||||
@@ -3323,7 +3381,7 @@ def setup_email_routes():
|
|||||||
if deleted:
|
if deleted:
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
if deleted:
|
if deleted:
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||||
@@ -3405,7 +3463,7 @@ def setup_email_routes():
|
|||||||
if moved:
|
if moved:
|
||||||
changed += 1
|
changed += 1
|
||||||
cleaned_uids.append(uid)
|
cleaned_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3494,6 +3552,8 @@ def setup_email_routes():
|
|||||||
folder (Gmail does), we transparently swap to All Mail so the
|
folder (Gmail does), we transparently swap to All Mail so the
|
||||||
search surfaces archived / labelled emails too. Plain IMAP
|
search surfaces archived / labelled emails too. Plain IMAP
|
||||||
accounts fall back to whatever folder the caller specified."""
|
accounts fall back to whatever folder the caller specified."""
|
||||||
|
with _LIST_CACHE_LOCK:
|
||||||
|
index_generation = _LIST_CACHE_GENERATION[0]
|
||||||
if not q or len(q) < 2:
|
if not q or len(q) < 2:
|
||||||
return {"emails": [], "total": 0, "query": q}
|
return {"emails": [], "total": 0, "query": q}
|
||||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||||
@@ -3637,7 +3697,9 @@ def setup_email_routes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||||
continue
|
continue
|
||||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
with _LIST_CACHE_LOCK:
|
||||||
|
if index_generation == _LIST_CACHE_GENERATION[0]:
|
||||||
|
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"emails": emails,
|
"emails": emails,
|
||||||
@@ -3774,8 +3836,7 @@ def setup_email_routes():
|
|||||||
# accepted it, so the index and list cache cannot drift ahead of
|
# accepted it, so the index and list cache cannot drift ahead of
|
||||||
# the mailbox.
|
# the mailbox.
|
||||||
if mark_seen and not mark_seen_failed:
|
if mark_seen and not mark_seen_failed:
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
|
|
||||||
related_attachments = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
@@ -3914,8 +3975,7 @@ def setup_email_routes():
|
|||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
@@ -5147,7 +5207,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5167,7 +5227,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "flagged": bool(on)}
|
return {"success": True, "flagged": bool(on)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5185,7 +5245,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5205,7 +5265,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5236,7 +5296,7 @@ def setup_email_routes():
|
|||||||
# click or by another mailbox client; it is already gone
|
# click or by another mailbox client; it is already gone
|
||||||
# from the requested folder, so do not trap the UI on a
|
# from the requested folder, so do not trap the UI on a
|
||||||
# permanent “Email not found” error.
|
# permanent “Email not found” error.
|
||||||
_email_index_delete(owner, account_id, folder, str(uid))
|
_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "already_deleted": True}
|
return {"success": True, "already_deleted": True}
|
||||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||||
@@ -5254,9 +5314,9 @@ def setup_email_routes():
|
|||||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||||
return {"success": False, "error": "Could not move email to Trash"}
|
return {"success": False, "error": "Could not move email to Trash"}
|
||||||
_email_index_delete(owner, account_id, folder, resolved_uid)
|
_index_delete(owner, account_id, folder, resolved_uid)
|
||||||
if resolved_uid != str(uid):
|
if resolved_uid != str(uid):
|
||||||
_email_index_delete(owner, account_id, folder, str(uid))
|
_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5272,7 +5332,7 @@ def setup_email_routes():
|
|||||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5340,7 +5400,7 @@ def setup_email_routes():
|
|||||||
pending_expunge.append(uid)
|
pending_expunge.append(uid)
|
||||||
continue
|
continue
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_uids.append(uid)
|
failed_uids.append(uid)
|
||||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||||
@@ -5362,7 +5422,7 @@ def setup_email_routes():
|
|||||||
if expunge_ok:
|
if expunge_ok:
|
||||||
for uid in pending_expunge:
|
for uid in pending_expunge:
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||||
# copied messages so future EXPUNGE operations do not destroy them.
|
# copied messages so future EXPUNGE operations do not destroy them.
|
||||||
@@ -5481,7 +5541,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, dest):
|
if not _move_email_message(conn, uid, dest):
|
||||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5567,7 +5627,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
@@ -5583,7 +5643,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -6250,7 +6310,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||||
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||||
_invalidate_list_cache(_account_id, _source_folder)
|
_invalidate_list_cache(_account_id, _source_folder)
|
||||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
|||||||
def _derive_family(filename):
|
def _derive_family(filename):
|
||||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||||
name = os.path.splitext(filename)[0]
|
name = os.path.splitext(filename)[0]
|
||||||
# Strip common weight/style suffixes
|
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||||
name = re.sub(
|
name = re.sub(
|
||||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||||
|
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||||
'', name, flags=re.IGNORECASE
|
'', name, flags=re.IGNORECASE
|
||||||
)
|
)
|
||||||
# Replace dashes/underscores with spaces
|
# Replace dashes/underscores with spaces
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||||
|
from src.session_search import escape_like_pattern
|
||||||
from src.upload_limits import (
|
from src.upload_limits import (
|
||||||
read_upload_limited,
|
read_upload_limited,
|
||||||
GALLERY_UPLOAD_MAX_BYTES,
|
GALLERY_UPLOAD_MAX_BYTES,
|
||||||
@@ -37,6 +38,24 @@ _SAM_STATE: Dict[str, Any] = {}
|
|||||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||||
|
from PIL import Image, ImageChops, ImageOps
|
||||||
|
|
||||||
|
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||||
|
if full_frame:
|
||||||
|
return generated, True
|
||||||
|
# Partial edits must share the source coordinate system, but resizing
|
||||||
|
# width/height independently distorts the subject when the provider changes ratio.
|
||||||
|
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||||
|
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||||
|
aligned = Image.new("RGBA", source.size)
|
||||||
|
aligned.paste(fitted, offset)
|
||||||
|
coverage = Image.new("L", source.size)
|
||||||
|
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||||
|
effective_mask = ImageChops.multiply(mask, coverage)
|
||||||
|
return Image.composite(aligned, source, effective_mask), False
|
||||||
|
|
||||||
|
|
||||||
def _rss_mb() -> float:
|
def _rss_mb() -> float:
|
||||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||||
try:
|
try:
|
||||||
@@ -305,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
|||||||
raise HTTPException(400, f"Invalid {label}") from exc
|
raise HTTPException(400, f"Invalid {label}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _json_object(request: Request) -> dict:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid JSON body")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HTTPException(400, "JSON object body required")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _image_ids(request: Request) -> list:
|
||||||
|
ids = (await _json_object(request)).get("image_ids", [])
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||||
|
raise HTTPException(400, "image_ids must be a list of image ids")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format=fmt)
|
img.save(buf, format=fmt)
|
||||||
@@ -568,6 +604,7 @@ def _is_openai_api_base(url: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||||
|
"/chat/completions",
|
||||||
"/images",
|
"/images",
|
||||||
"/images/edits",
|
"/images/edits",
|
||||||
"/images/generations",
|
"/images/generations",
|
||||||
@@ -683,9 +720,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
|
|||||||
configured.update(v for v in values if isinstance(v, str))
|
configured.update(v for v in values if isinstance(v, str))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
if model not in configured or not looks_like_image_generation_model(model):
|
if model:
|
||||||
continue
|
if model not in configured or not looks_like_image_generation_model(model):
|
||||||
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
continue
|
||||||
|
else:
|
||||||
|
if not any(looks_like_image_generation_model(v) for v in configured):
|
||||||
|
continue
|
||||||
|
ep_id = str(getattr(ep, "id", "") or "")
|
||||||
|
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
|
||||||
|
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
|
||||||
if owner and getattr(ep, "owner", None) == owner:
|
if owner and getattr(ep, "owner", None) == owner:
|
||||||
return ep
|
return ep
|
||||||
if fallback is None:
|
if fallback is None:
|
||||||
@@ -712,7 +755,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||||
async with httpx.AsyncClient(timeout=60) as c2:
|
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
|
||||||
ir = await c2.get(url)
|
ir = await c2.get(url)
|
||||||
if ir.status_code == 200:
|
if ir.status_code == 200:
|
||||||
return base64.b64encode(ir.content).decode()
|
return base64.b64encode(ir.content).decode()
|
||||||
@@ -779,14 +822,17 @@ async def _forward_seneca_image_op(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
||||||
result = r.json()
|
try:
|
||||||
|
result = r.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
|
||||||
raw_b64: Optional[str] = None
|
raw_b64: Optional[str] = None
|
||||||
used_model = ""
|
used_model = ""
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
used_model = str(result.get("model") or "")
|
used_model = str(result.get("model") or "")
|
||||||
data = result.get("data")
|
data = result.get("data")
|
||||||
if isinstance(data, list) and data:
|
if isinstance(data, list) and data:
|
||||||
item = data[0] or {}
|
item = data[0] if isinstance(data[0], dict) else {}
|
||||||
raw_b64 = item.get("b64_json") or None
|
raw_b64 = item.get("b64_json") or None
|
||||||
if not raw_b64 and item.get("url"):
|
if not raw_b64 and item.get("url"):
|
||||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
@@ -1175,15 +1221,18 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
)
|
)
|
||||||
q = _owner_filter(q, user)
|
q = _owner_filter(q, user)
|
||||||
|
|
||||||
# Search filter (prompt + tags + ai_tags)
|
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||||
|
# exact phrase, so "red car" missed "a car that is red".
|
||||||
|
# LIKE wildcards are escaped per token.
|
||||||
if search:
|
if search:
|
||||||
term = f"%{search}%"
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
q = q.filter(or_(
|
for tok in search.split():
|
||||||
GalleryImage.prompt.ilike(term),
|
term = f"%{escape_like_pattern(tok)}%"
|
||||||
GalleryImage.tags.ilike(term),
|
q = q.filter(or_(
|
||||||
GalleryImage.ai_tags.ilike(term),
|
GalleryImage.prompt.ilike(term, escape="\\"),
|
||||||
))
|
GalleryImage.tags.ilike(term, escape="\\"),
|
||||||
|
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
||||||
|
))
|
||||||
|
|
||||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||||
# comma-separated — each tag adds a separate AND-filter so the
|
# comma-separated — each tag adds a separate AND-filter so the
|
||||||
@@ -1194,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
for one in (t.strip() for t in tag.split(",")):
|
for one in (t.strip() for t in tag.split(",")):
|
||||||
if not one:
|
if not one:
|
||||||
continue
|
continue
|
||||||
|
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||||
q = q.filter(_or(
|
q = q.filter(_or(
|
||||||
GalleryImage.tags.ilike(f"%{one}%"),
|
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Model filter
|
# Model filter
|
||||||
@@ -1727,7 +1777,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
# SSRF hardening: validate a client-supplied endpoint before any
|
# SSRF hardening: validate a client-supplied endpoint before any
|
||||||
# outbound request (mirrors routes/embedding_routes.py).
|
# outbound request (mirrors routes/embedding_routes.py).
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -1736,6 +1786,17 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||||
chosen_model = (body.pop("_model", "") or "").strip()
|
chosen_model = (body.pop("_model", "") or "").strip()
|
||||||
|
if body.pop("_use_image_settings", False) is True:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
from src.ai_interaction import _resolve_model
|
||||||
|
chosen_model = editor_image_models(owner=user)["edit"]
|
||||||
|
if not chosen_model:
|
||||||
|
raise HTTPException(400, "Choose an image edit model in AI settings")
|
||||||
|
try:
|
||||||
|
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
|
||||||
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
||||||
# provider's step progress for this op. Ignored by providers that do not
|
# provider's step progress for this op. Ignored by providers that do not
|
||||||
# implement the progress contract.
|
# implement the progress contract.
|
||||||
@@ -1842,8 +1903,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
|
||||||
body.get("prompt", ""), oa_model, size, base,
|
oa_model, size, base,
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=300) as client:
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
if is_openrouter:
|
if is_openrouter:
|
||||||
@@ -1853,14 +1914,30 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
mask_png.save(reference_mask, format="PNG")
|
mask_png.save(reference_mask, format="PNG")
|
||||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||||
payload = {
|
payload = {
|
||||||
"model": oa_model, "size": size, "n": 1,
|
"model": oa_model, "n": 1,
|
||||||
"output_format": "png",
|
"output_format": "png",
|
||||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||||
"input_references": [{"type": "image_url", "image_url": {
|
"input_references": [{"type": "image_url", "image_url": {
|
||||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||||
}} for value in references],
|
}} for value in references],
|
||||||
}
|
}
|
||||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
from src.image_model_ids import image_aspect_ratio
|
||||||
|
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
|
||||||
|
from src.ai_interaction import _uses_openrouter_chat_images
|
||||||
|
if _uses_openrouter_chat_images(oa_model):
|
||||||
|
payload = {
|
||||||
|
"model": oa_model,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": payload["prompt"]},
|
||||||
|
*[{"type": "image_url", "image_url": ref["image_url"]}
|
||||||
|
for ref in payload["input_references"]],
|
||||||
|
]}],
|
||||||
|
"modalities": ["image", "text"], "stream": False,
|
||||||
|
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
|
||||||
|
}
|
||||||
|
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
|
||||||
|
else:
|
||||||
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||||
else:
|
else:
|
||||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
@@ -1876,6 +1953,12 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||||
result = r.json()
|
result = r.json()
|
||||||
|
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||||
|
provider_error = _image_response_error(result, oa_model)
|
||||||
|
if provider_error:
|
||||||
|
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||||
|
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||||
|
result = {"data": _chat_image_results(result)}
|
||||||
raw_b64 = None
|
raw_b64 = None
|
||||||
if result.get("data"):
|
if result.get("data"):
|
||||||
item = result["data"][0]
|
item = result["data"][0]
|
||||||
@@ -1895,17 +1978,13 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# the masked region actually changes.
|
# the masked region actually changes.
|
||||||
try:
|
try:
|
||||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||||
# Match the generated image to the source dims.
|
blended, full_frame = _compose_cloud_image_edit(
|
||||||
if generated.size != source_png.size:
|
source_png, generated, mask_png,
|
||||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
instruction=body.get("mode") == "instruction",
|
||||||
# mask_png: white = regenerate (use generated),
|
)
|
||||||
# black = keep (use source).
|
|
||||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
|
||||||
# Image.composite does exactly that with `mask`.
|
|
||||||
blended = Image.composite(generated, source_png, mask_png)
|
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
blended.save(out_buf, format="PNG")
|
blended.save(out_buf, format="PNG")
|
||||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||||
except Exception as comp_err:
|
except Exception as comp_err:
|
||||||
# Never return a full-image edit when masking fails.
|
# Never return a full-image edit when masking fails.
|
||||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||||
@@ -1978,9 +2057,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
"inpaint_proxy self-hosted /images/edits -> model=%r "
|
||||||
"size=%r steps=%r endpoint=%s",
|
"size=%r steps=%r endpoint=%s",
|
||||||
data.get("prompt"), data.get("model"), data.get("size"),
|
data.get("model"), data.get("size"),
|
||||||
data.get("steps"), base,
|
data.get("steps"), base,
|
||||||
)
|
)
|
||||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
@@ -2073,7 +2152,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
runs off the event loop (large images).
|
runs off the event loop (large images).
|
||||||
"""
|
"""
|
||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2166,26 +2245,38 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
ep = None
|
ep = None
|
||||||
if endpoint_id:
|
if endpoint_id:
|
||||||
ep = owner_filter(
|
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
|
||||||
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
|
if ep is None:
|
||||||
ModelEndpoint, user,
|
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||||
).first()
|
elif requested_base:
|
||||||
if ep is None and requested_base:
|
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
|
return {"available": False, "reason": "endpoint not found or disabled"}
|
||||||
|
else:
|
||||||
ep = _first_visible_image_endpoint(db, user)
|
ep = _first_visible_image_endpoint(db, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
return {"available": False, "reason": "no image endpoint configured"}
|
return {"available": False, "reason": "no image endpoint configured"}
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
from src.url_safety import check_outbound_url
|
||||||
|
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
||||||
|
ok, reason = check_outbound_url(base, block_private=block_private)
|
||||||
|
if not ok:
|
||||||
|
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
|
||||||
|
# exception details to clients or logs. Use a stable diagnostic message
|
||||||
|
# without interpolating untrusted exception content or destination strings.
|
||||||
|
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
|
||||||
|
return {"available": False, "reason": "endpoint URL rejected"}
|
||||||
|
|
||||||
if not base.endswith("/v1"):
|
if not base.endswith("/v1"):
|
||||||
base += "/v1"
|
base += "/v1"
|
||||||
# Dynamic path segment (validated token above), so not routed through
|
# Dynamic path segment (validated token above), so not routed through
|
||||||
# _join_checked_gallery_endpoint's exact-match whitelist.
|
# _join_checked_gallery_endpoint's exact-match whitelist.
|
||||||
url = f"{base}/images/progress/{request_id}"
|
url = f"{base}/images/progress/{request_id}"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -2223,7 +2314,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||||
if requested_base:
|
if requested_base and "://" in requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2250,7 +2341,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# so the outbound URL never depends directly on request-body input.
|
# so the outbound URL never depends directly on request-body input.
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(403, "Choose a registered image endpoint")
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
@@ -2412,11 +2503,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
image_b64 = body.get("image")
|
image_b64 = body.get("image")
|
||||||
amount = body.get("amount", 50) / 100.0
|
amount = body.get("amount", 50) / 100.0
|
||||||
|
|
||||||
from PIL import Image, ImageFilter
|
from PIL import ImageFilter
|
||||||
import base64, io
|
import base64, io
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
|
|
||||||
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
||||||
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
||||||
@@ -2451,8 +2541,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(500, "Server missing a required dependency")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||||
img_bytes = _b64_bytes(image_b64)
|
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
try:
|
try:
|
||||||
patch_realesrgan_torchvision_compat()
|
patch_realesrgan_torchvision_compat()
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
@@ -2504,8 +2593,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(500, "Server missing a required dependency")
|
raise HTTPException(500, "Server missing a required dependency")
|
||||||
img_bytes = _b64_bytes(image_b64)
|
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
try:
|
try:
|
||||||
patch_realesrgan_torchvision_compat()
|
patch_realesrgan_torchvision_compat()
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
@@ -2553,7 +2641,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
height = int((body or {}).get("height") or 0)
|
height = int((body or {}).get("height") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
width = height = 0
|
width = height = 0
|
||||||
if height > width:
|
if width > 0 and height > 0:
|
||||||
|
size = f"{width}x{height}"
|
||||||
|
elif height > width:
|
||||||
size = "1024x1536"
|
size = "1024x1536"
|
||||||
elif width > height:
|
elif width > height:
|
||||||
size = "1536x1024"
|
size = "1536x1024"
|
||||||
@@ -2566,6 +2656,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# The editor's model picker may choose a specific (cloud) image model;
|
# The editor's model picker may choose a specific (cloud) image model;
|
||||||
# forward it so settings defaults only apply when none is selected.
|
# forward it so settings defaults only apply when none is selected.
|
||||||
model_choice = str((body or {}).get("model") or "").strip()
|
model_choice = str((body or {}).get("model") or "").strip()
|
||||||
|
if body.get("_use_image_settings") is True:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
model_choice = editor_image_models(owner=user)["generation"]
|
||||||
payload = {"prompt": prompt, "size": size}
|
payload = {"prompt": prompt, "size": size}
|
||||||
if model_choice:
|
if model_choice:
|
||||||
payload["model"] = model_choice
|
payload["model"] = model_choice
|
||||||
@@ -2757,8 +2850,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
import base64, io
|
import base64, io
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64)
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
|
||||||
W, H = img.size
|
W, H = img.size
|
||||||
|
|
||||||
hint = None
|
hint = None
|
||||||
@@ -2847,8 +2939,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
from PIL import Image, ImageFilter, ImageEnhance
|
from PIL import Image, ImageFilter, ImageEnhance
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
img_bytes = _b64_bytes(image_b64)
|
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
|
||||||
|
|
||||||
# Try GFPGAN first (AI face restoration)
|
# Try GFPGAN first (AI face restoration)
|
||||||
try:
|
try:
|
||||||
@@ -2923,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.put("/api/gallery/albums/{album_id}")
|
@router.put("/api/gallery/albums/{album_id}")
|
||||||
async def update_album(request: Request, album_id: str):
|
async def update_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
data = await _json_object(request)
|
||||||
|
name = data.get("name")
|
||||||
|
if name is not None:
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise HTTPException(400, "Album name required")
|
||||||
|
name = name.strip()
|
||||||
|
description = data.get("description")
|
||||||
|
if description is not None and not isinstance(description, str):
|
||||||
|
raise HTTPException(400, "Album description must be a string")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
album = _get_or_404_album(db, album_id, user)
|
album = _get_or_404_album(db, album_id, user)
|
||||||
if data.get("name") is not None:
|
if name is not None:
|
||||||
album.name = data["name"]
|
album.name = name
|
||||||
if data.get("description") is not None:
|
if description is not None:
|
||||||
album.description = data["description"]
|
album.description = description
|
||||||
if data.get("cover_id") is not None:
|
if data.get("cover_id") is not None:
|
||||||
cover_id = data["cover_id"] or None
|
cover_id = data["cover_id"] or None
|
||||||
if cover_id:
|
if cover_id:
|
||||||
@@ -2960,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/add")
|
@router.post("/api/gallery/albums/{album_id}/add")
|
||||||
async def add_to_album(request: Request, album_id: str):
|
async def add_to_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
ids = await _image_ids(request)
|
||||||
ids = data.get("image_ids", [])
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
@@ -2978,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||||
async def remove_from_album(request: Request, album_id: str):
|
async def remove_from_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
ids = await _image_ids(request)
|
||||||
ids = data.get("image_ids", [])
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
|
|||||||
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(400, "Request body must be a JSON object")
|
||||||
role = body.get("role", "assistant")
|
role = body.get("role", "assistant")
|
||||||
content = body.get("content", "")
|
content = body.get("content", "")
|
||||||
|
if role not in ("user", "assistant"):
|
||||||
|
raise HTTPException(400, "role must be 'user' or 'assistant'")
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(400, "content is required")
|
raise HTTPException(400, "content is required")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise HTTPException(400, "content must be a string")
|
||||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||||
_reserve_message_uploads(request, content, metadata)
|
_reserve_message_uploads(request, content, metadata)
|
||||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||||
@@ -825,6 +831,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
|
# A message id is exact; keep_count from the client counts only the
|
||||||
|
# messages loaded in the page. before_msg_id forks before that
|
||||||
|
# message, through_msg_id includes it.
|
||||||
|
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||||
|
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||||
|
if before_msg_id or through_msg_id:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
all_db_messages = db.query(DbChatMessage).filter(
|
||||||
|
DbChatMessage.session_id == session_id
|
||||||
|
).order_by(DbChatMessage.timestamp).all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||||
|
if resolved is None:
|
||||||
|
raise HTTPException(404, "Message not found")
|
||||||
|
keep_count = resolved + (0 if before_msg_id else 1)
|
||||||
|
|
||||||
# Get the source session. keep_count indexes into source.history,
|
# Get the source session. keep_count indexes into source.history,
|
||||||
# so this must go through get_session — reading the cache directly
|
# so this must go through get_session — reading the cache directly
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import html
|
import html
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
@@ -12,7 +13,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
from core.database import McpServer, SessionLocal
|
||||||
from core.middleware import require_admin
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
|
||||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
|
|
||||||
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||||
"""
|
"""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||||
|
# Same boundary as POST /api/tools: an agent turn must not be able
|
||||||
|
# to re-enable MCP tools an admin switched off.
|
||||||
|
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
|||||||
+38
-5
@@ -7,6 +7,7 @@ import json
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
|
import secrets
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -417,6 +418,7 @@ _HOST_TO_CURATED = (
|
|||||||
("nvidia.com", "nvidia"),
|
("nvidia.com", "nvidia"),
|
||||||
("openrouter.ai", "openrouter"),
|
("openrouter.ai", "openrouter"),
|
||||||
("ollama.com", "ollama"),
|
("ollama.com", "ollama"),
|
||||||
|
("conifer.build", "openrouter"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -658,7 +660,7 @@ _NON_CHAT_CONTAINS = (
|
|||||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||||
"topic-control", "calibration",
|
"topic-control", "calibration",
|
||||||
"ai-synthetic-video", "cosmos-reason2",
|
"ai-synthetic-video", "cosmos-reason2",
|
||||||
"bge", "llama-guard",
|
"bge", "llama-guard", "-embed", "rerank",
|
||||||
)
|
)
|
||||||
_NON_CHAT_EXACT_PREFIXES = (
|
_NON_CHAT_EXACT_PREFIXES = (
|
||||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||||
@@ -2845,11 +2847,12 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
|
if model and not _is_chat_model(model):
|
||||||
|
model = ""
|
||||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||||
try:
|
try:
|
||||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||||
if visible:
|
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||||
model = visible[0]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||||
@@ -3042,20 +3045,44 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"dependents": _settings_using_endpoint(ep_id)}
|
return {"dependents": _settings_using_endpoint(ep_id)}
|
||||||
|
|
||||||
@router.delete("/model-endpoints/{ep_id}")
|
@router.delete("/model-endpoints/{ep_id}")
|
||||||
def delete_model_endpoint(ep_id: str, request: Request):
|
def delete_model_endpoint(
|
||||||
|
ep_id: str,
|
||||||
|
request: Request,
|
||||||
|
expected_cookbook_serve_session_id: Optional[str] = None,
|
||||||
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||||
raise HTTPException(404, "Endpoint not found")
|
raise HTTPException(404, "Endpoint not found")
|
||||||
|
if expected_cookbook_serve_session_id is not None:
|
||||||
|
# The DELETE itself checks ownership. An old serve must never
|
||||||
|
# mutate the settings of an endpoint reused by another serve.
|
||||||
|
if (
|
||||||
|
not ep_id.startswith(("local-", "img-"))
|
||||||
|
or not expected_cookbook_serve_session_id
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
|
||||||
|
deleted = (
|
||||||
|
db.query(ModelEndpoint)
|
||||||
|
.filter(
|
||||||
|
ModelEndpoint.id == ep_id,
|
||||||
|
ModelEndpoint.cookbook_serve_session_id
|
||||||
|
== expected_cookbook_serve_session_id,
|
||||||
|
)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(409, "Cookbook endpoint ownership changed")
|
||||||
# Clean up any settings that reference this endpoint
|
# Clean up any settings that reference this endpoint
|
||||||
cleared = _clear_settings_for_endpoint(ep_id)
|
cleared = _clear_settings_for_endpoint(ep_id)
|
||||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
||||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
||||||
auth_id = getattr(ep, "provider_auth_id", None)
|
auth_id = getattr(ep, "provider_auth_id", None)
|
||||||
db.delete(ep)
|
if expected_cookbook_serve_session_id is None:
|
||||||
|
db.delete(ep)
|
||||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
_invalidate_models_cache()
|
_invalidate_models_cache()
|
||||||
@@ -3091,6 +3118,12 @@ def setup_model_routes(model_discovery):
|
|||||||
def update_tools(body: ToolsUpdate, request: Request):
|
def update_tools(body: ToolsUpdate, request: Request):
|
||||||
"""Update which tools are disabled."""
|
"""Update which tools are disabled."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||||
|
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
|
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
||||||
|
# Owner impersonation can carry an admin username, so check the
|
||||||
|
# authenticated transport rather than request.state.current_user.
|
||||||
|
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
settings["disabled_tools"] = body.disabled
|
settings["disabled_tools"] = body.disabled
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
|
|||||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
|||||||
)
|
)
|
||||||
if body.title is not None:
|
if body.title is not None:
|
||||||
note.title = body.title
|
note.title = body.title
|
||||||
if body.content is not None:
|
# Omitted fields preserve their values; explicit null clears the
|
||||||
|
# nullable fields used by the editor's removal controls.
|
||||||
|
supplied = body.model_fields_set
|
||||||
|
if "content" in supplied:
|
||||||
note.content = body.content
|
note.content = body.content
|
||||||
if body.items is not None:
|
if "items" in supplied:
|
||||||
note.items = json.dumps(body.items)
|
note.items = json.dumps(body.items) if body.items is not None else None
|
||||||
flag_modified(note, "items")
|
flag_modified(note, "items")
|
||||||
if body.note_type is not None:
|
if body.note_type is not None:
|
||||||
note.note_type = body.note_type
|
note.note_type = body.note_type
|
||||||
if body.color is not None:
|
if "color" in supplied:
|
||||||
note.color = body.color
|
note.color = body.color
|
||||||
if body.label is not None:
|
if "label" in supplied:
|
||||||
note.label = body.label
|
note.label = body.label
|
||||||
if body.pinned is not None:
|
if body.pinned is not None:
|
||||||
note.pinned = body.pinned
|
note.pinned = body.pinned
|
||||||
if body.archived is not None:
|
if body.archived is not None:
|
||||||
note.archived = body.archived
|
note.archived = body.archived
|
||||||
if body.due_date is not None:
|
if "due_date" in supplied:
|
||||||
note.due_date = body.due_date
|
note.due_date = body.due_date
|
||||||
if body.image_url is not None:
|
if "image_url" in supplied:
|
||||||
note.image_url = body.image_url
|
note.image_url = body.image_url
|
||||||
if body.gallery_id is not None:
|
if "gallery_id" in supplied:
|
||||||
note.gallery_id = body.gallery_id
|
note.gallery_id = body.gallery_id
|
||||||
if body.repeat is not None:
|
if body.repeat is not None:
|
||||||
note.repeat = body.repeat
|
note.repeat = body.repeat
|
||||||
if body.sort_order is not None:
|
if body.sort_order is not None:
|
||||||
note.sort_order = body.sort_order
|
note.sort_order = body.sort_order
|
||||||
if body.agent_session_id is not None:
|
if "agent_session_id" in supplied:
|
||||||
note.agent_session_id = body.agent_session_id
|
note.agent_session_id = body.agent_session_id
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
|||||||
async def set_pref(request: Request, key: str, body: dict):
|
async def set_pref(request: Request, key: str, body: dict):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
prefs = _load_for_user(user)
|
prefs = _load_for_user(user)
|
||||||
prefs[key] = body.get("value")
|
val = body.get("value")
|
||||||
|
if val is None:
|
||||||
|
prefs.pop(key, None)
|
||||||
|
else:
|
||||||
|
prefs[key] = val
|
||||||
_save_for_user(user, prefs)
|
_save_for_user(user, prefs)
|
||||||
return {"key": key, "value": prefs[key]}
|
return {"key": key, "value": prefs.get(key)}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
"""
|
"""
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
|
_endpoint_enabled_models,
|
||||||
build_chat_url,
|
build_chat_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||||
@@ -197,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
ep_model = (model or "").strip()
|
ep_model = (model or "").strip()
|
||||||
if not ep_model:
|
if not ep_model:
|
||||||
try:
|
try:
|
||||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
models = _endpoint_enabled_models(ep)
|
||||||
if models:
|
if models:
|
||||||
ep_model = _first_chat_model(models)
|
ep_model = _first_chat_model(models)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -290,14 +291,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||||
# explicit empty status so loading a session does not log a spurious
|
# explicit empty status so loading a session does not log a spurious
|
||||||
# 404; every client treats a non-"running" status as idle anyway.
|
# 404; every client treats a non-"running" status as idle anyway.
|
||||||
# A session whose research exists but belongs to ANOTHER user stays a
|
# Research owned by ANOTHER user answers identically, so the response
|
||||||
# 404, so we do not leak the existence of someone else's research.
|
# does not reveal whether that research exists.
|
||||||
if not _owns_in_memory(session_id, user):
|
if not _owns_in_memory(session_id, user):
|
||||||
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
|
||||||
if not exists:
|
|
||||||
exists = _find_research_path(session_id) is not None
|
|
||||||
if exists:
|
|
||||||
raise HTTPException(404, "No research found for this session")
|
|
||||||
return {"status": "none"}
|
return {"status": "none"}
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
@@ -829,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
if not ep_url or not ep_model:
|
if not ep_url or not ep_model:
|
||||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
if ep:
|
if ep:
|
||||||
base = normalize_base(ep.base_url)
|
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||||
fallback_url = build_chat_url(base)
|
if resolved:
|
||||||
fallback_headers = build_headers(ep.api_key, base)
|
_merge(*resolved)
|
||||||
fallback_model = ""
|
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
models = json.loads(ep.cached_models)
|
|
||||||
if models:
|
|
||||||
fallback_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_merge(fallback_url, fallback_model, fallback_headers)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1315,7 +1315,7 @@ def setup_session_routes(
|
|||||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||||
"""Use AI to categorize all sessions into folders.
|
"""Use AI to categorize all sessions into folders.
|
||||||
|
|
||||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||||
to assign folders. When `skip_llm=true` the endpoint returns
|
to assign folders. When `skip_llm=true` the endpoint returns
|
||||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||||
users can clean junk without spending tokens.
|
users can clean junk without spending tokens.
|
||||||
@@ -1326,21 +1326,11 @@ def setup_session_routes(
|
|||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
user_sessions = session_manager.get_sessions_for_user(user)
|
user_sessions = session_manager.get_sessions_for_user(user)
|
||||||
|
|
||||||
# Delete empty and throwaway sessions before sorting
|
# Saved conversations are never disposable based on their title or prompt.
|
||||||
from core.database import ChatMessage as DbMsg
|
from core.database import ChatMessage as DbMsg
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
deleted_empty = 0
|
deleted_empty = 0
|
||||||
deleted_throwaway = 0
|
deleted_throwaway = 0
|
||||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
|
||||||
_THROWAWAY_NAMES = {
|
|
||||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
|
||||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
|
||||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
|
||||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
|
||||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
|
||||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
|
||||||
}
|
|
||||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
|
||||||
try:
|
try:
|
||||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||||
if user:
|
if user:
|
||||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
|||||||
rows_q = rows_q.filter(DbSession.owner == user)
|
rows_q = rows_q.filter(DbSession.owner == user)
|
||||||
rows = rows_q.limit(2000).all()
|
rows = rows_q.limit(2000).all()
|
||||||
folder_map = {r.id: r.folder for r in rows}
|
folder_map = {r.id: r.folder for r in rows}
|
||||||
# Precompute per-session message counts in TWO aggregate queries
|
# Precompute per-session message counts in one aggregate query
|
||||||
# instead of 1–3 queries PER session — with many chats the per-row
|
# instead of 1–3 queries PER session — with many chats the per-row
|
||||||
# loop was doing thousands of round-trips and blowing the timeout.
|
# loop was doing thousands of round-trips and blowing the timeout.
|
||||||
from sqlalchemy import func as _sa_func
|
from sqlalchemy import func as _sa_func
|
||||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||||
_asst_counts = dict(
|
|
||||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
|
||||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
|
||||||
)
|
|
||||||
cleanup_now = utcnow_naive()
|
cleanup_now = utcnow_naive()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# Never delete important sessions
|
# Never delete important sessions
|
||||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
|||||||
if (row.name or "").strip() == "Incognito":
|
if (row.name or "").strip() == "Incognito":
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_throwaway += 1
|
deleted_throwaway += 1
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
continue
|
continue
|
||||||
if is_session_recently_active(row, now=cleanup_now):
|
if is_session_recently_active(row, now=cleanup_now):
|
||||||
continue
|
continue
|
||||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
|||||||
if msg_count == 0:
|
if msg_count == 0:
|
||||||
should_delete = True
|
should_delete = True
|
||||||
deleted_empty += 1
|
deleted_empty += 1
|
||||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
|
||||||
name = (row.name or "").strip().lower()
|
|
||||||
# Check first user message content (AI renames sessions, so
|
|
||||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
|
||||||
first_msg = db.query(DbMsg.content).filter(
|
|
||||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
|
||||||
).order_by(DbMsg.timestamp).first()
|
|
||||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
|
||||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
|
||||||
assistant_count = _asst_counts.get(row.id, 0)
|
|
||||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
# Single user message with no AI response = dead session
|
|
||||||
elif msg_count == 1 and assistant_count == 0:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
|
||||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
|
||||||
should_delete = True
|
|
||||||
deleted_throwaway += 1
|
|
||||||
if should_delete:
|
if should_delete:
|
||||||
db.delete(row)
|
|
||||||
if hasattr(session_manager, 'delete_session'):
|
if hasattr(session_manager, 'delete_session'):
|
||||||
session_manager.delete_session(row.id)
|
session_manager.delete_session(row.id)
|
||||||
|
else:
|
||||||
|
db.delete(row)
|
||||||
if deleted_empty or deleted_throwaway:
|
if deleted_empty or deleted_throwaway:
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from services.memory.skill_format import slugify
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from services.skills import evaluation as _evaluation
|
from services.skills import evaluation as _evaluation
|
||||||
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
updates = body.dict(exclude_none=True)
|
updates = body.dict(exclude_none=True)
|
||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
if "name" in updates:
|
||||||
|
new_name = slugify(updates["name"] or match.get("name"))
|
||||||
|
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||||
|
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|||||||
+12
-17
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
|||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
)
|
)
|
||||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.endpoint_resolver import _endpoint_enabled_models
|
||||||
|
|
||||||
|
eps = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint, getattr(task, "owner", None),
|
||||||
|
).all()
|
||||||
for ep in eps:
|
for ep in eps:
|
||||||
cached = []
|
if model in _endpoint_enabled_models(ep):
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
cached = json.loads(ep.cached_models) or []
|
|
||||||
except Exception:
|
|
||||||
cached = []
|
|
||||||
if model in cached:
|
|
||||||
return ep.base_url or ""
|
return ep.base_url or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -340,6 +340,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
max_tokens=20,
|
max_tokens=20,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
thinking_mode="off",
|
||||||
)
|
)
|
||||||
title = result.strip().strip('"\'').strip()
|
title = result.strip().strip('"\'').strip()
|
||||||
return title[:60] if title else prompt[:50].strip()
|
return title[:60] if title else prompt[:50].strip()
|
||||||
@@ -475,10 +476,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
if req.trigger_type == "event" and not req.trigger_event:
|
if req.trigger_type == "event" and not req.trigger_event:
|
||||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||||
@@ -788,10 +786,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
task.character_id = req.character_id or None
|
task.character_id = req.character_id or None
|
||||||
if req.cron_expression is not None:
|
if req.cron_expression is not None:
|
||||||
if req.cron_expression:
|
if req.cron_expression:
|
||||||
try:
|
if not is_valid_cron(req.cron_expression):
|
||||||
from croniter import croniter
|
|
||||||
croniter(req.cron_expression)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(400, "Invalid cron expression")
|
raise HTTPException(400, "Invalid cron expression")
|
||||||
task.cron_expression = req.cron_expression or None
|
task.cron_expression = req.cron_expression or None
|
||||||
|
|
||||||
|
|||||||
+92
-46
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
|
|||||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||||
from src.attachment_refs import attachment_refs_from_metadata
|
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.path_confinement import is_inside
|
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
|
||||||
from src.upload_handler import (
|
from src.upload_handler import (
|
||||||
UploadCleanupSafetyError,
|
UploadCleanupSafetyError,
|
||||||
count_recent_uploads,
|
count_recent_uploads,
|
||||||
extract_upload_ids,
|
extract_upload_ids,
|
||||||
|
secure_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
def _upload_root() -> str:
|
def _upload_root() -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
from src.constants import UPLOAD_DIR
|
||||||
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||||
|
|
||||||
def _path_inside_upload_dir(path: str) -> bool:
|
def _path_inside_upload_dir(path: str) -> bool:
|
||||||
return is_inside(_upload_root(), path)
|
return is_inside(_upload_root(), path)
|
||||||
|
|
||||||
def _resolve_upload_path(file_id: str) -> str:
|
def _resolve_upload_path(file_id: str) -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
safe_id = secure_filename(os.path.basename(file_id))
|
||||||
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
if not safe_id:
|
||||||
direct = os.path.join(upload_root, file_id)
|
raise HTTPException(400, "Invalid file ID")
|
||||||
|
root = _upload_root()
|
||||||
|
|
||||||
|
direct = os.path.normpath(os.path.join(root, safe_id))
|
||||||
if os.path.lexists(direct):
|
if os.path.lexists(direct):
|
||||||
if not _path_inside_upload_dir(direct):
|
try:
|
||||||
|
canonical_direct = confine(root, direct, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(direct):
|
if os.path.isfile(canonical_direct):
|
||||||
return direct
|
return canonical_direct
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
info = _load_upload_info(safe_id)
|
||||||
if file_id not in files:
|
stored_path = (info or {}).get("path")
|
||||||
continue
|
if stored_path:
|
||||||
path = os.path.join(root, file_id)
|
try:
|
||||||
if not _path_inside_upload_dir(path):
|
canonical_stored = confine(root, stored_path, allow_root=False)
|
||||||
|
if os.path.isfile(canonical_stored):
|
||||||
|
return canonical_stored
|
||||||
|
except (PathEscape, ValueError):
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
for walk_root, _dirs, files in os.walk(root, followlinks=False):
|
||||||
|
if safe_id not in files:
|
||||||
|
continue
|
||||||
|
cand = os.path.normpath(os.path.join(walk_root, safe_id))
|
||||||
|
try:
|
||||||
|
canonical_cand = confine(root, cand, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if os.path.isfile(canonical_cand):
|
||||||
|
return canonical_cand
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
@@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler):
|
|||||||
logger.error(f"Failed to get upload stats: {e}")
|
logger.error(f"Failed to get upload stats: {e}")
|
||||||
raise HTTPException(500, "Failed to get upload statistics")
|
raise HTTPException(500, "Failed to get upload statistics")
|
||||||
|
|
||||||
@router.get("/{file_id}")
|
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
|
||||||
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
||||||
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
|
||||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
a small cached JPEG thumbnail for images (used by chat attachment previews)
|
||||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
so the client isn't downloading the full-resolution photo just to show it tiny."""
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
|
safe_file_id = secure_filename(os.path.basename(file_id))
|
||||||
import mimetypes as _mt
|
import mimetypes as _mt
|
||||||
# Look up original filename and owner from uploads.json
|
# Look up original filename and owner from uploads.json
|
||||||
original_name = file_id
|
original_name = safe_file_id
|
||||||
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||||
db = upload_handler._load_upload_index()
|
db = upload_handler._load_upload_index()
|
||||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
|
||||||
if info:
|
if info:
|
||||||
original_name = info.get("name", file_id)
|
original_name = info.get("name", safe_file_id)
|
||||||
file_owner = info.get("owner") if info else None
|
file_owner = info.get("owner") if info else None
|
||||||
_verify_upload_access(request, file_owner)
|
_verify_upload_access(request, file_owner)
|
||||||
path = _resolve_upload_path(file_id)
|
upload_root = _upload_root()
|
||||||
|
path = _resolve_upload_path(safe_file_id)
|
||||||
|
try:
|
||||||
|
path = confine(upload_root, path, allow_root=False)
|
||||||
|
except (PathEscape, ValueError):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
# Downscaled thumbnail for image previews — generated once and cached.
|
# Downscaled thumbnail for image previews — generated once and cached.
|
||||||
|
# Skip generation during HEAD probe to avoid loading/processing whole image.
|
||||||
if thumb and mime.startswith("image/"):
|
if thumb and mime.startswith("image/"):
|
||||||
|
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
|
||||||
|
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageOps
|
thumb_path = confine(upload_root, thumb_path, allow_root=False)
|
||||||
thumb_dir = os.path.join(_upload_root(), ".thumbs")
|
except (PathEscape, ValueError):
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
raise HTTPException(403, "Access denied")
|
||||||
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
|
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
|
||||||
if (not os.path.exists(thumb_path)
|
raise HTTPException(403, "Access denied")
|
||||||
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
if request.method == "HEAD":
|
||||||
im = Image.open(path)
|
if (os.path.isfile(thumb_path)
|
||||||
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
|
||||||
# the pixel data. Browsers honour that on the original via
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
# image-orientation:from-image, but PIL strips EXIF when it
|
# GET may generate a JPEG or fall back to the original on failure.
|
||||||
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
# Neither representation is known without decoding the image. HEAD
|
||||||
# the rotation into the pixels before thumbnailing.
|
# must not advertise the original/stale file's headers as a thumb.
|
||||||
im = ImageOps.exif_transpose(im)
|
from fastapi.responses import Response
|
||||||
im.thumbnail((320, 320))
|
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
if im.mode not in ("RGB", "L"):
|
del response.headers["content-length"]
|
||||||
im = im.convert("RGB")
|
return response
|
||||||
im.save(thumb_path, "JPEG", quality=80)
|
else:
|
||||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
try:
|
||||||
except Exception as e:
|
from PIL import Image, ImageOps
|
||||||
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
# Fall through to the full image.
|
if (not os.path.exists(thumb_path)
|
||||||
|
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
||||||
|
im = Image.open(path)
|
||||||
|
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
||||||
|
# the pixel data. Browsers honour that on the original via
|
||||||
|
# image-orientation:from-image, but PIL strips EXIF when it
|
||||||
|
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
||||||
|
# the rotation into the pixels before thumbnailing.
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
im.thumbnail((320, 320))
|
||||||
|
if im.mode not in ("RGB", "L"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.save(thumb_path, "JPEG", quality=80)
|
||||||
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
||||||
|
# Fall through to the full image.
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
|
|||||||
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
|
|
||||||
def _vision_cache_path(file_id: str) -> str:
|
def _vision_cache_path(file_id: str) -> str:
|
||||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
safe_id = secure_filename(os.path.basename(file_id))
|
||||||
|
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
return os.path.join(cache_dir, file_id + ".txt")
|
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
|
||||||
|
|
||||||
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
||||||
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
"""Fail image builds that select an unfixed Debian native Brotli decoder.
|
||||||
|
|
||||||
|
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
|
||||||
|
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
|
||||||
|
metadata, to establish the package floor for CVE-2026-111497:
|
||||||
|
https://security-tracker.debian.org/tracker/CVE-2026-111497
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
|
||||||
|
DECODER_SONAME = "libbrotlidec.so.1"
|
||||||
|
|
||||||
|
|
||||||
|
def command(*args):
|
||||||
|
return subprocess.check_output(args, text=True).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check_package():
|
||||||
|
release = platform.freedesktop_os_release()
|
||||||
|
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
|
||||||
|
if release.get("ID") != "debian" or floor is None:
|
||||||
|
raise ValueError("Brotli package floor is not defined for this distribution")
|
||||||
|
record = command(
|
||||||
|
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
|
||||||
|
).splitlines()
|
||||||
|
if len(record) != 2 or any(not value.strip() for value in record):
|
||||||
|
raise ValueError("invalid libbrotli1 package record: expected status and version")
|
||||||
|
status, version = record
|
||||||
|
if status != "install ok installed":
|
||||||
|
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
|
||||||
|
if subprocess.run(
|
||||||
|
["dpkg", "--compare-versions", version, "ge", floor], check=False
|
||||||
|
).returncode != 0:
|
||||||
|
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
|
||||||
|
owned = {
|
||||||
|
Path(line).resolve()
|
||||||
|
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
|
||||||
|
if Path(line).name == DECODER_SONAME
|
||||||
|
}
|
||||||
|
if not owned:
|
||||||
|
raise ValueError("libbrotli1 has no packaged native decoder")
|
||||||
|
return version, owned
|
||||||
|
|
||||||
|
|
||||||
|
def loaded_path(library):
|
||||||
|
# Inspect the library actually selected by the dynamic loader. An installed
|
||||||
|
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
|
||||||
|
class DlInfo(ctypes.Structure):
|
||||||
|
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
|
||||||
|
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
|
||||||
|
|
||||||
|
dladdr = ctypes.CDLL(None).dladdr
|
||||||
|
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
|
||||||
|
dladdr.restype = ctypes.c_int
|
||||||
|
info = DlInfo()
|
||||||
|
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
|
||||||
|
raise ValueError("cannot identify the loaded Brotli decoder")
|
||||||
|
return Path(info.filename.decode()).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def check_decoder(library):
|
||||||
|
# The fix adds error -17. Older decoders return INVALID here, including
|
||||||
|
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
|
||||||
|
# marker with different error-string prefixes.
|
||||||
|
# No malformed attacker-controlled stream is needed for this build check.
|
||||||
|
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
|
||||||
|
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
|
||||||
|
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
|
||||||
|
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
|
||||||
|
|
||||||
|
library.BrotliDecoderDecompress.argtypes = [
|
||||||
|
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
|
||||||
|
]
|
||||||
|
library.BrotliDecoderDecompress.restype = ctypes.c_int
|
||||||
|
# A small, valid uncompressed Brotli stream must still decode correctly.
|
||||||
|
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
|
||||||
|
output = ctypes.create_string_buffer(11)
|
||||||
|
size = ctypes.c_size_t(len(output))
|
||||||
|
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
|
||||||
|
if result != 1 or size.value != 11 or output.raw != b"hello world":
|
||||||
|
raise ValueError("native Brotli decoder failed the valid-stream control")
|
||||||
|
|
||||||
|
|
||||||
|
def check_node(node, decoder):
|
||||||
|
# ldd inspects the distro executable and its libnode dependency without
|
||||||
|
# executing JavaScript or relying on process.versions.brotli.
|
||||||
|
linked = command("ldd", str(node))
|
||||||
|
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
|
||||||
|
if not match or Path(match[1]).resolve() != decoder:
|
||||||
|
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
version, owned = check_package()
|
||||||
|
library = ctypes.CDLL(DECODER_SONAME)
|
||||||
|
decoder = loaded_path(library)
|
||||||
|
if decoder not in owned:
|
||||||
|
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
|
||||||
|
check_decoder(library)
|
||||||
|
if args.node:
|
||||||
|
check_node(args.node, decoder)
|
||||||
|
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -492,6 +492,25 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
|||||||
"tools that execute code, mutate state or cause external side effects need "
|
"tools that execute code, mutate state or cause external side effects need "
|
||||||
"a separate approval. Set to 0 to opt out.",
|
"a separate approval. Set to 0 to opt out.",
|
||||||
),
|
),
|
||||||
|
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
|
||||||
|
"Agent loop and tool execution", USER,
|
||||||
|
"Security-relevant. Comma-separated exact MCP read-tool names "
|
||||||
|
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||||
|
"Declarations do not override built-in tool effects. Output remains "
|
||||||
|
"untrusted to prevent privilege escalation, but allows continuation to "
|
||||||
|
"declared write tools on the same stdio server without external taint. "
|
||||||
|
"Stdio transport does not guarantee network isolation. Automatic "
|
||||||
|
"same-store writes carry residual prompt-injection risk.",
|
||||||
|
),
|
||||||
|
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
|
||||||
|
"Agent loop and tool execution", USER,
|
||||||
|
"Security-relevant. Comma-separated exact MCP write-tool names "
|
||||||
|
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||||
|
"Declarations do not override built-in tool effects. Allowed to execute "
|
||||||
|
"after declared local reads on the same stdio server, but blocked once "
|
||||||
|
"external untrusted context enters the run. Authorization must still "
|
||||||
|
"originate from the trusted request grant.",
|
||||||
|
),
|
||||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
|
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
|
||||||
"Agent loop and tool execution", USER,
|
"Agent loop and tool execution", USER,
|
||||||
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
|
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
|
||||||
@@ -951,14 +970,12 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
|||||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||||
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
|
||||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||||
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
|
||||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||||
|
|||||||
+12
-4
@@ -27,6 +27,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, Document, DocumentVersion
|
from core.database import SessionLocal, Document, DocumentVersion
|
||||||
|
from src.session_search import escape_like_pattern
|
||||||
quiet_logs()
|
quiet_logs()
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
||||||
@@ -139,10 +140,17 @@ def cmd_export(args):
|
|||||||
def cmd_search(args):
|
def cmd_search(args):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
like = f"%{args.query}%"
|
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||||
rows = db.query(Document).filter(
|
# exact phrase, so "machine learning" missed docs containing both
|
||||||
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
|
# words apart. LIKE wildcards are escaped per token.
|
||||||
).order_by(Document.updated_at.desc()).limit(args.limit).all()
|
q = db.query(Document)
|
||||||
|
for tok in args.query.split():
|
||||||
|
term = f"%{escape_like_pattern(tok)}%"
|
||||||
|
q = q.filter(
|
||||||
|
Document.title.ilike(term, escape="\\")
|
||||||
|
| Document.current_content.ilike(term, escape="\\")
|
||||||
|
)
|
||||||
|
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||||
emit([_serialize(d) for d in rows], args)
|
emit([_serialize(d) for d in rows], args)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
|||||||
album, and surfaces EXIF metadata.
|
album, and surfaces EXIF metadata.
|
||||||
|
|
||||||
odysseus-gallery list --limit 20 --favorites
|
odysseus-gallery list --limit 20 --favorites
|
||||||
|
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||||
odysseus-gallery show IMAGE_ID
|
odysseus-gallery show IMAGE_ID
|
||||||
odysseus-gallery albums | jq -r '.[] | .name'
|
odysseus-gallery albums | jq -r '.[] | .name'
|
||||||
odysseus-gallery search "sunset" # matches prompt + tags
|
odysseus-gallery search "sunset" # matches prompt + tags
|
||||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
|||||||
fail(f"no album named {args.album!r}")
|
fail(f"no album named {args.album!r}")
|
||||||
q = q.filter(GalleryImage.album_id == al.id)
|
q = q.filter(GalleryImage.album_id == al.id)
|
||||||
if args.tag:
|
if args.tag:
|
||||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
for tag in (value.strip() for value in args.tag.split(",")):
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
q = q.filter(
|
||||||
|
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||||
|
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||||
|
)
|
||||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||||
emit([_serialize_image(i) for i in q.all()], args)
|
emit([_serialize_image(i) for i in q.all()], args)
|
||||||
finally:
|
finally:
|
||||||
@@ -159,7 +166,7 @@ def _build_parser():
|
|||||||
pl.add_argument("--limit", type=int, default=50)
|
pl.add_argument("--limit", type=int, default=50)
|
||||||
pl.add_argument("--favorites", action="store_true")
|
pl.add_argument("--favorites", action="store_true")
|
||||||
pl.add_argument("--album", help="filter by album name")
|
pl.add_argument("--album", help="filter by album name")
|
||||||
pl.add_argument("--tag", help="substring match against tags column")
|
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||||
pl.set_defaults(func=cmd_list)
|
pl.set_defaults(func=cmd_list)
|
||||||
|
|
||||||
psh = sub.add_parser("show", parents=[common])
|
psh = sub.add_parser("show", parents=[common])
|
||||||
|
|||||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Anchor the path so the script works when invoked from anywhere.
|
# Anchor the path so the script works when invoked from anywhere.
|
||||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
|||||||
emit([], args)
|
emit([], args)
|
||||||
return
|
return
|
||||||
all_uids = data[0].split()
|
all_uids = data[0].split()
|
||||||
# Newest first, limit
|
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||||
uids = list(reversed(all_uids))[: args.limit]
|
uids = list(reversed(all_uids))[: args.limit]
|
||||||
out = []
|
out = []
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
|||||||
sender_name, sender_addr = parseaddr(sender)
|
sender_name, sender_addr = parseaddr(sender)
|
||||||
date_raw = msg.get("Date", "")
|
date_raw = msg.get("Date", "")
|
||||||
try:
|
try:
|
||||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||||
|
iso = parsed_date.isoformat() if parsed_date else ""
|
||||||
|
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||||
|
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||||
|
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||||
except Exception:
|
except Exception:
|
||||||
iso = ""
|
iso = ""
|
||||||
out.append({
|
date_epoch = float("-inf")
|
||||||
|
out.append((date_epoch, {
|
||||||
"uid": uid.decode(),
|
"uid": uid.decode(),
|
||||||
"date": iso,
|
"date": iso,
|
||||||
"from_addr": sender_addr,
|
"from_addr": sender_addr,
|
||||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
|||||||
"subject": subject,
|
"subject": subject,
|
||||||
"is_read": "\\Seen" in flags,
|
"is_read": "\\Seen" in flags,
|
||||||
"is_answered": "\\Answered" in flags,
|
"is_answered": "\\Answered" in flags,
|
||||||
})
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||||
emit(out, args)
|
out.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
emit([row for _, row in out], args)
|
||||||
|
|
||||||
|
|
||||||
# ─── read ────────────────────────────────────────────────────────────
|
# ─── read ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
|
||||||
|
|
||||||
|
Structured, format-aware fail-closed security:
|
||||||
|
Replaces naive regex-based replacement with structured JSON / HAR parsing,
|
||||||
|
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
|
||||||
|
safe source code masking, and strict fail-closed purge on corruption or error.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import zipfile
|
||||||
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
|
||||||
|
|
||||||
|
SENSITIVE_KEY_PATTERN = re.compile(
|
||||||
|
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
|
||||||
|
)
|
||||||
|
|
||||||
|
SENSITIVE_PARAM_NAMES = frozenset({
|
||||||
|
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
|
||||||
|
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
|
||||||
|
"signature", "client_secret", "credential"
|
||||||
|
})
|
||||||
|
|
||||||
|
SENSITIVE_HEADER_NAMES = frozenset({
|
||||||
|
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
|
||||||
|
"api-key", "x-auth-token", "x-access-token"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_url(url: str) -> str:
|
||||||
|
"""Sanitize query parameters and authority credentials in URLs."""
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlsplit(url)
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
|
netloc = parts.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
userinfo, host = netloc.split("@", 1)
|
||||||
|
if ":" in userinfo:
|
||||||
|
user, _ = userinfo.split(":", 1)
|
||||||
|
netloc = f"{user}:[REDACTED]@{host}"
|
||||||
|
else:
|
||||||
|
netloc = f"[REDACTED]@{host}"
|
||||||
|
|
||||||
|
query = parts.query
|
||||||
|
if query:
|
||||||
|
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
|
||||||
|
sanitized_pairs = []
|
||||||
|
for k, v in q_pairs:
|
||||||
|
k_lower = k.lower().replace("-", "_")
|
||||||
|
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||||
|
sanitized_pairs.append((k, "[REDACTED]"))
|
||||||
|
else:
|
||||||
|
sanitized_pairs.append((k, v))
|
||||||
|
query = urllib.parse.urlencode(sanitized_pairs)
|
||||||
|
|
||||||
|
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_body_text(text: str) -> str:
|
||||||
|
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 1. Try JSON
|
||||||
|
trimmed = text.strip()
|
||||||
|
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(trimmed)
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
return json.dumps(sanitized, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Try URL-encoded form data
|
||||||
|
if "=" in trimmed and not trimmed.startswith("<"):
|
||||||
|
try:
|
||||||
|
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
|
||||||
|
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
|
||||||
|
out_pairs = []
|
||||||
|
for k, v in pairs:
|
||||||
|
k_lower = k.lower().replace("-", "_")
|
||||||
|
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
||||||
|
out_pairs.append((k, "[REDACTED]"))
|
||||||
|
else:
|
||||||
|
out_pairs.append((k, v))
|
||||||
|
return urllib.parse.urlencode(out_pairs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_structured_obj(obj: Any) -> Any:
|
||||||
|
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
new_dict: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
|
||||||
|
name_val = obj.get("name")
|
||||||
|
val_val = obj.get("value")
|
||||||
|
if isinstance(name_val, str) and isinstance(val_val, str):
|
||||||
|
name_lower = name_val.lower().strip()
|
||||||
|
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k == "value":
|
||||||
|
new_dict[k] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
new_dict[k] = sanitize_structured_obj(v)
|
||||||
|
return new_dict
|
||||||
|
|
||||||
|
# Process standard dictionary keys
|
||||||
|
for k, v in obj.items():
|
||||||
|
k_lower = str(k).lower().replace("-", "_")
|
||||||
|
|
||||||
|
# Action parameters in Playwright traces (e.g. page.fill, page.type)
|
||||||
|
if k == "params" and isinstance(v, dict):
|
||||||
|
sanitized_params = sanitize_structured_obj(v)
|
||||||
|
# If this action involves fill, type, or sensitive inputs, redact value/text
|
||||||
|
if "value" in sanitized_params:
|
||||||
|
sanitized_params["value"] = "[REDACTED]"
|
||||||
|
if "text" in sanitized_params:
|
||||||
|
sanitized_params["text"] = "[REDACTED]"
|
||||||
|
new_dict[k] = sanitized_params
|
||||||
|
continue
|
||||||
|
|
||||||
|
# DOM snapshot nodes
|
||||||
|
if k == "attributes" and isinstance(v, (dict, list)):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
attr_dict = dict(v)
|
||||||
|
type_val = str(attr_dict.get("type") or "").lower()
|
||||||
|
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
|
||||||
|
if "value" in attr_dict:
|
||||||
|
attr_dict["value"] = "[REDACTED]"
|
||||||
|
new_dict[k] = attr_dict
|
||||||
|
continue
|
||||||
|
elif isinstance(v, list):
|
||||||
|
# List of attribute strings or pairs
|
||||||
|
new_list = []
|
||||||
|
is_pw = any("password" in str(x).lower() for x in v)
|
||||||
|
for x in v:
|
||||||
|
if is_pw and isinstance(x, str) and not x.startswith("type="):
|
||||||
|
new_list.append("[REDACTED]")
|
||||||
|
else:
|
||||||
|
new_list.append(sanitize_structured_obj(x))
|
||||||
|
new_dict[k] = new_list
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Request postData / text
|
||||||
|
if k in ("postData", "postDataText") and isinstance(v, str):
|
||||||
|
new_dict[k] = sanitize_body_text(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# URL fields
|
||||||
|
if k == "url" and isinstance(v, str):
|
||||||
|
new_dict[k] = sanitize_url(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cookie arrays
|
||||||
|
if k == "cookies" and isinstance(v, list):
|
||||||
|
sanitized_cookies = []
|
||||||
|
for c in v:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
c_copy = dict(c)
|
||||||
|
if "value" in c_copy:
|
||||||
|
c_copy["value"] = "[REDACTED]"
|
||||||
|
sanitized_cookies.append(c_copy)
|
||||||
|
else:
|
||||||
|
sanitized_cookies.append(sanitize_structured_obj(c))
|
||||||
|
new_dict[k] = sanitized_cookies
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Direct sensitive key matches
|
||||||
|
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
|
||||||
|
new_dict[k] = "[REDACTED]"
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_dict[k] = sanitize_structured_obj(v)
|
||||||
|
|
||||||
|
return new_dict
|
||||||
|
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
return [sanitize_structured_obj(item) for item in obj]
|
||||||
|
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
# Check for inline credentials/tokens
|
||||||
|
if obj.startswith("Bearer ") or obj.startswith("bearer "):
|
||||||
|
return "Bearer [REDACTED]"
|
||||||
|
if obj.startswith("Basic ") or obj.startswith("basic "):
|
||||||
|
return "Basic [REDACTED]"
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||||
|
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
|
||||||
|
lines = data.split(b"\n")
|
||||||
|
out_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(line.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
# Not valid JSON, keep as-is
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||||
|
return b"\n".join(out_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_trace_archive(zp: str) -> bool:
|
||||||
|
"""Sanitize a single zip archive in a strict fail-closed manner.
|
||||||
|
|
||||||
|
If the archive is malformed, partial, unreadable, or encounters an exception
|
||||||
|
during processing, the archive is permanently deleted so that raw traces
|
||||||
|
cannot leak.
|
||||||
|
Returns True if successfully sanitized, False if deleted due to error.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(zp):
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp_zp = zp + ".tmp"
|
||||||
|
try:
|
||||||
|
if os.path.getsize(zp) == 0:
|
||||||
|
raise zipfile.BadZipFile("Empty archive")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zp, "r") as zin:
|
||||||
|
infolist = zin.infolist()
|
||||||
|
if not infolist:
|
||||||
|
raise zipfile.BadZipFile("Archive has no entries")
|
||||||
|
|
||||||
|
corrupt_file = zin.testzip()
|
||||||
|
if corrupt_file is not None:
|
||||||
|
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in infolist:
|
||||||
|
filename_lower = item.filename.lower()
|
||||||
|
|
||||||
|
# 1. Binary image screenshots/diagnostics: preserve as-is
|
||||||
|
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
|
||||||
|
content = zin.read(item.filename)
|
||||||
|
zout.writestr(item, content)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
|
||||||
|
# Mask safely without syntax corruption!
|
||||||
|
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
|
||||||
|
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
|
||||||
|
zout.writestr(item, safe_placeholder)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. JSON Lines and structured JSON/HAR traces
|
||||||
|
content = zin.read(item.filename)
|
||||||
|
if filename_lower.endswith((".trace", ".network", ".stacks")):
|
||||||
|
sanitized_content = sanitize_jsonl_content(content)
|
||||||
|
zout.writestr(item, sanitized_content)
|
||||||
|
elif filename_lower.endswith((".json", ".har")):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content.decode("utf-8"))
|
||||||
|
sanitized = sanitize_structured_obj(parsed)
|
||||||
|
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
# If invalid JSON, treat as text line-by-line
|
||||||
|
zout.writestr(item, sanitize_jsonl_content(content))
|
||||||
|
else:
|
||||||
|
# Unknown binary or text member: exclude to fail closed
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Atomic replace
|
||||||
|
os.replace(temp_zp, zp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
|
||||||
|
for path_to_remove in (temp_zp, zp):
|
||||||
|
if os.path.exists(path_to_remove):
|
||||||
|
try:
|
||||||
|
os.remove(path_to_remove)
|
||||||
|
except OSError as remove_err:
|
||||||
|
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_directory(results_dir: str) -> int:
|
||||||
|
"""Walk results_dir and sanitize every .zip archive fail-closed.
|
||||||
|
|
||||||
|
Returns the number of archives processed. Exits with 1 if any archive fails.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(results_dir):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
all_ok = True
|
||||||
|
for root, _, files in os.walk(results_dir):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith(".zip"):
|
||||||
|
zp = os.path.join(root, f)
|
||||||
|
ok = sanitize_trace_archive(zp)
|
||||||
|
if not ok:
|
||||||
|
all_ok = False
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if not all_ok:
|
||||||
|
sys.exit(1)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <results_directory>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_dir = sys.argv[1]
|
||||||
|
sanitize_directory(target_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -11,6 +11,38 @@ import time
|
|||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_owned_server(server, identity):
|
||||||
|
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||||
|
|
||||||
|
Capture before signalling the parent: an orphaned stderr holder can no
|
||||||
|
longer be attributed by ancestry. Never search by executable name or kill
|
||||||
|
the caller's process group.
|
||||||
|
"""
|
||||||
|
from src import process_lifecycle as lifecycle
|
||||||
|
|
||||||
|
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||||
|
if identity.verdict() == lifecycle.OWNED else [])
|
||||||
|
if os.name == "nt":
|
||||||
|
if identity.verdict() == lifecycle.OWNED:
|
||||||
|
lifecycle.taskkill_tree(identity.pid)
|
||||||
|
else:
|
||||||
|
outcome = lifecycle.terminate_identities(
|
||||||
|
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||||
|
steps=lifecycle.term_kill_steps(10),
|
||||||
|
)
|
||||||
|
if outcome.survivors or outcome.unverified:
|
||||||
|
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||||
|
try:
|
||||||
|
logs, _ = server.communicate(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||||
|
server.stdout.close()
|
||||||
|
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -36,7 +68,10 @@ def main():
|
|||||||
server = subprocess.Popen(
|
server = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
start_new_session=(os.name != "nt"),
|
||||||
)
|
)
|
||||||
|
from src.process_lifecycle import ProcessIdentity
|
||||||
|
server_identity = ProcessIdentity.capture(server.pid)
|
||||||
result = None
|
result = None
|
||||||
try:
|
try:
|
||||||
origin = f"http://127.0.0.1:{port}"
|
origin = f"http://127.0.0.1:{port}"
|
||||||
@@ -62,12 +97,7 @@ def main():
|
|||||||
print(result.stderr, file=sys.stderr)
|
print(result.stderr, file=sys.stderr)
|
||||||
return result.returncode
|
return result.returncode
|
||||||
finally:
|
finally:
|
||||||
server.terminate()
|
logs = _stop_owned_server(server, server_identity)
|
||||||
try:
|
|
||||||
logs, _ = server.communicate(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
server.kill()
|
|
||||||
logs, _ = server.communicate()
|
|
||||||
if result is None or result.returncode:
|
if result is None or result.returncode:
|
||||||
print(logs[-8000:], file=sys.stderr)
|
print(logs[-8000:], file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -660,6 +660,8 @@ def _detect_windows():
|
|||||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||||
"gpu_count": _as_int(d.get("gpu_count"), 0),
|
"gpu_count": _as_int(d.get("gpu_count"), 0),
|
||||||
"backend": d.get("gpu_backend", "cpu_x86"),
|
"backend": d.get("gpu_backend", "cpu_x86"),
|
||||||
|
"gpus": [],
|
||||||
|
"gpu_groups": [],
|
||||||
"homogeneous": True,
|
"homogeneous": True,
|
||||||
"gpu_error": None,
|
"gpu_error": None,
|
||||||
"platform": "windows",
|
"platform": "windows",
|
||||||
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
if result:
|
if result:
|
||||||
result = _attach_probe_context(result, host=host)
|
result = _attach_probe_context(result, host=host)
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
# SSH may work while the PowerShell hardware probe still fails.
|
# SSH may work while the PowerShell hardware probe still fails.
|
||||||
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
return result
|
return result
|
||||||
available_ram = round(_get_available_ram_gb(), 1)
|
available_ram = round(_get_available_ram_gb(), 1)
|
||||||
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
"gpu_name": None,
|
"gpu_name": None,
|
||||||
"gpu_vram_gb": None,
|
"gpu_vram_gb": None,
|
||||||
"gpu_count": 0,
|
"gpu_count": 0,
|
||||||
|
"gpus": [],
|
||||||
|
"gpu_groups": [],
|
||||||
|
"homogeneous": True,
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
# Set when nvidia-smi exists but failed (e.g. driver/library
|
# Set when nvidia-smi exists but failed (e.g. driver/library
|
||||||
# version mismatch) — lets the UI say "GPU driver error" instead
|
# version mismatch) — lets the UI say "GPU driver error" instead
|
||||||
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
|
|
||||||
result = _attach_probe_context(result, host=host)
|
result = _attach_probe_context(result, host=host)
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
|
_remote_port = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
|||||||
return "general"
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
|
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||||
|
# encoders, time-series forecasters, image/video generators). Image models
|
||||||
|
# have their own registry, so keep these out of the LLM catalog.
|
||||||
|
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||||
|
"time-series-forecasting",
|
||||||
|
"zero-shot-image-classification",
|
||||||
|
"image-classification",
|
||||||
|
"image-feature-extraction",
|
||||||
|
"text-to-image",
|
||||||
|
"image-to-video",
|
||||||
|
"text-to-video",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _entry_from_collection_item(collection, item, source):
|
def _entry_from_collection_item(collection, item, source):
|
||||||
repo_id = item.get("id") or ""
|
repo_id = item.get("id") or ""
|
||||||
if item.get("type") != "model" or not repo_id:
|
if item.get("type") != "model" or not repo_id:
|
||||||
return None
|
return None
|
||||||
|
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||||
|
return None
|
||||||
repo_prefix = source.get("repo_prefix")
|
repo_prefix = source.get("repo_prefix")
|
||||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||||
return None
|
return None
|
||||||
raw_params = item.get("numParameters") or 0
|
raw_params = item.get("numParameters") or 0
|
||||||
active = None
|
# Read the name even when HF gives a total: it is the only source of the
|
||||||
|
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||||
|
name_params, active = _parse_params_from_name(repo_id)
|
||||||
if not raw_params:
|
if not raw_params:
|
||||||
raw_params, active = _parse_params_from_name(repo_id)
|
raw_params = name_params
|
||||||
param_label, raw_params = _format_params(raw_params)
|
param_label, raw_params = _format_params(raw_params)
|
||||||
if not raw_params:
|
if not raw_params:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -628,6 +628,14 @@ async def extract_and_store(
|
|||||||
if not fact_text or len(fact_text) < 5:
|
if not fact_text or len(fact_text) < 5:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# A fuller owner-specific name must not rewrite a shared legacy
|
||||||
|
# entry, but that less-specific entry must not suppress it either.
|
||||||
|
legacy_refinements = {
|
||||||
|
id(entry) for entry in existing
|
||||||
|
if _owner and entry.get("owner") is None
|
||||||
|
and _name_enrichment_target(fact_text, [entry]) is entry
|
||||||
|
}
|
||||||
|
|
||||||
# Dedup: check vector similarity first (fast), then exact text match.
|
# Dedup: check vector similarity first (fast), then exact text match.
|
||||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||||
# remote endpoint down) must not abort the whole batch — fall through
|
# remote endpoint down) must not abort the whole batch — fall through
|
||||||
@@ -649,18 +657,25 @@ async def extract_and_store(
|
|||||||
# silently dropped. Mirror the owner predicate used by the
|
# silently dropped. Mirror the owner predicate used by the
|
||||||
# text dedup below; cross-tenant/stale matches fall through.
|
# text dedup below; cross-tenant/stale matches fall through.
|
||||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
if (_match is not None and id(_match) not in legacy_refinements
|
||||||
|
and (_match.get("owner") == _owner or _match.get("owner") is None)):
|
||||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Text dedup fallback: exact match + fuzzy similarity
|
# Text dedup fallback: exact match + fuzzy similarity
|
||||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
user_existing = [e for e in existing
|
||||||
|
if (e.get("owner") == _owner or e.get("owner") is None)
|
||||||
|
and id(e) not in legacy_refinements] if _owner else existing
|
||||||
|
|
||||||
# Name enrichment: if an existing name fact is a strict prefix of
|
# Name enrichment: if an existing name fact is a strict prefix of
|
||||||
# this more complete one, upgrade it in place instead of adding a
|
# this more complete one, upgrade it in place instead of adding a
|
||||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||||
_enrich = _name_enrichment_target(fact_text, user_existing)
|
# Legacy unowned entries may be visible to multiple owners; never
|
||||||
|
# rewrite one with a private fact from the current owner.
|
||||||
|
_enrich = _name_enrichment_target(
|
||||||
|
fact_text, [e for e in user_existing if e.get("owner") == _owner]
|
||||||
|
)
|
||||||
if _enrich is not None:
|
if _enrich is not None:
|
||||||
_enrich["text"] = fact_text
|
_enrich["text"] = fact_text
|
||||||
_enrich["timestamp"] = int(time.time())
|
_enrich["timestamp"] = int(time.time())
|
||||||
|
|||||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
|||||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||||
|
|
||||||
|
|
||||||
def _parse_scalar(raw: str) -> Any:
|
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw == "":
|
if raw == "":
|
||||||
return ""
|
return ""
|
||||||
|
# String fields keep their spelling; quoted values still use the decoder
|
||||||
|
# below so existing escapes round-trip as before.
|
||||||
|
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||||
|
return raw
|
||||||
if raw.startswith("[") and raw.endswith("]"):
|
if raw.startswith("[") and raw.endswith("]"):
|
||||||
inner = raw[1:-1].strip()
|
inner = raw[1:-1].strip()
|
||||||
if not inner:
|
if not inner:
|
||||||
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
|
|||||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||||
out, buf, depth, quote = [], [], 0, None
|
out, buf, depth, quote = [], [], 0, None
|
||||||
|
escaped = False
|
||||||
for ch in s:
|
for ch in s:
|
||||||
if quote:
|
if quote:
|
||||||
buf.append(ch)
|
buf.append(ch)
|
||||||
if ch == quote:
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
elif ch == quote:
|
||||||
quote = None
|
quote = None
|
||||||
continue
|
continue
|
||||||
if ch in ("'", '"'):
|
if ch in ("'", '"'):
|
||||||
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|||||||
pending_key = key
|
pending_key = key
|
||||||
fm[key] = []
|
fm[key] = []
|
||||||
else:
|
else:
|
||||||
fm[key] = _parse_scalar(val)
|
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||||
pending_key = None
|
pending_key = None
|
||||||
continue
|
continue
|
||||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||||
|
|||||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
|||||||
sk.path = self._skill_file(cat, nm)
|
sk.path = self._skill_file(cat, nm)
|
||||||
return sk.to_dict()
|
return sk.to_dict()
|
||||||
|
|
||||||
|
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||||
|
"""Whether `owner` already has a skill called `name`, in any category.
|
||||||
|
|
||||||
|
Update and delete find a skill by name within its owner, so two of
|
||||||
|
one owner's skills sharing a name would make them act on whichever
|
||||||
|
file is walked first.
|
||||||
|
"""
|
||||||
|
for path in self._iter_skill_files():
|
||||||
|
sk = self._read_skill(path)
|
||||||
|
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||||
"""`skill_id` is the slug name. Allows updating any field plus
|
"""`skill_id` is the slug name. Allows updating any field plus
|
||||||
renames if `name` changes (file is moved on disk).
|
renames if `name` changes (file is moved on disk).
|
||||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
|||||||
# Rename
|
# Rename
|
||||||
new_name = slugify(updates.get("name") or sk.name)
|
new_name = slugify(updates.get("name") or sk.name)
|
||||||
if new_name != sk.name:
|
if new_name != sk.name:
|
||||||
|
if self.has_skill(new_name, owner=owner):
|
||||||
|
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||||
|
return False
|
||||||
sk.name = new_name
|
sk.name = new_name
|
||||||
|
|
||||||
# Write to potentially new path
|
# Write to potentially new path
|
||||||
|
|||||||
+16
-1
@@ -64,6 +64,21 @@ _SHELL_COMMAND = (
|
|||||||
r"nvcc|docker|systemctl|journalctl|tmux|git)"
|
r"nvcc|docker|systemctl|journalctl|tmux|git)"
|
||||||
)
|
)
|
||||||
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
|
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
|
||||||
|
# "on <word>" and "from <word>" alone describe people, companies, and topics
|
||||||
|
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
|
||||||
|
# only counts as a computer next to wording that describes work on a machine.
|
||||||
|
_NAMED_COMPUTER = (
|
||||||
|
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
|
||||||
|
r"[a-z][a-z0-9_.-]{1,31}\b"
|
||||||
|
)
|
||||||
|
NAMED_COMPUTER_TASK = (
|
||||||
|
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
|
||||||
|
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
|
||||||
|
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
|
||||||
|
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
|
||||||
|
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
|
||||||
|
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
|
||||||
|
)
|
||||||
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
|
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
|
||||||
_CODE_ARTIFACT = (
|
_CODE_ARTIFACT = (
|
||||||
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
|
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
|
||||||
@@ -164,7 +179,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
|||||||
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
("workspace", "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", "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", "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|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
|
||||||
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
|
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
|
||||||
|
|
||||||
# Shell / remote-host intent.
|
# Shell / remote-host intent.
|
||||||
|
|||||||
+197
-157
@@ -14,12 +14,12 @@ import csv
|
|||||||
import difflib
|
import difflib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import logging
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from src.web_recovery import WebRecoveryBudget
|
from src.web_recovery import WebRecoveryBudget
|
||||||
from itertools import count
|
from itertools import count
|
||||||
@@ -44,6 +44,7 @@ from src.model_profiles import (
|
|||||||
is_odysseus_merged_tools_model,
|
is_odysseus_merged_tools_model,
|
||||||
tool_schema_profile,
|
tool_schema_profile,
|
||||||
)
|
)
|
||||||
|
from src.action_intents import NAMED_COMPUTER_TASK
|
||||||
from src.agent_evidence import (
|
from src.agent_evidence import (
|
||||||
EvidenceLedger,
|
EvidenceLedger,
|
||||||
command_has_mutation_effect,
|
command_has_mutation_effect,
|
||||||
@@ -2834,7 +2835,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
|
|||||||
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||||
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
|
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||||
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
||||||
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
||||||
@@ -8057,7 +8058,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
|
|||||||
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
||||||
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
||||||
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
||||||
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
|
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
|
||||||
"manage_notes": """\
|
"manage_notes": """\
|
||||||
```manage_notes
|
```manage_notes
|
||||||
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
||||||
@@ -8764,7 +8765,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
|
|||||||
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||||
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||||
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
|
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
|
||||||
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
|
rf"|{NAMED_COMPUTER_TASK}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
|
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
|
||||||
@@ -16994,6 +16995,20 @@ def _detect_runaway_call(call_freq, threshold=15):
|
|||||||
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
|
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
|
||||||
return sig.split(":", 1)[0] if sig else None
|
return sig.split(":", 1)[0] if sig else None
|
||||||
|
|
||||||
|
def _tool_approval_gate_bypassed(
|
||||||
|
exact_approval: Optional[ExactToolApproval] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether the post-external-context tool approval gate is bypassed.
|
||||||
|
|
||||||
|
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
|
||||||
|
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
|
||||||
|
A chat-session grant bypasses it only within the scope of its own task.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
get_setting("tool_approval_mode", "ask") == "off"
|
||||||
|
or bool(exact_approval and exact_approval.allow_remaining_actions)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _tool_result_signature(tool_result_records: list[dict]) -> str:
|
def _tool_result_signature(tool_result_records: list[dict]) -> str:
|
||||||
"""Return a bounded fingerprint of the observable result of a tool batch.
|
"""Return a bounded fingerprint of the observable result of a tool batch.
|
||||||
@@ -21061,6 +21076,103 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
|
from src.spec_router import latest_decision
|
||||||
|
_spec_decision = latest_decision() or {}
|
||||||
|
_spec_choice = _spec_decision.get("choice")
|
||||||
|
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
and decided and _spec_choice == "answer_directly")
|
||||||
|
try:
|
||||||
|
_mgr = get_mcp_manager()
|
||||||
|
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||||
|
except Exception:
|
||||||
|
mcp_schemas = []
|
||||||
|
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||||
|
if _spec_choice == "ask_user":
|
||||||
|
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||||
|
# can act when the detail is there or ask when it is not.
|
||||||
|
_top = _spec_decision.get("top") or []
|
||||||
|
if _top:
|
||||||
|
picks = set(picks) | {_top[0][0]}
|
||||||
|
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||||
|
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
|
base_offered = set(turn_contract.offered)
|
||||||
|
# The router sees the full catalog, while this request's policy may
|
||||||
|
# deny a tool (for example, a browser turn with Web access switched
|
||||||
|
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||||
|
# old empty menu instead lets the model invent a tool result in prose.
|
||||||
|
_top = _spec_decision.get("top") or []
|
||||||
|
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||||
|
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||||
|
_primary = f"mcp__email__{_primary}"
|
||||||
|
# A policy denial is surfaced even if the router chose outside the
|
||||||
|
# base inventory; it never grants access to that tool.
|
||||||
|
def _denied(name):
|
||||||
|
return (name in disabled
|
||||||
|
or (tool_policy is not None and (
|
||||||
|
tool_policy.blocks(name)
|
||||||
|
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||||
|
_unavailable_pick = ""
|
||||||
|
if _primary in picks and _denied(_primary):
|
||||||
|
_unavailable_pick = _primary
|
||||||
|
elif picks and all(_denied(name) for name in picks):
|
||||||
|
_unavailable_pick = sorted(picks)[0]
|
||||||
|
if _unavailable_pick:
|
||||||
|
blocked = replace(
|
||||||
|
turn_contract, required=frozenset(), offered=frozenset(),
|
||||||
|
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||||
|
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||||
|
)
|
||||||
|
_spec_active_contract.set(blocked)
|
||||||
|
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||||
|
return blocked
|
||||||
|
want = set(picks) - disabled
|
||||||
|
# Always-on basics only when the base turn offers them, so the Spec
|
||||||
|
# arm never offers a tool the base arm would not.
|
||||||
|
if not _spec_direct:
|
||||||
|
want |= {"ask_user", "update_plan"} & base_offered
|
||||||
|
if not (strict and decided):
|
||||||
|
want |= base_offered
|
||||||
|
want &= base_offered
|
||||||
|
if not want:
|
||||||
|
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||||
|
sorted(picks), sorted(base_offered))
|
||||||
|
return turn_contract
|
||||||
|
offer = want & base_offered & set(turn_contract.executable)
|
||||||
|
# A router may narrow the menu only when every existing obligation
|
||||||
|
# remains satisfiable. It cannot redefine the user's requested work.
|
||||||
|
required_read = getattr(turn_contract, "required_read_operation", None)
|
||||||
|
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
||||||
|
or (required_read is not None and required_read.tool not in offer)):
|
||||||
|
return turn_contract
|
||||||
|
by_name = {}
|
||||||
|
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer and nm not in by_name:
|
||||||
|
by_name[nm] = sc
|
||||||
|
for sc in turn_contract.schemas():
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer:
|
||||||
|
by_name[nm] = sc
|
||||||
|
if not by_name and not _spec_direct:
|
||||||
|
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||||
|
return turn_contract
|
||||||
|
# Required tools and the required read carry over unchanged: harness
|
||||||
|
# completion logic keys on them (e.g. a calendar list is a read, not an
|
||||||
|
# effect that may end with "Done."). If one lost its schema, contract
|
||||||
|
# validation fails and the original contract is retained below.
|
||||||
|
new = replace(
|
||||||
|
turn_contract, offered=frozenset(by_name),
|
||||||
|
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||||
|
routing_experiment="spec-router")
|
||||||
|
_spec_active_contract.set(new)
|
||||||
|
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||||
|
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=None):
|
tool_policy=None):
|
||||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||||
@@ -21069,10 +21181,17 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
Runs before any runtime dispatch so the compact preview runtime and the
|
Runs before any runtime dispatch so the compact preview runtime and the
|
||||||
main loop both serve the router's set; only the offered tools change.
|
main loop both serve the router's set; only the offered tools change.
|
||||||
"""
|
"""
|
||||||
|
# Contexts can be inherited by a child task; a turn must never inherit a
|
||||||
|
# previous turn's decision when routing is disabled or unavailable.
|
||||||
|
from src import spec_router as _router_context
|
||||||
|
if hasattr(_router_context, "clear_decision"):
|
||||||
|
_router_context.clear_decision()
|
||||||
if (turn_contract is None
|
if (turn_contract is None
|
||||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
return turn_contract
|
return turn_contract
|
||||||
|
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||||
|
return turn_contract
|
||||||
try:
|
try:
|
||||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
return turn_contract
|
return turn_contract
|
||||||
@@ -21080,105 +21199,38 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
return turn_contract
|
return turn_contract
|
||||||
try:
|
try:
|
||||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
|
||||||
disabled = set(disabled_tools or ())
|
|
||||||
picks, decided = _spec_route_tools_early(
|
picks, decided = _spec_route_tools_early(
|
||||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||||
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
|
||||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
and decided and _spec_choice == "answer_directly")
|
|
||||||
try:
|
|
||||||
_mgr = get_mcp_manager()
|
|
||||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
|
||||||
except Exception:
|
|
||||||
mcp_schemas = []
|
|
||||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
|
||||||
if _spec_choice == "ask_user":
|
|
||||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
|
||||||
# can act when the detail is there or ask when it is not.
|
|
||||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
|
||||||
if _top:
|
|
||||||
picks = set(picks) | {_top[0][0]}
|
|
||||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
|
||||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
base_offered = set(turn_contract.offered)
|
|
||||||
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
|
||||||
# The router sees the full catalog, while this request's policy may
|
|
||||||
# deny a tool (for example, a browser turn with Web access switched
|
|
||||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
|
||||||
# old empty menu instead lets the model invent a tool result in prose.
|
|
||||||
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
|
||||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
|
||||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
|
||||||
_primary = f"mcp__email__{_primary}"
|
|
||||||
# `executable` belongs to the base contract. In subset=0, Spec may
|
|
||||||
# legitimately add a policy-permitted tool that the base inventory did
|
|
||||||
# not include. Only an explicit request-policy denial blocks the pick.
|
|
||||||
def _denied(name):
|
|
||||||
return (name in disabled
|
|
||||||
or (tool_policy is not None and (
|
|
||||||
tool_policy.blocks(name)
|
|
||||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
|
||||||
_unavailable_pick = ""
|
|
||||||
if _primary in picks and _denied(_primary):
|
|
||||||
_unavailable_pick = _primary
|
|
||||||
elif picks and all(_denied(name) for name in picks):
|
|
||||||
_unavailable_pick = sorted(picks)[0]
|
|
||||||
if _unavailable_pick:
|
|
||||||
blocked = replace(
|
|
||||||
turn_contract, required=frozenset(), offered=frozenset(),
|
|
||||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
|
||||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
|
||||||
)
|
|
||||||
_spec_active_contract.set(blocked)
|
|
||||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
|
||||||
return blocked
|
|
||||||
want = set(picks) - disabled
|
|
||||||
# Always-on basics only when the base turn offers them, so the Spec
|
|
||||||
# arm never offers a tool the base arm would not.
|
|
||||||
if not _spec_direct:
|
|
||||||
want |= {"ask_user", "update_plan"} & base_offered
|
|
||||||
if not (strict and decided):
|
|
||||||
want |= base_offered
|
|
||||||
if subset_of_base:
|
|
||||||
# A/B control: Spec offered set is a subset of the base offered set
|
|
||||||
# (measures narrowing only). Empty intersection keeps the base set.
|
|
||||||
want &= base_offered
|
|
||||||
if not want:
|
|
||||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
|
||||||
sorted(picks), sorted(base_offered))
|
|
||||||
return turn_contract
|
|
||||||
offer = want if strict else (want & set(turn_contract.executable))
|
|
||||||
by_name = {}
|
|
||||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer and nm not in by_name:
|
|
||||||
by_name[nm] = sc
|
|
||||||
for sc in turn_contract.schemas():
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer:
|
|
||||||
by_name[nm] = sc
|
|
||||||
if not by_name and not _spec_direct:
|
|
||||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
|
||||||
return turn_contract
|
|
||||||
# Keep the contract's required tools and required read whenever they
|
|
||||||
# are still offered: harness completion logic keys on them (e.g. a
|
|
||||||
# calendar list is a read, not an effect that may end with "Done.").
|
|
||||||
_rro = getattr(turn_contract, "required_read_operation", None)
|
|
||||||
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
|
||||||
new = replace(
|
|
||||||
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
|
||||||
offered=frozenset(by_name),
|
|
||||||
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
|
||||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
|
||||||
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
|
||||||
_spec_active_contract.set(new)
|
|
||||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
|
||||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
|
||||||
return new
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||||
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
|
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
|
tool_policy=None):
|
||||||
|
"""Asynchronous non-blocking turn contract routing."""
|
||||||
|
from src import spec_router as _router_context
|
||||||
|
if hasattr(_router_context, "clear_decision"):
|
||||||
|
_router_context.clear_decision()
|
||||||
|
if (turn_contract is None
|
||||||
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
|
return turn_contract
|
||||||
|
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
|
return turn_contract
|
||||||
|
except Exception:
|
||||||
|
return turn_contract
|
||||||
|
try:
|
||||||
|
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
||||||
|
picks, decided = await _spec_route_tools_early_async(
|
||||||
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
|
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
||||||
return turn_contract
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
@@ -21263,20 +21315,11 @@ async def stream_agent_loop(
|
|||||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||||
from src.spec_driver import stream_spec_turn
|
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
||||||
async for chunk in stream_spec_turn(
|
yield 'data: [DONE]\n\n'
|
||||||
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
|
||||||
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
|
||||||
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
|
||||||
client_runtime_context=client_runtime_context,
|
|
||||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
|
||||||
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
|
||||||
temperature=0.0, request_authority=request_authority,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
return
|
return
|
||||||
|
|
||||||
turn_contract = _spec_router_contract(
|
turn_contract = await _spec_router_contract_async(
|
||||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=tool_policy)
|
tool_policy=tool_policy)
|
||||||
if (turn_contract is not None
|
if (turn_contract is not None
|
||||||
@@ -21289,6 +21332,20 @@ async def stream_agent_loop(
|
|||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_turn = False
|
||||||
|
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
||||||
|
# routing applies; the turn runs on the compact surface as usual.
|
||||||
|
if _spec_turn and _blocks_before_inference(turn_contract):
|
||||||
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
|
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
||||||
|
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
if is_compact_preview_contract(turn_contract):
|
if is_compact_preview_contract(turn_contract):
|
||||||
from src.clean_agent_preview import stream_preview
|
from src.clean_agent_preview import stream_preview
|
||||||
async for chunk in stream_preview(
|
async for chunk in stream_preview(
|
||||||
@@ -21314,26 +21371,9 @@ async def stream_agent_loop(
|
|||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
_spec_turn = False
|
|
||||||
try:
|
|
||||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
|
||||||
except Exception:
|
|
||||||
_spec_turn = False
|
|
||||||
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
|
||||||
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
|
||||||
# Drop the contract here and in the dispatcher's context so neither the
|
|
||||||
# route build nor execute_tool_block enforces its offered set.
|
|
||||||
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
|
||||||
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
|
||||||
turn_contract = None
|
|
||||||
try:
|
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
|
||||||
_spec_active_contract.set(None)
|
|
||||||
except Exception as _exc:
|
|
||||||
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
|
||||||
if turn_contract is not None:
|
if turn_contract is not None:
|
||||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
if not _spec_turn and _blocks_before_inference(turn_contract):
|
if _blocks_before_inference(turn_contract):
|
||||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
clarification = (
|
clarification = (
|
||||||
"I can’t perform this request with the currently permitted tools "
|
"I can’t perform this request with the currently permitted tools "
|
||||||
@@ -21412,9 +21452,7 @@ async def stream_agent_loop(
|
|||||||
)
|
)
|
||||||
or messages_contain_external_untrusted_context(messages)
|
or messages_contain_external_untrusted_context(messages)
|
||||||
),
|
),
|
||||||
approval_gate_bypassed=bool(
|
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
|
||||||
exact_approval and exact_approval.allow_remaining_actions
|
|
||||||
),
|
|
||||||
delegated_credential=bool(delegated_credential),
|
delegated_credential=bool(delegated_credential),
|
||||||
)
|
)
|
||||||
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
|
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
|
||||||
@@ -24168,11 +24206,14 @@ async def stream_agent_loop(
|
|||||||
budget_is_explicit,
|
budget_is_explicit,
|
||||||
hard_max=hard_max,
|
hard_max=hard_max,
|
||||||
)
|
)
|
||||||
if candidate_context <= 8192:
|
if 0 < candidate_context <= 8192:
|
||||||
# Small local servers often tokenize chat wrappers and tool
|
# Small local servers often tokenize chat wrappers and tool
|
||||||
# results much more generously than our rough estimator. Keep
|
# results much more generously than our rough estimator. Keep
|
||||||
# substantial headroom for those wrappers and generation so a
|
# substantial headroom for those wrappers and generation so a
|
||||||
# follow-up tool round cannot exceed the server's n_ctx.
|
# follow-up tool round cannot exceed the server's n_ctx.
|
||||||
|
# 0 means the window is unknown, which already gets the
|
||||||
|
# conservative default budget; clamping it as well cut every
|
||||||
|
# request to 1200 tokens and dropped open documents (#6678).
|
||||||
effective_budget = min(
|
effective_budget = min(
|
||||||
effective_budget,
|
effective_budget,
|
||||||
max(1200, int(candidate_context * 0.40)),
|
max(1200, int(candidate_context * 0.40)),
|
||||||
@@ -24336,7 +24377,7 @@ async def stream_agent_loop(
|
|||||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||||
# surface already reads "compact", so detect spec from the raw mode.
|
# surface already reads "compact", so detect spec from the raw mode.
|
||||||
try:
|
try:
|
||||||
_spec_surface = _configured_model_tool_surface_raw(
|
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
||||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -24410,7 +24451,7 @@ async def stream_agent_loop(
|
|||||||
if _spec_mode == "strict" and _spec_decided:
|
if _spec_mode == "strict" and _spec_decided:
|
||||||
# The router decides which tools are injected: its pick(s),
|
# The router decides which tools are injected: its pick(s),
|
||||||
# tools already used in this chat, and the always-on basics.
|
# tools already used in this chat, and the always-on basics.
|
||||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
||||||
if _spec_strict:
|
if _spec_strict:
|
||||||
route_tools = _spec_strict
|
route_tools = _spec_strict
|
||||||
_spec_offered = frozenset(route_tools)
|
_spec_offered = frozenset(route_tools)
|
||||||
@@ -25361,15 +25402,6 @@ async def stream_agent_loop(
|
|||||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
in _spec_names
|
in _spec_names
|
||||||
]
|
]
|
||||||
_have = {
|
|
||||||
(schema.get("function", {}).get("name") or schema.get("name"))
|
|
||||||
for schema in contract_schemas
|
|
||||||
}
|
|
||||||
contract_schemas += [
|
|
||||||
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
|
||||||
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
|
||||||
and schema.get("function", {}).get("name") not in disabled_tools
|
|
||||||
]
|
|
||||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||||
if route_state["is_api_model"]:
|
if route_state["is_api_model"]:
|
||||||
if tool_surface == "full":
|
if tool_surface == "full":
|
||||||
@@ -26321,7 +26353,7 @@ async def stream_agent_loop(
|
|||||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||||
if round_num == 1:
|
if round_num == 1:
|
||||||
try:
|
try:
|
||||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||||
@@ -27817,16 +27849,25 @@ async def stream_agent_loop(
|
|||||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||||
_streamed_tool_markup += _delta_text
|
_streamed_tool_markup += _delta_text
|
||||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||||
continue
|
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||||
_visible_markup_tail = strip_tool_blocks(
|
continue
|
||||||
_streamed_tool_markup,
|
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||||
skip_fenced=True,
|
# a tag prefix, but the buffer can no longer become a
|
||||||
).strip()
|
# tool tag. Release it as prose instead of holding the
|
||||||
_streamed_tool_markup = ""
|
# rest of the answer forever.
|
||||||
if not _visible_markup_tail:
|
_delta_text = _streamed_tool_markup
|
||||||
continue
|
_streamed_tool_markup = ""
|
||||||
_delta_text = _visible_markup_tail
|
data["delta"] = _delta_text
|
||||||
data["delta"] = _delta_text
|
else:
|
||||||
|
_visible_markup_tail = strip_tool_blocks(
|
||||||
|
_streamed_tool_markup,
|
||||||
|
skip_fenced=True,
|
||||||
|
).strip()
|
||||||
|
_streamed_tool_markup = ""
|
||||||
|
if not _visible_markup_tail:
|
||||||
|
continue
|
||||||
|
_delta_text = _visible_markup_tail
|
||||||
|
data["delta"] = _delta_text
|
||||||
full_response += _delta_text
|
full_response += _delta_text
|
||||||
if not _suppress_unavailable_web_delta:
|
if not _suppress_unavailable_web_delta:
|
||||||
_qwen_buffered_route = bool(
|
_qwen_buffered_route = bool(
|
||||||
@@ -33925,7 +33966,6 @@ async def stream_agent_loop(
|
|||||||
"mutation_epoch": _workspace_mutation_epoch,
|
"mutation_epoch": _workspace_mutation_epoch,
|
||||||
"error": _failure_text[:600],
|
"error": _failure_text[:600],
|
||||||
}
|
}
|
||||||
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||||
_call_signature,
|
_call_signature,
|
||||||
|
|||||||
@@ -371,6 +371,101 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
|||||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
|
||||||
|
"""Seal local MCP operations from operator declarations and trusted request intent.
|
||||||
|
|
||||||
|
Discovery validates an operator-declared tool but cannot grant it. For the
|
||||||
|
notes family, only an explicit request for local notes admits matching
|
||||||
|
read operations and explicitly requested non-destructive write verbs. Other
|
||||||
|
MCP domains require the user to name the qualified operation explicitly.
|
||||||
|
"""
|
||||||
|
from src.tool_capabilities import (
|
||||||
|
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
|
||||||
|
_local_mcp_tools, _is_declared_local_mcp_read_tool,
|
||||||
|
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
|
||||||
|
)
|
||||||
|
from src.tool_utils import get_mcp_manager
|
||||||
|
|
||||||
|
manager = get_mcp_manager()
|
||||||
|
if manager is None:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
request = request_text.casefold()
|
||||||
|
local_notes = (
|
||||||
|
intent.selected_tools is None
|
||||||
|
and intent.required_read is None
|
||||||
|
and "notes" in intent.capabilities
|
||||||
|
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
|
||||||
|
)
|
||||||
|
write_verbs = {
|
||||||
|
"create": r"\b(?:create|add|make)\b",
|
||||||
|
"add": r"\b(?:add|create)\b",
|
||||||
|
"write": r"\bwrite\b",
|
||||||
|
"save": r"\bsave\b",
|
||||||
|
"update": r"\b(?:update|edit|modify|change)\b",
|
||||||
|
"edit": r"\b(?:edit|update|modify|change)\b",
|
||||||
|
"append": r"\bappend\b",
|
||||||
|
"modify": r"\b(?:modify|edit|update|change)\b",
|
||||||
|
}
|
||||||
|
negative_write = re.search(
|
||||||
|
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
|
||||||
|
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||||
|
admitted = set()
|
||||||
|
for name in sorted(declared):
|
||||||
|
parts = name.split("__", 2)
|
||||||
|
if len(parts) != 3 or parts[0] != "mcp":
|
||||||
|
continue
|
||||||
|
_, server, bare = parts
|
||||||
|
if manager.is_builtin(server):
|
||||||
|
continue
|
||||||
|
conn = manager.get_server_status(server)
|
||||||
|
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
|
||||||
|
continue
|
||||||
|
resource = manager.resource_identity(name)
|
||||||
|
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
|
||||||
|
continue
|
||||||
|
rows = manager._tools.get(server, [])
|
||||||
|
row = next((item for item in rows if item.get("name") == bare), None)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
read_ok = _is_declared_local_mcp_read_tool(name)
|
||||||
|
write_ok = _is_declared_local_mcp_write_tool(name)
|
||||||
|
if not (read_ok or write_ok):
|
||||||
|
continue
|
||||||
|
caps = capabilities_for_tool(name)
|
||||||
|
if not caps.known or caps.effects != frozenset({
|
||||||
|
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
|
||||||
|
}):
|
||||||
|
continue
|
||||||
|
ann = row.get("annotations") or {}
|
||||||
|
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
|
||||||
|
if destructive is True:
|
||||||
|
continue
|
||||||
|
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Exact qualified names in the trusted request permit any configured
|
||||||
|
# and validated local operation. Model/tool content is not an input.
|
||||||
|
explicitly_named = bool(re.search(
|
||||||
|
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
|
||||||
|
request,
|
||||||
|
))
|
||||||
|
if not explicitly_named:
|
||||||
|
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
|
||||||
|
continue
|
||||||
|
if write_ok:
|
||||||
|
action = bare.split("_", 1)[0].casefold()
|
||||||
|
if action not in write_verbs or not re.search(write_verbs[action], request):
|
||||||
|
continue
|
||||||
|
if negative_write:
|
||||||
|
continue
|
||||||
|
admitted.add(name)
|
||||||
|
return admitted
|
||||||
|
|
||||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||||
history=(), policy=None, active_document=False,
|
history=(), policy=None, active_document=False,
|
||||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||||
@@ -393,6 +488,7 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
|||||||
tools = {canonical_tool(operation.tool)}
|
tools = {canonical_tool(operation.tool)}
|
||||||
else:
|
else:
|
||||||
operation = None
|
operation = None
|
||||||
|
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
|
||||||
grants = []
|
grants = []
|
||||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||||
name = canonical_tool(name)
|
name = canonical_tool(name)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
|
|||||||
_RUN_ID = re.compile(r"[a-f0-9]{32}")
|
_RUN_ID = re.compile(r"[a-f0-9]{32}")
|
||||||
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
|
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
|
||||||
_VERSION = 1
|
_VERSION = 1
|
||||||
|
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
|
||||||
|
|
||||||
|
|
||||||
def _fsync_directory(directory: str | os.PathLike) -> None:
|
def _fsync_directory(directory: str | os.PathLike) -> None:
|
||||||
@@ -243,7 +244,7 @@ class EffectLog:
|
|||||||
"""Hold the file exclusively with every durable record merged."""
|
"""Hold the file exclusively with every durable record merged."""
|
||||||
assert self.path is not None
|
assert self.path is not None
|
||||||
_ensure_directory(self.path.parent)
|
_ensure_directory(self.path.parent)
|
||||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||||
descriptor = os.open(self.path, flags, 0o600)
|
descriptor = os.open(self.path, flags, 0o600)
|
||||||
try:
|
try:
|
||||||
if fcntl is not None:
|
if fcntl is not None:
|
||||||
@@ -358,7 +359,7 @@ class EffectLog:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
| getattr(os, "O_CLOEXEC", 0))
|
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -394,7 +395,7 @@ class EffectLog:
|
|||||||
log = cls(run_id, directory=directory)
|
log = cls(run_id, directory=directory)
|
||||||
assert log.path is not None
|
assert log.path is not None
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
|
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return log
|
return log
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
@@ -460,7 +461,7 @@ class EffectLog:
|
|||||||
temporary = target.with_suffix(".tmp")
|
temporary = target.with_suffix(".tmp")
|
||||||
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
|
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
|
||||||
_ensure_directory(target.parent)
|
_ensure_directory(target.parent)
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||||
descriptor = os.open(temporary, flags, 0o600)
|
descriptor = os.open(temporary, flags, 0o600)
|
||||||
try:
|
try:
|
||||||
view = memoryview(data)
|
view = memoryview(data)
|
||||||
@@ -478,7 +479,7 @@ class EffectLog:
|
|||||||
return None
|
return None
|
||||||
base = Path(directory) if directory is not None else effects_dir()
|
base = Path(directory) if directory is not None else effects_dir()
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
|
||||||
with os.fdopen(descriptor, "rb") as stream:
|
with os.fdopen(descriptor, "rb") as stream:
|
||||||
if os.fstat(stream.fileno()).st_nlink != 1:
|
if os.fstat(stream.fileno()).st_nlink != 1:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
def _is_managed_key(key):
|
def _is_managed_key(key):
|
||||||
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
# Tool policy is not a general setting, even if a future default
|
||||||
|
# is added: resetting it must never re-enable disabled tools.
|
||||||
|
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||||
|
|
||||||
_ENUMS = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
save_settings(s)
|
save_settings(s)
|
||||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||||
|
|
||||||
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
elif action == "enable_tool":
|
||||||
|
# The global disabled_tools denylist is the one hard permission
|
||||||
|
# boundary the Settings -> Agent Tools panel offers admins. It
|
||||||
|
# must only ever be *narrowed* from inside an agent turn: a
|
||||||
|
# model that can call enable_tool can undo the boundary itself,
|
||||||
|
# and any web page, email, or document the agent reads can ask
|
||||||
|
# it to (prompt injection re-enabling bash/email/etc.). Widening
|
||||||
|
# the tool set stays a human action in the Settings panel.
|
||||||
|
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
||||||
|
return {
|
||||||
|
"error": (
|
||||||
|
f"Re-enabling tools is not available from chat"
|
||||||
|
f"{f' (requested: {tool_name})' if tool_name else ''}. "
|
||||||
|
"Disabled tools are an admin policy: ask the user to turn the tool "
|
||||||
|
"back on in Settings > Agent Tools. You can still disable tools "
|
||||||
|
"(disable_tool) or list the current state (list_tools)."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action in ("disable_tool", "list_tools"):
|
||||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
# (the global list read on every chat request) rather than
|
# (the global list read on every chat request) rather than
|
||||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
# "search" -> "web_search", "browser" -> browser tools,
|
# "search" -> "web_search", "browser" -> browser tools,
|
||||||
# "documents" -> the document tool set, "memory" ->
|
# "documents" -> the document tool set, "memory" ->
|
||||||
# manage_memory, etc.
|
# manage_memory, etc. Only disable_tool mutates: see the
|
||||||
|
# enable_tool branch above for why the agent can never widen
|
||||||
|
# the denylist.
|
||||||
from src.settings import get_setting, save_settings, load_settings
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
_ALIASES = {
|
_ALIASES = {
|
||||||
"shell": ["bash"],
|
"shell": ["bash"],
|
||||||
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"response": (
|
"response": (
|
||||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||||
"memory, skills, images, tasks, notes, calendar, email."
|
"memory, skills, images, tasks, notes, calendar, email. "
|
||||||
|
"To re-enable a tool, open Settings > Agent Tools."
|
||||||
),
|
),
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
current = list(settings.get("disabled_tools") or [])
|
current = list(settings.get("disabled_tools") or [])
|
||||||
before = set(current)
|
before = set(current)
|
||||||
if action == "disable_tool":
|
for t in targets:
|
||||||
for t in targets:
|
if t not in current:
|
||||||
if t not in current:
|
current.append(t)
|
||||||
current.append(t)
|
|
||||||
else: # enable_tool
|
|
||||||
current = [t for t in current if t not in targets]
|
|
||||||
after = set(current)
|
after = set(current)
|
||||||
settings["disabled_tools"] = current
|
settings["disabled_tools"] = current
|
||||||
save_settings(settings)
|
save_settings(settings)
|
||||||
|
|
||||||
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
|
||||||
changed = sorted(after.symmetric_difference(before))
|
changed = sorted(after.symmetric_difference(before))
|
||||||
return {
|
return {
|
||||||
"response": (
|
"response": (
|
||||||
f"{verb} {tool_name} ({', '.join(targets)}). "
|
f"Disabled {tool_name} ({', '.join(targets)}). "
|
||||||
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
f"Now disabled: {', '.join(current) if current else '(none)'}. "
|
||||||
|
"Re-enabling is done by the user in Settings > Agent Tools."
|
||||||
),
|
),
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import html
|
|||||||
import difflib
|
import difflib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from src.constants import MAX_READ_CHARS
|
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
|
||||||
from src.tool_approvals import document_content_digest
|
from src.tool_approvals import document_content_digest
|
||||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
offset = 0
|
offset = 0
|
||||||
offset = min(offset, len(body))
|
offset = min(offset, len(body))
|
||||||
|
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||||
|
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
|
||||||
|
# Reserve the closing fence and the longest possible paging
|
||||||
|
# notice before selecting the page. The common output cap must
|
||||||
|
# never hide content that next_offset claims was delivered.
|
||||||
|
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
|
||||||
|
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
|
||||||
|
if page_budget < 1:
|
||||||
|
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
|
||||||
|
preview_limit = min(preview_limit, page_budget)
|
||||||
end = min(offset + preview_limit, len(body))
|
end = min(offset + preview_limit, len(body))
|
||||||
truncated = end < len(body)
|
truncated = end < len(body)
|
||||||
preview = body[offset:end]
|
preview = body[offset:end]
|
||||||
if truncated:
|
if truncated:
|
||||||
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
||||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
|
||||||
return {
|
return {
|
||||||
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
"response": f"{header}{preview}\n```",
|
||||||
"document": {
|
"document": {
|
||||||
"id": doc.id,
|
"id": doc.id,
|
||||||
"title": doc.title,
|
"title": doc.title,
|
||||||
|
|||||||
@@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
# Explicit editor session bound in context is authoritative;
|
||||||
|
# otherwise fallback to caller/tool args, then default.
|
||||||
|
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
|
||||||
|
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
|
||||||
|
|
||||||
|
|
||||||
|
def _incognito(ctx: Dict[str, Any]) -> bool:
|
||||||
|
runtime = ctx.get("client_runtime_context")
|
||||||
|
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
|
||||||
|
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
|
||||||
|
|
||||||
|
|
||||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||||
@@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes:
|
|||||||
return base64.b64decode(b64)
|
return base64.b64decode(b64)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_reference_path(value: str) -> bool:
|
||||||
|
# Never use the internal tool credential to fetch an arbitrary app route.
|
||||||
|
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
|
||||||
|
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
|
||||||
|
return bool(generated or upload)
|
||||||
|
|
||||||
|
|
||||||
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
|
||||||
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||||
from core.database import GalleryImage, SessionLocal
|
from core.database import GalleryImage, SessionLocal
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
image_path = None
|
||||||
try:
|
try:
|
||||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
(root / filename).write_bytes(image_bytes)
|
image_path = root / filename
|
||||||
|
image_path.write_bytes(image_bytes)
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
width = height = 0
|
width = height = 0
|
||||||
try:
|
try:
|
||||||
@@ -163,12 +181,17 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
|||||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
if image_path is not None:
|
||||||
|
try:
|
||||||
|
image_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||||
params: Dict[str, Any] = {
|
params: Dict[str, Any] = {
|
||||||
"dataUrl": data_url,
|
"dataUrl": data_url,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -176,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
|||||||
"blend": blend or "normal",
|
"blend": blend or "normal",
|
||||||
"opacity": opacity,
|
"opacity": opacity,
|
||||||
}
|
}
|
||||||
|
if native_size:
|
||||||
|
params["nativeSize"] = True
|
||||||
# Forward the edit mode so the client can build a difference mask for
|
# Forward the edit mode so the client can build a difference mask for
|
||||||
# instruction-mode results: the whole image was regenerated under an
|
# instruction-mode results: the whole image was regenerated under an
|
||||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||||
@@ -198,7 +223,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||||
return data.get("ok") is not False
|
return bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
logger.warning("editor layer placement failed: %s", exc)
|
logger.warning("editor layer placement failed: %s", exc)
|
||||||
return False
|
return False
|
||||||
@@ -367,28 +392,46 @@ class RunEditorCommandTool:
|
|||||||
args = _parse(content)
|
args = _parse(content)
|
||||||
op = str(args.get("op") or "").strip()
|
op = str(args.get("op") or "").strip()
|
||||||
if not op:
|
if not op:
|
||||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
|
||||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
wait = bool(args.get("wait", True))
|
||||||
payload = {
|
payload = {
|
||||||
"session": _session(args, ctx),
|
"session": _session(args, ctx),
|
||||||
"op": op,
|
"op": op,
|
||||||
"params": params,
|
"params": params,
|
||||||
"wait": bool(args.get("wait", True)),
|
"wait": wait,
|
||||||
"timeout": float(args.get("timeout") or 60),
|
"timeout": float(args.get("timeout") or 60),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
|
||||||
ok = data.get("ok")
|
|
||||||
|
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
|
||||||
|
if is_queued and (not wait or not data.get("ok")):
|
||||||
|
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
|
||||||
|
return f"run_editor_command: {op}", {
|
||||||
|
"output": summary,
|
||||||
|
"queued": data.get("queued"),
|
||||||
|
"status": "queued",
|
||||||
|
"pending": True,
|
||||||
|
"completed": False,
|
||||||
|
"ok": False,
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||||
|
status = data.get("status") or ("completed" if ok else "failed")
|
||||||
result = data.get("result")
|
result = data.get("result")
|
||||||
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
return f"run_editor_command: {op}", {
|
return f"run_editor_command: {op}", {
|
||||||
"output": summary[:4000],
|
"output": summary[:4000],
|
||||||
"result": result,
|
"result": result,
|
||||||
"screenshot": data.get("screenshot"),
|
"screenshot": data.get("screenshot"),
|
||||||
|
"status": status,
|
||||||
|
"completed": ok,
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"exit_code": 0 if ok is not False else 1,
|
"exit_code": 0 if ok else 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -442,10 +485,14 @@ class AiEditImageTool:
|
|||||||
image = _as_data_url(args.get("image"))
|
image = _as_data_url(args.get("image"))
|
||||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
||||||
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
|
||||||
|
if not _image_reference_path(args["image"]):
|
||||||
|
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
if not resp.headers.get("content-type", "").lower().startswith("image/"):
|
||||||
|
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
|
||||||
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||||
except Exception:
|
except Exception:
|
||||||
image = None
|
image = None
|
||||||
@@ -461,6 +508,8 @@ class AiEditImageTool:
|
|||||||
}
|
}
|
||||||
_t["image"] = time.perf_counter()
|
_t["image"] = time.perf_counter()
|
||||||
payload: Dict[str, Any] = {"image": image}
|
payload: Dict[str, Any] = {"image": image}
|
||||||
|
if args.get("_use_image_settings") is True:
|
||||||
|
payload["_use_image_settings"] = True
|
||||||
model_choice = str(args.get("model") or "").strip()
|
model_choice = str(args.get("model") or "").strip()
|
||||||
mask_used = False
|
mask_used = False
|
||||||
place_mode: Optional[str] = None
|
place_mode: Optional[str] = None
|
||||||
@@ -550,10 +599,13 @@ class AiEditImageTool:
|
|||||||
"model": data.get("model") or model_choice or "",
|
"model": data.get("model") or model_choice or "",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
logger.warning("ai_edit_image persist failed: %s", exc)
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||||
_t["persist"] = time.perf_counter()
|
_t["persist"] = time.perf_counter()
|
||||||
if bool(args.get("place", True)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
@@ -561,10 +613,11 @@ class AiEditImageTool:
|
|||||||
f"AI {op}",
|
f"AI {op}",
|
||||||
owner,
|
owner,
|
||||||
session,
|
session,
|
||||||
masked=mask_used,
|
masked=mask_used and data.get("full_frame") is not True,
|
||||||
blend=str(args.get("blend") or "normal"),
|
blend=str(args.get("blend") or "normal"),
|
||||||
opacity=float(args.get("opacity") or 100),
|
opacity=float(args.get("opacity") or 100),
|
||||||
mode=place_mode,
|
mode=place_mode,
|
||||||
|
native_size=data.get("full_frame") is True,
|
||||||
)
|
)
|
||||||
info["placed"] = placed
|
info["placed"] = placed
|
||||||
if placed:
|
if placed:
|
||||||
@@ -626,16 +679,20 @@ class GenerateImageLayerTool:
|
|||||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
logger.warning("generate_image persist failed: %s", exc)
|
try:
|
||||||
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("generate_image persist failed: %s", exc)
|
||||||
if bool(args.get("place", True)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
data_url,
|
data_url,
|
||||||
str(args.get("name") or "Generated image"),
|
str(args.get("name") or "Generated image"),
|
||||||
owner,
|
owner,
|
||||||
session,
|
session,
|
||||||
|
native_size=True,
|
||||||
masked=bool(args.get("mask")),
|
masked=bool(args.get("mask")),
|
||||||
blend=str(args.get("blend") or "normal"),
|
blend=str(args.get("blend") or "normal"),
|
||||||
opacity=float(args.get("opacity") or 100),
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
|||||||
+136
-23
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
|||||||
# served internal id. Include pinned/cached ids in the match set
|
# served internal id. Include pinned/cached ids in the match set
|
||||||
# so chat sessions using the HF repo id still resolve. Do not use
|
# so chat sessions using the HF repo id still resolve. Do not use
|
||||||
# stale cached aliases when the endpoint itself is unreachable.
|
# stale cached aliases when the endpoint itself is unreachable.
|
||||||
if model_type == "image" and endpoint_reachable:
|
if endpoint_reachable:
|
||||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
# Gateway image models may live in a dedicated catalogue,
|
||||||
|
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||||
|
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||||
|
if model_type == "image":
|
||||||
|
extras += _json_list(getattr(ep, "cached_models", None))
|
||||||
|
for extra in extras:
|
||||||
if extra not in model_ids:
|
if extra not in model_ids:
|
||||||
model_ids.append(extra)
|
model_ids.append(extra)
|
||||||
|
|
||||||
@@ -1176,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
|||||||
request on this so it never falls back to deterministic approximations.
|
request on this so it never falls back to deterministic approximations.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
if not load_settings().get("image_gen_enabled", True):
|
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||||
|
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||||
|
leaf = model_id_leaf(model_id).lower()
|
||||||
|
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_image_results(data: dict) -> list:
|
||||||
|
images = []
|
||||||
|
for choice in data.get("choices") or []:
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
for item in message.get("images") or []:
|
||||||
|
url = (item.get("image_url") or {}).get("url")
|
||||||
|
if not isinstance(url, str):
|
||||||
|
continue
|
||||||
|
if url.startswith("data:image/") and ";base64," in url:
|
||||||
|
images.append({"b64_json": url.split(",", 1)[1]})
|
||||||
|
elif url.startswith(("https://", "http://")):
|
||||||
|
images.append({"url": url})
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||||
|
errors = [data.get("error")]
|
||||||
|
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||||
|
for error in errors:
|
||||||
|
if not error:
|
||||||
|
continue
|
||||||
|
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||||
|
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||||
|
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||||
|
|
||||||
@@ -1207,7 +1245,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
Line 1: prompt describing the image
|
Line 1: prompt describing the image
|
||||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||||
Line 3: size (optional, defaults to 1024x1024)
|
Line 3: size (optional, defaults to 1024x1024)
|
||||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import httpx
|
import httpx
|
||||||
@@ -1225,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
prompt = str(args.get('prompt') or '').strip()
|
prompt = str(args.get('prompt') or '').strip()
|
||||||
model_spec = str(args.get('model') or '').strip()
|
model_spec = str(args.get('model') or '').strip()
|
||||||
size = str(args.get('size') or '1024x1024')
|
size = str(args.get('size') or '1024x1024')
|
||||||
quality = str(args.get('quality') or 'medium')
|
quality = str(args.get('quality') or '')
|
||||||
else:
|
else:
|
||||||
lines = content.strip().split("\n")
|
lines = content.strip().split("\n")
|
||||||
prompt = lines[0].strip() if lines else ""
|
prompt = lines[0].strip() if lines else ""
|
||||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return {"error": "Image prompt is required (line 1)"}
|
return {"error": "Image prompt is required (line 1)"}
|
||||||
|
|
||||||
# Load admin settings for defaults
|
# Load settings for defaults
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_settings = {}
|
||||||
|
def get_user_setting(key, owner="", default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# The administrator's global image-generation disable switch is authoritative.
|
||||||
|
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||||
if not _settings.get("image_gen_enabled", True):
|
if not _settings.get("image_gen_enabled", True):
|
||||||
return {"error": "Image generation is disabled by the administrator."}
|
return {"error": "Image generation is disabled by the administrator."}
|
||||||
|
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||||
|
return {"error": "Image generation is disabled by user preference."}
|
||||||
|
|
||||||
# Use admin-configured model/quality if not specified by the tool call
|
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if not quality:
|
||||||
quality = _settings["image_quality"]
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
|
|
||||||
# Auto-detect best available image model if still not set
|
# Auto-detect best available image model if still not set
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
@@ -1342,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
images_url = base_url + "/images/generations"
|
images_url = base_url + "/images/generations"
|
||||||
from src.model_capability_readers.base import detect_vendor
|
from src.model_capability_readers.base import detect_vendor
|
||||||
|
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||||
if detect_vendor(url) == "openrouter":
|
if detect_vendor(url) == "openrouter":
|
||||||
images_url = base_url + "/images"
|
images_url = base_url + "/images"
|
||||||
|
if chat_images:
|
||||||
|
images_url = base_url + "/chat/completions"
|
||||||
|
|
||||||
|
# Capture the requested geometry before older OpenAI size restrictions.
|
||||||
|
from src.image_model_ids import image_aspect_ratio
|
||||||
|
try:
|
||||||
|
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||||
|
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
requested_ratio = "1:1"
|
||||||
|
|
||||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||||
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
"n": 1,
|
"n": 1,
|
||||||
"size": size,
|
"size": size,
|
||||||
}
|
}
|
||||||
|
if detect_vendor(url) == "openrouter":
|
||||||
|
payload.pop("size", None)
|
||||||
|
payload["aspect_ratio"] = requested_ratio
|
||||||
|
|
||||||
# GPT image models and local diffusion support quality; DALL-E does not
|
# GPT image models and local diffusion support quality; DALL-E does not
|
||||||
if is_gpt_image or is_local_diffusion:
|
if is_gpt_image or is_local_diffusion:
|
||||||
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
try:
|
try:
|
||||||
# GPT image models can take 30-120s+ depending on quality
|
# GPT image models can take 30-120s+ depending on quality
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||||
resp = await client.post(images_url, json=payload, headers=headers)
|
request_payload = payload
|
||||||
|
if chat_images:
|
||||||
|
request_payload = {
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"modalities": ["image", "text"],
|
||||||
|
"stream": False,
|
||||||
|
"image_config": {"aspect_ratio": requested_ratio},
|
||||||
|
}
|
||||||
|
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
error_text = resp.text[:500]
|
error_text = resp.text[:500]
|
||||||
@@ -1387,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
provider_error = _image_response_error(data, model_id)
|
||||||
|
if provider_error:
|
||||||
|
return {"error": provider_error, "untrusted_content": True}
|
||||||
|
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||||
if not images:
|
if not images:
|
||||||
return {"error": "No images returned from API"}
|
return {"error": (
|
||||||
|
f"Image model '{model_id}' returned no image. "
|
||||||
|
"Choose another image generation model in AI settings."
|
||||||
|
)}
|
||||||
|
|
||||||
img = images[0]
|
img = images[0]
|
||||||
image_url = None
|
image_url = None
|
||||||
@@ -1482,7 +1555,7 @@ async def do_edit_image(
|
|||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
size: str = "auto",
|
size: str = "auto",
|
||||||
quality: str = "medium",
|
quality: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||||
incognito: bool = False,
|
incognito: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
@@ -1502,21 +1575,27 @@ async def do_edit_image(
|
|||||||
return {"error": "Attached image file was not found"}
|
return {"error": "Attached image file was not found"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_settings = {}
|
||||||
|
def get_user_setting(key, owner="", default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||||
if quality == "medium" and _settings.get("image_quality"):
|
if quality is None:
|
||||||
quality = _settings["image_quality"]
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
return {"error": "No image model selected for image editing"}
|
return {"error": "No image model selected for image editing"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
|
try:
|
||||||
|
return _resolve_model(model_spec, owner=owner)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||||
except TypeError as exc:
|
except TypeError as exc:
|
||||||
@@ -1529,6 +1608,7 @@ async def do_edit_image(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||||
|
|
||||||
|
requested_edit_size = size
|
||||||
if not size or size == "auto":
|
if not size or size == "auto":
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from src.image_model_ids import image_edit_size
|
from src.image_model_ids import image_edit_size
|
||||||
@@ -1704,7 +1784,35 @@ async def do_edit_image(
|
|||||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
from src.image_model_ids import image_aspect_ratio
|
||||||
|
from PIL import Image as ReferenceImage
|
||||||
|
with ReferenceImage.open(path) as reference:
|
||||||
|
reference_width, reference_height = reference.size
|
||||||
|
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||||
|
reference_width, reference_height = reference_height, reference_width
|
||||||
|
if requested_edit_size and requested_edit_size != "auto":
|
||||||
|
try:
|
||||||
|
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
edit_payload.pop("size", None)
|
||||||
|
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||||
|
if _uses_openrouter_chat_images(model_id):
|
||||||
|
edit_payload = {
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {
|
||||||
|
"url": f"data:{mime};base64,{image_b64}",
|
||||||
|
}},
|
||||||
|
]}],
|
||||||
|
"modalities": ["image", "text"],
|
||||||
|
"stream": False,
|
||||||
|
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||||
|
}
|
||||||
|
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||||
|
else:
|
||||||
|
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||||
else:
|
else:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
files = {"image": (path.name, f, mime)}
|
files = {"image": (path.name, f, mime)}
|
||||||
@@ -1747,7 +1855,12 @@ async def do_edit_image(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
provider_error = _image_response_error(data, model_id)
|
||||||
|
if provider_error:
|
||||||
|
return {"error": provider_error, "untrusted_content": True}
|
||||||
|
images = (_chat_image_results(data)
|
||||||
|
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||||
|
else data.get("data", []))
|
||||||
if not images:
|
if not images:
|
||||||
return {"error": "No image returned from edit API"}
|
return {"error": "No image returned from edit API"}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
|||||||
# Snapshot text already contains labels and refs in DOM order.
|
# Snapshot text already contains labels and refs in DOM order.
|
||||||
# The refs mapping repeats them and buries menus in raw JSON.
|
# The refs mapping repeats them and buries menus in raw JSON.
|
||||||
lines = [line for line in snapshot.splitlines()
|
lines = [line for line in snapshot.splitlines()
|
||||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||||
elif url:
|
elif url:
|
||||||
notices.append('URL: ' + str(url))
|
notices.append('URL: ' + str(url))
|
||||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
|||||||
parsed = json.loads(item)
|
parsed = json.loads(item)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# CLI status text precedes the JSON post-interaction state.
|
# CLI status text precedes the JSON post-interaction state.
|
||||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
# Labels are one line; stopping at \n keeps the split linear.
|
||||||
|
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
for part in parts:
|
for part in parts:
|
||||||
visit(part)
|
visit(part)
|
||||||
|
|||||||
+37
-5
@@ -10,7 +10,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||||
|
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||||
|
if local_dt.tzinfo is None and tz is not None:
|
||||||
|
local_dt = local_dt.replace(tzinfo=tz)
|
||||||
|
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_brief_local_start(event, tz=None):
|
||||||
|
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||||
|
start = event.dtstart
|
||||||
|
if event.is_utc and not event.all_day:
|
||||||
|
if start.tzinfo is None:
|
||||||
|
start = start.replace(tzinfo=timezone.utc)
|
||||||
|
return start.astimezone(tz).replace(tzinfo=None)
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Build a short morning digest: today's calendar events, unread email count
|
"""Build a short morning digest: today's calendar events, unread email count
|
||||||
+ top-N senders/subjects, active todos."""
|
+ top-N senders/subjects, active todos."""
|
||||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||||
from routes.email_helpers import _imap_connect, _decode_header
|
from routes.email_helpers import _imap_connect, _decode_header
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
# ----- Calendar: today's events -----
|
# ----- Calendar: today's events -----
|
||||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
tomorrow = today + _td(days=1)
|
tomorrow = today + _td(days=1)
|
||||||
|
utc_today = _daily_brief_utc_boundary(today)
|
||||||
|
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||||
# user's daily brief must not include another user's notes or
|
# user's daily brief must not include another user's notes or
|
||||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||||
CalendarEvent.dtstart < tomorrow,
|
or_(
|
||||||
CalendarEvent.dtend > today,
|
and_(
|
||||||
|
CalendarEvent.is_utc.is_(True),
|
||||||
|
CalendarEvent.all_day.is_not(True),
|
||||||
|
CalendarEvent.dtstart < utc_tomorrow,
|
||||||
|
CalendarEvent.dtend > utc_today,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||||
|
CalendarEvent.dtstart < tomorrow,
|
||||||
|
CalendarEvent.dtend > today,
|
||||||
|
),
|
||||||
|
),
|
||||||
CalendarEvent.status != "cancelled",
|
CalendarEvent.status != "cancelled",
|
||||||
)
|
)
|
||||||
if owner:
|
if owner:
|
||||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
events = ev_q.all()
|
||||||
|
events.sort(key=_daily_brief_local_start)
|
||||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||||
if owner:
|
if owner:
|
||||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
if events:
|
if events:
|
||||||
plain.append("Calendar:")
|
plain.append("Calendar:")
|
||||||
for e in events:
|
for e in events:
|
||||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||||
loc = f" @ {e.location}" if e.location else ""
|
loc = f" @ {e.location}" if e.location else ""
|
||||||
plain.append(f" {t} {e.summary}{loc}")
|
plain.append(f" {t} {e.summary}{loc}")
|
||||||
plain.append("")
|
plain.append("")
|
||||||
|
|||||||
+12
-1
@@ -211,10 +211,15 @@ class ChatHandler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
vision_enabled = get_setting("vision_enabled", True)
|
vision_enabled = get_setting("vision_enabled", True)
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
|
from src.owner_identity import auth_disabled
|
||||||
|
sess_owner = getattr(sess, "owner", None)
|
||||||
|
if not sess_owner and not auth_disabled():
|
||||||
|
sess_owner = ""
|
||||||
main_is_vision = await asyncio.to_thread(
|
main_is_vision = await asyncio.to_thread(
|
||||||
model_supports_vision,
|
model_supports_vision,
|
||||||
sess.model or "",
|
sess.model or "",
|
||||||
getattr(sess, "endpoint_url", "") or "",
|
getattr(sess, "endpoint_url", "") or "",
|
||||||
|
sess_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
if effective_att_ids and vision_enabled:
|
if effective_att_ids and vision_enabled:
|
||||||
@@ -277,7 +282,13 @@ class ChatHandler:
|
|||||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||||
|
if vl_desc:
|
||||||
|
# A separate vision call wrote this, but the chat model sees
|
||||||
|
# one message and answers the prose as if the user wrote it
|
||||||
|
# (#5573). Label it below the marker line — the client strips
|
||||||
|
# the block by matching `[Image: <name>]\n`.
|
||||||
|
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||||
# Surface the description to the client live so it renders as a
|
# Surface the description to the client live so it renders as a
|
||||||
# collapsible "image description" on the user bubble (not just
|
# collapsible "image description" on the user bubble (not just
|
||||||
# after a refresh that re-parses the stored message).
|
# after a refresh that re-parses the stored message).
|
||||||
|
|||||||
+243
-24
@@ -5,9 +5,14 @@ import re
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
import typing
|
||||||
|
import httpcore
|
||||||
|
from httpcore._backends.sync import SyncBackend, SyncStream
|
||||||
|
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ORIGINAL_HTTPX_GET = httpx.get
|
||||||
|
_ORIGINAL_HTTPX_POST = httpx.post
|
||||||
|
|
||||||
|
|
||||||
def extract_urls(text: str) -> List[str]:
|
def extract_urls(text: str) -> List[str]:
|
||||||
"""Extract URLs from text using regex pattern."""
|
"""Extract URLs from text using regex pattern."""
|
||||||
@@ -101,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
|||||||
_lmstudio_models_cache: dict = {}
|
_lmstudio_models_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _is_local_host(host: Optional[str]) -> bool:
|
_BLOCKED_METADATA_IPS = {
|
||||||
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
ipaddress.ip_address("169.254.169.254"),
|
||||||
host = (host or "").lower()
|
ipaddress.ip_address("169.254.169.253"),
|
||||||
|
ipaddress.ip_address("100.100.100.200"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
||||||
|
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
if ip in _BLOCKED_METADATA_IPS:
|
||||||
|
return False
|
||||||
|
if ip.is_link_local:
|
||||||
|
return False
|
||||||
|
# Explicit loopback check before reserved rejection:
|
||||||
|
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
||||||
|
if ip.is_loopback:
|
||||||
|
return True
|
||||||
|
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||||
|
return False
|
||||||
|
if ip.is_private:
|
||||||
|
return True
|
||||||
|
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_local_host(host: Optional[str]) -> bool:
|
||||||
|
"""True for loopback/LAN/Tailscale hosts.
|
||||||
|
|
||||||
|
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
||||||
|
unresolved single-label names, and public domains.
|
||||||
|
"""
|
||||||
|
host = (host or "").strip().lower()
|
||||||
if not host:
|
if not host:
|
||||||
return False
|
return False
|
||||||
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
# Strip optional IPv6 square brackets: [::1] -> ::1
|
||||||
return True
|
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(host)
|
ip = ipaddress.ip_address(literal)
|
||||||
|
return _is_ip_safe_local(ip)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "." not in host
|
pass
|
||||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
|
||||||
return True
|
allowed_names = {"localhost", "host.docker.internal"}
|
||||||
return ip in ipaddress.ip_network("100.64.0.0/10")
|
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
if not infos:
|
||||||
|
return False
|
||||||
|
resolved_ips = []
|
||||||
|
for info in infos:
|
||||||
|
sockaddr = info[4]
|
||||||
|
ip_str = str(sockaddr[0]).split("%")[0]
|
||||||
|
resolved_ips.append(ipaddress.ip_address(ip_str))
|
||||||
|
if not resolved_ips:
|
||||||
|
return False
|
||||||
|
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
_is_local_host = _is_safe_local_host
|
||||||
|
|
||||||
|
|
||||||
|
class SafeProbeBackend(SyncBackend):
|
||||||
|
"""Enforces SSRF address policies at TCP connection time.
|
||||||
|
|
||||||
|
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
||||||
|
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
||||||
|
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||||
|
) -> httpcore.NetworkStream:
|
||||||
|
source_address = None if local_address is None else (local_address, 0)
|
||||||
|
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
||||||
|
with map_exceptions(exc_map):
|
||||||
|
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
||||||
|
if not infos:
|
||||||
|
raise ConnectError(f"Host {host} did not resolve")
|
||||||
|
for family, socktype, proto, _canon, sockaddr in infos:
|
||||||
|
ip_str = str(sockaddr[0]).split("%")[0]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
except ValueError:
|
||||||
|
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
||||||
|
if not _is_ip_safe_local(ip):
|
||||||
|
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
||||||
|
last_err: Optional[OSError] = None
|
||||||
|
for family, socktype, proto, _canon, sockaddr in infos:
|
||||||
|
sock = socket.socket(family, socktype, proto)
|
||||||
|
try:
|
||||||
|
if timeout is not None:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sockaddr)
|
||||||
|
if socket_options:
|
||||||
|
for opt in socket_options:
|
||||||
|
sock.setsockopt(*opt)
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
return SyncStream(sock)
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
sock.close()
|
||||||
|
if last_err is not None:
|
||||||
|
raise last_err
|
||||||
|
raise ConnectError(f"Could not connect to {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
||||||
|
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
||||||
|
try:
|
||||||
|
if headers:
|
||||||
|
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
||||||
|
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||||
|
except TypeError:
|
||||||
|
if headers:
|
||||||
|
return httpx.get(url, headers=headers, timeout=timeout)
|
||||||
|
return httpx.get(url, timeout=timeout)
|
||||||
|
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||||
|
transport = httpx.HTTPTransport(trust_env=False)
|
||||||
|
transport._pool = pool
|
||||||
|
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||||
|
return client.get(url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
||||||
|
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
||||||
|
try:
|
||||||
|
if headers:
|
||||||
|
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
||||||
|
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
||||||
|
except TypeError:
|
||||||
|
if headers:
|
||||||
|
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
||||||
|
return httpx.post(url, json=json, timeout=timeout)
|
||||||
|
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
||||||
|
transport = httpx.HTTPTransport(trust_env=False)
|
||||||
|
transport._pool = pool
|
||||||
|
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
||||||
|
return client.post(url, headers=headers, json=json, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
||||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -127,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
cached = _lmstudio_models_cache.get(key)
|
cached = _lmstudio_models_cache.get(key)
|
||||||
if cached is not None and cached[1] > now:
|
if cached is not None and cached[1] > now:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
||||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||||
try:
|
try:
|
||||||
r = httpx.get(probe_url, timeout=1.0)
|
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -148,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
|
|||||||
return models
|
return models
|
||||||
|
|
||||||
|
|
||||||
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
||||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
||||||
if not _is_local_host(urlparse(url).hostname):
|
if not _is_safe_local_host(urlparse(url).hostname):
|
||||||
return None
|
return None
|
||||||
models = _probe_lmstudio_models(url)
|
models = _probe_lmstudio_models(url, headers=headers)
|
||||||
if not models:
|
if not models:
|
||||||
return None
|
return None
|
||||||
want = model.strip().lower()
|
want = model.strip().lower()
|
||||||
@@ -172,21 +324,79 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
def model_supports_vision(
|
||||||
|
model_name: str,
|
||||||
|
endpoint_url: str = "",
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
"""Whether a model accepts images, using the endpoint's reported
|
"""Whether a model accepts images, using the endpoint's reported
|
||||||
capability when available (LM Studio), a cached one-time probe for local
|
capability when available (LM Studio), a cached one-time probe for local
|
||||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||||
if is_vision_model(model_name):
|
if is_vision_model(model_name):
|
||||||
return True
|
return True
|
||||||
if endpoint_url:
|
if endpoint_url:
|
||||||
|
target_url = endpoint_url
|
||||||
|
headers: Optional[dict] = None
|
||||||
|
if owner is not None:
|
||||||
|
if not owner:
|
||||||
|
# Authentication enabled, but caller has no owner: refuse probing immediately
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
normalize_base,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
||||||
|
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||||
|
target_url = normalize_base(resolved_base or ep.base_url)
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||||
|
except ValueError:
|
||||||
|
# Foreign-owned, disabled, or unregistered endpoint
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Endpoint authorization check failed: %s", e)
|
||||||
|
return is_vision_model(model_name)
|
||||||
|
else:
|
||||||
|
# Single-user / auth-disabled mode (owner is None):
|
||||||
|
try:
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
normalize_base,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
resolve_owner_registered_endpoint,
|
||||||
|
)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
||||||
|
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
||||||
|
target_url = normalize_base(resolved_base or ep.base_url)
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||||
|
except Exception:
|
||||||
|
# In single-user mode, an unregistered endpoint is allowed
|
||||||
|
# only if target URL passes safe local host checks (probed with headers=None).
|
||||||
|
target_url = endpoint_url
|
||||||
|
headers = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
if headers:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
||||||
|
else:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||||
|
except TypeError:
|
||||||
|
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
advertised = None
|
advertised = None
|
||||||
if advertised is not None:
|
if advertised is not None:
|
||||||
return advertised
|
return advertised
|
||||||
try:
|
try:
|
||||||
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
if headers:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
||||||
|
else:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||||
|
except TypeError:
|
||||||
|
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
probed = None
|
probed = None
|
||||||
if probed is not None:
|
if probed is not None:
|
||||||
@@ -215,7 +425,11 @@ def _chat_completions_url(endpoint_url: str) -> str:
|
|||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
def probe_endpoint_supports_vision(
|
||||||
|
endpoint_url: str,
|
||||||
|
model_name: str,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
) -> Optional[bool]:
|
||||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||||
an image_url content part for `model_name`?
|
an image_url content part for `model_name`?
|
||||||
|
|
||||||
@@ -226,8 +440,8 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
|||||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||||
return None
|
return None
|
||||||
parsed = urlparse(endpoint_url)
|
parsed = urlparse(endpoint_url)
|
||||||
if not _is_local_host(parsed.hostname):
|
if not _is_safe_local_host(parsed.hostname):
|
||||||
return None # never probe a public provider
|
return None # never probe a public provider or disallowed host
|
||||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _vision_probe_cache.get(key)
|
cached = _vision_probe_cache.get(key)
|
||||||
@@ -247,7 +461,12 @@ def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Option
|
|||||||
}
|
}
|
||||||
verdict: Optional[bool] = None
|
verdict: Optional[bool] = None
|
||||||
try:
|
try:
|
||||||
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
r = _safe_probe_post(
|
||||||
|
_chat_completions_url(endpoint_url),
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
verdict = True
|
verdict = True
|
||||||
elif r.status_code in (400, 413, 415, 422):
|
elif r.status_code in (400, 413, 415, 422):
|
||||||
|
|||||||
+60
-11
@@ -9,9 +9,61 @@ from src.chat_helpers import extract_urls
|
|||||||
from src.youtube_handler import is_youtube_url
|
from src.youtube_handler import is_youtube_url
|
||||||
from src.search import comprehensive_web_search, fetch_webpage_content
|
from src.search import comprehensive_web_search, fetch_webpage_content
|
||||||
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
|
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
|
||||||
|
from src.text_helpers import strip_think
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SEARCH_QUERY_HISTORY_TURNS = 4
|
||||||
|
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
|
||||||
|
"""Build the query extraction prompt, with recent turns to resolve references.
|
||||||
|
|
||||||
|
A follow-up such as "Does he work with Jane?" has no searchable subject on
|
||||||
|
its own. The current turn is already the last history entry when the session
|
||||||
|
persists it, so a trailing user entry is dropped rather than repeated.
|
||||||
|
"""
|
||||||
|
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
|
||||||
|
if turns and turns[-1].role == "user":
|
||||||
|
turns.pop()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
|
||||||
|
content = turn.content
|
||||||
|
if isinstance(content, list):
|
||||||
|
content = " ".join(
|
||||||
|
str(part.get("text") or "") for part in content if isinstance(part, dict)
|
||||||
|
)
|
||||||
|
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
|
||||||
|
if text:
|
||||||
|
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": message},
|
||||||
|
]
|
||||||
|
conversation = "\n".join(lines)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Extract a concise search query from the user's latest message. "
|
||||||
|
"Use the earlier conversation only to resolve references such as pronouns, "
|
||||||
|
"and leave out earlier topics the latest message does not refer to. "
|
||||||
|
"Reply ONLY with the query."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _clean_search_query(query: str, max_len: int = 200) -> str:
|
def _clean_search_query(query: str, max_len: int = 200) -> str:
|
||||||
"""Strip fenced code blocks from a search query while preserving inline
|
"""Strip fenced code blocks from a search query while preserving inline
|
||||||
@@ -422,21 +474,18 @@ class ChatProcessor:
|
|||||||
generated_query = llm_call(
|
generated_query = llm_call(
|
||||||
t_url,
|
t_url,
|
||||||
t_model,
|
t_model,
|
||||||
[
|
_search_query_messages(
|
||||||
{
|
message,
|
||||||
"role": "system",
|
[] if incognito else getattr(session, "history", None) or [],
|
||||||
"content": (
|
),
|
||||||
"Extract a concise search query from the user's message. "
|
|
||||||
"Reply ONLY with the query."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{"role": "user", "content": message},
|
|
||||||
],
|
|
||||||
headers=t_headers,
|
headers=t_headers,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=50,
|
max_tokens=50,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
).strip()
|
thinking_mode="off",
|
||||||
|
)
|
||||||
|
# Models without a reasoning parser put thinking in the content.
|
||||||
|
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
|
||||||
|
|
||||||
if generated_query:
|
if generated_query:
|
||||||
# LLM successfully generated a non-empty query -> use the generated query
|
# LLM successfully generated a non-empty query -> use the generated query
|
||||||
|
|||||||
+53
-16
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
|||||||
text = str(payload or '').strip()
|
text = str(payload or '').strip()
|
||||||
rows = []
|
rows = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||||
if match:
|
if match:
|
||||||
details = match[3].strip()
|
details = match[3].strip()
|
||||||
rows.append({
|
rows.append({
|
||||||
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
|||||||
continue
|
continue
|
||||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||||
if not match:
|
if not match:
|
||||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||||
if match and status:
|
if match and status:
|
||||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -1466,6 +1466,29 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
||||||
|
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
||||||
|
try:
|
||||||
|
import sys as _sys
|
||||||
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
|
if spec_router is None:
|
||||||
|
from src import spec_router # noqa: F401
|
||||||
|
if not spec_router.think_router_enabled():
|
||||||
|
return None
|
||||||
|
from src.agent_loop import _configured_model_tool_surface_raw
|
||||||
|
if _configured_model_tool_surface_raw(
|
||||||
|
endpoint_url, model, owner, headers=headers
|
||||||
|
) != 'spec':
|
||||||
|
return None
|
||||||
|
if hasattr(spec_router, 'route_thinking_async'):
|
||||||
|
return await spec_router.route_thinking_async(direct_user_text)
|
||||||
|
return spec_router.route_thinking(direct_user_text)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
'spec think router decision failed; using rules only', exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||||
other rule already selected a deliberate reason."""
|
other rule already selected a deliberate reason."""
|
||||||
@@ -1508,10 +1531,7 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
|||||||
spec_router = _sys.modules.get('src.spec_router')
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
if spec_router is None:
|
if spec_router is None:
|
||||||
from src import spec_router # noqa: F401
|
from src import spec_router # noqa: F401
|
||||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
decision = spec_router.latest_decision()
|
||||||
if not decisions:
|
|
||||||
return None
|
|
||||||
decision = decisions[-1]
|
|
||||||
if not isinstance(decision, dict):
|
if not isinstance(decision, dict):
|
||||||
return None
|
return None
|
||||||
# Guard against a stale decision from a previous turn.
|
# Guard against a stale decision from a previous turn.
|
||||||
@@ -1573,8 +1593,7 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
|||||||
set(disabled_tools or ()), list(conversation or []),
|
set(disabled_tools or ()), list(conversation or []),
|
||||||
return_decision=True,
|
return_decision=True,
|
||||||
)
|
)
|
||||||
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
decision = spec_router.latest_decision() or {}
|
||||||
decision = decisions[-1] if decisions else {}
|
|
||||||
if not isinstance(decision, dict) or decision.get('error'):
|
if not isinstance(decision, dict) or decision.get('error'):
|
||||||
return None
|
return None
|
||||||
choice = decision.get('choice')
|
choice = decision.get('choice')
|
||||||
@@ -3862,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
|||||||
or bare in BROKERED_JOB_TOOLS
|
or bare in BROKERED_JOB_TOOLS
|
||||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
||||||
|
# The editor command is conservatively classified as a write;
|
||||||
|
# document.info is a read even when the user requested no edit.
|
||||||
|
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
||||||
@@ -5043,7 +5065,7 @@ def email_search_result_empty(value):
|
|||||||
try:
|
try:
|
||||||
decoded = json.loads(value)
|
decoded = json.loads(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||||
if decoded == value:
|
if decoded == value:
|
||||||
return False
|
return False
|
||||||
value = decoded
|
value = decoded
|
||||||
@@ -5317,6 +5339,16 @@ def private_browser_effective_url(result):
|
|||||||
return urls[-1] if urls else ''
|
return urls[-1] if urls else ''
|
||||||
|
|
||||||
|
|
||||||
|
def is_google_sorry_url(url):
|
||||||
|
"""Recognize Google's rate-limit interstitial."""
|
||||||
|
# Matching the host separately keeps this linear; one combined pattern
|
||||||
|
# rescans the host from every dot.
|
||||||
|
return any(
|
||||||
|
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||||
|
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def browser_observation_page_missing(raw):
|
def browser_observation_page_missing(raw):
|
||||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||||
text = str(raw or '')
|
text = str(raw or '')
|
||||||
@@ -5600,6 +5632,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
external_tool_schemas=None, temperature=0.0,
|
external_tool_schemas=None, temperature=0.0,
|
||||||
context_resolution=None,
|
context_resolution=None,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
|
if spec_driver_active(endpoint_url, model, owner, headers):
|
||||||
|
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
||||||
|
yield 'data: [DONE]\n\n'
|
||||||
|
return
|
||||||
from src.generation_sampling import validate_temperature
|
from src.generation_sampling import validate_temperature
|
||||||
temperature = validate_temperature(temperature)
|
temperature = validate_temperature(temperature)
|
||||||
# This path sends requests directly with httpx and therefore bypasses
|
# This path sends requests directly with httpx and therefore bypasses
|
||||||
@@ -6095,7 +6131,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||||
# Optional env-gated router decision (spec surface only). Computed
|
# Optional env-gated router decision (spec surface only). Computed
|
||||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||||
_spec_route_think = spec_router_thinking_decision(
|
_spec_route_think = await spec_router_thinking_decision_async(
|
||||||
endpoint_url, model, owner, headers, direct_user_text)
|
endpoint_url, model, owner, headers, direct_user_text)
|
||||||
if _spec_route_think is not None:
|
if _spec_route_think is not None:
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
@@ -6108,12 +6144,17 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||||
_spec_direct_p = spec_direct_no_tools_p(
|
_spec_direct_p = spec_direct_no_tools_p(
|
||||||
direct_user_text, endpoint_url, model, owner, headers)
|
direct_user_text, endpoint_url, model, owner, headers)
|
||||||
|
if (turn_contract.required or turn_contract.unavailable
|
||||||
|
or turn_contract.required_read_operation is not None):
|
||||||
|
_spec_direct_p = None
|
||||||
# Option B: send round 1 with no tools at all for a direct answer.
|
# Option B: send round 1 with no tools at all for a direct answer.
|
||||||
_spec_direct_empty_menu = (
|
_spec_direct_empty_menu = (
|
||||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||||
# every round; the 9B only writes the arguments or the answer.
|
# every round; the 9B only writes the arguments or the answer.
|
||||||
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
# Driver execution is refused at entry. Keep this false even if
|
||||||
|
# configuration changes while the stream is already in progress.
|
||||||
|
_spec_driver_on = False
|
||||||
# Driver mode narrows the contract per round to the Spec pick.
|
# Driver mode narrows the contract per round to the Spec pick.
|
||||||
# Rebuild each round from this frozen base so the narrowing does
|
# Rebuild each round from this frozen base so the narrowing does
|
||||||
# not accumulate/mutate across rounds.
|
# not accumulate/mutate across rounds.
|
||||||
@@ -8182,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
browser_search_url = requested_browser_url or effective_browser_url
|
browser_search_url = requested_browser_url or effective_browser_url
|
||||||
is_search_engine_navigation = contains_search_engine_navigation(
|
is_search_engine_navigation = contains_search_engine_navigation(
|
||||||
browser_search_url
|
browser_search_url
|
||||||
) or bool(re.search(
|
) or is_google_sorry_url(effective_browser_url)
|
||||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
|
||||||
effective_browser_url,
|
|
||||||
re.I,
|
|
||||||
))
|
|
||||||
has_native_search = any(
|
has_native_search = any(
|
||||||
canonical(schema['function']['name']) == 'web_search'
|
canonical(schema['function']['name']) == 'web_search'
|
||||||
for schema in offered
|
for schema in offered
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
|
|||||||
|
|
||||||
# Auth policy
|
# Auth policy
|
||||||
PASSWORD_MIN_LENGTH = 8
|
PASSWORD_MIN_LENGTH = 8
|
||||||
|
USERNAME_MAX_LENGTH = 64
|
||||||
|
|
||||||
# Default parameters
|
# Default parameters
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
|
|||||||
+93
-16
@@ -42,6 +42,15 @@ def _content_as_text(content: Any) -> str:
|
|||||||
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
|
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_preservable_content(content: Any) -> bool:
|
||||||
|
"""Retain nonblank text or nonempty multimodal blocks after tool-call removal."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
if isinstance(content, list):
|
||||||
|
return bool(content)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def prune_multimodal_images(
|
def prune_multimodal_images(
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
*,
|
*,
|
||||||
@@ -92,7 +101,7 @@ def prune_multimodal_images(
|
|||||||
return pruned
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _image_turn_label(message: Dict) -> str:
|
def _image_turn_label(message: Dict) -> str:
|
||||||
@@ -102,7 +111,8 @@ def _image_turn_label(message: Dict) -> str:
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||||
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
image_url = item.get("image_url")
|
||||||
|
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
||||||
if candidate:
|
if candidate:
|
||||||
name = str(candidate)
|
name = str(candidate)
|
||||||
break
|
break
|
||||||
@@ -146,7 +156,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
|
|||||||
trimmed.append(message)
|
trimmed.append(message)
|
||||||
continue
|
continue
|
||||||
kept_blocks = [
|
kept_blocks = [
|
||||||
item for item in content
|
dict(item) if isinstance(item, dict) else item for item in content
|
||||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||||
]
|
]
|
||||||
if len(kept_blocks) == len(content):
|
if len(kept_blocks) == len(content):
|
||||||
@@ -221,22 +231,81 @@ def normalize_compaction_summary(summary: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
||||||
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
|
"""Normalize tool-call message ordering so strict providers accept the transcript.
|
||||||
|
|
||||||
OpenAI's API requires every `role:"tool"` message to immediately
|
OpenAI's API requires every `role:"tool"` message to immediately
|
||||||
follow an assistant message that carries `tool_calls` (or another
|
follow an assistant message that carries `tool_calls` (or another
|
||||||
tool message in the same batch). Front-trimming the history can cut
|
tool message in the same batch). Gemini and some other providers
|
||||||
the assistant `tool_calls` parent while keeping its tool responses,
|
additionally require a function call turn to come immediately after
|
||||||
which triggers: "messages with role 'tool' must be a response to a
|
a user turn or a function response turn. Trimming the history can
|
||||||
preceding message with 'tool_calls'". This pass repairs that:
|
break either rule, which triggers errors such as "messages with role
|
||||||
- drops `tool` messages with no valid preceding tool_calls
|
'tool' must be a response to a preceding message with 'tool_calls'"
|
||||||
- drops assistant `tool_calls` messages whose tool responses were
|
and "Please ensure that function call turn comes immediately after
|
||||||
all trimmed away (some providers reject unanswered tool_calls)
|
a user turn or after a function response turn."
|
||||||
|
|
||||||
|
The transcript is normalized against its own effective output: each
|
||||||
|
pass repairs the violations it can see and the passes repeat until
|
||||||
|
the result is stable, so the returned transcript itself satisfies
|
||||||
|
the ordering contract and sanitizing it again is a no-op. A
|
||||||
|
completed call/result batch whose predecessor is an adjacent
|
||||||
|
assistant text turn keeps its calls by merging that text into the
|
||||||
|
call message instead of dropping executed-tool evidence; a batch
|
||||||
|
that cannot get a user/tool predecessor (e.g. a transcript that
|
||||||
|
starts mid-history) is the only one dropped.
|
||||||
"""
|
"""
|
||||||
# Pass 1: drop orphan tool messages.
|
work = list(msgs)
|
||||||
|
for _ in range(2 * len(work) + 2):
|
||||||
|
normalized = _normalize_tool_messages_once(work)
|
||||||
|
if normalized == work:
|
||||||
|
break
|
||||||
|
work = normalized
|
||||||
|
return work
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
|
||||||
|
"""One normalization pass over an already-trimmed message list."""
|
||||||
|
# Merge the run of assistant text turns directly before an assistant
|
||||||
|
# tool_calls turn into a single assistant message carrying both, so the
|
||||||
|
# batch inherits the text run's predecessor (a user or tool message)
|
||||||
|
# instead of losing its tool_calls. Only plain-text content is merged;
|
||||||
|
# multimodal content stays untouched and falls through to the strip logic.
|
||||||
|
merged: List[Dict] = []
|
||||||
|
for m in msgs:
|
||||||
|
cur_text = m.get("content")
|
||||||
|
if (
|
||||||
|
m.get("role") == "assistant"
|
||||||
|
and m.get("tool_calls")
|
||||||
|
and (cur_text is None or isinstance(cur_text, str))
|
||||||
|
):
|
||||||
|
text_run: List[Dict] = []
|
||||||
|
while (
|
||||||
|
merged
|
||||||
|
and merged[-1].get("role") == "assistant"
|
||||||
|
and not merged[-1].get("tool_calls")
|
||||||
|
and isinstance(merged[-1].get("content"), str)
|
||||||
|
):
|
||||||
|
text_run.append(merged.pop())
|
||||||
|
if text_run:
|
||||||
|
text_parts = [
|
||||||
|
msg["content"].strip()
|
||||||
|
for msg in reversed(text_run)
|
||||||
|
if msg["content"].strip()
|
||||||
|
]
|
||||||
|
if isinstance(cur_text, str) and cur_text.strip():
|
||||||
|
text_parts.append(cur_text.strip())
|
||||||
|
merged_msg = dict(m)
|
||||||
|
if text_parts:
|
||||||
|
merged_msg["content"] = "\n\n".join(text_parts)
|
||||||
|
merged.append(merged_msg)
|
||||||
|
continue
|
||||||
|
merged.append(m)
|
||||||
|
|
||||||
|
# Strip tool_calls from assistant messages whose predecessor is not a
|
||||||
|
# user or tool message, then drop orphan tool messages and dangling
|
||||||
|
# tool_calls whose responses are gone.
|
||||||
cleaned: List[Dict] = []
|
cleaned: List[Dict] = []
|
||||||
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
|
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
|
||||||
for m in msgs:
|
for m in merged:
|
||||||
role = m.get("role")
|
role = m.get("role")
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
if in_batch:
|
if in_batch:
|
||||||
@@ -244,13 +313,21 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
|||||||
# else: orphan — drop
|
# else: orphan — drop
|
||||||
continue
|
continue
|
||||||
if role == "assistant" and m.get("tool_calls"):
|
if role == "assistant" and m.get("tool_calls"):
|
||||||
|
prev_role = cleaned[-1].get("role") if cleaned else None
|
||||||
|
if prev_role not in ("user", "tool"):
|
||||||
|
# Invalid predecessor — strip tool_calls to avoid provider
|
||||||
|
# errors; keep the message only if it still carries content.
|
||||||
|
m = {k: v for k, v in m.items() if k != "tool_calls"}
|
||||||
|
in_batch = False
|
||||||
|
if not _has_preservable_content(m.get("content")):
|
||||||
|
continue # nothing left worth keeping
|
||||||
|
cleaned.append(m)
|
||||||
|
continue
|
||||||
in_batch = True
|
in_batch = True
|
||||||
else:
|
else:
|
||||||
in_batch = False
|
in_batch = False
|
||||||
cleaned.append(m)
|
cleaned.append(m)
|
||||||
|
|
||||||
# Pass 2: drop assistant tool_calls messages that have NO following
|
|
||||||
# tool response (dangling) — walk backwards so we know what follows.
|
|
||||||
out: List[Dict] = []
|
out: List[Dict] = []
|
||||||
for i, m in enumerate(cleaned):
|
for i, m in enumerate(cleaned):
|
||||||
if m.get("role") == "assistant" and m.get("tool_calls"):
|
if m.get("role") == "assistant" and m.get("tool_calls"):
|
||||||
@@ -260,7 +337,7 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
|||||||
# tool_calls so it's a plain assistant turn (preserves any
|
# tool_calls so it's a plain assistant turn (preserves any
|
||||||
# text content the model produced alongside the calls).
|
# text content the model produced alongside the calls).
|
||||||
m = {k: v for k, v in m.items() if k != "tool_calls"}
|
m = {k: v for k, v in m.items() if k != "tool_calls"}
|
||||||
if not (m.get("content") or "").strip():
|
if not _has_preservable_content(m.get("content")):
|
||||||
continue # nothing left worth keeping
|
continue # nothing left worth keeping
|
||||||
out.append(m)
|
out.append(m)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -31,63 +31,43 @@ def _internal_headers() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def _delete_endpoint_for_task(task: dict) -> None:
|
async def _delete_endpoint_for_task(task: dict) -> None:
|
||||||
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
|
"""Remove a stopped serve's endpoint using the normal deletion contract."""
|
||||||
|
import re
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
Without this, killing the tmux session leaves the endpoint sitting in
|
endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||||
the picker (probe goes offline; chats still try to route there) and
|
serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
|
||||||
the user has to delete it by hand in Settings -> Endpoints.
|
if (
|
||||||
"""
|
not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
|
||||||
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
|
or not serve_session_id
|
||||||
if not endpoint_id:
|
):
|
||||||
logger.info(
|
logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
|
||||||
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
|
|
||||||
task.get("sessionId") or task.get("id") or "",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
import re as _re
|
|
||||||
payload = task.get("payload") or {}
|
|
||||||
cmd = str(payload.get("_cmd") or "")
|
|
||||||
remote = task.get("remoteHost") or ""
|
|
||||||
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
|
|
||||||
if remote:
|
|
||||||
host = remote.split("@")[-1] if "@" in remote else remote
|
|
||||||
else:
|
|
||||||
host = "host.docker.internal"
|
|
||||||
port_match = _re.search(r"--port\s+(\d+)", cmd)
|
|
||||||
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
|
|
||||||
if port_match:
|
|
||||||
port = int(port_match.group(1))
|
|
||||||
elif ollama_host_match:
|
|
||||||
port = int(ollama_host_match.group(1))
|
|
||||||
elif "ollama" in cmd:
|
|
||||||
port = 11434
|
|
||||||
else:
|
|
||||||
port = 8080
|
|
||||||
base_url = f"http://{host}:{port}/v1"
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=8) as client:
|
async with httpx.AsyncClient(timeout=8) as client:
|
||||||
r = await client.get(
|
response = await client.delete(
|
||||||
f"{internal_api_base()}/api/model-endpoints",
|
f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||||
|
params={"expected_cookbook_serve_session_id": serve_session_id},
|
||||||
headers=_internal_headers(),
|
headers=_internal_headers(),
|
||||||
)
|
)
|
||||||
if r.status_code >= 400:
|
if response.status_code == 200:
|
||||||
return
|
logger.info(
|
||||||
eps = r.json() if r.content else []
|
"cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
|
||||||
# Delete only the endpoint created by this scheduled serve. URL
|
endpoint_id, serve_session_id,
|
||||||
# matching is unsafe because a later scheduled serve can reuse the
|
)
|
||||||
# same host:port after an older task has gone stale.
|
elif response.status_code in (404, 409):
|
||||||
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
|
logger.info(
|
||||||
if ep:
|
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
|
||||||
await client.delete(
|
endpoint_id,
|
||||||
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
)
|
||||||
headers=_internal_headers(),
|
else:
|
||||||
)
|
logger.warning(
|
||||||
logger.info(
|
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
|
||||||
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
|
endpoint_id, response.status_code,
|
||||||
f"({ep.get('base_url')}) after scheduled stop"
|
)
|
||||||
)
|
except Exception as exc:
|
||||||
except Exception as e:
|
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
|
||||||
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||||
|
|||||||
@@ -625,7 +625,8 @@ class DeepResearcher:
|
|||||||
# LLM helper
|
# LLM helper
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
|
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
|
||||||
max_tokens: int = 4096, timeout: int = 60) -> str:
|
max_tokens: int = 4096, timeout: int = 60,
|
||||||
|
thinking_mode: Optional[str] = None) -> str:
|
||||||
"""Call the LLM asynchronously and strip thinking tags."""
|
"""Call the LLM asynchronously and strip thinking tags."""
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
response = await llm_call_async(
|
response = await llm_call_async(
|
||||||
@@ -636,6 +637,7 @@ class DeepResearcher:
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
headers=self.llm_headers,
|
headers=self.llm_headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
thinking_mode=thinking_mode,
|
||||||
)
|
)
|
||||||
return strip_thinking(response)
|
return strip_thinking(response)
|
||||||
|
|
||||||
@@ -696,7 +698,7 @@ class DeepResearcher:
|
|||||||
try:
|
try:
|
||||||
result = await self._llm(
|
result = await self._llm(
|
||||||
[{"role": "user", "content": prompt}],
|
[{"role": "user", "content": prompt}],
|
||||||
temperature=0, max_tokens=20, timeout=15,
|
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
|
||||||
)
|
)
|
||||||
cat = (result or "").strip().lower()
|
cat = (result or "").strip().lower()
|
||||||
# Clean one-word answer first.
|
# Clean one-word answer first.
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ def _process_pdf(
|
|||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
temp_img_path = tmp.name
|
temp_img_path = tmp.name
|
||||||
try:
|
try:
|
||||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
image = img.image
|
||||||
|
if image.mode == "CMYK":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.save(temp_img_path, "PNG")
|
||||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||||
|
|||||||
+240
-6
@@ -339,14 +339,231 @@ async def self_check(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resolve an explicit agent_model override under server authority.
|
||||||
|
|
||||||
|
Guarantees:
|
||||||
|
- Client-provided raw URLs never determine arbitrary outbound network destinations.
|
||||||
|
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
|
||||||
|
- Shared endpoints (owner IS NULL) remain visible to authorized users.
|
||||||
|
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
|
||||||
|
- Admin privileges do not allow arbitrary unconfigured destinations.
|
||||||
|
"""
|
||||||
|
raw = str(override or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "::" in raw:
|
||||||
|
target, requested_model = raw.split("::", 1)
|
||||||
|
target = target.strip()
|
||||||
|
requested_model = requested_model.strip()
|
||||||
|
else:
|
||||||
|
target = ""
|
||||||
|
requested_model = raw
|
||||||
|
|
||||||
|
# 1. Explicit local selection or matching a configured local base
|
||||||
|
if target == "local":
|
||||||
|
bases = local_bases()
|
||||||
|
if not bases:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"base": bases[0],
|
||||||
|
"model": requested_model or local_model(),
|
||||||
|
"headers": None,
|
||||||
|
"source": "override",
|
||||||
|
}
|
||||||
|
for lb in local_bases():
|
||||||
|
if target and target.rstrip("/") == lb.rstrip("/"):
|
||||||
|
return {
|
||||||
|
"base": lb,
|
||||||
|
"model": requested_model or local_model(),
|
||||||
|
"headers": None,
|
||||||
|
"source": "override",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Explicit hosted fallback selection
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model:
|
||||||
|
if target in ("default", "hosted"):
|
||||||
|
return {
|
||||||
|
"base": url,
|
||||||
|
"model": requested_model or model,
|
||||||
|
"headers": headers,
|
||||||
|
"source": "hosted",
|
||||||
|
}
|
||||||
|
if target and target.rstrip("/") == url.rstrip("/"):
|
||||||
|
if not requested_model or requested_model == model:
|
||||||
|
return {
|
||||||
|
"base": url,
|
||||||
|
"model": requested_model or model,
|
||||||
|
"headers": headers,
|
||||||
|
"source": "hosted",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if target in ("default", "hosted"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Bare model id without endpoint prefix
|
||||||
|
if not target:
|
||||||
|
if requested_model == "local":
|
||||||
|
bases = local_bases()
|
||||||
|
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
|
||||||
|
if requested_model in ("default", "hosted"):
|
||||||
|
return _resolve_override_candidate("default::", owner)
|
||||||
|
|
||||||
|
# Exact match with local model
|
||||||
|
if requested_model == local_model():
|
||||||
|
bases = local_bases()
|
||||||
|
if bases:
|
||||||
|
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
|
||||||
|
|
||||||
|
# Exact match with hosted default/utility
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
|
||||||
|
for prefix in ("utility", "default"):
|
||||||
|
try:
|
||||||
|
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if url and model and model == requested_model:
|
||||||
|
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check registered database endpoints for this bare model
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter, _auth_disabled
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
_endpoint_enabled_models,
|
||||||
|
_endpoint_hidden_models,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not owner and not _auth_disabled():
|
||||||
|
q = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True,
|
||||||
|
ModelEndpoint.owner == None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
matching_eps = []
|
||||||
|
for ep in q.all():
|
||||||
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
|
hidden_models = _endpoint_hidden_models(ep)
|
||||||
|
if requested_model in enabled_models and requested_model not in hidden_models:
|
||||||
|
matching_eps.append(ep)
|
||||||
|
|
||||||
|
# Avoid model ID collision across providers: only accept if unique
|
||||||
|
if len(matching_eps) == 1:
|
||||||
|
ep = matching_eps[0]
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
return {
|
||||||
|
"base": build_chat_url(base),
|
||||||
|
"model": requested_model,
|
||||||
|
"headers": build_headers(api_key, base),
|
||||||
|
"source": "registered",
|
||||||
|
"endpoint_id": ep.id,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Target is an endpoint_id or a registered legacy endpoint URL
|
||||||
|
try:
|
||||||
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter, _auth_disabled
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
_endpoint_enabled_models,
|
||||||
|
_endpoint_hidden_models,
|
||||||
|
_first_chat_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if not owner and not _auth_disabled():
|
||||||
|
q = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True,
|
||||||
|
ModelEndpoint.owner == None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
# Lookup by ID
|
||||||
|
ep = q.filter(ModelEndpoint.id == target).first()
|
||||||
|
|
||||||
|
# If not found by ID, match legacy registered base URL (strictly registered rows only)
|
||||||
|
if ep is None and ("://" in target or target.startswith("http")):
|
||||||
|
target_norm = target.rstrip("/")
|
||||||
|
for cand_ep in q.all():
|
||||||
|
cand_base = (cand_ep.base_url or "").rstrip("/")
|
||||||
|
if (
|
||||||
|
cand_base == target_norm
|
||||||
|
or cand_base + "/v1" == target_norm
|
||||||
|
or cand_base == target_norm + "/v1"
|
||||||
|
):
|
||||||
|
ep = cand_ep
|
||||||
|
break
|
||||||
|
|
||||||
|
if ep is None:
|
||||||
|
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
|
||||||
|
return None
|
||||||
|
|
||||||
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
|
hidden_models = _endpoint_hidden_models(ep)
|
||||||
|
if requested_model:
|
||||||
|
if requested_model in hidden_models:
|
||||||
|
return None
|
||||||
|
if enabled_models and requested_model not in enabled_models:
|
||||||
|
# Model not served by this endpoint -> fail closed against forged model IDs
|
||||||
|
return None
|
||||||
|
model = requested_model
|
||||||
|
else:
|
||||||
|
model = _first_chat_model(enabled_models)
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
return {
|
||||||
|
"base": build_chat_url(base),
|
||||||
|
"model": model,
|
||||||
|
"headers": build_headers(api_key, base),
|
||||||
|
"source": "registered",
|
||||||
|
"endpoint_id": ep.id,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||||
cands: List[Dict[str, Any]] = []
|
cands: List[Dict[str, Any]] = []
|
||||||
if override:
|
|
||||||
base, model = _split_override(override)
|
|
||||||
if model:
|
|
||||||
cands.append(
|
|
||||||
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
|
|
||||||
)
|
|
||||||
for base in local_bases():
|
for base in local_bases():
|
||||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||||
try:
|
try:
|
||||||
@@ -362,6 +579,14 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if override:
|
||||||
|
cand = _resolve_override_candidate(override, owner)
|
||||||
|
if cand is not None:
|
||||||
|
cands.insert(0, cand)
|
||||||
|
return cands
|
||||||
|
# Unauthorized, invalid, foreign, disabled, or forged override:
|
||||||
|
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
|
||||||
|
return []
|
||||||
return cands
|
return cands
|
||||||
|
|
||||||
|
|
||||||
@@ -433,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
"source": "local",
|
"source": "local",
|
||||||
"vision": True,
|
"vision": True,
|
||||||
"base_url": base,
|
"base_url": base,
|
||||||
|
"endpoint_id": "local",
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
for prefix in ("utility", "default"):
|
for prefix in ("utility", "default"):
|
||||||
try:
|
try:
|
||||||
@@ -446,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
key = ("hosted", model)
|
key = ("hosted", model)
|
||||||
if key not in seen:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
|
ep_id = None
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
models.append({
|
models.append({
|
||||||
"id": model,
|
"id": model,
|
||||||
"label": model,
|
"label": model,
|
||||||
"source": "hosted",
|
"source": "hosted",
|
||||||
"vision": None,
|
"vision": None,
|
||||||
"base_url": url,
|
"base_url": url,
|
||||||
|
"endpoint_id": ep_id or prefix,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""The shared model defaults used by Quick Edit's image operations."""
|
||||||
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
|
|
||||||
|
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||||
|
settings = load_settings()
|
||||||
|
generation = ""
|
||||||
|
edit_model = ""
|
||||||
|
if owner:
|
||||||
|
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||||
|
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||||
|
if not generation:
|
||||||
|
generation = str(settings.get("image_model") or "").strip()
|
||||||
|
if not edit_model:
|
||||||
|
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||||
|
return {
|
||||||
|
"generation": generation,
|
||||||
|
"edit": edit_model or generation,
|
||||||
|
}
|
||||||
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECOLOR_TRAILING_ADJ = re.compile(
|
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
|
||||||
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
|
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
|
||||||
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
|
"lighter", "deep", "pale", "more", "less", "very", "really",
|
||||||
re.I,
|
"slightly", "totally", "completely",
|
||||||
)
|
})
|
||||||
|
|
||||||
|
|
||||||
def _hex_rgb01(hex_color: str):
|
def _hex_rgb01(hex_color: str):
|
||||||
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
|
|||||||
words.pop(0)
|
words.pop(0)
|
||||||
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
||||||
words.pop()
|
words.pop()
|
||||||
|
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
|
||||||
|
words.pop()
|
||||||
phrase = " ".join(words)
|
phrase = " ".join(words)
|
||||||
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
|
|
||||||
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
|
|
||||||
return phrase or "the subject"
|
return phrase or "the subject"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Shared MIME text decoding for the web email helpers and MCP server."""
|
||||||
|
|
||||||
|
|
||||||
|
def decode_text_part(part) -> str:
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if not payload:
|
||||||
|
return ""
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
return payload.decode(charset, errors="replace")
|
||||||
|
except (LookupError, ValueError):
|
||||||
|
# Broken senders can label valid UTF-8 with an unsupported codec.
|
||||||
|
return payload.decode("utf-8", errors="replace")
|
||||||
@@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
bare = tool.removeprefix("mcp__email__")
|
bare = tool.removeprefix("mcp__email__")
|
||||||
if bare == "list_email_accounts":
|
if bare == "list_email_accounts":
|
||||||
accounts = {}
|
|
||||||
for index, message in enumerate(messages):
|
|
||||||
row = _row(index, message)
|
|
||||||
identity = (row["account_id"], row["account"], row["account_email"])
|
|
||||||
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
|
|
||||||
"email": row["account_email"], "default": not accounts})
|
|
||||||
return {
|
return {
|
||||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||||
"output": "Fixture mailbox (default)",
|
"output": "Fixture mailbox (default)",
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
|||||||
return f"{width}x{height}"
|
return f"{width}x{height}"
|
||||||
|
|
||||||
|
|
||||||
|
def image_aspect_ratio(width: int, height: int) -> str:
|
||||||
|
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("Image dimensions must be positive")
|
||||||
|
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||||
|
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||||
|
return f"{w}:{h}"
|
||||||
|
|
||||||
|
|
||||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||||
"""Return True when a model id should use image generation routes.
|
"""Return True when a model id should use image generation routes.
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -17,6 +17,7 @@ Env:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -98,18 +99,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
|||||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||||
if not isinstance(answer, dict):
|
if not isinstance(answer, dict):
|
||||||
return None
|
return None
|
||||||
if answer.get("type") == "score":
|
try:
|
||||||
try:
|
if answer.get("type") == "score":
|
||||||
return float(answer.get("score"))
|
value = float(answer.get("score"))
|
||||||
except Exception:
|
elif answer.get("type") == "choice":
|
||||||
|
probs = answer.get("probabilities") or {}
|
||||||
|
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
if answer.get("type") == "choice":
|
except Exception:
|
||||||
probs = answer.get("probabilities") or {}
|
return None
|
||||||
try:
|
# A malformed adapter reply must fall back like any other failure.
|
||||||
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
return value if math.isfinite(value) else None
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
|||||||
+77
-36
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
|||||||
return f"data: {json.dumps(payload)}\n\n"
|
return f"data: {json.dumps(payload)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||||
|
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||||
|
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||||
|
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||||
|
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||||
|
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_numeric_token(token: str) -> bool:
|
||||||
|
if token.isdigit():
|
||||||
|
return True
|
||||||
|
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||||
|
|
||||||
|
|
||||||
class _DegenerateStreamGuard:
|
class _DegenerateStreamGuard:
|
||||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
|||||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||||
same-token runs or a very dominant repeated token in the recent window.
|
same-token runs or a very dominant repeated token in the recent window.
|
||||||
|
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||||
|
padded buffer), so it only stops at a long all-numeric backstop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model: str):
|
def __init__(self, model: str):
|
||||||
self.model = model or "model"
|
self.model = model or "model"
|
||||||
self.last_token = ""
|
self.last_token = ""
|
||||||
self.same_run = 0
|
self.same_run = 0
|
||||||
|
self.numeric_run = 0
|
||||||
self.recent_tokens: List[str] = []
|
self.recent_tokens: List[str] = []
|
||||||
self.periodic_tokens: List[str] = []
|
self.periodic_tokens: List[str] = []
|
||||||
self.total_chars = 0
|
self.total_chars = 0
|
||||||
|
self._buffer = ""
|
||||||
|
|
||||||
def check(self, text: str) -> Optional[str]:
|
def check(self, text: str) -> Optional[str]:
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
self.total_chars += len(text)
|
self.total_chars += len(text)
|
||||||
# Keep one-character tokens. Structured output often varies through
|
self._buffer += text
|
||||||
# numeric selectors or coordinates; dropping those tokens can make
|
|
||||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
if m_delims:
|
||||||
|
split_idx = m_delims[-1].end()
|
||||||
|
head = self._buffer[:split_idx]
|
||||||
|
self._buffer = self._buffer[split_idx:]
|
||||||
|
elif len(self._buffer) >= 256:
|
||||||
|
head = self._buffer
|
||||||
|
self._buffer = ""
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
for m in _TOKEN_RE.finditer(head):
|
||||||
|
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return None
|
return None
|
||||||
for token in tokens:
|
|
||||||
|
for token, is_num in tokens:
|
||||||
if token == self.last_token:
|
if token == self.last_token:
|
||||||
self.same_run += 1
|
self.same_run += 1
|
||||||
else:
|
else:
|
||||||
self.last_token = token
|
self.last_token = token
|
||||||
self.same_run = 1
|
self.same_run = 1
|
||||||
|
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||||
self.recent_tokens.append(token)
|
self.recent_tokens.append(token)
|
||||||
self.periodic_tokens.append(token)
|
self.periodic_tokens.append(token)
|
||||||
if len(self.recent_tokens) > 96:
|
if len(self.recent_tokens) > 96:
|
||||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
|||||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||||
|
|
||||||
reason = None
|
reason = None
|
||||||
if self.same_run >= 28 and self.total_chars >= 100:
|
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||||
elif len(self.recent_tokens) >= 72:
|
elif len(self.recent_tokens) >= 72:
|
||||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||||
count = self.recent_tokens.count(top)
|
count = self.recent_tokens.count(top)
|
||||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||||
if not reason and len(self.recent_tokens) >= 48:
|
if (
|
||||||
|
not reason
|
||||||
|
and len(self.recent_tokens) >= 48
|
||||||
|
# A window that is mostly numbers is matrix/table data; its
|
||||||
|
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||||
|
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||||
|
):
|
||||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||||
# generation repeats nearly every token at a short fixed period.
|
# generation repeats nearly every token at a short fixed period.
|
||||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
|||||||
if history[previous:previous + anchor_size] != anchor:
|
if history[previous:previous + anchor_size] != anchor:
|
||||||
continue
|
continue
|
||||||
period = current_anchor - previous
|
period = current_anchor - previous
|
||||||
if period < 24 or len(history) < period * 2:
|
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||||
continue
|
continue
|
||||||
if history[-2 * period:-period] == history[-period:]:
|
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||||
reason = f"repeated an exact {period}-token block twice"
|
reason = f"repeated an exact {period}-token block twice"
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not reason and self.numeric_run >= 2048:
|
||||||
|
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||||
|
|
||||||
if not reason:
|
if not reason:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1556,8 +1594,9 @@ def _build_chatgpt_responses_payload(
|
|||||||
"stream": stream,
|
"stream": stream,
|
||||||
"store": False,
|
"store": False,
|
||||||
}
|
}
|
||||||
if not _restricts_temperature(model):
|
# ChatGPT plan Responses requests reject temperature for every model.
|
||||||
payload["temperature"] = temperature
|
# The common caller still passes its preset value, but this provider must
|
||||||
|
# omit the field even for model names outside the known reasoning families.
|
||||||
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
||||||
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
||||||
# Do not include it in the payload.
|
# Do not include it in the payload.
|
||||||
@@ -2282,6 +2321,25 @@ def _apply_explicit_nonstream_reasoning_effort(
|
|||||||
payload["reasoning_effort"] = effort
|
payload["reasoning_effort"] = effort
|
||||||
|
|
||||||
|
|
||||||
|
def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str:
|
||||||
|
"""Return the answer text of an OpenAI-compatible non-streaming message.
|
||||||
|
|
||||||
|
With thinking off the caller wants only the answer, so an empty answer
|
||||||
|
stays empty even when the model spent its whole budget on reasoning.
|
||||||
|
Otherwise reasoning stands in for a missing answer, and Mistral's
|
||||||
|
structured thinking is kept ahead of its text.
|
||||||
|
"""
|
||||||
|
thinking_off = _normalize_thinking_mode(thinking_mode) == "off"
|
||||||
|
reasoning = "" if thinking_off else msg.get("reasoning_content")
|
||||||
|
content = msg.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return content or reasoning or ""
|
||||||
|
text_part, thinking_part = _normalize_mistral_content(content)
|
||||||
|
if thinking_part and not thinking_off:
|
||||||
|
return thinking_part + "\n\n" + (text_part or "")
|
||||||
|
return text_part or reasoning or ""
|
||||||
|
|
||||||
|
|
||||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
@@ -2373,17 +2431,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
elif provider == "ollama":
|
elif provider == "ollama":
|
||||||
response = _parse_ollama_response(data)
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
msg = data["choices"][0]["message"]
|
response = _nonstream_openai_response_text(
|
||||||
content = msg.get("content")
|
data["choices"][0]["message"], thinking_mode
|
||||||
if isinstance(content, list):
|
)
|
||||||
# Mistral structured content — extract thinking + text
|
|
||||||
text_part, thinking_part = _normalize_mistral_content(content)
|
|
||||||
if thinking_part:
|
|
||||||
response = thinking_part + "\n\n" + (text_part or "")
|
|
||||||
else:
|
|
||||||
response = text_part or msg.get("reasoning_content") or ""
|
|
||||||
else:
|
|
||||||
response = content or msg.get("reasoning_content") or ""
|
|
||||||
_set_cached_response(cache_key, response)
|
_set_cached_response(cache_key, response)
|
||||||
return response
|
return response
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2821,18 +2871,9 @@ async def llm_call_async(
|
|||||||
elif provider == "ollama":
|
elif provider == "ollama":
|
||||||
response = _parse_ollama_response(data)
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
msg = data["choices"][0]["message"]
|
response = _nonstream_openai_response_text(
|
||||||
content = msg.get("content")
|
data["choices"][0]["message"], thinking_mode
|
||||||
if isinstance(content, list):
|
)
|
||||||
# Mistral structured content — extract thinking + text
|
|
||||||
# (same contract as llm_call / stream_llm; see #5435).
|
|
||||||
text_part, thinking_part = _normalize_mistral_content(content)
|
|
||||||
if thinking_part:
|
|
||||||
response = thinking_part + "\n\n" + (text_part or "")
|
|
||||||
else:
|
|
||||||
response = text_part or msg.get("reasoning_content") or ""
|
|
||||||
else:
|
|
||||||
response = content or msg.get("reasoning_content") or ""
|
|
||||||
_set_cached_response(
|
_set_cached_response(
|
||||||
cache_key,
|
cache_key,
|
||||||
response,
|
response,
|
||||||
|
|||||||
+19
-6
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
from src.database import McpServer, SessionLocal
|
from src.database import McpServer, SessionLocal
|
||||||
|
|
||||||
@@ -383,12 +384,21 @@ class McpManager:
|
|||||||
|
|
||||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
try:
|
||||||
read_stream, write_stream, _get_session_id = transport
|
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
read_stream, write_stream, _get_session_id = transport
|
||||||
await session.initialize()
|
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||||
|
await session.initialize()
|
||||||
|
|
||||||
tools_result = await session.list_tools()
|
tools_result = await session.list_tools()
|
||||||
|
except BaseException:
|
||||||
|
# Close the half-open transport here, in the task that entered
|
||||||
|
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||||
|
# exited from the wrong task and the event loop keeps spinning
|
||||||
|
# (#5518). Its own teardown error would only mask the real one.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await stack.aclose()
|
||||||
|
raise
|
||||||
tools = []
|
tools = []
|
||||||
for tool in tools_result.tools:
|
for tool in tools_result.tools:
|
||||||
tools.append({
|
tools.append({
|
||||||
@@ -760,8 +770,11 @@ class McpManager:
|
|||||||
qualified.add(f"mcp__{server_id}__{tool['name']}")
|
qualified.add(f"mcp__{server_id}__{tool['name']}")
|
||||||
return disabled_map, qualified
|
return disabled_map, qualified
|
||||||
|
|
||||||
def is_builtin(self, server_id: str) -> bool:
|
@staticmethod
|
||||||
|
def is_builtin(server_id: str) -> bool:
|
||||||
"""Check if a server is a built-in (auto-registered) server."""
|
"""Check if a server is a built-in (auto-registered) server."""
|
||||||
|
if not isinstance(server_id, str):
|
||||||
|
return False
|
||||||
return server_id.startswith("builtin_") or server_id in {
|
return server_id.startswith("builtin_") or server_id in {
|
||||||
"image_gen",
|
"image_gen",
|
||||||
"memory",
|
"memory",
|
||||||
|
|||||||
+15
-26
@@ -364,32 +364,32 @@ class MemoryManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Define keyword categories for semantic matching
|
# Define keyword categories for semantic matching
|
||||||
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
|
identity_words = ["name", "called", "identity", "myself"]
|
||||||
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
|
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
|
||||||
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
|
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
|
||||||
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
|
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
|
||||||
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
|
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
|
||||||
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
|
query_words = set(re.findall(r'\w+', query_lower))
|
||||||
|
query_tokens = set(tokenize(query_lower))
|
||||||
|
|
||||||
# Determine query type based on keywords
|
# Determine query type based on keywords
|
||||||
query_type = None
|
query_type = None
|
||||||
if any(word in query_lower for word in identity_words):
|
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
|
||||||
query_type = "identity"
|
query_type = "identity"
|
||||||
elif any(word in query_lower for word in contact_words):
|
elif query_words.intersection(contact_words):
|
||||||
query_type = "contact"
|
query_type = "contact"
|
||||||
elif any(word in query_lower for word in preference_words):
|
elif query_words.intersection(preference_words):
|
||||||
query_type = "preference"
|
query_type = "preference"
|
||||||
elif any(word in query_lower for word in task_words):
|
elif query_words.intersection(task_words):
|
||||||
query_type = "task"
|
query_type = "task"
|
||||||
elif any(word in query_lower for word in fact_words):
|
elif query_words.intersection(fact_words):
|
||||||
query_type = "fact"
|
query_type = "fact"
|
||||||
|
|
||||||
relevant = []
|
relevant = []
|
||||||
identity_memories = []
|
# Every memory participates in the same ranking and threshold check.
|
||||||
other_memories = []
|
# Query type is a scoring hint, not an unconditional insertion path.
|
||||||
|
|
||||||
# Separate identity memories from others
|
|
||||||
for memory in memories:
|
for memory in memories:
|
||||||
memory_text = memory["text"].lower()
|
memory_text = memory["text"].lower()
|
||||||
# Check if this is an identity memory (contains name patterns or identity indicators)
|
# Check if this is an identity memory (contains name patterns or identity indicators)
|
||||||
@@ -397,22 +397,7 @@ class MemoryManager:
|
|||||||
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
|
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
|
||||||
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
|
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
|
||||||
])
|
])
|
||||||
if is_identity:
|
|
||||||
identity_memories.append(memory)
|
|
||||||
else:
|
|
||||||
other_memories.append(memory)
|
|
||||||
|
|
||||||
# For identity queries, include all identity memories regardless of similarity
|
|
||||||
if query_type == "identity" and identity_memories:
|
|
||||||
# Give them high scores to ensure they're included first
|
|
||||||
for memory in identity_memories:
|
|
||||||
relevant.append((0.9, memory)) # High score for identity memories in identity queries
|
|
||||||
|
|
||||||
# Process other memories with similarity scoring
|
|
||||||
for memory in other_memories:
|
|
||||||
memory_text = memory["text"].lower()
|
|
||||||
memory_tokens = set(tokenize(memory_text))
|
memory_tokens = set(tokenize(memory_text))
|
||||||
query_tokens = set(tokenize(query_lower))
|
|
||||||
|
|
||||||
# Calculate base Jaccard similarity
|
# Calculate base Jaccard similarity
|
||||||
if not query_tokens or not memory_tokens:
|
if not query_tokens or not memory_tokens:
|
||||||
@@ -422,7 +407,11 @@ class MemoryManager:
|
|||||||
final_score = base_similarity
|
final_score = base_similarity
|
||||||
|
|
||||||
# Apply boosts based on semantic matching
|
# Apply boosts based on semantic matching
|
||||||
if query_type == "contact":
|
if query_type == "identity" and is_identity:
|
||||||
|
# Keep explicit name/identity recall useful without outranking
|
||||||
|
# a closer textual match or bypassing the caller's threshold.
|
||||||
|
final_score = max(final_score * 1.3, 0.6)
|
||||||
|
elif query_type == "contact":
|
||||||
# Boost memories with contact information
|
# Boost memories with contact information
|
||||||
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
|
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
|
||||||
"phone", "number", "address",
|
"phone", "number", "address",
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
ids = [
|
||||||
|
m["id"] for m in items
|
||||||
|
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||||
|
]
|
||||||
if ids:
|
if ids:
|
||||||
return {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
|
|||||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
|||||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||||
|
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||||
|
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||||
_FIELD_BULLET_RE = re.compile(
|
_FIELD_BULLET_RE = re.compile(
|
||||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
|||||||
"""
|
"""
|
||||||
out = []
|
out = []
|
||||||
for ch in name or "":
|
for ch in name or "":
|
||||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||||
out.append(ch)
|
out.append(ch)
|
||||||
else:
|
else:
|
||||||
for b in ch.encode("utf-8"):
|
for b in ch.encode("utf-8"):
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ def untrusted_context_message(
|
|||||||
"""
|
"""
|
||||||
safe_label = _sanitize_label(label)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
text = "" if content is None else str(content)
|
||||||
import logging as _lg
|
|
||||||
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"trusted": False,
|
"trusted": False,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from markdown.blockprocessors import BlockProcessor
|
||||||
|
from markdown.extensions import Extension
|
||||||
|
from markdown.inlinepatterns import InlineProcessor
|
||||||
|
from markdown.util import AtomicString
|
||||||
|
|
||||||
|
|
||||||
|
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
|
||||||
|
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
|
||||||
|
_DOLLAR_MATH = (
|
||||||
|
r"\$\$([\s\S]*?)\$\$|"
|
||||||
|
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder(tex, display):
|
||||||
|
node = ElementTree.Element("span", {
|
||||||
|
"class": "ody-math-pending", "data-display": str(display).lower(),
|
||||||
|
})
|
||||||
|
node.text = AtomicString(tex)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
class _HtmlContext(HTMLParser):
|
||||||
|
"""Inline HTML is still raw when math runs, including tag attributes."""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.protected = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag in {"code", "pre", "script", "style", "textarea"}:
|
||||||
|
self.protected.append(tag)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in self.protected:
|
||||||
|
del self.protected[self.protected.index(tag):]
|
||||||
|
|
||||||
|
|
||||||
|
class _InlineMath(InlineProcessor):
|
||||||
|
def handleMatch(self, match, data):
|
||||||
|
prefix = data[:match.start()]
|
||||||
|
if "<" in prefix:
|
||||||
|
context = _HtmlContext()
|
||||||
|
context.feed(prefix)
|
||||||
|
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
|
||||||
|
return None, None, None
|
||||||
|
# Backticks run before this processor. Preserve a backslash-escaped
|
||||||
|
# dollar too; math must run before Markdown consumes \( and \[.
|
||||||
|
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
|
||||||
|
return None, None, None
|
||||||
|
tex = next(group for group in match.groups() if group is not None)
|
||||||
|
display = match.group(1) is not None
|
||||||
|
return _placeholder(tex, display), match.start(), match.end()
|
||||||
|
|
||||||
|
|
||||||
|
class _DisplayMath(BlockProcessor):
|
||||||
|
def test(self, parent, block):
|
||||||
|
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
|
||||||
|
|
||||||
|
def run(self, parent, blocks):
|
||||||
|
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
|
||||||
|
closing = "$$" if opening.group(1) == "$$" else "\\]"
|
||||||
|
source = blocks[0][opening.end():]
|
||||||
|
consumed = 1
|
||||||
|
while closing not in source:
|
||||||
|
if consumed == len(blocks):
|
||||||
|
return False # Leave incomplete input to ordinary Markdown.
|
||||||
|
source += "\n\n" + blocks[consumed]
|
||||||
|
consumed += 1
|
||||||
|
tex, _, tail = source.partition(closing)
|
||||||
|
del blocks[:consumed]
|
||||||
|
if tail.strip():
|
||||||
|
blocks.insert(0, tail)
|
||||||
|
parent.append(_placeholder(tex, True))
|
||||||
|
|
||||||
|
|
||||||
|
class ReportMathExtension(Extension):
|
||||||
|
def extendMarkdown(self, md):
|
||||||
|
# Backticks (190) and indented code (80) retain precedence.
|
||||||
|
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
|
||||||
|
# Parse links before dollar math so URL destinations stay literal.
|
||||||
|
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
|
||||||
|
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user