mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 06:02:30 +01:00
Compare commits
8
Commits
adc6ac9394
...
d6882a895e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6882a895e | ||
|
|
4a9085d252 | ||
|
|
aab203cf51 | ||
|
|
ab2f7cffca | ||
|
|
fe8d8cd020 | ||
|
|
233390546c | ||
|
|
1e0d9b92af | ||
|
|
ac94885c84 |
@@ -56,6 +56,13 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# SQLite database path (default: sqlite:///./data/app.db)
|
# SQLite database path (default: sqlite:///./data/app.db)
|
||||||
# DATABASE_URL=sqlite:///./data/app.db
|
# DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Data directory
|
||||||
|
# ============================================================
|
||||||
|
# Move everything that lives under data/ - settings, sessions, database, auth,
|
||||||
|
# cache, uploads, etc. - to another path:
|
||||||
|
# ODYSSEUS_DATA_DIR=C:\path\to\dir
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Auth & Security
|
# Auth & Security
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).)
|
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).)
|
||||||
required: true
|
required: true
|
||||||
- label: I am running the latest code from `main`.
|
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
name: ci / docker publish
|
||||||
|
|
||||||
|
# Build the Odysseus image and publish to GHCR.
|
||||||
|
# push to main -> :latest, :X.Y.Z (curated release; main is fast-forwarded at releases)
|
||||||
|
# push to dev -> :dev, :X.Y.Z-dev.<sha> (rolling dev + an immutable, traceable pin)
|
||||||
|
# Multi-arch (linux/amd64 + linux/arm64): each arch builds on its own native
|
||||||
|
# runner and pushes by digest, then a merge job stitches the digests into one
|
||||||
|
# manifest list and applies the tags (faster + cleaner than QEMU emulation).
|
||||||
|
# Registry: ghcr.io/<owner>/<repo>.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-publish-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build (${{ matrix.arch }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.arch }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: merge manifest + tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
- name: Read APP_VERSION + short sha
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
v=$(grep -E '^APP_VERSION' src/constants.py | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
[ -n "$v" ] || { echo "APP_VERSION not found"; exit 1; }
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
type=raw,value=${{ steps.ver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||||
|
type=raw,value=${{ steps.ver.outputs.version }}-dev.${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/dev' }}
|
||||||
|
- name: Create manifest list + push tags
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
|
digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *)
|
||||||
|
docker buildx imagetools create $tags $digests
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
|
- name: Inspect
|
||||||
|
run: |
|
||||||
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then ref=latest; else ref=dev; fi
|
||||||
|
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}"
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||||
+3
-3
@@ -98,11 +98,11 @@ If you are unsure whether a change is "visual," it is. Default to attaching a sc
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree or hardcode `/app/...`. Use `DATA_DIR` (and the other path constants) from `core.constants`, e.g. `Path(DATA_DIR) / "logs" / "x.log"`. 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:** 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.
|
||||||
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `core.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
|
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `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, 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.
|
||||||
|
|
||||||
If you need a value that has no constant or helper yet, add one in the appropriate module (usually `core/constants.py` or `src/constants.py`) and import it, rather than repeating a literal across files.
|
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), `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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
# Core imports
|
# Core imports
|
||||||
from core.constants import (
|
from core.constants import (
|
||||||
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
|
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
|
||||||
REQUEST_TIMEOUT, OPENAI_API_KEY,
|
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
||||||
)
|
)
|
||||||
from core.database import SessionLocal, ApiToken
|
from core.database import SessionLocal, ApiToken
|
||||||
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
||||||
@@ -598,6 +598,10 @@ app.include_router(setup_model_routes(model_discovery))
|
|||||||
from routes.copilot_routes import setup_copilot_routes
|
from routes.copilot_routes import setup_copilot_routes
|
||||||
app.include_router(setup_copilot_routes())
|
app.include_router(setup_copilot_routes())
|
||||||
|
|
||||||
|
# ChatGPT Subscription device-flow login
|
||||||
|
from routes.chatgpt_subscription_routes import setup_chatgpt_subscription_routes
|
||||||
|
app.include_router(setup_chatgpt_subscription_routes())
|
||||||
|
|
||||||
# TTS
|
# TTS
|
||||||
from routes.tts_routes import setup_tts_routes
|
from routes.tts_routes import setup_tts_routes
|
||||||
app.include_router(setup_tts_routes(tts_service))
|
app.include_router(setup_tts_routes(tts_service))
|
||||||
@@ -954,7 +958,7 @@ async def _startup_event():
|
|||||||
owners = set()
|
owners = set()
|
||||||
try:
|
try:
|
||||||
import json as _json
|
import json as _json
|
||||||
auth_path = "data/auth.json"
|
auth_path = AUTH_FILE
|
||||||
with open(auth_path, encoding="utf-8") as f:
|
with open(auth_path, encoding="utf-8") as f:
|
||||||
users = _json.load(f).get("users", {})
|
users = _json.load(f).get("users", {})
|
||||||
owners.update(users.keys())
|
owners.update(users.keys())
|
||||||
@@ -1001,7 +1005,7 @@ async def _startup_event():
|
|||||||
# does not make an existing library look empty after auth/account changes.
|
# does not make an existing library look empty after auth/account changes.
|
||||||
try:
|
try:
|
||||||
import json as _json
|
import json as _json
|
||||||
auth_path = "data/auth.json"
|
auth_path = AUTH_FILE
|
||||||
with open(auth_path, encoding="utf-8") as f:
|
with open(auth_path, encoding="utf-8") as f:
|
||||||
users = _json.load(f).get("users", {})
|
users = _json.load(f).get("users", {})
|
||||||
primary_owner = None
|
primary_owner = None
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import uuid
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
|
from src.constants import AUTH_FILE
|
||||||
|
|
||||||
PAIRING_VERSION = 1
|
PAIRING_VERSION = 1
|
||||||
COMPANION_SCOPE = "chat"
|
COMPANION_SCOPE = "chat"
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ def lan_ip_candidates() -> list[str]:
|
|||||||
def find_admin_user() -> str | None:
|
def find_admin_user() -> str | None:
|
||||||
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
||||||
falling back to the first user."""
|
falling back to the first user."""
|
||||||
auth_path = os.path.join("data", "auth.json")
|
auth_path = AUTH_FILE
|
||||||
try:
|
try:
|
||||||
with open(auth_path, "r", encoding="utf-8") as f:
|
with open(auth_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|||||||
+2
-3
@@ -37,9 +37,8 @@ DEFAULT_PRIVILEGES = {
|
|||||||
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
|
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
|
||||||
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
||||||
|
|
||||||
DEFAULT_AUTH_PATH = os.path.join(
|
from src.constants import AUTH_FILE
|
||||||
Path(__file__).parent.parent, "data", "auth.json"
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||||
)
|
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||||
|
|||||||
+11
-58
@@ -1,59 +1,12 @@
|
|||||||
# src/constants.py
|
# core/constants.py
|
||||||
"""Application-wide constants and configuration values."""
|
"""Backward-compatible shim — the single source of truth is src/constants.py.
|
||||||
import os
|
|
||||||
|
|
||||||
APP_VERSION = "0.9.1"
|
Historically there were two copies of this module (this one lagged behind at
|
||||||
|
APP_VERSION 0.9.1 and was missing the consolidated tool-output constants). To
|
||||||
# Base paths
|
kill the drift, this now simply re-exports everything from src.constants so
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
there is exactly one place that defines paths and reads ODYSSEUS_DATA_DIR.
|
||||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
internal_api_base() also lives in src.constants now and is re-exported here so
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
existing `from core.constants import internal_api_base` callers keep working.
|
||||||
|
"""
|
||||||
# Data file paths
|
from src.constants import * # noqa: F401,F403
|
||||||
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
from src.constants import internal_api_base # noqa: F401 (explicit: functions aren't covered by some linters' * checks)
|
||||||
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
|
||||||
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
|
|
||||||
PERSONAL_DIR = os.path.join(DATA_DIR, "personal_docs")
|
|
||||||
RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook")
|
|
||||||
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
|
||||||
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
|
|
||||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
MAX_CONTEXT_MESSAGES = 90
|
|
||||||
REQUEST_TIMEOUT = 20
|
|
||||||
OPENAI_COMPAT_PATH = "/v1/chat/completions"
|
|
||||||
|
|
||||||
# Environment variables with defaults
|
|
||||||
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
|
|
||||||
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
|
|
||||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
||||||
SEARXNG_INSTANCE = os.getenv('SEARXNG_INSTANCE', 'http://localhost:8080')
|
|
||||||
|
|
||||||
|
|
||||||
# Cleanup configuration
|
|
||||||
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
|
||||||
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
|
||||||
|
|
||||||
# Default parameters
|
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
|
||||||
DEFAULT_MAX_TOKENS = 0
|
|
||||||
|
|
||||||
|
|
||||||
def internal_api_base() -> str:
|
|
||||||
"""Base URL for in-process loopback calls to Odysseus's own API.
|
|
||||||
|
|
||||||
Agent tools and background jobs reach admin-gated routes by calling the
|
|
||||||
running server over HTTP. Resolution order:
|
|
||||||
1. ODYSSEUS_INTERNAL_BASE - explicit override (e.g. behind a TLS proxy).
|
|
||||||
2. APP_PORT - http://127.0.0.1:$APP_PORT (docker-compose).
|
|
||||||
3. Fallback http://127.0.0.1:7000 - legacy default.
|
|
||||||
|
|
||||||
127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local
|
|
||||||
call. Without this, loopback tools fail with "All connection attempts
|
|
||||||
failed" whenever the server is not on port 7000.
|
|
||||||
"""
|
|
||||||
override = os.environ.get("ODYSSEUS_INTERNAL_BASE")
|
|
||||||
if override:
|
|
||||||
return override.rstrip("/")
|
|
||||||
return f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}"
|
|
||||||
|
|||||||
+46
-6
@@ -29,8 +29,9 @@ class TimestampMixin:
|
|||||||
def updated_at(cls):
|
def updated_at(cls):
|
||||||
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
||||||
|
|
||||||
# Get database URL from environment, default to SQLite
|
# Get database URL from environment, default to SQLite in DATA_DIR
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
from src.constants import DATA_DIR, AUTH_FILE, MEMORY_FILE, USER_PREFS_FILE, SETTINGS_FILE
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR}/app.db")
|
||||||
|
|
||||||
# Create engine
|
# Create engine
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
@@ -360,6 +361,24 @@ class ModelEndpoint(TimestampMixin, Base):
|
|||||||
# is the historical default. When non-null, the model picker only shows
|
# is the historical default. When non-null, the model picker only shows
|
||||||
# the endpoint to that user (admins always see everything).
|
# the endpoint to that user (admins always see everything).
|
||||||
owner = Column(String, nullable=True, index=True)
|
owner = Column(String, nullable=True, index=True)
|
||||||
|
# Optional OAuth/session-backed credential row. Used by subscription-backed
|
||||||
|
# providers that need refresh tokens instead of a static API key.
|
||||||
|
provider_auth_id = Column(String, nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAuthSession(TimestampMixin, Base):
|
||||||
|
"""Encrypted OAuth/session credentials for refresh-aware model providers."""
|
||||||
|
__tablename__ = "provider_auth_sessions"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
provider = Column(String, nullable=False, index=True)
|
||||||
|
owner = Column(String, nullable=True, index=True)
|
||||||
|
label = Column(String, nullable=True)
|
||||||
|
base_url = Column(String, nullable=False)
|
||||||
|
access_token = Column(EncryptedText, nullable=True)
|
||||||
|
refresh_token = Column(EncryptedText, nullable=True)
|
||||||
|
last_refresh = Column(DateTime, nullable=True)
|
||||||
|
auth_mode = Column(String, nullable=True)
|
||||||
|
|
||||||
class McpServer(TimestampMixin, Base):
|
class McpServer(TimestampMixin, Base):
|
||||||
"""Admin-configured MCP (Model Context Protocol) tool servers."""
|
"""Admin-configured MCP (Model Context Protocol) tool servers."""
|
||||||
@@ -800,6 +819,26 @@ def _migrate_add_model_endpoint_owner_column():
|
|||||||
logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}")
|
logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_provider_auth_id_column():
|
||||||
|
"""Add provider_auth_id column to model_endpoints if it doesn't exist."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.execute("PRAGMA table_info(model_endpoints)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
if columns and "provider_auth_id" not in columns:
|
||||||
|
conn.execute("ALTER TABLE model_endpoints ADD COLUMN provider_auth_id VARCHAR")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_provider_auth_id ON model_endpoints(provider_auth_id)")
|
||||||
|
conn.commit()
|
||||||
|
logging.getLogger(__name__).info("Migrated: added 'provider_auth_id' column + index to model_endpoints")
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"model_endpoints.provider_auth_id migration failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_model_type_column():
|
def _migrate_add_model_type_column():
|
||||||
"""Add model_type column to model_endpoints if it doesn't exist."""
|
"""Add model_type column to model_endpoints if it doesn't exist."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -1065,7 +1104,7 @@ def _migrate_assign_legacy_owner():
|
|||||||
# fell through to "first user" every time.
|
# fell through to "first user" every time.
|
||||||
auth_path = os.path.join(os.path.dirname(DATABASE_URL.replace("sqlite:///", "")), "auth.json")
|
auth_path = os.path.join(os.path.dirname(DATABASE_URL.replace("sqlite:///", "")), "auth.json")
|
||||||
if not os.path.isabs(auth_path):
|
if not os.path.isabs(auth_path):
|
||||||
auth_path = os.path.join("data", "auth.json")
|
auth_path = AUTH_FILE
|
||||||
admin_user = None
|
admin_user = None
|
||||||
try:
|
try:
|
||||||
with open(auth_path, "r", encoding="utf-8") as f:
|
with open(auth_path, "r", encoding="utf-8") as f:
|
||||||
@@ -1118,7 +1157,7 @@ def _migrate_assign_legacy_owner():
|
|||||||
logger.warning(f"Legacy owner migration failed: {e}")
|
logger.warning(f"Legacy owner migration failed: {e}")
|
||||||
|
|
||||||
# Also migrate memory.json
|
# Also migrate memory.json
|
||||||
mem_path = os.path.join("data", "memory.json")
|
mem_path = MEMORY_FILE
|
||||||
try:
|
try:
|
||||||
if os.path.exists(mem_path):
|
if os.path.exists(mem_path):
|
||||||
with open(mem_path, "r", encoding="utf-8") as f:
|
with open(mem_path, "r", encoding="utf-8") as f:
|
||||||
@@ -1136,7 +1175,7 @@ def _migrate_assign_legacy_owner():
|
|||||||
logger.warning(f"memory.json legacy migration failed: {e}")
|
logger.warning(f"memory.json legacy migration failed: {e}")
|
||||||
|
|
||||||
# Also migrate user_prefs.json to per-user format
|
# Also migrate user_prefs.json to per-user format
|
||||||
prefs_path = os.path.join("data", "user_prefs.json")
|
prefs_path = USER_PREFS_FILE
|
||||||
try:
|
try:
|
||||||
if os.path.exists(prefs_path):
|
if os.path.exists(prefs_path):
|
||||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||||
@@ -1530,7 +1569,7 @@ def _migrate_seed_email_account():
|
|||||||
import json as _json
|
import json as _json
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
settings_file = Path("data/settings.json")
|
settings_file = Path(SETTINGS_FILE)
|
||||||
if not settings_file.exists():
|
if not settings_file.exists():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -1598,6 +1637,7 @@ def init_db():
|
|||||||
_migrate_add_model_type_column()
|
_migrate_add_model_type_column()
|
||||||
_migrate_add_model_endpoint_refresh_columns()
|
_migrate_add_model_endpoint_refresh_columns()
|
||||||
_migrate_add_model_endpoint_owner_column()
|
_migrate_add_model_endpoint_owner_column()
|
||||||
|
_migrate_add_provider_auth_id_column()
|
||||||
_migrate_add_supports_tools_column()
|
_migrate_add_supports_tools_column()
|
||||||
_migrate_add_task_run_model_column()
|
_migrate_add_task_run_model_column()
|
||||||
_migrate_add_owner_column()
|
_migrate_add_owner_column()
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
|
|
||||||
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"))
|
||||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
||||||
|
DATA_DIR = Path(_DATA_DIR)
|
||||||
|
|
||||||
|
|
||||||
def _b(value) -> bytes:
|
def _b(value) -> bytes:
|
||||||
@@ -63,7 +64,7 @@ def _clean_header_value(value) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _db_path() -> Path:
|
def _db_path() -> Path:
|
||||||
return DATA_DIR / "app.db"
|
return Path(APP_DB)
|
||||||
|
|
||||||
|
|
||||||
def _list_accounts_raw() -> list:
|
def _list_accounts_raw() -> list:
|
||||||
@@ -162,7 +163,7 @@ def _load_config(account: str | None = None) -> dict:
|
|||||||
"trash_folder": os.environ.get("TRASH_FOLDER", "Trash"),
|
"trash_folder": os.environ.get("TRASH_FOLDER", "Trash"),
|
||||||
"cache_db": os.environ.get(
|
"cache_db": os.environ.get(
|
||||||
"EMAIL_CACHE_DB",
|
"EMAIL_CACHE_DB",
|
||||||
str(DATA_DIR / "email_cache.db"),
|
EMAIL_CACHE_DB,
|
||||||
),
|
),
|
||||||
"account_id": None,
|
"account_id": None,
|
||||||
"account_name": None,
|
"account_name": None,
|
||||||
@@ -204,7 +205,7 @@ def _load_config(account: str | None = None) -> dict:
|
|||||||
else:
|
else:
|
||||||
# Legacy fallback: settings.json flat keys
|
# Legacy fallback: settings.json flat keys
|
||||||
try:
|
try:
|
||||||
settings_path = Path(__file__).resolve().parent.parent / "data" / "settings.json"
|
settings_path = Path(_SETTINGS_FILE)
|
||||||
if settings_path.exists():
|
if settings_path.exists():
|
||||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
for key in (
|
for key in (
|
||||||
@@ -1061,7 +1062,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
|||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
msg = email.message_from_bytes(raw)
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
target_dir = DATA_DIR / "mail-attachments" / f"{folder}_{uid}"
|
target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}"
|
||||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||||
if not filepath:
|
if not filepath:
|
||||||
return {"error": f"Attachment index {index} not found"}
|
return {"error": f"Attachment index {index} not found"}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from mcp.types import Tool, TextContent
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
server = Server("image_gen")
|
server = Server("image_gen")
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
_pub_base = (get_setting("app_public_url", "") or "").rstrip("/")
|
_pub_base = (get_setting("app_public_url", "") or "").rstrip("/")
|
||||||
|
|
||||||
if img.get("b64_json"):
|
if img.get("b64_json"):
|
||||||
img_dir = Path("data/generated_images")
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from core.database import (
|
|||||||
CalendarEvent,
|
CalendarEvent,
|
||||||
CalendarCal,
|
CalendarCal,
|
||||||
)
|
)
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def setup_admin_wipe_routes(session_manager):
|
|||||||
# Skills live as SKILL.md files under data/skills/. Drop
|
# Skills live as SKILL.md files under data/skills/. Drop
|
||||||
# the entire directory; the SkillsManager re-creates the
|
# the entire directory; the SkillsManager re-creates the
|
||||||
# tree on next write.
|
# tree on next write.
|
||||||
skills_dir = os.path.join(DATA_DIR, "skills")
|
skills_dir = SKILLS_DIR
|
||||||
count = 0
|
count = 0
|
||||||
if os.path.isdir(skills_dir):
|
if os.path.isdir(skills_dir):
|
||||||
# Count SKILL.md files for the response — quick walk.
|
# Count SKILL.md files for the response — quick walk.
|
||||||
@@ -115,7 +115,7 @@ def setup_admin_wipe_routes(session_manager):
|
|||||||
count += sum(1 for f in files if f == "SKILL.md")
|
count += sum(1 for f in files if f == "SKILL.md")
|
||||||
_rmtree_quiet(skills_dir)
|
_rmtree_quiet(skills_dir)
|
||||||
# Legacy fallback file
|
# Legacy fallback file
|
||||||
legacy = os.path.join(DATA_DIR, "skills.json")
|
legacy = SKILLS_FILE
|
||||||
if os.path.exists(legacy):
|
if os.path.exists(legacy):
|
||||||
try:
|
try:
|
||||||
os.remove(legacy)
|
os.remove(legacy)
|
||||||
@@ -151,8 +151,8 @@ def setup_admin_wipe_routes(session_manager):
|
|||||||
db.query(GalleryAlbum).delete()
|
db.query(GalleryAlbum).delete()
|
||||||
db.commit()
|
db.commit()
|
||||||
# Also drop the upload dir so disk doesn't keep orphans.
|
# Also drop the upload dir so disk doesn't keep orphans.
|
||||||
_rmtree_quiet(os.path.join(DATA_DIR, "gallery"))
|
_rmtree_quiet(GALLERY_DIR)
|
||||||
_rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads"))
|
_rmtree_quiet(GALLERY_UPLOADS_DIR)
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
if kind == "calendar":
|
if kind == "calendar":
|
||||||
|
|||||||
+86
-28
@@ -196,14 +196,26 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||||
"""
|
"""
|
||||||
import requests as _req
|
import requests as _req
|
||||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
build_models_url,
|
||||||
|
normalize_base,
|
||||||
|
resolve_endpoint_runtime,
|
||||||
|
)
|
||||||
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
|
|
||||||
current_url = sess.endpoint_url or ""
|
current_url = sess.endpoint_url or ""
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
endpoints = db.query(ModelEndpoint).filter(
|
q = db.query(ModelEndpoint).filter(
|
||||||
ModelEndpoint.is_enabled == True
|
ModelEndpoint.is_enabled == True
|
||||||
).all()
|
)
|
||||||
|
if owner:
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
endpoints = q.all()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -212,26 +224,33 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
# Skip current endpoint
|
# Skip current endpoint
|
||||||
if current_url and base in current_url:
|
if current_url and base in current_url:
|
||||||
continue
|
continue
|
||||||
# Quick ping
|
|
||||||
ping_url = build_models_url(base)
|
|
||||||
headers = build_headers(ep.api_key, base)
|
|
||||||
try:
|
try:
|
||||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
r.raise_for_status()
|
except Exception:
|
||||||
data = r.json()
|
continue
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
ping_url = build_models_url(base)
|
||||||
if not models:
|
headers = build_headers(api_key, base)
|
||||||
models = [
|
try:
|
||||||
m.get("name") or m.get("model")
|
if ping_url:
|
||||||
for m in (data.get("models") or [])
|
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||||
if m.get("name") or m.get("model")
|
r.raise_for_status()
|
||||||
]
|
data = r.json()
|
||||||
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not models:
|
||||||
|
models = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
models = json.loads(ep.cached_models or "[]")
|
||||||
if not models:
|
if not models:
|
||||||
continue
|
continue
|
||||||
# Found a working endpoint — update session
|
# Found a working endpoint — update session
|
||||||
new_model = models[0]
|
new_model = models[0]
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
new_headers = build_headers(ep.api_key, base)
|
new_headers = build_headers(api_key, base)
|
||||||
|
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
|
||||||
|
|
||||||
sess.model = new_model
|
sess.model = new_model
|
||||||
sess.endpoint_url = chat_url
|
sess.endpoint_url = chat_url
|
||||||
@@ -243,7 +262,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
||||||
"model": new_model,
|
"model": new_model,
|
||||||
"endpoint_url": chat_url,
|
"endpoint_url": chat_url,
|
||||||
"headers": json.dumps(new_headers),
|
"headers": persisted_headers,
|
||||||
})
|
})
|
||||||
_db.commit()
|
_db.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -336,16 +355,26 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_auth_keys(headers) -> bool:
|
||||||
|
"""True if a headers dict carries an Authorization/x-api-key entry."""
|
||||||
|
return isinstance(headers, dict) and any(
|
||||||
|
k.lower() in ('authorization', 'x-api-key') for k in headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||||
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
||||||
has_auth = sess.headers and isinstance(sess.headers, dict) and any(
|
try:
|
||||||
k.lower() in ('authorization', 'x-api-key') for k in sess.headers
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
)
|
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
||||||
if has_auth:
|
except Exception:
|
||||||
|
is_chatgpt_subscription = False
|
||||||
|
has_auth = _has_auth_keys(sess.headers)
|
||||||
|
if has_auth and not is_chatgpt_subscription:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import build_headers, normalize_base
|
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
target_url = getattr(sess, "endpoint_url", "") or ""
|
target_url = getattr(sess, "endpoint_url", "") or ""
|
||||||
@@ -361,10 +390,30 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
|||||||
for ep in q.all():
|
for ep in q.all():
|
||||||
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
||||||
continue
|
continue
|
||||||
if not ep.api_key:
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
|
||||||
|
return
|
||||||
|
if not api_key:
|
||||||
|
# No usable key (e.g. ChatGPT Subscription needs re-auth).
|
||||||
|
return
|
||||||
|
sess.headers = build_headers(api_key, base)
|
||||||
|
if is_chatgpt_subscription:
|
||||||
|
# The bearer is short-lived and re-resolved per request, so it
|
||||||
|
# stays request-local and is never written to the plaintext
|
||||||
|
# sessions.headers column. Proactively strip any bearer an
|
||||||
|
# older code path may have persisted so it does not linger.
|
||||||
|
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||||
|
if owner:
|
||||||
|
stale_q = stale_q.filter(DBSession.owner == owner)
|
||||||
|
stored = stale_q.first()
|
||||||
|
if stored is not None and _has_auth_keys(stored.headers):
|
||||||
|
stale_q.update({"headers": {}})
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
|
||||||
|
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
|
||||||
return
|
return
|
||||||
base = normalize_base(ep.base_url or "")
|
|
||||||
sess.headers = build_headers(ep.api_key, base)
|
|
||||||
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||||
if owner:
|
if owner:
|
||||||
update_q = update_q.filter(DBSession.owner == owner)
|
update_q = update_q.filter(DBSession.owner == owner)
|
||||||
@@ -408,7 +457,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
|
owner = getattr(sess, "owner", None)
|
||||||
|
if owner:
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
endpoints = q.all()
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
try:
|
try:
|
||||||
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
||||||
@@ -542,7 +596,11 @@ async def build_chat_context(
|
|||||||
|
|
||||||
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
||||||
# re-hit slow local /models endpoints on every participant turn.
|
# re-hit slow local /models endpoints on every participant turn.
|
||||||
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(sess.endpoint_url, sess.model)
|
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(
|
||||||
|
sess.endpoint_url,
|
||||||
|
sess.model,
|
||||||
|
owner=getattr(sess, "owner", None),
|
||||||
|
)
|
||||||
if norm:
|
if norm:
|
||||||
sess.model = norm
|
sess.model = norm
|
||||||
|
|
||||||
|
|||||||
+57
-12
@@ -169,13 +169,20 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
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
|
||||||
written (Issue #587 — UI uses the cached endpoint list, not s.model).
|
written (Issue #587 — UI uses the cached endpoint list, not s.model).
|
||||||
Without this, we'd POST the upstream with model="" and get a generic
|
For ChatGPT Subscription, also repairs stale OpenAI API model names such as
|
||||||
401/503 instead of using the model the user already picked.
|
``gpt-5`` that are not accepted by the Codex-backed ChatGPT account route.
|
||||||
|
|
||||||
Returns True iff sess.model was repaired.
|
|
||||||
"""
|
"""
|
||||||
if getattr(sess, "model", None):
|
current_model = (getattr(sess, "model", "") or "").strip()
|
||||||
return False
|
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||||
|
is_chatgpt_subscription = False
|
||||||
|
if current_model:
|
||||||
|
try:
|
||||||
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
|
is_chatgpt_subscription = is_chatgpt_subscription_base(endpoint_url)
|
||||||
|
if not is_chatgpt_subscription:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Prefer the endpoint whose base URL matches the session — we know the
|
# Prefer the endpoint whose base URL matches the session — we know the
|
||||||
@@ -194,16 +201,51 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
break
|
break
|
||||||
if not ep:
|
if not ep:
|
||||||
return False
|
return False
|
||||||
|
if not is_chatgpt_subscription:
|
||||||
|
try:
|
||||||
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
|
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(ep, "base_url", "") or endpoint_url)
|
||||||
|
except Exception:
|
||||||
|
is_chatgpt_subscription = False
|
||||||
try:
|
try:
|
||||||
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
|
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
cached = []
|
cached = []
|
||||||
if not cached:
|
if not cached:
|
||||||
|
visible = []
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
|
except Exception:
|
||||||
|
visible = cached
|
||||||
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
return False
|
return False
|
||||||
try:
|
if is_chatgpt_subscription:
|
||||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
live_models = []
|
||||||
except Exception:
|
if getattr(ep, "provider_auth_id", None):
|
||||||
visible = cached
|
try:
|
||||||
|
from src.chatgpt_subscription import fetch_available_models
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
if api_key:
|
||||||
|
live_models = fetch_available_models(api_key)
|
||||||
|
if live_models:
|
||||||
|
ep.cached_models = json.dumps(live_models)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
live_models = []
|
||||||
|
# ChatGPT Subscription recovery must use the live Codex catalog.
|
||||||
|
# Cached rows are only trusted above to avoid revalidating a model
|
||||||
|
# that is already present in the visible picker list.
|
||||||
|
cached = live_models
|
||||||
|
if not cached:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
|
except Exception:
|
||||||
|
visible = cached
|
||||||
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
|
return False
|
||||||
if not visible:
|
if not visible:
|
||||||
return False
|
return False
|
||||||
model = visible[0]
|
model = visible[0]
|
||||||
@@ -213,14 +255,17 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
# Persist so the next request, websocket reconnect, or page reload
|
# Persist so the next request, websocket reconnect, or page reload
|
||||||
# picks up the same model (we'd otherwise re-pick on every send
|
# picks up the same model (we'd otherwise re-pick on every send
|
||||||
# and silently switch on the user if the cached order shifts).
|
# and silently switch on the user if the cached order shifts).
|
||||||
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
|
db_session_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||||
|
if owner:
|
||||||
|
db_session_q = db_session_q.filter(DBSession.owner == owner)
|
||||||
|
db_session = db_session_q.first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.model = model
|
db_session.model = model
|
||||||
db_session.updated_at = datetime.utcnow()
|
db_session.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
sess.model = model
|
sess.model = model
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recovered empty session model for %s — picked %r from endpoint %s",
|
"Recovered session model for %s — picked %r from endpoint %s",
|
||||||
session_id, model, ep.id,
|
session_id, model, ep.id,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""ChatGPT Subscription device-flow setup routes."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
|
||||||
|
from routes.device_flow import (
|
||||||
|
DeviceFlowPoll,
|
||||||
|
DeviceFlowStart,
|
||||||
|
PendingDeviceFlowStore,
|
||||||
|
create_device_flow_router,
|
||||||
|
)
|
||||||
|
from src.auth_helpers import get_current_user
|
||||||
|
from src import chatgpt_subscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||||
|
access_token = tokens.get("access_token")
|
||||||
|
refresh_token = tokens.get("refresh_token")
|
||||||
|
if not access_token or not refresh_token:
|
||||||
|
raise ValueError("ChatGPT token response was missing access_token or refresh_token")
|
||||||
|
|
||||||
|
base = chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL
|
||||||
|
models = chatgpt_subscription.fetch_available_models(access_token)
|
||||||
|
if not models:
|
||||||
|
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
auth = (
|
||||||
|
db.query(ProviderAuthSession)
|
||||||
|
.filter(
|
||||||
|
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
ProviderAuthSession.owner == owner,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if auth is None:
|
||||||
|
auth = ProviderAuthSession(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
owner=owner,
|
||||||
|
label="ChatGPT Subscription",
|
||||||
|
base_url=base,
|
||||||
|
auth_mode="chatgpt",
|
||||||
|
)
|
||||||
|
db.add(auth)
|
||||||
|
auth.base_url = base
|
||||||
|
auth.access_token = access_token
|
||||||
|
auth.refresh_token = refresh_token
|
||||||
|
auth.last_refresh = utcnow_naive()
|
||||||
|
auth.auth_mode = "chatgpt"
|
||||||
|
|
||||||
|
ep = (
|
||||||
|
db.query(ModelEndpoint)
|
||||||
|
.filter(
|
||||||
|
ModelEndpoint.base_url == base,
|
||||||
|
ModelEndpoint.provider_auth_id == auth.id,
|
||||||
|
ModelEndpoint.owner == owner,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if ep is None:
|
||||||
|
ep = ModelEndpoint(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
name="ChatGPT Subscription",
|
||||||
|
base_url=base,
|
||||||
|
model_type="llm",
|
||||||
|
endpoint_kind="api",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
db.add(ep)
|
||||||
|
ep.name = "ChatGPT Subscription"
|
||||||
|
ep.base_url = base
|
||||||
|
ep.api_key = None
|
||||||
|
ep.provider_auth_id = auth.id
|
||||||
|
ep.is_enabled = True
|
||||||
|
ep.supports_tools = False
|
||||||
|
ep.model_type = "llm"
|
||||||
|
ep.endpoint_kind = "api"
|
||||||
|
ep.model_refresh_mode = "manual"
|
||||||
|
ep.cached_models = json.dumps(models)
|
||||||
|
db.commit()
|
||||||
|
result = {
|
||||||
|
"id": ep.id,
|
||||||
|
"name": ep.name,
|
||||||
|
"base_url": ep.base_url,
|
||||||
|
"models": models,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from routes.model_routes import _invalidate_models_cache
|
||||||
|
|
||||||
|
_invalidate_models_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
|
||||||
|
try:
|
||||||
|
data = chatgpt_subscription.request_device_code()
|
||||||
|
except Exception as exc:
|
||||||
|
raise chatgpt_subscription.to_http_exception(exc)
|
||||||
|
|
||||||
|
device_auth_id = data.get("device_auth_id")
|
||||||
|
user_code = data.get("user_code")
|
||||||
|
if not device_auth_id or not user_code:
|
||||||
|
raise HTTPException(502, "ChatGPT did not return a complete device code")
|
||||||
|
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
|
||||||
|
return DeviceFlowStart(
|
||||||
|
pending={
|
||||||
|
"device_auth_id": device_auth_id,
|
||||||
|
"user_code": user_code,
|
||||||
|
"owner": get_current_user(request) or None,
|
||||||
|
},
|
||||||
|
response={
|
||||||
|
"user_code": user_code,
|
||||||
|
"verification_uri": verification_uri,
|
||||||
|
},
|
||||||
|
interval=int(data.get("interval") or 5),
|
||||||
|
expires_in=int(data.get("expires_in") or 900),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||||
|
try:
|
||||||
|
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("ChatGPT device poll failed: %s", exc)
|
||||||
|
return DeviceFlowPoll.pending(str(exc))
|
||||||
|
|
||||||
|
authorization_code = data.get("authorization_code")
|
||||||
|
code_verifier = data.get("code_verifier")
|
||||||
|
if authorization_code and code_verifier:
|
||||||
|
try:
|
||||||
|
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
|
||||||
|
result = _provision_endpoint(tokens, pending["owner"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ChatGPT Subscription endpoint provisioning failed")
|
||||||
|
raise chatgpt_subscription.to_http_exception(exc)
|
||||||
|
return DeviceFlowPoll.authorized(result)
|
||||||
|
|
||||||
|
err = data.get("error") or data.get("status")
|
||||||
|
if err in ("authorization_pending", "pending", None):
|
||||||
|
return DeviceFlowPoll.pending()
|
||||||
|
if err == "slow_down":
|
||||||
|
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
|
||||||
|
if err in ("expired_token", "access_denied", "denied"):
|
||||||
|
return DeviceFlowPoll.failed(err)
|
||||||
|
return DeviceFlowPoll.pending(err or "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_chatgpt_subscription_routes():
|
||||||
|
return create_device_flow_router(
|
||||||
|
prefix="/api/chatgpt-subscription",
|
||||||
|
tags=["chatgpt-subscription"],
|
||||||
|
store=_DEVICE_FLOW_STORE,
|
||||||
|
start_flow=_start_device_flow,
|
||||||
|
poll_flow=_poll_device_flow,
|
||||||
|
)
|
||||||
@@ -17,7 +17,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
|
|
||||||
from src.auth_helpers import require_authenticated_request, require_user
|
from src.auth_helpers import require_authenticated_request, require_user
|
||||||
from src.tool_implementations import do_manage_notes
|
from src.tool_implementations import do_manage_notes
|
||||||
from core.constants import DATA_DIR
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
|
|
||||||
|
|
||||||
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
||||||
@@ -425,8 +425,8 @@ def setup_codex_routes(
|
|||||||
|
|
||||||
def _read_cookbook_state() -> dict:
|
def _read_cookbook_state() -> dict:
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
import os as _os, json as _json
|
import json as _json
|
||||||
p = _Path(DATA_DIR) / "cookbook_state.json"
|
p = _Path(COOKBOOK_STATE_FILE)
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
@@ -734,7 +734,7 @@ def setup_codex_routes(
|
|||||||
import time as _t, json as _json
|
import time as _t, json as _json
|
||||||
from core.atomic_io import atomic_write_json
|
from core.atomic_io import atomic_write_json
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
cookbook_state_path = _Path(DATA_DIR) / "cookbook_state.json"
|
cookbook_state_path = _Path(COOKBOOK_STATE_FILE)
|
||||||
try:
|
try:
|
||||||
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
|
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+110
-22
@@ -12,6 +12,7 @@ import logging
|
|||||||
from core.database import Comparison, SessionLocal
|
from core.database import Comparison, SessionLocal
|
||||||
from core.session_manager import SessionManager
|
from core.session_manager import SessionManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,6 +39,24 @@ def _owned_endpoint_by_url(db, base_url, owner):
|
|||||||
return owner_filter(q, ModelEndpoint, owner).first()
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_endpoint_by_id(db, endpoint_id, owner):
|
||||||
|
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
|
||||||
|
own rows + legacy null-owner "shared" rows); None otherwise.
|
||||||
|
|
||||||
|
Preferred over _owned_endpoint_by_url for credential resolution: two visible
|
||||||
|
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
|
||||||
|
accounts on the same provider). A base_url-only match returns whichever row
|
||||||
|
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
|
||||||
|
An id pins the exact registered endpoint, so /api/compare/start prefers it and
|
||||||
|
only falls back to URL matching for legacy / admin raw-URL callers. Owner
|
||||||
|
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
|
||||||
|
"""
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
|
||||||
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
class RecordVoteRequest(BaseModel):
|
class RecordVoteRequest(BaseModel):
|
||||||
prompt: str
|
prompt: str
|
||||||
models: List[str]
|
models: List[str]
|
||||||
@@ -54,8 +73,10 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
prompt: str = Form(...),
|
prompt: str = Form(...),
|
||||||
model_a: str = Form(...),
|
model_a: str = Form(...),
|
||||||
model_b: str = Form(...),
|
model_b: str = Form(...),
|
||||||
endpoint_a: str = Form(...),
|
endpoint_a: str = Form(""),
|
||||||
endpoint_b: str = Form(...),
|
endpoint_b: str = Form(""),
|
||||||
|
endpoint_a_id: str = Form(""),
|
||||||
|
endpoint_b_id: str = Form(""),
|
||||||
is_blind: str = Form("true"),
|
is_blind: str = Form("true"),
|
||||||
):
|
):
|
||||||
"""Create two ephemeral sessions and a comparison record.
|
"""Create two ephemeral sessions and a comparison record.
|
||||||
@@ -63,10 +84,10 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
Returns the comparison ID and the two session IDs so the client
|
Returns the comparison ID and the two session IDs so the client
|
||||||
can fire two independent SSE streams to /api/chat_stream.
|
can fire two independent SSE streams to /api/chat_stream.
|
||||||
"""
|
"""
|
||||||
|
user = getattr(request.state, 'current_user', None)
|
||||||
comp_id = str(uuid.uuid4())
|
comp_id = str(uuid.uuid4())
|
||||||
sid_a = str(uuid.uuid4())
|
sid_a = str(uuid.uuid4())
|
||||||
sid_b = str(uuid.uuid4())
|
sid_b = str(uuid.uuid4())
|
||||||
user = getattr(request.state, 'current_user', None)
|
|
||||||
|
|
||||||
# Blind mapping: randomly assign left/right
|
# Blind mapping: randomly assign left/right
|
||||||
blind = str(is_blind).lower() == "true"
|
blind = str(is_blind).lower() == "true"
|
||||||
@@ -87,31 +108,94 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
# de-anonymizing the comparison before the user votes (issue #1285).
|
# de-anonymizing the comparison before the user votes (issue #1285).
|
||||||
slot_name = {session_left: "Model A", session_right: "Model B"}
|
slot_name = {session_left: "Model A", session_right: "Model B"}
|
||||||
|
|
||||||
# Create ephemeral sessions (prefixed [CMP])
|
# SECURITY: resolve and validate BOTH endpoints before creating any
|
||||||
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
|
# session. Compare copies a registered endpoint's Authorization header
|
||||||
|
# into the [CMP] session, so validating one endpoint while creating its
|
||||||
|
# session, then rejecting the other, would leave a partial compare
|
||||||
|
# session behind with that header attached. Doing all the owner-scope
|
||||||
|
# resolution + raw-URL rejection up front means a 403 on either endpoint
|
||||||
|
# aborts the whole request with nothing created and no header copied.
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
||||||
|
resolved = []
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for sid, model, endpoint, endpoint_id in [
|
||||||
|
(sid_a, model_a, endpoint_a, endpoint_a_id),
|
||||||
|
(sid_b, model_b, endpoint_b, endpoint_b_id),
|
||||||
|
]:
|
||||||
|
# Prefer an explicit endpoint id: it pins the EXACT registered
|
||||||
|
# endpoint (and its api_key), even when two endpoints visible to
|
||||||
|
# the caller share a base_url with different keys — a URL-only
|
||||||
|
# match would copy whichever row sorts first, i.e. possibly the
|
||||||
|
# wrong key. Fall back to URL resolution only for legacy / admin
|
||||||
|
# raw-URL callers that don't send an id.
|
||||||
|
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
|
||||||
|
if eid:
|
||||||
|
ep = _owned_endpoint_by_id(db, eid, user)
|
||||||
|
if ep is None:
|
||||||
|
# An id the caller can't see (wrong owner / deleted) must
|
||||||
|
# NOT silently fall back to a same-URL row with a different
|
||||||
|
# key — that's exactly the mix-up ids exist to prevent.
|
||||||
|
raise HTTPException(404, "Model endpoint not found")
|
||||||
|
# The id already resolved the endpoint; ignore any raw URL the
|
||||||
|
# caller also sent and dial the stored config instead.
|
||||||
|
endpoint = ep.base_url
|
||||||
|
elif not endpoint:
|
||||||
|
raise HTTPException(
|
||||||
|
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Resolve the supplied URL to a ModelEndpoint the caller owns
|
||||||
|
# (their own rows + legacy null-owner shared rows), scoped so a
|
||||||
|
# comparison can't borrow another user's private endpoint key.
|
||||||
|
base = normalize_base(endpoint)
|
||||||
|
ep = _owned_endpoint_by_url(db, base, user)
|
||||||
|
# Reject *unregistered* raw URLs for signed-in non-admins; a
|
||||||
|
# matched registered endpoint supplies an id so the caller can
|
||||||
|
# still compare endpoints they own. Blanket-rejecting here (the
|
||||||
|
# earlier `endpoint_id=None` call) locked non-admins out of
|
||||||
|
# compare entirely, since compare resolves endpoints by URL with
|
||||||
|
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
|
||||||
|
# Raised here (phase 1), before any session exists.
|
||||||
|
_reject_raw_endpoint_url_for_non_admin(
|
||||||
|
request, user, str(ep.id) if ep is not None else None, endpoint
|
||||||
|
)
|
||||||
|
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
||||||
|
# caller-supplied string. When the URL matches a registered
|
||||||
|
# endpoint visible to the caller, use that row's own normalized
|
||||||
|
# base URL (the same value owner scoping + endpoint validation
|
||||||
|
# already vetted) so the session dials exactly where the stored
|
||||||
|
# config points. The raw `endpoint` only survives for callers
|
||||||
|
# allowed to pass one — admins / single-user mode, where
|
||||||
|
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
|
||||||
|
# is None. Mirrors the registered-endpoint path in session_routes.
|
||||||
|
session_endpoint_url = (
|
||||||
|
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
|
||||||
|
)
|
||||||
|
# Headers come only from a matched endpoint's key; None when
|
||||||
|
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||||
|
# never inherit another user's key/headers.
|
||||||
|
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||||
|
resolved.append((sid, model, session_endpoint_url, headers))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Both endpoints validated — only now create the ephemeral [CMP]
|
||||||
|
# sessions and copy any resolved headers.
|
||||||
|
for sid, model, session_endpoint_url, headers in resolved:
|
||||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||||
session_manager.create_session(
|
session_manager.create_session(
|
||||||
session_id=sid,
|
session_id=sid,
|
||||||
name=name,
|
name=name,
|
||||||
endpoint_url=endpoint,
|
endpoint_url=session_endpoint_url,
|
||||||
model=model,
|
model=model,
|
||||||
rag=False,
|
rag=False,
|
||||||
owner=user,
|
owner=user,
|
||||||
)
|
)
|
||||||
# Copy API key from endpoint config
|
if headers:
|
||||||
db = SessionLocal()
|
s = session_manager.sessions.get(sid)
|
||||||
try:
|
if s:
|
||||||
from src.endpoint_resolver import build_headers, normalize_base
|
s.headers = headers
|
||||||
# Find matching endpoint by URL, scoped to the caller so a
|
|
||||||
# comparison can't borrow another user's private endpoint key.
|
|
||||||
base = normalize_base(endpoint)
|
|
||||||
ep = _owned_endpoint_by_url(db, base, user)
|
|
||||||
if ep and ep.api_key:
|
|
||||||
s = session_manager.sessions.get(sid)
|
|
||||||
if s:
|
|
||||||
s.headers = build_headers(ep.api_key, ep.base_url)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Store comparison record
|
# Store comparison record
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -121,8 +205,12 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model_a=model_a,
|
model_a=model_a,
|
||||||
model_b=model_b,
|
model_b=model_b,
|
||||||
endpoint_a=endpoint_a,
|
# Record the URL the session actually dials. For URL callers this
|
||||||
endpoint_b=endpoint_b,
|
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
||||||
|
# fall back to the resolved endpoint URL so the column stays
|
||||||
|
# meaningful and non-null. resolved is in [a, b] order.
|
||||||
|
endpoint_a=endpoint_a or resolved[0][2],
|
||||||
|
endpoint_b=endpoint_b or resolved[1][2],
|
||||||
is_blind=blind,
|
is_blind=blind,
|
||||||
blind_mapping=json.dumps(mapping),
|
blind_mapping=json.dumps(mapping),
|
||||||
owner=user,
|
owner=user,
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ from src.url_safety import check_outbound_url
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE
|
||||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
DATA_DIR = Path(_DATA_DIR)
|
||||||
LOCAL_CONTACTS_FILE = DATA_DIR / "contacts.json"
|
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||||
|
LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE)
|
||||||
|
|
||||||
|
|
||||||
def _load_settings():
|
def _load_settings():
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
|
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import require_user
|
||||||
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
@@ -33,7 +34,7 @@ from core.platform_compat import (
|
|||||||
get_wsl_windows_user_profile,
|
get_wsl_windows_user_profile,
|
||||||
)
|
)
|
||||||
from routes.shell_routes import TMUX_LOG_DIR
|
from routes.shell_routes import TMUX_LOG_DIR
|
||||||
from core.constants import DATA_DIR
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
|||||||
|
|
||||||
def setup_cookbook_routes() -> APIRouter:
|
def setup_cookbook_routes() -> APIRouter:
|
||||||
router = APIRouter(tags=["cookbook"])
|
router = APIRouter(tags=["cookbook"])
|
||||||
_cookbook_state_path = Path(DATA_DIR) / "cookbook_state.json"
|
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
|
|
||||||
def _mask_secret(value: str) -> str:
|
def _mask_secret(value: str) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
+67
-117
@@ -20,39 +20,26 @@ All routes are admin-gated (endpoint/provider management is an admin action).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Request, Form, HTTPException
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
from core.middleware import require_admin
|
from routes.device_flow import (
|
||||||
|
DeviceFlowPoll,
|
||||||
|
DeviceFlowStart,
|
||||||
|
PendingDeviceFlowStore,
|
||||||
|
create_device_flow_router,
|
||||||
|
)
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src import copilot
|
from src import copilot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
|
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
||||||
# bearer-like secret, so it lives here (server memory) rather than in the
|
|
||||||
# browser. Entries expire with the GitHub device code.
|
|
||||||
#
|
|
||||||
# NOTE: this is per-process state. The device flow assumes a single worker
|
|
||||||
# (Odysseus' default): with multiple uvicorn workers, the poll request can land
|
|
||||||
# on a worker that never saw the start, returning "Unknown or expired login
|
|
||||||
# session". Move this to a shared store (DB/Redis) if running multi-worker.
|
|
||||||
_PENDING: Dict[str, Dict] = {}
|
|
||||||
_PENDING_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_expired() -> None:
|
|
||||||
now = time.time()
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]:
|
|
||||||
_PENDING.pop(k, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
|
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
|
||||||
@@ -112,112 +99,75 @@ def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def setup_copilot_routes() -> APIRouter:
|
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
|
||||||
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
|
host = copilot.GITHUB_HOST
|
||||||
|
ent = str(form.get("enterprise_url") or "").strip()
|
||||||
|
if ent:
|
||||||
|
host = copilot.normalize_domain(ent)
|
||||||
|
try:
|
||||||
|
data = copilot.request_device_code(host)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
status = e.response.status_code if e.response is not None else "unknown"
|
||||||
|
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"GitHub device-code request failed: {e}")
|
||||||
|
|
||||||
@router.post("/device/start")
|
device_code = data.get("device_code")
|
||||||
def device_start(request: Request, enterprise_url: str = Form("")):
|
if not device_code:
|
||||||
require_admin(request)
|
raise HTTPException(502, "GitHub did not return a device code")
|
||||||
_prune_expired()
|
|
||||||
host = copilot.GITHUB_HOST
|
|
||||||
ent = (enterprise_url or "").strip()
|
|
||||||
if ent:
|
|
||||||
host = copilot.normalize_domain(ent)
|
|
||||||
try:
|
|
||||||
data = copilot.request_device_code(host)
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
status = e.response.status_code if e.response is not None else "unknown"
|
|
||||||
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(502, f"GitHub device-code request failed: {e}")
|
|
||||||
|
|
||||||
device_code = data.get("device_code")
|
# verification_uri_complete embeds the user code, so the browser tab we
|
||||||
if not device_code:
|
# open lands the user straight on GitHub's "Authorize" screen with the
|
||||||
raise HTTPException(502, "GitHub did not return a device code")
|
# code pre-filled — one click, no manual code entry.
|
||||||
interval = int(data.get("interval") or 5)
|
return DeviceFlowStart(
|
||||||
expires_in = int(data.get("expires_in") or 900)
|
pending={
|
||||||
poll_id = uuid.uuid4().hex
|
"device_code": device_code,
|
||||||
with _PENDING_LOCK:
|
"host": host,
|
||||||
_PENDING[poll_id] = {
|
"enterprise_url": ent,
|
||||||
"device_code": device_code,
|
"owner": get_current_user(request) or None,
|
||||||
"host": host,
|
},
|
||||||
"enterprise_url": ent,
|
response={
|
||||||
"interval": interval,
|
|
||||||
"owner": get_current_user(request) or None,
|
|
||||||
"expires_at": time.time() + expires_in,
|
|
||||||
"next_poll_at": 0.0,
|
|
||||||
}
|
|
||||||
# verification_uri_complete embeds the user code, so the browser tab we
|
|
||||||
# open lands the user straight on GitHub's "Authorize" screen with the
|
|
||||||
# code pre-filled — one click, no manual code entry.
|
|
||||||
return {
|
|
||||||
"poll_id": poll_id,
|
|
||||||
"user_code": data.get("user_code"),
|
"user_code": data.get("user_code"),
|
||||||
"verification_uri": data.get("verification_uri"),
|
"verification_uri": data.get("verification_uri"),
|
||||||
"verification_uri_complete": data.get("verification_uri_complete"),
|
"verification_uri_complete": data.get("verification_uri_complete"),
|
||||||
"interval": interval,
|
},
|
||||||
"expires_in": expires_in,
|
interval=int(data.get("interval") or 5),
|
||||||
}
|
expires_in=int(data.get("expires_in") or 900),
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/device/poll")
|
|
||||||
def device_poll(request: Request, poll_id: str = Form(...)):
|
|
||||||
require_admin(request)
|
|
||||||
_prune_expired()
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
pending = _PENDING.get(poll_id)
|
|
||||||
if not pending:
|
|
||||||
raise HTTPException(404, "Unknown or expired login session")
|
|
||||||
|
|
||||||
# Enforce GitHub's polling interval server-side so a chatty client
|
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||||
# can't trip slow_down.
|
try:
|
||||||
now = time.time()
|
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
||||||
if now < pending.get("next_poll_at", 0):
|
except Exception as e:
|
||||||
return {"status": "pending"}
|
return DeviceFlowPoll.pending(f"poll error: {e}")
|
||||||
|
|
||||||
|
token = data.get("access_token")
|
||||||
|
if token:
|
||||||
|
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
|
||||||
try:
|
try:
|
||||||
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
result = _provision_endpoint(token, base, pending["owner"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "pending", "detail": f"poll error: {e}"}
|
logger.exception("Copilot endpoint provisioning failed")
|
||||||
|
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
|
||||||
|
return DeviceFlowPoll.authorized(result)
|
||||||
|
|
||||||
token = data.get("access_token")
|
err = data.get("error")
|
||||||
if token:
|
if err == "authorization_pending":
|
||||||
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
|
return DeviceFlowPoll.pending()
|
||||||
try:
|
if err == "slow_down":
|
||||||
result = _provision_endpoint(token, base, pending["owner"])
|
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
|
||||||
except Exception as e:
|
if err in ("expired_token", "access_denied"):
|
||||||
logger.exception("Copilot endpoint provisioning failed")
|
return DeviceFlowPoll.failed(err)
|
||||||
with _PENDING_LOCK:
|
# Unknown error — surface but keep the session for another try.
|
||||||
_PENDING.pop(poll_id, None)
|
return DeviceFlowPoll.pending(err or "unknown")
|
||||||
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
_PENDING.pop(poll_id, None)
|
|
||||||
return {"status": "authorized", "endpoint": result}
|
|
||||||
|
|
||||||
err = data.get("error")
|
|
||||||
if err == "authorization_pending":
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
if poll_id in _PENDING:
|
|
||||||
_PENDING[poll_id]["next_poll_at"] = now + pending["interval"]
|
|
||||||
return {"status": "pending"}
|
|
||||||
if err == "slow_down":
|
|
||||||
new_interval = int(data.get("interval") or (pending["interval"] + 5))
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
if poll_id in _PENDING:
|
|
||||||
_PENDING[poll_id]["interval"] = new_interval
|
|
||||||
_PENDING[poll_id]["next_poll_at"] = now + new_interval
|
|
||||||
return {"status": "pending"}
|
|
||||||
if err in ("expired_token", "access_denied"):
|
|
||||||
with _PENDING_LOCK:
|
|
||||||
_PENDING.pop(poll_id, None)
|
|
||||||
return {"status": "failed", "error": err}
|
|
||||||
# Unknown error — surface but keep the session for another try.
|
|
||||||
return {"status": "pending", "detail": err or "unknown"}
|
|
||||||
|
|
||||||
@router.post("/device/cancel")
|
def setup_copilot_routes():
|
||||||
def device_cancel(request: Request, poll_id: str = Form(...)):
|
return create_device_flow_router(
|
||||||
require_admin(request)
|
prefix="/api/copilot",
|
||||||
with _PENDING_LOCK:
|
tags=["copilot"],
|
||||||
_PENDING.pop(poll_id, None)
|
store=_DEVICE_FLOW_STORE,
|
||||||
return {"status": "cancelled"}
|
start_flow=_start_device_flow,
|
||||||
|
poll_flow=_poll_device_flow,
|
||||||
return router
|
)
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Shared OAuth/device-flow route scaffolding for provider setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Iterable, Mapping, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
|
|
||||||
|
from core.middleware import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeviceFlowStart:
|
||||||
|
"""Provider-specific start result consumed by the shared route wrapper."""
|
||||||
|
|
||||||
|
pending: Mapping[str, Any]
|
||||||
|
response: Mapping[str, Any]
|
||||||
|
interval: int = 5
|
||||||
|
expires_in: int = 900
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeviceFlowPoll:
|
||||||
|
"""Normalized provider poll outcome."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
endpoint: Optional[Mapping[str, Any]] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
detail: Optional[str] = None
|
||||||
|
interval: Optional[int] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pending(cls, detail: Optional[str] = None) -> "DeviceFlowPoll":
|
||||||
|
return cls(status="pending", detail=detail)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def slow_down(cls, interval: Optional[int] = None, detail: Optional[str] = None) -> "DeviceFlowPoll":
|
||||||
|
return cls(status="slow_down", interval=interval, detail=detail)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def authorized(cls, endpoint: Mapping[str, Any]) -> "DeviceFlowPoll":
|
||||||
|
return cls(status="authorized", endpoint=endpoint)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failed(cls, error: str) -> "DeviceFlowPoll":
|
||||||
|
return cls(status="failed", error=error)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeviceFlowStore:
|
||||||
|
"""Thread-safe in-memory pending device-flow store.
|
||||||
|
|
||||||
|
Device codes and provider-side secrets stay inside this process. Each entry
|
||||||
|
stores provider payload separately from poll metadata so provider callbacks
|
||||||
|
only receive the fields they created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, time_func: Callable[[], float] = time.time):
|
||||||
|
self._pending: dict[str, dict[str, Any]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._time = time_func
|
||||||
|
|
||||||
|
def _now(self) -> float:
|
||||||
|
return float(self._time())
|
||||||
|
|
||||||
|
def prune_expired(self) -> None:
|
||||||
|
now = self._now()
|
||||||
|
with self._lock:
|
||||||
|
for key in [k for k, v in self._pending.items() if v.get("expires_at", 0) < now]:
|
||||||
|
self._pending.pop(key, None)
|
||||||
|
|
||||||
|
def add(self, payload: Mapping[str, Any], *, interval: int, expires_in: int) -> str:
|
||||||
|
self.prune_expired()
|
||||||
|
poll_id = uuid.uuid4().hex
|
||||||
|
with self._lock:
|
||||||
|
self._pending[poll_id] = {
|
||||||
|
"payload": dict(payload),
|
||||||
|
"interval": max(int(interval or 5), 1),
|
||||||
|
"expires_at": self._now() + max(int(expires_in or 900), 1),
|
||||||
|
"next_poll_at": 0.0,
|
||||||
|
}
|
||||||
|
return poll_id
|
||||||
|
|
||||||
|
def get_payload(self, poll_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
self.prune_expired()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._pending.get(poll_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
return dict(entry.get("payload") or {})
|
||||||
|
|
||||||
|
def is_throttled(self, poll_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._pending.get(poll_id)
|
||||||
|
return bool(entry and self._now() < float(entry.get("next_poll_at") or 0))
|
||||||
|
|
||||||
|
def schedule_next(self, poll_id: str) -> None:
|
||||||
|
now = self._now()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._pending.get(poll_id)
|
||||||
|
if entry is not None:
|
||||||
|
entry["next_poll_at"] = now + int(entry.get("interval") or 5)
|
||||||
|
|
||||||
|
def slow_down(self, poll_id: str, interval: Optional[int] = None) -> None:
|
||||||
|
now = self._now()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._pending.get(poll_id)
|
||||||
|
if entry is not None:
|
||||||
|
new_interval = int(interval or (int(entry.get("interval") or 5) + 5))
|
||||||
|
entry["interval"] = max(new_interval, 1)
|
||||||
|
entry["next_poll_at"] = now + entry["interval"]
|
||||||
|
|
||||||
|
def pop(self, poll_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._pending.pop(poll_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_await(value: Any) -> Any:
|
||||||
|
if inspect.isawaitable(value):
|
||||||
|
return await value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_response(detail: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
response: dict[str, Any] = {"status": "pending"}
|
||||||
|
if detail:
|
||||||
|
response["detail"] = detail
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_device_flow_router(
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
tags: Iterable[str],
|
||||||
|
store: PendingDeviceFlowStore,
|
||||||
|
start_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowStart],
|
||||||
|
poll_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowPoll],
|
||||||
|
) -> APIRouter:
|
||||||
|
"""Create standard `/device/start|poll|cancel` routes for a provider."""
|
||||||
|
|
||||||
|
router = APIRouter(prefix=prefix, tags=list(tags))
|
||||||
|
|
||||||
|
@router.post("/device/start")
|
||||||
|
async def device_start(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
form = await request.form()
|
||||||
|
start = await _maybe_await(start_flow(request, form))
|
||||||
|
interval = int(start.interval or 5)
|
||||||
|
expires_in = int(start.expires_in or 900)
|
||||||
|
poll_id = store.add(start.pending, interval=interval, expires_in=expires_in)
|
||||||
|
response = dict(start.response)
|
||||||
|
response.update({"poll_id": poll_id, "interval": interval, "expires_in": expires_in})
|
||||||
|
return response
|
||||||
|
|
||||||
|
@router.post("/device/poll")
|
||||||
|
async def device_poll(request: Request, poll_id: str = Form(...)):
|
||||||
|
require_admin(request)
|
||||||
|
payload = store.get_payload(poll_id)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(404, "Unknown or expired login session")
|
||||||
|
if store.is_throttled(poll_id):
|
||||||
|
return {"status": "pending"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
outcome = await _maybe_await(poll_flow(request, payload))
|
||||||
|
except Exception:
|
||||||
|
store.pop(poll_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if outcome.status == "authorized":
|
||||||
|
store.pop(poll_id)
|
||||||
|
return {"status": "authorized", "endpoint": dict(outcome.endpoint or {})}
|
||||||
|
if outcome.status == "failed":
|
||||||
|
store.pop(poll_id)
|
||||||
|
return {"status": "failed", "error": outcome.error or "denied"}
|
||||||
|
if outcome.status == "slow_down":
|
||||||
|
store.slow_down(poll_id, outcome.interval)
|
||||||
|
return _pending_response(outcome.detail)
|
||||||
|
|
||||||
|
store.schedule_next(poll_id)
|
||||||
|
return _pending_response(outcome.detail)
|
||||||
|
|
||||||
|
@router.post("/device/cancel")
|
||||||
|
def device_cancel(request: Request, poll_id: str = Form(...)):
|
||||||
|
require_admin(request)
|
||||||
|
store.pop(poll_id)
|
||||||
|
return {"status": "cancelled"}
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -11,6 +11,7 @@ from sqlalchemy import case, func, or_
|
|||||||
from core.database import SessionLocal, Document, DocumentVersion
|
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
|
from src.auth_helpers import get_current_user
|
||||||
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1542,10 +1543,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# don't import from a routes file (cycle-prone). Same env override
|
# don't import from a routes file (cycle-prone). Same env override
|
||||||
# as email_routes (ODYSSEUS_MAIL_ATTACHMENTS_DIR).
|
# as email_routes (ODYSSEUS_MAIL_ATTACHMENTS_DIR).
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
import os as _os
|
_COMPOSE_DIR = _Path(MAIL_ATTACHMENTS_DIR) / "_compose"
|
||||||
_DATA_DIR = _Path(__file__).resolve().parent.parent / "data"
|
|
||||||
_BASE = _os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(_DATA_DIR / "mail-attachments"))
|
|
||||||
_COMPOSE_DIR = _Path(_BASE) / "_compose"
|
|
||||||
_COMPOSE_DIR.mkdir(parents=True, exist_ok=True)
|
_COMPOSE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
|
|||||||
@@ -254,16 +254,17 @@ def _cleanup_compose_uploads(tokens) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
from src.constants import DATA_DIR as _DATA_DIR, MAIL_ATTACHMENTS_DIR, SETTINGS_FILE as _SETTINGS_FILE, SCHEDULED_EMAILS_DB
|
||||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
DATA_DIR = Path(_DATA_DIR)
|
||||||
|
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||||
# Override at deploy time via ODYSSEUS_MAIL_ATTACHMENTS_DIR. Defaults to a
|
# Override at deploy time via ODYSSEUS_MAIL_ATTACHMENTS_DIR. Defaults to a
|
||||||
# subdir of the install's data/ tree so the app works out-of-the-box without
|
# subdir of the install's data/ tree so the app works out-of-the-box without
|
||||||
# a hardcoded /home/<user>/ path.
|
# a hardcoded /home/<user>/ path.
|
||||||
ATTACHMENTS_DIR = Path(os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(DATA_DIR / "mail-attachments")))
|
ATTACHMENTS_DIR = Path(MAIL_ATTACHMENTS_DIR)
|
||||||
ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
COMPOSE_UPLOADS_DIR = ATTACHMENTS_DIR / "_compose"
|
COMPOSE_UPLOADS_DIR = ATTACHMENTS_DIR / "_compose"
|
||||||
COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
SCHEDULED_DB = DATA_DIR / "scheduled_emails.db"
|
SCHEDULED_DB = Path(SCHEDULED_EMAILS_DB)
|
||||||
|
|
||||||
|
|
||||||
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from email.mime.multipart import MIMEMultipart
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.upload_limits import read_upload_limited
|
from src.upload_limits import read_upload_limited
|
||||||
@@ -2904,7 +2905,7 @@ def setup_email_routes():
|
|||||||
from pathlib import Path as _P
|
from pathlib import Path as _P
|
||||||
import json as _json
|
import json as _json
|
||||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
path = _P(f"data/email_urgency_state_{_slug}.json")
|
path = _P(DATA_DIR) / f"email_urgency_state_{_slug}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"total_unread": 0, "total_urgent": 0, "max_score": 0, "per_uid": {}}
|
return {"total_unread": 0, "total_urgent": 0, "max_score": 0, "per_uid": {}}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, HTTPException, Form, Depends
|
from fastapi import APIRouter, HTTPException, Form, Depends
|
||||||
from core.constants import BASE_DIR
|
from core.constants import EMBEDDING_ENDPOINT_FILE, FASTEMBED_CACHE_DIR
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ENDPOINT_FILE = os.path.join(BASE_DIR, "data", "embedding_endpoint.json")
|
_ENDPOINT_FILE = EMBEDDING_ENDPOINT_FILE
|
||||||
|
|
||||||
# Track in-progress downloads
|
# Track in-progress downloads
|
||||||
_downloading: dict = {}
|
_downloading: dict = {}
|
||||||
@@ -35,13 +35,7 @@ def _cache_dir() -> str:
|
|||||||
default lived in /tmp, which many systems wipe on reboot — forcing a
|
default lived in /tmp, which many systems wipe on reboot — forcing a
|
||||||
full re-download of the embedding model after every restart.
|
full re-download of the embedding model after every restart.
|
||||||
"""
|
"""
|
||||||
env = os.environ.get("FASTEMBED_CACHE_PATH")
|
return FASTEMBED_CACHE_DIR
|
||||||
if env:
|
|
||||||
return env
|
|
||||||
return os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
"data", "fastembed_cache",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _model_cache_name(hf_source: str) -> str:
|
def _model_cache_name(hf_source: str) -> str:
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import httpx
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from src.constants import EMOJI_CACHE_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "emoji_cache"
|
_CACHE_DIR = Path(EMOJI_CACHE_DIR)
|
||||||
# OpenMoji "black" set = monochrome line-art SVGs. Filenames are the codepoints
|
# OpenMoji "black" set = monochrome line-art SVGs. Filenames are the codepoints
|
||||||
# in UPPERCASE (FE0F dropped, same as we compute), '-' joined.
|
# in UPPERCASE (FE0F dropped, same as we compute), '-' joined.
|
||||||
_OPENMOJI_BASE = "https://cdn.jsdelivr.net/npm/openmoji@15.0.0/black/svg"
|
_OPENMOJI_BASE = "https://cdn.jsdelivr.net/npm/openmoji@15.0.0/black/svg"
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ 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, require_privilege
|
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||||
from src.upload_limits import read_upload_limited
|
from src.upload_limits import read_upload_limited
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
from routes.gallery_helpers import (
|
from routes.gallery_helpers import (
|
||||||
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
||||||
@@ -25,6 +26,19 @@ GALLERY_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", st
|
|||||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024)))
|
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024)))
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
|
is_admin = getattr(auth_mgr, "is_admin", None)
|
||||||
|
if not callable(is_admin):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(is_admin(user))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_gallery_filename(filename: str) -> str:
|
def _sanitize_gallery_filename(filename: str) -> str:
|
||||||
"""Return a local filename safe to join under generated_images."""
|
"""Return a local filename safe to join under generated_images."""
|
||||||
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(str(filename or "")).name)[:128]
|
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(str(filename or "")).name)[:128]
|
||||||
@@ -33,7 +47,7 @@ def _sanitize_gallery_filename(filename: str) -> str:
|
|||||||
return safe_name
|
return safe_name
|
||||||
|
|
||||||
|
|
||||||
GALLERY_IMAGE_DIR = Path("data/generated_images")
|
GALLERY_IMAGE_DIR = Path(GENERATED_IMAGES_DIR)
|
||||||
|
|
||||||
|
|
||||||
def _gallery_image_path(filename: str) -> Path:
|
def _gallery_image_path(filename: str) -> Path:
|
||||||
@@ -133,7 +147,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
return {"ok": False, "duplicate": True, "filename": existing.filename,
|
return {"ok": False, "duplicate": True, "filename": existing.filename,
|
||||||
"id": existing.id, "message": "Duplicate photo skipped"}
|
"id": existing.id, "message": "Duplicate photo skipped"}
|
||||||
|
|
||||||
img_dir = Path("data/generated_images")
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "png"
|
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "png"
|
||||||
@@ -199,7 +213,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
raise HTTPException(400, "No image provided")
|
raise HTTPException(400, "No image provided")
|
||||||
|
|
||||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
||||||
img_dir = Path("data/generated_images")
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
img_path = img_dir / _sanitize_gallery_filename(img.filename)
|
img_path = img_dir / _sanitize_gallery_filename(img.filename)
|
||||||
img_path.write_bytes(content)
|
img_path.write_bytes(content)
|
||||||
@@ -1042,7 +1056,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, _target, user)
|
ep = _visible_image_endpoint_for_base(db, _target, user)
|
||||||
if ep:
|
if ep:
|
||||||
|
base = (ep.base_url or base).rstrip("/")
|
||||||
api_key = ep.api_key
|
api_key = ep.api_key
|
||||||
|
elif user and not _current_user_is_admin(request, user):
|
||||||
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1233,7 +1250,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, base, user)
|
ep = _visible_image_endpoint_for_base(db, base, user)
|
||||||
if ep:
|
if ep:
|
||||||
|
base = (ep.base_url or base).rstrip("/")
|
||||||
api_key = ep.api_key
|
api_key = ep.api_key
|
||||||
|
elif user and not _current_user_is_admin(request, user):
|
||||||
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
from core.database import McpServer, SessionLocal
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
|||||||
|
|
||||||
def _mcp_oauth_base_dir() -> Path:
|
def _mcp_oauth_base_dir() -> Path:
|
||||||
"""Directory that may contain OAuth files managed by Odysseus."""
|
"""Directory that may contain OAuth files managed by Odysseus."""
|
||||||
return (Path(DATA_DIR) / "mcp_oauth").resolve(strict=False)
|
return Path(MCP_OAUTH_DIR).resolve(strict=False)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
||||||
|
|||||||
+107
-13
@@ -283,6 +283,7 @@ _HOST_TO_CURATED = (
|
|||||||
("fireworks.ai", "fireworks"),
|
("fireworks.ai", "fireworks"),
|
||||||
("googleapis.com", "google"),
|
("googleapis.com", "google"),
|
||||||
("x.ai", "xai"),
|
("x.ai", "xai"),
|
||||||
|
|
||||||
("openrouter.ai", "openrouter"),
|
("openrouter.ai", "openrouter"),
|
||||||
("ollama.com", "ollama"),
|
("ollama.com", "ollama"),
|
||||||
("opencode.ai/zen/go", "opencode-go"),
|
("opencode.ai/zen/go", "opencode-go"),
|
||||||
@@ -493,6 +494,8 @@ _NON_CHAT_EXACT_PREFIXES = (
|
|||||||
def _is_chat_model(model_id: str) -> bool:
|
def _is_chat_model(model_id: str) -> bool:
|
||||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||||
mid = model_id.lower()
|
mid = model_id.lower()
|
||||||
|
if mid in {"gpt-5.1-codex"}:
|
||||||
|
return True
|
||||||
for prefix in _NON_CHAT_PREFIXES:
|
for prefix in _NON_CHAT_PREFIXES:
|
||||||
if mid.startswith(prefix):
|
if mid.startswith(prefix):
|
||||||
return False
|
return False
|
||||||
@@ -505,9 +508,67 @@ def _is_chat_model(model_id: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 10, with_tools: bool = False) -> dict:
|
def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Optional[str] = None) -> bool:
|
||||||
|
"""Delete a ProviderAuthSession once no endpoint still references it.
|
||||||
|
|
||||||
|
Subscription providers (e.g. ChatGPT Subscription) keep their refresh token
|
||||||
|
in ProviderAuthSession rather than ModelEndpoint.api_key. When the last
|
||||||
|
endpoint backed by that auth row is removed, the stored credentials should
|
||||||
|
be cleared instead of lingering. Returns True if a row was deleted.
|
||||||
|
``exclude_ep_id`` drops the endpoint currently being deleted from the
|
||||||
|
reference count so it does not keep its own auth alive.
|
||||||
|
"""
|
||||||
|
if not auth_id:
|
||||||
|
return False
|
||||||
|
from core.database import ProviderAuthSession
|
||||||
|
still_referenced = db.query(ModelEndpoint.id).filter(
|
||||||
|
ModelEndpoint.provider_auth_id == auth_id,
|
||||||
|
ModelEndpoint.id != exclude_ep_id,
|
||||||
|
).first()
|
||||||
|
if still_referenced is not None:
|
||||||
|
return False
|
||||||
|
auth_row = db.query(ProviderAuthSession).filter(ProviderAuthSession.id == auth_id).first()
|
||||||
|
if auth_row is None:
|
||||||
|
return False
|
||||||
|
db.delete(auth_row)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_discovery_only_provider(provider: str) -> bool:
|
||||||
|
"""Provider that only supports model discovery, not live probing.
|
||||||
|
|
||||||
|
ChatGPT Subscription speaks the Responses/Codex API and has no
|
||||||
|
chat-completions or general health endpoint, so completion probes and
|
||||||
|
reachability pings are skipped — status is derived from cached models.
|
||||||
|
"""
|
||||||
|
return provider == "chatgpt-subscription"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_probe_key(ep) -> Optional[str]:
|
||||||
|
"""API key/bearer to probe an endpoint with.
|
||||||
|
|
||||||
|
Delegates to ``resolve_endpoint_runtime``, which already returns the static
|
||||||
|
``ModelEndpoint.api_key`` for keyed endpoints and resolves (and refreshes)
|
||||||
|
the runtime bearer for session-backed providers (e.g. ChatGPT Subscription).
|
||||||
|
Returns None if resolution fails (e.g. re-auth required) so probing skips
|
||||||
|
rather than raising. Reads only already-loaded scalar attributes of ``ep``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
|
||||||
|
return key
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_single_model(base: str, api_key: Optional[str], model_id: str, timeout: int = 10, with_tools: bool = False) -> dict:
|
||||||
"""Send a realistic completion request to a single model. Returns {status, latency_ms, error?}."""
|
"""Send a realistic completion request to a single model. Returns {status, latency_ms, error?}."""
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
|
if _is_discovery_only_provider(provider):
|
||||||
|
# Responses/Codex API, not chat-completions: a completion probe would
|
||||||
|
# 400 and the re-probe flow would then hide every model. Discovery-only.
|
||||||
|
return {"status": "ok", "latency_ms": 0, "skipped": True}
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
{"role": "user", "content": "Say OK"},
|
{"role": "user", "content": "Say OK"},
|
||||||
@@ -621,6 +682,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
base = resolve_url(_normalize_base(base_url))
|
base = resolve_url(_normalize_base(base_url))
|
||||||
|
if _detect_provider(base) == "chatgpt-subscription":
|
||||||
|
from src.chatgpt_subscription import fetch_available_models
|
||||||
|
if api_key:
|
||||||
|
return fetch_available_models(api_key, timeout=timeout)
|
||||||
|
return []
|
||||||
if _detect_provider(base) == "anthropic":
|
if _detect_provider(base) == "anthropic":
|
||||||
# Try Anthropic's /v1/models endpoint first
|
# Try Anthropic's /v1/models endpoint first
|
||||||
url = build_models_url(base)
|
url = build_models_url(base)
|
||||||
@@ -647,6 +713,10 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
|
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
|
||||||
return list(ANTHROPIC_MODELS)
|
return list(ANTHROPIC_MODELS)
|
||||||
url = build_models_url(base)
|
url = build_models_url(base)
|
||||||
|
if not url:
|
||||||
|
curated_key = _match_provider_curated(base, None)
|
||||||
|
fallback = _PROVIDER_CURATED.get(curated_key) if curated_key else None
|
||||||
|
return list(fallback or [])
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||||
@@ -998,6 +1068,17 @@ def setup_model_routes(model_discovery):
|
|||||||
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
||||||
if not ok:
|
if not ok:
|
||||||
continue
|
continue
|
||||||
|
if getattr(ep, "provider_auth_id", None):
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
info["base"], info["api_key"] = resolve_endpoint_runtime(
|
||||||
|
ep,
|
||||||
|
owner=getattr(ep, "owner", None),
|
||||||
|
)
|
||||||
|
info["key"] = _refresh_key(info["base"], info["api_key"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Skipping model refresh for %s: could not resolve provider auth: %s", getattr(ep, "name", ep.id), e)
|
||||||
|
continue
|
||||||
groups.setdefault(info["key"], {
|
groups.setdefault(info["key"], {
|
||||||
"base": info["base"],
|
"base": info["base"],
|
||||||
"api_key": info["api_key"],
|
"api_key": info["api_key"],
|
||||||
@@ -1266,12 +1347,20 @@ def setup_model_routes(model_discovery):
|
|||||||
"endpoint_kind": kind,
|
"endpoint_kind": kind,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
t0 = _time.time()
|
if _is_discovery_only_provider(provider):
|
||||||
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
# No general health endpoint — an unauthenticated GET just
|
||||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
# 401s. Report status from cached models instead of pinging.
|
||||||
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
entry["latency_ms"] = None
|
||||||
entry["error"] = ping.get("error")
|
entry["status"] = "online" if cached_count else "offline"
|
||||||
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
entry["error"] = None
|
||||||
|
entry["model_count"] = cached_count
|
||||||
|
else:
|
||||||
|
t0 = _time.time()
|
||||||
|
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||||
|
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||||
|
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
||||||
|
entry["error"] = ping.get("error")
|
||||||
|
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
entry["latency_ms"] = None
|
entry["latency_ms"] = None
|
||||||
entry["status"] = "online" if cached_count else "offline"
|
entry["status"] = "online" if cached_count else "offline"
|
||||||
@@ -1304,7 +1393,7 @@ def setup_model_routes(model_discovery):
|
|||||||
if ep_id and ep_id not in endpoints_cache:
|
if ep_id and ep_id not in endpoints_cache:
|
||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||||
if ep:
|
if ep:
|
||||||
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key}
|
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||||
ep_data = endpoints_cache.get(ep_id)
|
ep_data = endpoints_cache.get(ep_id)
|
||||||
if not ep_data:
|
if not ep_data:
|
||||||
# Try to find by base_url from the model's endpoint field
|
# Try to find by base_url from the model's endpoint field
|
||||||
@@ -1343,7 +1432,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"id": ep.id,
|
"id": ep.id,
|
||||||
"name": ep.name,
|
"name": ep.name,
|
||||||
"base_url": ep.base_url,
|
"base_url": ep.base_url,
|
||||||
"api_key": ep.api_key,
|
"api_key": _resolve_probe_key(ep),
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1432,12 +1521,14 @@ def setup_model_routes(model_discovery):
|
|||||||
# Endpoint counts as reachable if it has any model — including
|
# Endpoint counts as reachable if it has any model — including
|
||||||
# admin-pinned IDs that a probe would never surface.
|
# admin-pinned IDs that a probe would never surface.
|
||||||
status = "online" if (all_models or pinned) else "offline"
|
status = "online" if (all_models or pinned) else "offline"
|
||||||
|
base = _normalize_base(r.base_url)
|
||||||
ping = None
|
ping = None
|
||||||
if not all_models and not pinned and r.is_enabled:
|
# Discovery-only providers have no health endpoint — an
|
||||||
|
# unauthenticated ping just 401s, so don't bother.
|
||||||
|
if not all_models and not pinned and r.is_enabled and not _is_discovery_only_provider(_detect_provider(base)):
|
||||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
||||||
if ping.get("reachable"):
|
if ping.get("reachable"):
|
||||||
status = "empty"
|
status = "empty"
|
||||||
base = _normalize_base(r.base_url)
|
|
||||||
kind = _effective_endpoint_kind(r, base)
|
kind = _effective_endpoint_kind(r, base)
|
||||||
results.append({
|
results.append({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@@ -1713,7 +1804,7 @@ def setup_model_routes(model_discovery):
|
|||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(404, "Endpoint not found")
|
raise HTTPException(404, "Endpoint not found")
|
||||||
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key}
|
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1777,7 +1868,7 @@ def setup_model_routes(model_discovery):
|
|||||||
category = _classify_endpoint(base, kind)
|
category = _classify_endpoint(base, kind)
|
||||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||||
try:
|
try:
|
||||||
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||||
probed = []
|
probed = []
|
||||||
@@ -2116,7 +2207,9 @@ def setup_model_routes(model_discovery):
|
|||||||
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)
|
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
|
||||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
|
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
|
||||||
|
auth_id = getattr(ep, "provider_auth_id", None)
|
||||||
db.delete(ep)
|
db.delete(ep)
|
||||||
|
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()
|
||||||
_local_probe_cache["data"] = None
|
_local_probe_cache["data"] = None
|
||||||
@@ -2126,6 +2219,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"cleared_user_preferences": cleared_user_preferences,
|
"cleared_user_preferences": cleared_user_preferences,
|
||||||
"cleared_sessions": cleared_sessions,
|
"cleared_sessions": cleared_sessions,
|
||||||
"cleared_loaded_sessions": cleared_loaded_sessions,
|
"cleared_loaded_sessions": cleared_loaded_sessions,
|
||||||
|
"cleared_provider_auth": cleared_provider_auth,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, Note
|
from core.database import SessionLocal, Note
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from src.constants import DATA_DIR
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -170,7 +171,7 @@ async def dispatch_reminder(
|
|||||||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
from pathlib import Path as _P
|
from pathlib import Path as _P
|
||||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
cache_path = _P(f"data/note_pings_{_slug}.json")
|
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
|
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
last = cache.get(cache_key)
|
last = cache.get(cache_key)
|
||||||
@@ -523,7 +524,7 @@ async def dispatch_reminder(
|
|||||||
_STATE = cache_path
|
_STATE = cache_path
|
||||||
if _STATE is None:
|
if _STATE is None:
|
||||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
_STATE = _P(f"data/note_pings_{_slug}.json")
|
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
||||||
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
|
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import uuid
|
|||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||||
from src.request_models import DirectoryRequest
|
from src.request_models import DirectoryRequest
|
||||||
from core.constants import BASE_DIR, PERSONAL_DIR
|
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||||
from src.rag_singleton import get_rag_manager
|
from src.rag_singleton import get_rag_manager
|
||||||
from src.auth_helpers import require_privilege, require_user
|
from src.auth_helpers import require_privilege, require_user
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from src.upload_handler import secure_filename
|
from src.upload_handler import secure_filename
|
||||||
|
|
||||||
UPLOADS_DIR = os.path.join(BASE_DIR, "data", "personal_uploads")
|
UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
||||||
MAX_PERSONAL_UPLOAD_BYTES = int(
|
MAX_PERSONAL_UPLOAD_BYTES = int(
|
||||||
os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024))
|
os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
PREFS_FILE = os.path.join("data", "user_prefs.json")
|
PREFS_FILE = USER_PREFS_FILE
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
|
|||||||
+50
-36
@@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
|
|
||||||
@@ -37,9 +38,9 @@ def _first_chat_model(models) -> str:
|
|||||||
return (models[0] if models else "")
|
return (models[0] if models else "")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_research_endpoint(sess) -> tuple:
|
def _resolve_research_endpoint(sess, owner: Optional[str] = None) -> tuple:
|
||||||
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
"""Return (endpoint_url, model, headers) for Deep Research, checking admin overrides."""
|
||||||
owner = getattr(sess, "owner", None) or None
|
owner = owner or getattr(sess, "owner", None) or None
|
||||||
url, model, headers = resolve_endpoint(
|
url, model, headers = resolve_endpoint(
|
||||||
"research",
|
"research",
|
||||||
fallback_url=sess.endpoint_url,
|
fallback_url=sess.endpoint_url,
|
||||||
@@ -74,6 +75,38 @@ def _owned_enabled_endpoint(db, owner, endpoint_id=None):
|
|||||||
return owner_filter(q, ModelEndpoint, owner).first()
|
return owner_filter(q, ModelEndpoint, owner).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||||
|
"""Resolve a ModelEndpoint row into (chat_url, model, headers).
|
||||||
|
|
||||||
|
Mirrors endpoint_resolver.resolve_endpoint's provider-auth handling for
|
||||||
|
panel-selected research endpoints. ChatGPT Subscription endpoints keep
|
||||||
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
|
"""
|
||||||
|
from src.endpoint_resolver import (
|
||||||
|
build_chat_url,
|
||||||
|
build_headers,
|
||||||
|
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
base, api_key = resolve_model_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not resolve endpoint credentials for research: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
ep_model = (model or "").strip()
|
||||||
|
if not ep_model:
|
||||||
|
try:
|
||||||
|
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||||
|
if models:
|
||||||
|
ep_model = _first_chat_model(models)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not ep_model:
|
||||||
|
return None
|
||||||
|
return build_chat_url(base), ep_model, build_headers(api_key, base)
|
||||||
|
|
||||||
|
|
||||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||||
router = APIRouter(tags=["research"])
|
router = APIRouter(tags=["research"])
|
||||||
|
|
||||||
@@ -100,7 +133,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
if entry is not None:
|
if entry is not None:
|
||||||
return entry.get("owner", "") == user
|
return entry.get("owner", "") == user
|
||||||
# Task no longer in memory — check the persisted JSON.
|
# Task no longer in memory — check the persisted JSON.
|
||||||
path = Path("data/deep_research") / f"{session_id}.json"
|
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -164,7 +197,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
def _assert_owns_research(session_id: str, user: str) -> None:
|
def _assert_owns_research(session_id: str, user: str) -> None:
|
||||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||||
Use BEFORE returning any data or mutating the file."""
|
Use BEFORE returning any data or mutating the file."""
|
||||||
path = Path("data/deep_research") / f"{session_id}.json"
|
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Research not found")
|
raise HTTPException(404, "Research not found")
|
||||||
try:
|
try:
|
||||||
@@ -227,7 +260,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
):
|
):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
"""List all completed research for the Library panel."""
|
"""List all completed research for the Library panel."""
|
||||||
data_dir = Path("data/deep_research")
|
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
items = []
|
items = []
|
||||||
for p in data_dir.glob("*.json"):
|
for p in data_dir.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
@@ -277,7 +310,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
summary, stats — used by the Library preview panel."""
|
summary, stats — used by the Library preview panel."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
path = Path("data/deep_research") / f"{session_id}.json"
|
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Research not found")
|
raise HTTPException(404, "Research not found")
|
||||||
try:
|
try:
|
||||||
@@ -294,7 +327,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
"""Soft-archive / restore a research report (sets `archived` in its JSON)."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
path = Path("data/deep_research") / f"{session_id}.json"
|
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Research not found")
|
raise HTTPException(404, "Research not found")
|
||||||
try:
|
try:
|
||||||
@@ -314,7 +347,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
"""Delete a research result from disk."""
|
"""Delete a research result from disk."""
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
_validate_session_id(session_id)
|
_validate_session_id(session_id)
|
||||||
data_dir = Path("data/deep_research")
|
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
json_path = data_dir / f"{session_id}.json"
|
json_path = data_dir / f"{session_id}.json"
|
||||||
deleted = False
|
deleted = False
|
||||||
if json_path.exists():
|
if json_path.exists():
|
||||||
@@ -370,7 +403,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
|
|
||||||
if body.endpoint_id:
|
if body.endpoint_id:
|
||||||
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:
|
||||||
# Owner-scoped: never resolve another user's private endpoint
|
# Owner-scoped: never resolve another user's private endpoint
|
||||||
@@ -379,18 +411,10 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
ep = _owned_enabled_endpoint(db, user, body.endpoint_id)
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(404, "Endpoint not found or disabled")
|
raise HTTPException(404, "Endpoint not found or disabled")
|
||||||
base = normalize_base(ep.base_url)
|
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
||||||
ep_url = build_chat_url(base)
|
if not resolved:
|
||||||
ep_headers = build_headers(ep.api_key, base)
|
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
||||||
ep_model = body.model or ""
|
ep_url, ep_model, ep_headers = resolved
|
||||||
if not ep_model:
|
|
||||||
try:
|
|
||||||
import json as _json
|
|
||||||
models = _json.loads(ep.cached_models) if ep.cached_models else []
|
|
||||||
if models:
|
|
||||||
ep_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
else:
|
else:
|
||||||
@@ -407,7 +431,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
ep_url, ep_model, ep_headers = resolve_endpoint("chat", owner=user)
|
||||||
if not ep_url:
|
if not ep_url:
|
||||||
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:
|
||||||
# Owner-scoped first-enabled fallback: the caller's own rows
|
# Owner-scoped first-enabled fallback: the caller's own rows
|
||||||
@@ -416,18 +439,9 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
# /api/v1/chat fallback (webhook_routes._first_enabled_endpoint).
|
||||||
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)
|
||||||
ep_url = build_chat_url(base)
|
if resolved:
|
||||||
ep_headers = build_headers(ep.api_key, base)
|
ep_url, ep_model, ep_headers = resolved
|
||||||
ep_model = ""
|
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
import json as _json
|
|
||||||
models = _json.loads(ep.cached_models)
|
|
||||||
if models:
|
|
||||||
ep_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if not ep_url:
|
if not ep_url:
|
||||||
@@ -496,7 +510,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
raise HTTPException(404, "No research found for this session")
|
raise HTTPException(404, "No research found for this session")
|
||||||
result = research_handler.get_result(session_id)
|
result = research_handler.get_result(session_id)
|
||||||
if result is None:
|
if result is None:
|
||||||
p = Path("data/deep_research") / f"{session_id}.json"
|
p = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if p.exists():
|
if p.exists():
|
||||||
d = json.loads(p.read_text(encoding="utf-8"))
|
d = json.loads(p.read_text(encoding="utf-8"))
|
||||||
return {
|
return {
|
||||||
@@ -536,7 +550,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
sources = research_handler.get_sources(session_id) or []
|
sources = research_handler.get_sources(session_id) or []
|
||||||
query = ""
|
query = ""
|
||||||
|
|
||||||
path = Path("data/deep_research") / f"{session_id}.json"
|
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
disk = json.loads(path.read_text(encoding="utf-8"))
|
disk = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
+22
-17
@@ -92,18 +92,13 @@ def _reject_compact_during_active_run(session_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||||
"""Verify the current user owns the session. Raises 404 if not.
|
"""Verify the current user owns the session, honoring single-user modes.
|
||||||
|
|
||||||
Ownership is checked against the DB row when one exists (unchanged). If
|
Authenticated requests must match the stored DB or in-memory owner. When
|
||||||
there is no DB row but the caller owns an in-memory "ghost" session — one
|
auth is disabled and no user is present, treat the app as single-user mode:
|
||||||
that lives only in ``session_manager`` because it was never persisted, or
|
verify that the session exists, but do not compare its stored owner. This
|
||||||
its DB row was removed out-of-band — fall back to the in-memory owner so the
|
keeps QA/dev instances with AUTH_ENABLED=false from rejecting owner-stamped
|
||||||
user can still manage and delete it. Without this fallback such sessions are
|
rows created while auth was previously enabled.
|
||||||
listed by ``/api/sessions`` (they come from the in-memory manager) yet every
|
|
||||||
per-session operation 404s, making them impossible to delete (issue #1044).
|
|
||||||
|
|
||||||
``session_manager`` is optional and defaults to ``None`` so existing callers
|
|
||||||
that only care about persisted sessions keep their exact prior behavior.
|
|
||||||
"""
|
"""
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
if not user and not _auth_disabled():
|
if not user and not _auth_disabled():
|
||||||
@@ -114,13 +109,13 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
if row.owner != user:
|
if user and row.owner != user:
|
||||||
raise HTTPException(404, f"Session {session_id} not found")
|
raise HTTPException(404, f"Session {session_id} not found")
|
||||||
return
|
return
|
||||||
# No DB row — allow the caller to act on an in-memory ghost they own.
|
# No DB row — allow the caller to act on an in-memory ghost they own.
|
||||||
if session_manager is not None:
|
if session_manager is not None:
|
||||||
ghost = getattr(session_manager, "sessions", {}).get(session_id)
|
ghost = getattr(session_manager, "sessions", {}).get(session_id)
|
||||||
if ghost is not None and getattr(ghost, "owner", None) == user:
|
if ghost is not None and (not user or getattr(ghost, "owner", None) == user):
|
||||||
return
|
return
|
||||||
raise HTTPException(404, f"Session {session_id} not found")
|
raise HTTPException(404, f"Session {session_id} not found")
|
||||||
|
|
||||||
@@ -372,8 +367,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
pass
|
pass
|
||||||
elif not model_to_use:
|
elif not model_to_use:
|
||||||
from src.llm_core import list_model_ids
|
from src.llm_core import list_model_ids
|
||||||
ids = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
|
ids = list_model_ids(
|
||||||
headers=validation_headers)
|
endpoint_url,
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
headers=validation_headers,
|
||||||
|
owner=user,
|
||||||
|
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||||
|
)
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "Cannot reach /v1/models")
|
raise HTTPException(400, "Cannot reach /v1/models")
|
||||||
# Default to the first CHAT model — endpoints often list embedding/
|
# Default to the first CHAT model — endpoints often list embedding/
|
||||||
@@ -387,8 +387,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
|||||||
from src.llm_core import list_model_ids
|
from src.llm_core import list_model_ids
|
||||||
import os as _os
|
import os as _os
|
||||||
req_base = _os.path.basename(model_to_use.rstrip("/"))
|
req_base = _os.path.basename(model_to_use.rstrip("/"))
|
||||||
avail = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
|
avail = list_model_ids(
|
||||||
headers=validation_headers)
|
endpoint_url,
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
headers=validation_headers,
|
||||||
|
owner=user,
|
||||||
|
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||||
|
)
|
||||||
if not avail:
|
if not avail:
|
||||||
raise HTTPException(400, "Cannot reach /v1/models")
|
raise HTTPException(400, "Cannot reach /v1/models")
|
||||||
if model_to_use not in avail:
|
if model_to_use not in avail:
|
||||||
|
|||||||
@@ -1109,6 +1109,35 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
idx = skills_manager.index_for(owner=user)
|
idx = skills_manager.index_for(owner=user)
|
||||||
return {"index": idx, "count": len(idx)}
|
return {"index": idx, "count": len(idx)}
|
||||||
|
|
||||||
|
@router.get("/slash-catalog")
|
||||||
|
async def get_slash_catalog(request: Request):
|
||||||
|
"""Return skills that are available as slash commands.
|
||||||
|
|
||||||
|
Mirrors the agent prompt's published-skill index so the UI never offers
|
||||||
|
a slash command the model would not normally be allowed to discover.
|
||||||
|
"""
|
||||||
|
user = _owner(request)
|
||||||
|
all_skills = {s.get("name"): s for s in skills_manager.load(owner=user)}
|
||||||
|
entries = []
|
||||||
|
for s in skills_manager.index_for(owner=user):
|
||||||
|
name = (s.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
full = all_skills.get(name) or {}
|
||||||
|
category = (s.get("category") or full.get("category") or "general").strip() or "general"
|
||||||
|
entries.append({
|
||||||
|
"type": "skill",
|
||||||
|
"token": f"/{name}",
|
||||||
|
"name": name,
|
||||||
|
"category": f"Skills / {category}",
|
||||||
|
"help": s.get("description") or full.get("description") or "",
|
||||||
|
"usage": f"/{name} <request>",
|
||||||
|
"uses": int(full.get("uses") or 0),
|
||||||
|
"last_used": full.get("last_used"),
|
||||||
|
})
|
||||||
|
entries.sort(key=lambda row: row["name"])
|
||||||
|
return {"skills": entries, "count": len(entries)}
|
||||||
|
|
||||||
@router.get("/builtin")
|
@router.get("/builtin")
|
||||||
async def list_builtin_skills(request: Request):
|
async def list_builtin_skills(request: Request):
|
||||||
"""Read-only list of the agent's built-in tool capabilities (research,
|
"""Read-only list of the agent's built-in tool capabilities (research,
|
||||||
@@ -1272,6 +1301,47 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
_fire_skill_added(user)
|
_fire_skill_added(user)
|
||||||
return {"ok": True, "deduped": bool(entry.get("_deduped")), "skill": entry}
|
return {"ok": True, "deduped": bool(entry.get("_deduped")), "skill": entry}
|
||||||
|
|
||||||
|
@router.post("/{skill_id}/invoke")
|
||||||
|
async def invoke_skill(request: Request, skill_id: str):
|
||||||
|
"""Build a skill-pinned prompt for slash-command invocation.
|
||||||
|
|
||||||
|
This is intentionally server-side so availability, ownership, and usage
|
||||||
|
accounting use the same rules as the SkillsManager.
|
||||||
|
"""
|
||||||
|
user = _owner(request)
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
request_text = (body.get("request") or "").strip() if isinstance(body, dict) else ""
|
||||||
|
|
||||||
|
invokable = {
|
||||||
|
s.get("name"): s for s in skills_manager.index_for(owner=user)
|
||||||
|
if (s.get("name") or "").strip()
|
||||||
|
}
|
||||||
|
match = invokable.get(skill_id)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(404, "Skill is not available for slash invocation")
|
||||||
|
|
||||||
|
name = match.get("name")
|
||||||
|
md = skills_manager.read_skill_md(name, owner=user)
|
||||||
|
if md is None:
|
||||||
|
raise HTTPException(404, "Skill source unavailable")
|
||||||
|
|
||||||
|
skills_manager.record_use(name, owner=user)
|
||||||
|
message = (
|
||||||
|
"Apply the skill below to my request, following its Procedure / Pitfalls / Verification.\n\n"
|
||||||
|
f"--- BEGIN SKILL ---\n{md}\n--- END SKILL ---\n\n"
|
||||||
|
+ (f"Request: {request_text}" if request_text else "Request: (use the skill as appropriate)")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"type": "skill",
|
||||||
|
"name": name,
|
||||||
|
"command": f"/{name}",
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/{skill_id}")
|
@router.get("/{skill_id}")
|
||||||
async def get_skill(request: Request, skill_id: str):
|
async def get_skill(request: Request, skill_id: str):
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel
|
|||||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
|
|
||||||
@@ -621,7 +622,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
|
|
||||||
removed_files = 0
|
removed_files = 0
|
||||||
if action == "check_email_urgency":
|
if action == "check_email_urgency":
|
||||||
cache_dir = Path("data/email_urgency_cache")
|
cache_dir = Path(EMAIL_URGENCY_CACHE_DIR)
|
||||||
if cache_dir.exists():
|
if cache_dir.exists():
|
||||||
for child in cache_dir.glob("*.json"):
|
for child in cache_dir.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
@@ -630,7 +631,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default"))
|
owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (user or "default"))
|
||||||
for state_path in [Path(f"data/email_urgency_state_{owner_slug}.json")]:
|
for state_path in [Path(DATA_DIR) / f"email_urgency_state_{owner_slug}.json"]:
|
||||||
try:
|
try:
|
||||||
if state_path.exists():
|
if state_path.exists():
|
||||||
state_path.unlink()
|
state_path.unlink()
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||||
|
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
VAULT_FILE = Path("data/vault.json")
|
VAULT_FILE = Path(_VAULT_FILE)
|
||||||
|
|
||||||
|
|
||||||
def _find_bw() -> str:
|
def _find_bw() -> str:
|
||||||
|
|||||||
+21
-10
@@ -325,22 +325,33 @@ def setup_webhook_routes(
|
|||||||
endpoint_url = build_chat_url(base_url)
|
endpoint_url = build_chat_url(base_url)
|
||||||
model = body.model or "auto"
|
model = body.model or "auto"
|
||||||
api_key = ep.api_key
|
api_key = ep.api_key
|
||||||
|
if getattr(ep, "provider_auth_id", None):
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||||
|
|
||||||
if model == "auto":
|
if model == "auto":
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
models_url = build_models_url(base_url)
|
models_url = build_models_url(base_url)
|
||||||
hdrs = build_headers(api_key, base_url)
|
hdrs = build_headers(api_key, base_url)
|
||||||
resp = await client.get(models_url, headers=hdrs)
|
if models_url:
|
||||||
resp.raise_for_status()
|
resp = await client.get(models_url, headers=hdrs)
|
||||||
data = resp.json()
|
resp.raise_for_status()
|
||||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
data = resp.json()
|
||||||
if not ids:
|
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
ids = [
|
if not ids:
|
||||||
m.get("name") or m.get("model")
|
ids = [
|
||||||
for m in (data.get("models") or [])
|
m.get("name") or m.get("model")
|
||||||
if m.get("name") or m.get("model")
|
for m in (data.get("models") or [])
|
||||||
]
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
import json as _json
|
||||||
|
ids = _json.loads(ep.cached_models or "[]")
|
||||||
model = ids[0] if ids else "auto"
|
model = ids[0] if ids else "auto"
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(500, "Could not discover models from endpoint")
|
raise HTTPException(500, "Could not discover models from endpoint")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import json
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from src.constants import MEMORY_FILE, SKILLS_FILE
|
||||||
|
|
||||||
|
|
||||||
def claim_json_entries(entries, owner):
|
def claim_json_entries(entries, owner):
|
||||||
count = 0
|
count = 0
|
||||||
@@ -35,8 +37,8 @@ def main():
|
|||||||
|
|
||||||
# 1. Memories (JSON files)
|
# 1. Memories (JSON files)
|
||||||
for label, path in [
|
for label, path in [
|
||||||
("memory.json", "data/memory.json"),
|
("memory.json", MEMORY_FILE),
|
||||||
("skills.json", "data/skills.json"),
|
("skills.json", SKILLS_FILE),
|
||||||
]:
|
]:
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
print(f" {label}: not found, skipping")
|
print(f" {label}: not found, skipping")
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from src.constants import PERSONAL_DIR
|
||||||
|
|
||||||
# Configure logging for the script
|
# Configure logging for the script
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -45,7 +48,7 @@ def main():
|
|||||||
rag_manager = RAGManager()
|
rag_manager = RAGManager()
|
||||||
|
|
||||||
# Directory to scan
|
# Directory to scan
|
||||||
docs_directory = "data/personal_docs"
|
docs_directory = PERSONAL_DIR
|
||||||
directory_path = Path(docs_directory)
|
directory_path = Path(docs_directory)
|
||||||
|
|
||||||
# Check if directory exists
|
# Check if directory exists
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ def migrate_memories():
|
|||||||
"""Migrate memory vectors from FAISS to ChromaDB."""
|
"""Migrate memory vectors from FAISS to ChromaDB."""
|
||||||
from src.chroma_client import get_chroma_client
|
from src.chroma_client import get_chroma_client
|
||||||
from src.embeddings import get_embedding_client
|
from src.embeddings import get_embedding_client
|
||||||
from src.constants import DATA_DIR
|
from src.constants import MEMORY_VECTORS_DIR, MEMORY_FILE
|
||||||
|
|
||||||
ids_path = os.path.join(DATA_DIR, "memory_vectors", "ids.json")
|
ids_path = os.path.join(MEMORY_VECTORS_DIR, "ids.json")
|
||||||
memory_path = os.path.join(DATA_DIR, "memory.json")
|
memory_path = MEMORY_FILE
|
||||||
|
|
||||||
if not os.path.exists(ids_path):
|
if not os.path.exists(ids_path):
|
||||||
logger.info("No memory FAISS index found, skipping memory migration")
|
logger.info("No memory FAISS index found, skipping memory migration")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from src.rag_manager import RAGManager
|
from src.rag_manager import RAGManager
|
||||||
|
from src.constants import CHROMA_DIR
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -34,7 +35,7 @@ class DocsService:
|
|||||||
results = await service.query("what is async await?")
|
results = await service.query("what is async await?")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, persist_dir: str = "data/chroma"):
|
def __init__(self, persist_dir: str = CHROMA_DIR):
|
||||||
self.rag = RAGManager(persist_directory=persist_dir)
|
self.rag = RAGManager(persist_directory=persist_dir)
|
||||||
|
|
||||||
async def query(self, query: str, top_k: int = 5) -> List[DocChunk]:
|
async def query(self, query: str, top_k: int = 5) -> List[DocChunk]:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
from .memory import MemoryManager
|
from .memory import MemoryManager
|
||||||
from .memory_vector import MemoryVectorStore
|
from .memory_vector import MemoryVectorStore
|
||||||
from src.memory_provider import MemoryRecord, NativeMemoryProvider
|
from src.memory_provider import MemoryRecord, NativeMemoryProvider
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -38,7 +39,7 @@ class MemoryService:
|
|||||||
results = await service.recall("preferences")
|
results = await service.recall("preferences")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_dir: str = "data"):
|
def __init__(self, data_dir: str = DATA_DIR):
|
||||||
self.manager = MemoryManager(data_dir)
|
self.manager = MemoryManager(data_dir)
|
||||||
self.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
|
self.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
|
||||||
os.path.join(data_dir, "memory_vectors")
|
os.path.join(data_dir, "memory_vectors")
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from src.research_utils import is_low_quality
|
from src.research_utils import is_low_quality
|
||||||
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RESEARCH_DATA_DIR = Path("data/deep_research")
|
RESEARCH_DATA_DIR = Path(DEEP_RESEARCH_DIR)
|
||||||
|
|
||||||
|
|
||||||
class ResearchHandler:
|
class ResearchHandler:
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import httpx
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from src.constants import TTS_CACHE_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class TTSService:
|
|||||||
"endpoint:<id>" — OpenAI-compatible /audio/speech via ModelEndpoint
|
"endpoint:<id>" — OpenAI-compatible /audio/speech via ModelEndpoint
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cache_dir: str = "data/tts_cache"):
|
def __init__(self, cache_dir: str = TTS_CACHE_DIR):
|
||||||
self.cache_dir = Path(cache_dir)
|
self.cache_dir = Path(cache_dir)
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._kokoro = None # lazy-init
|
self._kokoro = None # lazy-init
|
||||||
|
|||||||
@@ -12,19 +12,24 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
from src.constants import (
|
||||||
|
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
|
||||||
|
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
|
||||||
|
RAG_DIR, MEMORY_VECTORS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
DIRS = [
|
DIRS = [
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
os.path.join(DATA_DIR, "uploads"),
|
UPLOAD_DIR,
|
||||||
os.path.join(DATA_DIR, "personal_docs"),
|
PERSONAL_DIR,
|
||||||
os.path.join(DATA_DIR, "personal_uploads"),
|
PERSONAL_UPLOADS_DIR,
|
||||||
os.path.join(DATA_DIR, "tts_cache"),
|
TTS_CACHE_DIR,
|
||||||
os.path.join(DATA_DIR, "generated_images"),
|
GENERATED_IMAGES_DIR,
|
||||||
os.path.join(DATA_DIR, "deep_research"),
|
DEEP_RESEARCH_DIR,
|
||||||
os.path.join(DATA_DIR, "chroma"),
|
CHROMA_DIR,
|
||||||
os.path.join(DATA_DIR, "rag"),
|
RAG_DIR,
|
||||||
os.path.join(DATA_DIR, "memory_vectors"),
|
MEMORY_VECTORS_DIR,
|
||||||
os.path.join(BASE_DIR, "logs"),
|
os.path.join(BASE_DIR, "logs"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ def _prompt_admin_credentials():
|
|||||||
|
|
||||||
def create_default_admin():
|
def create_default_admin():
|
||||||
"""Create an initial admin user if none exists."""
|
"""Create an initial admin user if none exists."""
|
||||||
auth_path = os.path.join(DATA_DIR, "auth.json")
|
auth_path = AUTH_FILE
|
||||||
if os.path.exists(auth_path):
|
if os.path.exists(auth_path):
|
||||||
print(" [skip] auth.json already exists")
|
print(" [skip] auth.json already exists")
|
||||||
return "exists"
|
return "exists"
|
||||||
|
|||||||
+43
-27
@@ -14,6 +14,8 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
AI_CHAT_TIMEOUT = 120 # seconds for a single LLM call
|
AI_CHAT_TIMEOUT = 120 # seconds for a single LLM call
|
||||||
@@ -55,7 +57,7 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
|
|||||||
# Model resolution
|
# Model resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, build_models_url
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
|
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||||
@@ -96,9 +98,12 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
|||||||
(f" matching '{target_endpoint_name}'" if target_endpoint_name else ""))
|
(f" matching '{target_endpoint_name}'" if target_endpoint_name else ""))
|
||||||
|
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
headers = build_headers(ep.api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
|
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
# Anthropic: match against hardcoded model list
|
# Anthropic: match against hardcoded model list
|
||||||
@@ -112,16 +117,20 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
|||||||
else:
|
else:
|
||||||
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
||||||
try:
|
try:
|
||||||
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
models_url = build_models_url(base)
|
||||||
r.raise_for_status()
|
if models_url:
|
||||||
data = r.json()
|
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
r.raise_for_status()
|
||||||
if not model_ids:
|
data = r.json()
|
||||||
model_ids = [
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
m.get("name") or m.get("model")
|
if not model_ids:
|
||||||
for m in (data.get("models") or [])
|
model_ids = [
|
||||||
if m.get("name") or m.get("model")
|
m.get("name") or m.get("model")
|
||||||
]
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
model_ids = json.loads(ep.cached_models or "[]")
|
||||||
except Exception:
|
except Exception:
|
||||||
model_ids = []
|
model_ids = []
|
||||||
|
|
||||||
@@ -1119,25 +1128,32 @@ async def do_list_models(content: str, session_id: Optional[str] = None, owner:
|
|||||||
total_models = 0
|
total_models = 0
|
||||||
|
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
headers = build_headers(ep.api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
|
|
||||||
model_ids = []
|
model_ids = []
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
model_ids = list(ANTHROPIC_MODELS)
|
model_ids = list(ANTHROPIC_MODELS)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
models_url = build_models_url(base)
|
||||||
r.raise_for_status()
|
if models_url:
|
||||||
data = r.json()
|
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
r.raise_for_status()
|
||||||
if not model_ids:
|
data = r.json()
|
||||||
model_ids = [
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
m.get("name") or m.get("model")
|
if not model_ids:
|
||||||
for m in (data.get("models") or [])
|
model_ids = [
|
||||||
if m.get("name") or m.get("model")
|
m.get("name") or m.get("model")
|
||||||
]
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
model_ids = json.loads(ep.cached_models or "[]")
|
||||||
except Exception:
|
except Exception:
|
||||||
model_ids = ["(endpoint offline)"]
|
model_ids = ["(endpoint offline)"]
|
||||||
|
|
||||||
@@ -1715,7 +1731,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
|
|
||||||
# GPT image models always return b64_json; DALL-E may return url
|
# GPT image models always return b64_json; DALL-E may return url
|
||||||
if img.get("b64_json"):
|
if img.get("b64_json"):
|
||||||
img_dir = Path("data/generated_images")
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
@@ -1728,7 +1744,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
try:
|
try:
|
||||||
dl_resp = httpx.get(img["url"], timeout=60)
|
dl_resp = httpx.get(img["url"], timeout=60)
|
||||||
if dl_resp.status_code == 200:
|
if dl_resp.status_code == 200:
|
||||||
img_dir = Path("data/generated_images")
|
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
|
|||||||
+4
-3
@@ -38,9 +38,10 @@ from core.platform_compat import (
|
|||||||
pid_alive,
|
pid_alive,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
|
from src.constants import BG_JOBS_DIR, BG_JOBS_FILE
|
||||||
_JOBS_DIR = _DATA_DIR / "bg_jobs"
|
|
||||||
_STORE = _DATA_DIR / "bg_jobs.json"
|
_JOBS_DIR = Path(BG_JOBS_DIR)
|
||||||
|
_STORE = Path(BG_JOBS_FILE)
|
||||||
|
|
||||||
# A job that runs longer than this is presumed stuck and reaped (the agent
|
# A job that runs longer than this is presumed stuck and reaped (the agent
|
||||||
# still gets a "timed out" follow-up so nothing hangs forever).
|
# still gets a "timed out" follow-up so nothing hangs forever).
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from typing import Tuple
|
|||||||
|
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
from core.platform_compat import IS_WINDOWS, find_bash
|
from core.platform_compat import IS_WINDOWS, find_bash
|
||||||
from core.constants import DATA_DIR, internal_api_base
|
from core.constants import internal_api_base
|
||||||
|
from src.constants import DATA_DIR, DEEP_RESEARCH_DIR, TIDY_CALENDAR_STATE_FILE, EMAIL_URGENCY_CACHE_DIR, COOKBOOK_STATE_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -349,7 +350,7 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
try:
|
try:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json as _json
|
import json as _json
|
||||||
research_dir = Path("data/deep_research")
|
research_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
if not research_dir.exists():
|
if not research_dir.exists():
|
||||||
raise TaskNoop("no research directory")
|
raise TaskNoop("no research directory")
|
||||||
files = list(research_dir.glob("*.json"))
|
files = list(research_dir.glob("*.json"))
|
||||||
@@ -387,7 +388,7 @@ async def action_tidy_calendar(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
from core.database import SessionLocal, CalendarEvent
|
from core.database import SessionLocal, CalendarEvent
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
STATE_FILE = Path("data/tidy_calendar_state.json")
|
STATE_FILE = Path(TIDY_CALENDAR_STATE_FILE)
|
||||||
last_watermark = None
|
last_watermark = None
|
||||||
try:
|
try:
|
||||||
if STATE_FILE.exists():
|
if STATE_FILE.exists():
|
||||||
@@ -1304,12 +1305,12 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
# users' entries (review C4). Legacy path kept as fallback so a
|
# users' entries (review C4). Legacy path kept as fallback so a
|
||||||
# single-user install (empty owner) doesn't lose its history.
|
# single-user install (empty owner) doesn't lose its history.
|
||||||
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
STATE = _P(f"data/note_pings_{_owner_slug}.json")
|
STATE = _P(DATA_DIR) / f"note_pings_{_owner_slug}.json"
|
||||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# One-time migration: if legacy global file exists and per-owner file
|
# One-time migration: if legacy global file exists and per-owner file
|
||||||
# doesn't, seed from global (entries for OTHER owners still get pruned
|
# doesn't, seed from global (entries for OTHER owners still get pruned
|
||||||
# on their first run — acceptable, prevents silent loss).
|
# on their first run — acceptable, prevents silent loss).
|
||||||
_legacy = _P("data/note_pings.json")
|
_legacy = _P(DATA_DIR) / "note_pings.json"
|
||||||
if _legacy.exists() and not STATE.exists():
|
if _legacy.exists() and not STATE.exists():
|
||||||
try:
|
try:
|
||||||
STATE.write_text(_legacy.read_text(encoding="utf-8"), encoding="utf-8")
|
STATE.write_text(_legacy.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
@@ -1466,8 +1467,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
# notified_uids / urgency counts. Empty owner falls back to a generic
|
# notified_uids / urgency counts. Empty owner falls back to a generic
|
||||||
# filename for single-user installs (matches prior behaviour).
|
# filename for single-user installs (matches prior behaviour).
|
||||||
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||||
STATE_PATH = _P(f"data/email_urgency_state_{_owner_slug}.json")
|
STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json"
|
||||||
CACHE_DIR = _P("data/email_urgency_cache")
|
CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR)
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
|
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
|
||||||
@@ -2043,7 +2044,7 @@ async def action_cookbook_serve(
|
|||||||
except Exception:
|
except Exception:
|
||||||
end_after_min = 0
|
end_after_min = 0
|
||||||
|
|
||||||
state_path = Path(DATA_DIR) / "cookbook_state.json"
|
state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
try:
|
try:
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""ChatGPT subscription / Codex backend OAuth helpers.
|
||||||
|
|
||||||
|
This provider is intentionally separate from OpenAI API-key endpoints. It uses
|
||||||
|
OpenAI account OAuth device authorization, stores refresh tokens server-side,
|
||||||
|
and resolves a fresh bearer token at request time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from core.database import ProviderAuthSession, SessionLocal, utcnow_naive
|
||||||
|
|
||||||
|
DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = (
|
||||||
|
os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/")
|
||||||
|
or "https://chatgpt.com/backend-api/codex"
|
||||||
|
)
|
||||||
|
CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription"
|
||||||
|
CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||||
|
CHATGPT_OAUTH_ISSUER = "https://auth.openai.com"
|
||||||
|
CHATGPT_OAUTH_REDIRECT_URI = f"{CHATGPT_OAUTH_ISSUER}/deviceauth/callback"
|
||||||
|
CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||||
|
_AUTH_REFRESH_LOCKS: dict[str, threading.Lock] = {}
|
||||||
|
_AUTH_REFRESH_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_lock_for(auth_id: str) -> threading.Lock:
|
||||||
|
with _AUTH_REFRESH_LOCKS_GUARD:
|
||||||
|
lock = _AUTH_REFRESH_LOCKS.get(auth_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.Lock()
|
||||||
|
_AUTH_REFRESH_LOCKS[auth_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTSubscriptionError(RuntimeError):
|
||||||
|
"""Base error for ChatGPT subscription provider failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTSubscriptionReauthRequired(ChatGPTSubscriptionError):
|
||||||
|
"""Stored OAuth credentials are invalid or expired beyond refresh."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTSubscriptionRateLimited(ChatGPTSubscriptionError):
|
||||||
|
"""Upstream quota/rate limit; reconnecting will not fix it."""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTSubscriptionAuthNotFound(ChatGPTSubscriptionError):
|
||||||
|
"""No matching owner-scoped auth session exists."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_chatgpt_subscription_base(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
host = (parsed.hostname or "").lower().rstrip(".")
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return host == "chatgpt.com" and (
|
||||||
|
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chatgpt_headers(access_token: Optional[str]) -> Dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Origin": "https://chatgpt.com",
|
||||||
|
"Referer": "https://chatgpt.com/codex",
|
||||||
|
"User-Agent": "Odysseus ChatGPT Subscription",
|
||||||
|
}
|
||||||
|
if access_token:
|
||||||
|
headers["Authorization"] = f"Bearer {access_token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str]:
|
||||||
|
if not access_token:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||||
|
headers=chatgpt_headers(access_token),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
data = response.json()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
entries = data.get("models", []) if isinstance(data, dict) else []
|
||||||
|
sortable: list[tuple[int, str]] = []
|
||||||
|
for item in entries:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
slug = item.get("slug")
|
||||||
|
if not isinstance(slug, str) or not slug.strip():
|
||||||
|
continue
|
||||||
|
visibility = item.get("visibility", "")
|
||||||
|
if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}:
|
||||||
|
continue
|
||||||
|
priority = item.get("priority")
|
||||||
|
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
|
||||||
|
sortable.append((rank, slug.strip()))
|
||||||
|
sortable.sort(key=lambda item: (item[0], item[1]))
|
||||||
|
ordered: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for _, slug in sortable:
|
||||||
|
if slug not in seen:
|
||||||
|
ordered.append(slug)
|
||||||
|
seen.add(slug)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_for_oauth_response(response: httpx.Response, action: str) -> None:
|
||||||
|
if response.status_code < 400:
|
||||||
|
return
|
||||||
|
code = ""
|
||||||
|
message = f"ChatGPT Subscription {action} failed with HTTP {response.status_code}."
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
err = payload.get("error") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(err, dict):
|
||||||
|
code = str(err.get("code") or err.get("type") or "").strip()
|
||||||
|
msg = err.get("message")
|
||||||
|
if msg:
|
||||||
|
message = f"ChatGPT Subscription {action} failed: {msg}"
|
||||||
|
elif isinstance(err, str):
|
||||||
|
code = err.strip()
|
||||||
|
desc = payload.get("error_description") or payload.get("message")
|
||||||
|
if desc:
|
||||||
|
message = f"ChatGPT Subscription {action} failed: {desc}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise ChatGPTSubscriptionRateLimited(
|
||||||
|
"ChatGPT Subscription quota or rate limit was reached. Credentials are still valid."
|
||||||
|
)
|
||||||
|
if response.status_code in (401, 403) or code in {"invalid_grant", "invalid_token", "invalid_request", "refresh_token_reused"}:
|
||||||
|
raise ChatGPTSubscriptionReauthRequired(message)
|
||||||
|
raise ChatGPTSubscriptionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_or_error(response: httpx.Response, action: str) -> Dict[str, Any]:
|
||||||
|
_raise_for_oauth_response(response, action)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned invalid JSON.") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned an unexpected response.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def request_device_code(timeout: float = 15.0) -> Dict[str, Any]:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/usercode",
|
||||||
|
json={"client_id": CHATGPT_OAUTH_CLIENT_ID},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
data = _json_or_error(response, "device-code request")
|
||||||
|
if not data.get("device_auth_id") or not data.get("user_code"):
|
||||||
|
raise ChatGPTSubscriptionError("ChatGPT device-code response was missing required fields.")
|
||||||
|
data.setdefault("verification_uri", f"{CHATGPT_OAUTH_ISSUER}/codex/device")
|
||||||
|
data.setdefault("interval", 5)
|
||||||
|
data.setdefault("expires_in", 900)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def poll_device_auth(device_auth_id: str, user_code: str, timeout: float = 15.0) -> Dict[str, Any]:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/token",
|
||||||
|
json={"device_auth_id": device_auth_id, "user_code": user_code},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if response.status_code in (403, 404):
|
||||||
|
return {"status": "pending", "error": "authorization_pending"}
|
||||||
|
return _json_or_error(response, "device-code poll")
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_authorization_code(authorization_code: str, code_verifier: str, timeout: float = 15.0) -> Dict[str, Any]:
|
||||||
|
response = httpx.post(
|
||||||
|
CHATGPT_OAUTH_TOKEN_URL,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": authorization_code,
|
||||||
|
"redirect_uri": CHATGPT_OAUTH_REDIRECT_URI,
|
||||||
|
"client_id": CHATGPT_OAUTH_CLIENT_ID,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
data = _json_or_error(response, "token exchange")
|
||||||
|
if not data.get("access_token"):
|
||||||
|
raise ChatGPTSubscriptionReauthRequired("ChatGPT token exchange did not return an access token.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_oauth_tokens(access_token: str, refresh_token: str, timeout: float = 20.0) -> Dict[str, Any]:
|
||||||
|
del access_token
|
||||||
|
if not refresh_token:
|
||||||
|
raise ChatGPTSubscriptionReauthRequired("ChatGPT Subscription is missing a refresh token. Reconnect the provider.")
|
||||||
|
response = httpx.post(
|
||||||
|
CHATGPT_OAUTH_TOKEN_URL,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": CHATGPT_OAUTH_CLIENT_ID,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
data = _json_or_error(response, "token refresh")
|
||||||
|
if not data.get("access_token"):
|
||||||
|
raise ChatGPTSubscriptionReauthRequired("ChatGPT token refresh did not return an access token.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
||||||
|
parts = (token or "").split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("not a JWT")
|
||||||
|
segment = parts[1]
|
||||||
|
segment += "=" * (-len(segment) % 4)
|
||||||
|
raw = base64.urlsafe_b64decode(segment.encode("ascii"))
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
|
||||||
|
try:
|
||||||
|
exp = int(_decode_jwt_payload(access_token).get("exp") or 0)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return exp <= int(time.time()) + int(skew_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
q = db.query(ProviderAuthSession).filter(
|
||||||
|
ProviderAuthSession.id == auth_id,
|
||||||
|
ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
)
|
||||||
|
if owner:
|
||||||
|
q = q.filter(ProviderAuthSession.owner == owner)
|
||||||
|
row = q.first()
|
||||||
|
if row is None:
|
||||||
|
raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.")
|
||||||
|
|
||||||
|
access_token = row.access_token or ""
|
||||||
|
if force_refresh or access_token_is_expiring(access_token):
|
||||||
|
with _refresh_lock_for(auth_id):
|
||||||
|
db.refresh(row)
|
||||||
|
access_token = row.access_token or ""
|
||||||
|
refresh_token = row.refresh_token or ""
|
||||||
|
if force_refresh or access_token_is_expiring(access_token):
|
||||||
|
refreshed = refresh_oauth_tokens(access_token, refresh_token)
|
||||||
|
row.access_token = refreshed["access_token"]
|
||||||
|
if refreshed.get("refresh_token"):
|
||||||
|
row.refresh_token = refreshed["refresh_token"]
|
||||||
|
row.last_refresh = utcnow_naive()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
access_token = row.access_token or ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
"base_url": (row.base_url or DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL).rstrip("/"),
|
||||||
|
"api_key": access_token,
|
||||||
|
"auth_mode": row.auth_mode or "chatgpt",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def to_http_exception(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, ChatGPTSubscriptionRateLimited):
|
||||||
|
return HTTPException(429, str(exc))
|
||||||
|
if isinstance(exc, (ChatGPTSubscriptionReauthRequired, ChatGPTSubscriptionAuthNotFound)):
|
||||||
|
return HTTPException(401, f"{exc} Reconnect the provider.")
|
||||||
|
return HTTPException(502, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def build_responses_input(messages: list[dict]) -> list[dict]:
|
||||||
|
input_items: list[dict] = []
|
||||||
|
for msg in messages or []:
|
||||||
|
role = msg.get("role") or "user"
|
||||||
|
if role == "tool":
|
||||||
|
role = "user"
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
text = "\n".join(str(part.get("text") or part.get("content") or "") for part in content if isinstance(part, dict))
|
||||||
|
else:
|
||||||
|
text = "" if content is None else str(content)
|
||||||
|
input_type = "output_text" if role == "assistant" else "input_text"
|
||||||
|
input_items.append({"role": role, "content": [{"type": input_type, "text": text}]})
|
||||||
|
return input_items
|
||||||
+10
-8
@@ -4,6 +4,8 @@ from typing import List, Optional
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from src.constants import DATA_DIR as _DATA_DIR_CONST
|
||||||
|
|
||||||
# Cross-platform OS flag, exposed here so callers can `from src.config import
|
# Cross-platform OS flag, exposed here so callers can `from src.config import
|
||||||
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
|
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
|
||||||
# from core.platform_compat, to keep this dependency-light config module from
|
# from core.platform_compat, to keep this dependency-light config module from
|
||||||
@@ -20,13 +22,13 @@ class DataConfig(BaseSettings):
|
|||||||
base_dir: Path = Field(default=Path(__file__).parent.parent, description="Base directory for the application")
|
base_dir: Path = Field(default=Path(__file__).parent.parent, description="Base directory for the application")
|
||||||
|
|
||||||
# Data paths
|
# Data paths
|
||||||
data_dir: Path = Field(default=Path("data"), description="Main data directory")
|
data_dir: Path = Field(default=Path(_DATA_DIR_CONST), description="Main data directory")
|
||||||
uploads_dir: Path = Field(default=Path("data/uploads"), description="Directory for uploaded files")
|
uploads_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "uploads", description="Directory for uploaded files")
|
||||||
sessions_file: Path = Field(default=Path("data/sessions.json"), description="Sessions storage file")
|
sessions_file: Path = Field(default=Path(_DATA_DIR_CONST) / "sessions.json", description="Sessions storage file")
|
||||||
memory_file: Path = Field(default=Path("data/memory.json"), description="Memory storage file")
|
memory_file: Path = Field(default=Path(_DATA_DIR_CONST) / "memory.json", description="Memory storage file")
|
||||||
memory_doc: Path = Field(default=Path("data/memory_doc.md"), description="Memory document file")
|
memory_doc: Path = Field(default=Path(_DATA_DIR_CONST) / "memory_doc.md", description="Memory document file")
|
||||||
personal_dir: Path = Field(default=Path("data/personal_docs"), description="Personal documents directory")
|
personal_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs", description="Personal documents directory")
|
||||||
runbook_dir: Path = Field(default=Path("data/personal_docs/runbook"), description="Runbook directory")
|
runbook_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs" / "runbook", description="Runbook directory")
|
||||||
|
|
||||||
# Upload settings
|
# Upload settings
|
||||||
max_upload_size: int = Field(default=10 * 1024 * 1024, description="Maximum upload size in bytes (10MB)")
|
max_upload_size: int = Field(default=10 * 1024 * 1024, description="Maximum upload size in bytes (10MB)")
|
||||||
@@ -139,7 +141,7 @@ class AppConfig(BaseSettings):
|
|||||||
base_dir = Path(__file__).parent.parent
|
base_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
# Convert string paths to Path objects relative to base_dir
|
# Convert string paths to Path objects relative to base_dir
|
||||||
data_dir = base_dir / "data"
|
data_dir = Path(_DATA_DIR_CONST)
|
||||||
|
|
||||||
# Get values from the input dict or use defaults
|
# Get values from the input dict or use defaults
|
||||||
max_upload_size = v.get("max_upload_size", 10 * 1024 * 1024) if isinstance(v, dict) else 10 * 1024 * 1024
|
max_upload_size = v.get("max_upload_size", 10 * 1024 * 1024) if isinstance(v, dict) else 10 * 1024 * 1024
|
||||||
|
|||||||
+58
-1
@@ -7,9 +7,12 @@ APP_VERSION = "1.0.0"
|
|||||||
# Base paths
|
# Base paths
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
||||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", os.path.join(BASE_DIR, "data"))
|
||||||
|
|
||||||
# Data file paths
|
# Data file paths
|
||||||
|
# Single source of truth: every persisted file/dir lives under DATA_DIR, which
|
||||||
|
# is the ONLY place ODYSSEUS_DATA_DIR is read. Import these constants instead of
|
||||||
|
# re-deriving paths from __file__ or a relative "data" literal.
|
||||||
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
||||||
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
||||||
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
|
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
|
||||||
@@ -18,6 +21,41 @@ RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook")
|
|||||||
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
||||||
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
|
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
|
||||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||||
|
AUTH_FILE = os.path.join(DATA_DIR, "auth.json")
|
||||||
|
USER_PREFS_FILE = os.path.join(DATA_DIR, "user_prefs.json")
|
||||||
|
PRESETS_FILE = os.path.join(DATA_DIR, "presets.json")
|
||||||
|
INTEGRATIONS_FILE = os.path.join(DATA_DIR, "integrations.json")
|
||||||
|
CONTACTS_FILE = os.path.join(DATA_DIR, "contacts.json")
|
||||||
|
APP_KEY_FILE = os.path.join(DATA_DIR, ".app_key")
|
||||||
|
EMBEDDING_ENDPOINT_FILE = os.path.join(DATA_DIR, "embedding_endpoint.json")
|
||||||
|
COOKBOOK_STATE_FILE = os.path.join(DATA_DIR, "cookbook_state.json")
|
||||||
|
BG_JOBS_FILE = os.path.join(DATA_DIR, "bg_jobs.json")
|
||||||
|
VAULT_FILE = os.path.join(DATA_DIR, "vault.json")
|
||||||
|
TIDY_CALENDAR_STATE_FILE = os.path.join(DATA_DIR, "tidy_calendar_state.json")
|
||||||
|
SKILLS_FILE = os.path.join(DATA_DIR, "skills.json")
|
||||||
|
APP_DB = os.path.join(DATA_DIR, "app.db")
|
||||||
|
SCHEDULED_EMAILS_DB = os.path.join(DATA_DIR, "scheduled_emails.db")
|
||||||
|
EMAIL_CACHE_DB = os.path.join(DATA_DIR, "email_cache.db")
|
||||||
|
|
||||||
|
# Data subdirectories
|
||||||
|
PERSONAL_UPLOADS_DIR = os.path.join(DATA_DIR, "personal_uploads")
|
||||||
|
EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache")
|
||||||
|
RAG_DIR = os.path.join(DATA_DIR, "rag")
|
||||||
|
CHROMA_DIR = os.path.join(DATA_DIR, "chroma")
|
||||||
|
BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs")
|
||||||
|
DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research")
|
||||||
|
MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth")
|
||||||
|
GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
|
||||||
|
TTS_CACHE_DIR = os.path.join(DATA_DIR, "tts_cache")
|
||||||
|
EMAIL_URGENCY_CACHE_DIR = os.path.join(DATA_DIR, "email_urgency_cache")
|
||||||
|
SKILLS_DIR = os.path.join(DATA_DIR, "skills")
|
||||||
|
GALLERY_DIR = os.path.join(DATA_DIR, "gallery")
|
||||||
|
GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads")
|
||||||
|
MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors")
|
||||||
|
|
||||||
|
# Paths with an intentional dedicated env override, defaulting under DATA_DIR.
|
||||||
|
MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments"))
|
||||||
|
FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH", os.path.join(DATA_DIR, "fastembed_cache"))
|
||||||
|
|
||||||
# Agent tool output limits (single source of truth — imported by tool_execution.py,
|
# Agent tool output limits (single source of truth — imported by tool_execution.py,
|
||||||
# tool_implementations.py, agent_tools.py, and any other module that needs them)
|
# tool_implementations.py, agent_tools.py, and any other module that needs them)
|
||||||
@@ -44,3 +82,22 @@ CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
|||||||
# Default parameters
|
# Default parameters
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
DEFAULT_MAX_TOKENS = 0
|
DEFAULT_MAX_TOKENS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def internal_api_base() -> str:
|
||||||
|
"""Base URL for in-process loopback calls to Odysseus's own API.
|
||||||
|
|
||||||
|
Agent tools and background jobs reach admin-gated routes by calling the
|
||||||
|
running server over HTTP. Resolution order:
|
||||||
|
1. ODYSSEUS_INTERNAL_BASE - explicit override (e.g. behind a TLS proxy).
|
||||||
|
2. APP_PORT - http://127.0.0.1:$APP_PORT (docker-compose).
|
||||||
|
3. Fallback http://127.0.0.1:7000 - legacy default.
|
||||||
|
|
||||||
|
127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local
|
||||||
|
call. Without this, loopback tools fail with "All connection attempts
|
||||||
|
failed" whenever the server is not on port 7000.
|
||||||
|
"""
|
||||||
|
override = os.environ.get("ODYSSEUS_INTERNAL_BASE")
|
||||||
|
if override:
|
||||||
|
return override.rstrip("/")
|
||||||
|
return f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}"
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from core.constants import DATA_DIR, internal_api_base
|
from core.constants import internal_api_base
|
||||||
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = ""
|
|||||||
|
|
||||||
|
|
||||||
async def _tick() -> None:
|
async def _tick() -> None:
|
||||||
state_path = Path(DATA_DIR) / "cookbook_state.json"
|
state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
if not state_path.exists():
|
if not state_path.exists():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
+4
-8
@@ -14,6 +14,8 @@ Set EMBEDDING_URL in .env, e.g.:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from src.constants import FASTEMBED_CACHE_DIR, EMBEDDING_ENDPOINT_FILE
|
||||||
|
|
||||||
# Windows: force HuggingFace/fastembed to COPY model files rather than symlink
|
# Windows: force HuggingFace/fastembed to COPY model files rather than symlink
|
||||||
# them. On a network-share/UNC cache dir Windows can't follow HF's symlinks
|
# them. On a network-share/UNC cache dir Windows can't follow HF's symlinks
|
||||||
# ([WinError 1463] "symbolic link cannot be followed"), so ONNX fails to load the
|
# ([WinError 1463] "symbolic link cannot be followed"), so ONNX fails to load the
|
||||||
@@ -117,10 +119,7 @@ class FastEmbedClient:
|
|||||||
# Persistent cache under data/ so the model survives reboots and so
|
# Persistent cache under data/ so the model survives reboots and so
|
||||||
# the download lands exactly where the admin panel's _is_downloaded()
|
# the download lands exactly where the admin panel's _is_downloaded()
|
||||||
# check looks (both default to this same path).
|
# check looks (both default to this same path).
|
||||||
cache_dir = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join(
|
cache_dir = FASTEMBED_CACHE_DIR
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
"data", "fastembed_cache",
|
|
||||||
)
|
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
# Windows self-heal: the HuggingFace-hub cache stores model files as
|
# Windows self-heal: the HuggingFace-hub cache stores model files as
|
||||||
# symlinks (snapshots/<rev>/model.onnx -> ../../blobs/<hash>). On a
|
# symlinks (snapshots/<rev>/model.onnx -> ../../blobs/<hash>). On a
|
||||||
@@ -188,10 +187,7 @@ class FastEmbedClient:
|
|||||||
def _load_persisted_endpoint() -> dict:
|
def _load_persisted_endpoint() -> dict:
|
||||||
"""Load the custom embedding endpoint saved from the admin panel."""
|
"""Load the custom embedding endpoint saved from the admin panel."""
|
||||||
try:
|
try:
|
||||||
endpoint_file = os.path.join(
|
endpoint_file = EMBEDDING_ENDPOINT_FILE
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
||||||
"data", "embedding_endpoint.json",
|
|
||||||
)
|
|
||||||
if os.path.exists(endpoint_file):
|
if os.path.exists(endpoint_file):
|
||||||
import json
|
import json
|
||||||
data = json.loads(open(endpoint_file, encoding="utf-8").read())
|
data = json.loads(open(endpoint_file, encoding="utf-8").read())
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ def _endpoint_enabled_models(ep) -> list:
|
|||||||
return [m for m in _endpoint_cached_models(ep) if m not in hidden]
|
return [m for m in _endpoint_cached_models(ep) if m not in hidden]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Resolve a ModelEndpoint row to its runtime base URL and bearer/API key.
|
||||||
|
|
||||||
|
Static-key providers use ``ModelEndpoint.api_key``. Session-backed providers
|
||||||
|
store refreshable credentials in ProviderAuthSession and must resolve a
|
||||||
|
current access token at call time.
|
||||||
|
"""
|
||||||
|
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||||
|
api_key = getattr(ep, "api_key", None)
|
||||||
|
auth_id = getattr(ep, "provider_auth_id", None)
|
||||||
|
if auth_id:
|
||||||
|
from src.chatgpt_subscription import resolve_runtime_credentials
|
||||||
|
|
||||||
|
creds = resolve_runtime_credentials(auth_id, owner=owner)
|
||||||
|
base = normalize_base(creds.get("base_url") or base)
|
||||||
|
api_key = creds.get("api_key")
|
||||||
|
return base, api_key
|
||||||
|
|
||||||
|
|
||||||
# Cache for Tailscale hostname → IP resolution
|
# Cache for Tailscale hostname → IP resolution
|
||||||
_tailscale_cache: Dict[str, Optional[str]] = {}
|
_tailscale_cache: Dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
@@ -133,7 +152,7 @@ def resolve_url(url: str) -> str:
|
|||||||
def normalize_base(url: str) -> str:
|
def normalize_base(url: str) -> str:
|
||||||
"""Strip known API path suffixes from a base URL."""
|
"""Strip known API path suffixes from a base URL."""
|
||||||
url = (url or "").strip().rstrip("/")
|
url = (url or "").strip().rstrip("/")
|
||||||
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
|
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages", "/responses"]:
|
||||||
if url.endswith(suffix):
|
if url.endswith(suffix):
|
||||||
url = url[: -len(suffix)].rstrip("/")
|
url = url[: -len(suffix)].rstrip("/")
|
||||||
for suffix in ["/chat", "/tags", "/generate"]:
|
for suffix in ["/chat", "/tags", "/generate"]:
|
||||||
@@ -158,10 +177,12 @@ def build_chat_url(base: str) -> str:
|
|||||||
return _anthropic_api_root(base) + "/v1/messages"
|
return _anthropic_api_root(base) + "/v1/messages"
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
return _ollama_api_root(base) + "/chat"
|
return _ollama_api_root(base) + "/chat"
|
||||||
|
if provider == "chatgpt-subscription":
|
||||||
|
return base.rstrip("/") + "/responses"
|
||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
def build_models_url(base: str) -> str:
|
def build_models_url(base: str) -> Optional[str]:
|
||||||
"""Return the provider-specific model-list endpoint URL for a base."""
|
"""Return the provider-specific model-list endpoint URL for a base."""
|
||||||
base = resolve_url(base)
|
base = resolve_url(base)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
@@ -169,6 +190,8 @@ def build_models_url(base: str) -> str:
|
|||||||
return _anthropic_api_root(base) + "/v1/models"
|
return _anthropic_api_root(base) + "/v1/models"
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
return _ollama_api_root(base) + "/tags"
|
return _ollama_api_root(base) + "/tags"
|
||||||
|
if provider == "chatgpt-subscription":
|
||||||
|
return None
|
||||||
return base + "/models"
|
return base + "/models"
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +207,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
|||||||
if provider == "copilot":
|
if provider == "copilot":
|
||||||
from src.copilot import copilot_headers
|
from src.copilot import copilot_headers
|
||||||
return copilot_headers(api_key)
|
return copilot_headers(api_key)
|
||||||
|
if provider == "chatgpt-subscription":
|
||||||
|
from src.chatgpt_subscription import chatgpt_headers
|
||||||
|
return chatgpt_headers(api_key)
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
@@ -262,9 +288,13 @@ def resolve_endpoint(
|
|||||||
if not ep:
|
if not ep:
|
||||||
return fallback_url, fallback_model, fallback_headers
|
return fallback_url, fallback_model, fallback_headers
|
||||||
|
|
||||||
base = normalize_base(ep.base_url)
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
||||||
|
return fallback_url, fallback_model, fallback_headers
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
headers = build_headers(ep.api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
|
|
||||||
# Discard a configured model the user has since disabled on the
|
# Discard a configured model the user has since disabled on the
|
||||||
# endpoint (e.g. a stale `default_model` left pointing at a now-hidden
|
# endpoint (e.g. a stale `default_model` left pointing at a now-hidden
|
||||||
@@ -308,9 +338,13 @@ def resolve_endpoint_by_id(
|
|||||||
ep = q.first()
|
ep = q.first()
|
||||||
if not ep:
|
if not ep:
|
||||||
return None
|
return None
|
||||||
base = normalize_base(ep.base_url)
|
try:
|
||||||
|
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
||||||
|
return None
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
headers = build_headers(ep.api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
m = (model or "").strip()
|
m = (model or "").strip()
|
||||||
# Drop a model the user disabled on the endpoint, then pick the first
|
# Drop a model the user disabled on the endpoint, then pick the first
|
||||||
# enabled chat model rather than a hidden one.
|
# enabled chat model rather than a hidden one.
|
||||||
|
|||||||
+3
-3
@@ -12,6 +12,8 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.constants import AUTH_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_task_scheduler = None
|
_task_scheduler = None
|
||||||
@@ -54,9 +56,7 @@ def _resolve_event_owner(owner: Optional[str]) -> Optional[str]:
|
|||||||
return owner
|
return owner
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.constants import DATA_DIR
|
auth_path = AUTH_FILE
|
||||||
|
|
||||||
auth_path = os.path.join(DATA_DIR, "auth.json")
|
|
||||||
with open(auth_path, "r", encoding="utf-8") as f:
|
with open(auth_path, "r", encoding="utf-8") as f:
|
||||||
users = (json.load(f).get("users") or {})
|
users = (json.load(f).get("users") or {})
|
||||||
for username, data in users.items():
|
for username, data in users.items():
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
GENERATED_IMAGE_DIR = Path("data/generated_images")
|
|
||||||
|
GENERATED_IMAGE_DIR = Path(GENERATED_IMAGES_DIR)
|
||||||
GENERATED_IMAGE_RE = re.compile(
|
GENERATED_IMAGE_RE = re.compile(
|
||||||
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
|
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-2
@@ -10,10 +10,11 @@ import httpx
|
|||||||
from core.atomic_io import atomic_write_json
|
from core.atomic_io import atomic_write_json
|
||||||
from core.platform_compat import safe_chmod
|
from core.platform_compat import safe_chmod
|
||||||
from src.secret_storage import decrypt, encrypt, is_encrypted
|
from src.secret_storage import decrypt, encrypt, is_encrypted
|
||||||
|
from src.constants import DATA_DIR, INTEGRATIONS_FILE, SETTINGS_FILE
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json")
|
DATA_FILE = INTEGRATIONS_FILE
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Presets
|
# Presets
|
||||||
@@ -471,7 +472,7 @@ def get_integrations_prompt() -> str:
|
|||||||
def migrate_from_settings() -> None:
|
def migrate_from_settings() -> None:
|
||||||
"""If data/settings.json has miniflux_url and miniflux_api_key, create a
|
"""If data/settings.json has miniflux_url and miniflux_api_key, create a
|
||||||
Miniflux integration and clear those keys from settings."""
|
Miniflux integration and clear those keys from settings."""
|
||||||
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "settings.json")
|
settings_path = SETTINGS_FILE
|
||||||
if not os.path.exists(settings_path):
|
if not os.path.exists(settings_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+217
-7
@@ -426,6 +426,9 @@ def _detect_provider(url: str) -> str:
|
|||||||
return "openrouter"
|
return "openrouter"
|
||||||
if _host_match(url, "groq.com"):
|
if _host_match(url, "groq.com"):
|
||||||
return "groq"
|
return "groq"
|
||||||
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
|
if is_chatgpt_subscription_base(url):
|
||||||
|
return "chatgpt-subscription"
|
||||||
from src.copilot import is_copilot_base
|
from src.copilot import is_copilot_base
|
||||||
if is_copilot_base(url):
|
if is_copilot_base(url):
|
||||||
return "copilot"
|
return "copilot"
|
||||||
@@ -462,6 +465,8 @@ def _provider_label(url: str) -> str:
|
|||||||
if _host_match(url, "opencode.ai/zen/go"): return "OpenCode Go"
|
if _host_match(url, "opencode.ai/zen/go"): return "OpenCode Go"
|
||||||
if _host_match(url, "opencode.ai/zen"): return "OpenCode Zen"
|
if _host_match(url, "opencode.ai/zen"): return "OpenCode Zen"
|
||||||
if _host_match(url, "groq.com"): return "Groq"
|
if _host_match(url, "groq.com"): return "Groq"
|
||||||
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
|
if is_chatgpt_subscription_base(url): return "ChatGPT Subscription"
|
||||||
from src.copilot import is_copilot_base
|
from src.copilot import is_copilot_base
|
||||||
if is_copilot_base(url): return "GitHub Copilot"
|
if is_copilot_base(url): return "GitHub Copilot"
|
||||||
if _host_match(url, "mistral.ai"): return "Mistral"
|
if _host_match(url, "mistral.ai"): return "Mistral"
|
||||||
@@ -479,6 +484,77 @@ def _provider_label(url: str) -> str:
|
|||||||
return host or "provider"
|
return host or "provider"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chatgpt_subscription_url(url: str) -> str:
|
||||||
|
base = (url or "").strip().rstrip("/")
|
||||||
|
if base.endswith("/responses"):
|
||||||
|
return base
|
||||||
|
return base + "/responses"
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content_as_text(content) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
if part:
|
||||||
|
parts.append(str(part))
|
||||||
|
continue
|
||||||
|
if isinstance(part.get("text"), str):
|
||||||
|
parts.append(part["text"])
|
||||||
|
continue
|
||||||
|
if isinstance(part.get("content"), str):
|
||||||
|
parts.append(part["content"])
|
||||||
|
return "\n".join(parts)
|
||||||
|
return "" if content is None else str(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _chatgpt_subscription_instructions(messages: List[Dict]) -> str:
|
||||||
|
instructions = [
|
||||||
|
_message_content_as_text(msg.get("content")).strip()
|
||||||
|
for msg in messages or []
|
||||||
|
if (msg.get("role") or "") == "system"
|
||||||
|
]
|
||||||
|
instructions = [part for part in instructions if part]
|
||||||
|
if instructions:
|
||||||
|
return "\n\n".join(instructions)
|
||||||
|
return "You are a helpful AI assistant."
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chatgpt_responses_payload(
|
||||||
|
model: str,
|
||||||
|
messages: List[Dict],
|
||||||
|
temperature: float,
|
||||||
|
max_tokens: int,
|
||||||
|
*,
|
||||||
|
stream: bool = False,
|
||||||
|
) -> Dict:
|
||||||
|
from src.chatgpt_subscription import build_responses_input
|
||||||
|
|
||||||
|
conversation = [msg for msg in (messages or []) if (msg.get("role") or "") != "system"]
|
||||||
|
payload: Dict = {
|
||||||
|
"model": model,
|
||||||
|
"instructions": _chatgpt_subscription_instructions(messages),
|
||||||
|
"input": build_responses_input(conversation),
|
||||||
|
"stream": stream,
|
||||||
|
"store": False,
|
||||||
|
}
|
||||||
|
if not _restricts_temperature(model):
|
||||||
|
payload["temperature"] = temperature
|
||||||
|
if max_tokens and max_tokens > 0:
|
||||||
|
payload["max_output_tokens"] = max_tokens
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _format_chatgpt_subscription_error(status_code: int, text: str) -> str:
|
||||||
|
if status_code in (401, 403):
|
||||||
|
return "ChatGPT Subscription credentials expired or were rejected. Reconnect the provider."
|
||||||
|
if status_code == 429:
|
||||||
|
return "ChatGPT Subscription quota or rate limit was reached. Retry after the upstream limit resets."
|
||||||
|
return _format_upstream_error(status_code, text, "https://chatgpt.com/backend-api/codex")
|
||||||
|
|
||||||
|
|
||||||
def _format_upstream_error(status: int, body: bytes | str, url: str) -> str:
|
def _format_upstream_error(status: int, body: bytes | str, url: str) -> str:
|
||||||
"""Turn an upstream HTTP error into a user-readable sentence.
|
"""Turn an upstream HTTP error into a user-readable sentence.
|
||||||
|
|
||||||
@@ -874,7 +950,7 @@ def _normalize_anthropic_url(url: str) -> str:
|
|||||||
def _model_list_base(url: str) -> str:
|
def _model_list_base(url: str) -> str:
|
||||||
"""Normalize model/chat URLs to the configured endpoint base."""
|
"""Normalize model/chat URLs to the configured endpoint base."""
|
||||||
base = (url or "").strip().rstrip("/")
|
base = (url or "").strip().rstrip("/")
|
||||||
for suffix in ("/models", "/chat/completions", "/completions", "/v1/messages"):
|
for suffix in ("/models", "/chat/completions", "/completions", "/v1/messages", "/responses"):
|
||||||
if base.endswith(suffix):
|
if base.endswith(suffix):
|
||||||
base = base[: -len(suffix)].rstrip("/")
|
base = base[: -len(suffix)].rstrip("/")
|
||||||
for suffix in ("/chat", "/tags", "/generate"):
|
for suffix in ("/chat", "/tags", "/generate"):
|
||||||
@@ -903,7 +979,12 @@ def _parse_model_cache(raw) -> List[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
def _configured_cached_model_ids(
|
||||||
|
endpoint_url: str,
|
||||||
|
*,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
endpoint_id: Optional[str] = None,
|
||||||
|
) -> List[str]:
|
||||||
"""Return cached models for a configured endpoint matching endpoint_url."""
|
"""Return cached models for a configured endpoint matching endpoint_url."""
|
||||||
target = _model_list_base(endpoint_url)
|
target = _model_list_base(endpoint_url)
|
||||||
if not target:
|
if not target:
|
||||||
@@ -914,7 +995,13 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
|
if endpoint_id:
|
||||||
|
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||||
|
if owner:
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
q = owner_filter(q, ModelEndpoint, owner)
|
||||||
|
rows = q.all()
|
||||||
for ep in rows:
|
for ep in rows:
|
||||||
if _model_list_base(getattr(ep, "base_url", "")) != target:
|
if _model_list_base(getattr(ep, "base_url", "")) != target:
|
||||||
continue
|
continue
|
||||||
@@ -933,9 +1020,16 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, headers: Optional[Dict] = None) -> List[str]:
|
def list_model_ids(
|
||||||
|
base_chat_url: str,
|
||||||
|
timeout: int = LLMConfig.DEFAULT_TIMEOUT,
|
||||||
|
headers: Optional[Dict] = None,
|
||||||
|
*,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
endpoint_id: Optional[str] = None,
|
||||||
|
) -> List[str]:
|
||||||
"""List available model IDs from an endpoint."""
|
"""List available model IDs from an endpoint."""
|
||||||
cached = _configured_cached_model_ids(base_chat_url)
|
cached = _configured_cached_model_ids(base_chat_url, owner=owner, endpoint_id=endpoint_id)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
provider = _detect_provider(base_chat_url)
|
provider = _detect_provider(base_chat_url)
|
||||||
@@ -971,9 +1065,16 @@ def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT,
|
|||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def normalize_model_id(endpoint_url: str, requested: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT) -> Optional[str]:
|
def normalize_model_id(
|
||||||
|
endpoint_url: str,
|
||||||
|
requested: str,
|
||||||
|
timeout: int = LLMConfig.DEFAULT_TIMEOUT,
|
||||||
|
*,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
endpoint_id: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
"""Normalize a model ID to match available models."""
|
"""Normalize a model ID to match available models."""
|
||||||
avail = list_model_ids(endpoint_url, timeout)
|
avail = list_model_ids(endpoint_url, timeout, owner=owner, endpoint_id=endpoint_id)
|
||||||
if not avail:
|
if not avail:
|
||||||
return None
|
return None
|
||||||
if requested in avail:
|
if requested in avail:
|
||||||
@@ -1169,6 +1270,49 @@ async def llm_call_async(
|
|||||||
logger.debug(f"Returning cached response for key: {cache_key}")
|
logger.debug(f"Returning cached response for key: {cache_key}")
|
||||||
return cached_response
|
return cached_response
|
||||||
|
|
||||||
|
if provider == "chatgpt-subscription":
|
||||||
|
# ChatGPT/Codex requires streamed Responses requests even for callers
|
||||||
|
# that want a plain string (auto-title, memory extraction, etc.).
|
||||||
|
# Reuse stream_llm's validated Codex SSE path and collect deltas.
|
||||||
|
parts: List[str] = []
|
||||||
|
async for chunk in stream_llm(
|
||||||
|
url,
|
||||||
|
model,
|
||||||
|
messages_copy,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
):
|
||||||
|
event_is_error = False
|
||||||
|
for line in str(chunk).splitlines():
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_is_error = line[6:].strip() == "error"
|
||||||
|
continue
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
raw = line[5:].strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
if raw == "[DONE]":
|
||||||
|
response = "".join(parts)
|
||||||
|
_set_cached_response(cache_key, response)
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if event_is_error or data.get("error") or (data.get("status") and data.get("text")):
|
||||||
|
status = int(data.get("status") or 502)
|
||||||
|
text = data.get("text") or data.get("error") or "ChatGPT Subscription request failed"
|
||||||
|
raise HTTPException(status, text)
|
||||||
|
delta = data.get("delta")
|
||||||
|
if isinstance(delta, str):
|
||||||
|
parts.append(delta)
|
||||||
|
response = "".join(parts)
|
||||||
|
_set_cached_response(cache_key, response)
|
||||||
|
return response
|
||||||
|
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
target_url = _normalize_anthropic_url(url)
|
target_url = _normalize_anthropic_url(url)
|
||||||
h = _build_anthropic_headers(headers)
|
h = _build_anthropic_headers(headers)
|
||||||
@@ -1294,6 +1438,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
model, messages_copy, temperature, max_tokens,
|
model, messages_copy, temperature, max_tokens,
|
||||||
stream=True, tools=tools, num_ctx=get_context_length(url, model),
|
stream=True, tools=tools, num_ctx=get_context_length(url, model),
|
||||||
)
|
)
|
||||||
|
elif provider == "chatgpt-subscription":
|
||||||
|
target_url = _normalize_chatgpt_subscription_url(url)
|
||||||
|
h = _provider_headers(provider, headers)
|
||||||
|
payload = _build_chatgpt_responses_payload(model, messages_copy, temperature, max_tokens, stream=True)
|
||||||
else:
|
else:
|
||||||
target_url = url
|
target_url = url
|
||||||
payload = {
|
payload = {
|
||||||
@@ -1325,6 +1473,68 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
return
|
return
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
|
|
||||||
|
# ── ChatGPT Subscription / Codex Responses streaming ──
|
||||||
|
if provider == "chatgpt-subscription":
|
||||||
|
event_name = ""
|
||||||
|
input_tokens = 0
|
||||||
|
output_tokens = 0
|
||||||
|
try:
|
||||||
|
client = _get_http_client()
|
||||||
|
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||||
|
_clear_host_dead(target_url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raw = (await r.aread()).decode(errors="replace")
|
||||||
|
friendly = _format_chatgpt_subscription_error(r.status_code, raw)
|
||||||
|
yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n'
|
||||||
|
return
|
||||||
|
async for line in r.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("event:"):
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
continue
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
raw = line[5:].strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
evt = data.get("type") or event_name
|
||||||
|
if evt == "response.output_text.delta":
|
||||||
|
delta = data.get("delta") or ""
|
||||||
|
if delta:
|
||||||
|
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
||||||
|
elif evt == "response.completed":
|
||||||
|
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
||||||
|
input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or input_tokens
|
||||||
|
output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or output_tokens
|
||||||
|
if input_tokens or output_tokens:
|
||||||
|
yield f'data: {json.dumps({"type": "usage", "data": {"input_tokens": input_tokens, "output_tokens": output_tokens}})}\n\n'
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
return
|
||||||
|
elif evt in ("response.failed", "error"):
|
||||||
|
err = data.get("error") or (data.get("response") or {}).get("error") or {}
|
||||||
|
text = err.get("message") if isinstance(err, dict) else str(err or "ChatGPT Subscription request failed")
|
||||||
|
yield f'event: error\ndata: {json.dumps({"status": 502, "text": text})}\n\n'
|
||||||
|
return
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
||||||
|
_cooled = _mark_host_dead(target_url)
|
||||||
|
_tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry"
|
||||||
|
logger.warning(f"ChatGPT Subscription stream connect to {target_url} failed: {e}{_tail}")
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": f"Cannot reach {_host_key(target_url)}", "status": 503})}\n\n'
|
||||||
|
except httpx.ReadTimeout:
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": "Read timeout", "status": 504})}\n\n'
|
||||||
|
except httpx.NetworkError:
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502})}\n\n'
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ChatGPT Subscription stream error: {e}")
|
||||||
|
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502})}\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
# ── Native Ollama streaming ──
|
# ── Native Ollama streaming ──
|
||||||
if provider == "ollama":
|
if provider == "ollama":
|
||||||
_ollama_tool_calls: List[Dict] = []
|
_ollama_tool_calls: List[Dict] = []
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,8 @@ A thin wrapper around VectorRAG for backward compatibility and additional featur
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from src.constants import CHROMA_DIR
|
||||||
|
|
||||||
# Try to import from different possible locations
|
# Try to import from different possible locations
|
||||||
try:
|
try:
|
||||||
from rag_vector import VectorRAG
|
from rag_vector import VectorRAG
|
||||||
@@ -24,7 +26,7 @@ class RAGManager:
|
|||||||
Most methods delegate directly to VectorRAG.
|
Most methods delegate directly to VectorRAG.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, persist_directory: str = "data/chroma"):
|
def __init__(self, persist_directory: str = CHROMA_DIR):
|
||||||
"""Initialize the RAGManager with VectorRAG."""
|
"""Initialize the RAGManager with VectorRAG."""
|
||||||
self.vector_rag = VectorRAG(persist_directory=persist_directory)
|
self.vector_rag = VectorRAG(persist_directory=persist_directory)
|
||||||
logger.info("RAGManager initialized as wrapper for VectorRAG")
|
logger.info("RAGManager initialized as wrapper for VectorRAG")
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.constants import RAG_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
rag_instance = None
|
rag_instance = None
|
||||||
@@ -41,8 +43,7 @@ def get_rag_manager():
|
|||||||
try:
|
try:
|
||||||
from src.rag_vector import VectorRAG
|
from src.rag_vector import VectorRAG
|
||||||
|
|
||||||
base_dir = Path(__file__).parent.parent
|
persist_dir = RAG_DIR
|
||||||
persist_dir = os.path.join(base_dir, "data", "rag")
|
|
||||||
|
|
||||||
rag_instance = VectorRAG(persist_directory=persist_dir)
|
rag_instance = VectorRAG(persist_directory=persist_dir)
|
||||||
if not rag_instance.healthy:
|
if not rag_instance.healthy:
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,8 @@ import re
|
|||||||
import logging
|
import logging
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import List, Dict, Any, Optional, Set
|
from typing import List, Dict, Any, Optional, Set
|
||||||
|
|
||||||
|
from src.constants import CHROMA_DIR
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.embedding_lanes import (
|
from src.embedding_lanes import (
|
||||||
@@ -51,7 +53,7 @@ def _generate_doc_id(text: str, owner: str = "") -> str:
|
|||||||
class VectorRAG:
|
class VectorRAG:
|
||||||
"""RAG system using ChromaDB vector storage with hybrid search."""
|
"""RAG system using ChromaDB vector storage with hybrid search."""
|
||||||
|
|
||||||
def __init__(self, persist_directory: str = "data/chroma"):
|
def __init__(self, persist_directory: str = CHROMA_DIR):
|
||||||
self.persist_directory = persist_directory
|
self.persist_directory = persist_directory
|
||||||
self._collection = None
|
self._collection = None
|
||||||
self._model = None
|
self._model = None
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from src.research_utils import strip_thinking, is_low_quality
|
from src.research_utils import strip_thinking, is_low_quality
|
||||||
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RESEARCH_DATA_DIR = Path("data/deep_research")
|
RESEARCH_DATA_DIR = Path(DEEP_RESEARCH_DIR)
|
||||||
_RESEARCH_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,128}$")
|
_RESEARCH_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,128}$")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ from pathlib import Path
|
|||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
from core.platform_compat import safe_chmod
|
from core.platform_compat import safe_chmod
|
||||||
|
from src.constants import APP_KEY_FILE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_KEY_PATH = Path(__file__).resolve().parent.parent / "data" / ".app_key"
|
_KEY_PATH = Path(APP_KEY_FILE)
|
||||||
_PREFIX = "enc:"
|
_PREFIX = "enc:"
|
||||||
_fernet: Fernet | None = None
|
_fernet: Fernet | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
|
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
|
||||||
from src.tool_policy import ToolPolicy
|
from src.tool_policy import ToolPolicy
|
||||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES
|
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR
|
||||||
|
|
||||||
# Persistent working directory for agent subprocesses.
|
# Persistent working directory for agent subprocesses.
|
||||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||||
# (/app/data) and the local data directory for manual installs.
|
# (/app/data) and the local data directory for manual installs.
|
||||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||||
# in ephemeral container layers that are lost on the next rebuild.
|
# in ephemeral container layers that are lost on the next rebuild.
|
||||||
_AGENT_WORKDIR = str(pathlib.Path(__file__).parent.parent / "data")
|
_AGENT_WORKDIR = DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
|
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
||||||
from core.constants import internal_api_base
|
from core.constants import internal_api_base
|
||||||
|
|
||||||
|
|
||||||
@@ -4057,7 +4057,7 @@ async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
args = {}
|
args = {}
|
||||||
action = (args.get("action") or "list").lower()
|
action = (args.get("action") or "list").lower()
|
||||||
rid = (args.get("id") or args.get("session_id") or args.get("research_id") or "").strip()
|
rid = (args.get("id") or args.get("session_id") or args.get("research_id") or "").strip()
|
||||||
data_dir = _Path("data/deep_research")
|
data_dir = _Path(DEEP_RESEARCH_DIR)
|
||||||
|
|
||||||
# SECURITY: the research id is interpolated straight into a filesystem
|
# SECURITY: the research id is interpolated straight into a filesystem
|
||||||
# path (data/deep_research/<rid>.json) for read AND delete. Without this
|
# path (data/deep_research/<rid>.json) for read AND delete. Without this
|
||||||
@@ -4302,7 +4302,7 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
def _load_vault_config() -> Dict:
|
def _load_vault_config() -> Dict:
|
||||||
"""Load Vaultwarden config from data/vault.json."""
|
"""Load Vaultwarden config from data/vault.json."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
p = Path("data/vault.json")
|
p = Path(VAULT_FILE)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
try:
|
try:
|
||||||
return json.loads(p.read_text(encoding="utf-8"))
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
@@ -4456,7 +4456,7 @@ async def do_vault_unlock(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
|
|
||||||
# Save session to vault.json
|
# Save session to vault.json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
p = Path("data/vault.json")
|
p = Path(VAULT_FILE)
|
||||||
cfg = {}
|
cfg = {}
|
||||||
if p.exists():
|
if p.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
+17
-1
@@ -1591,7 +1591,15 @@ function initializeEventListeners() {
|
|||||||
function applyModeToToggles(mode) {
|
function applyModeToToggles(mode) {
|
||||||
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
|
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
|
||||||
const btn = el(btnId);
|
const btn = el(btnId);
|
||||||
if (!btn || btn.style.display === 'none') return;
|
if (!btn) return;
|
||||||
|
// Hide bash and plan buttons in chat mode
|
||||||
|
if (mode === 'chat' && (stateKey === 'bash' || stateKey === 'plan')) {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Show buttons in agent mode (or for web toggle in any mode)
|
||||||
|
btn.style.display = '';
|
||||||
|
if (btn.style.display === 'none') return;
|
||||||
const on = loadToolPref(stateKey, mode);
|
const on = loadToolPref(stateKey, mode);
|
||||||
btn.classList.toggle('active', on);
|
btn.classList.toggle('active', on);
|
||||||
if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
|
if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
|
||||||
@@ -1606,6 +1614,14 @@ function initializeEventListeners() {
|
|||||||
const state = loadToggleState();
|
const state = loadToggleState();
|
||||||
let currentMode = state.mode || 'chat';
|
let currentMode = state.mode || 'chat';
|
||||||
|
|
||||||
|
// Immediately hide bash/plan buttons in chat mode on page load
|
||||||
|
if (currentMode === 'chat') {
|
||||||
|
const bashBtn = el('bash-toggle-btn');
|
||||||
|
const planBtn = el('plan-toggle-btn');
|
||||||
|
if (bashBtn) bashBtn.style.display = 'none';
|
||||||
|
if (planBtn) planBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(mode) {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
const st = loadToggleState();
|
const st = loadToggleState();
|
||||||
|
|||||||
@@ -2108,6 +2108,8 @@
|
|||||||
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
|
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
|
||||||
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
|
||||||
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
||||||
|
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
|
||||||
|
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
|
||||||
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||||
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
|
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
|
||||||
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||||
@@ -2136,6 +2138,7 @@
|
|||||||
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||||
|
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+201
-68
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
|||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { sortModelObjects } from './modelSort.js';
|
import { sortModelObjects } from './modelSort.js';
|
||||||
|
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -707,6 +708,80 @@ function initEndpointForm() {
|
|||||||
const pickerBtn = el('adm-provider-btn');
|
const pickerBtn = el('adm-provider-btn');
|
||||||
const pickerMenu = el('adm-provider-menu');
|
const pickerMenu = el('adm-provider-menu');
|
||||||
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
||||||
|
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
|
||||||
|
let deviceAuthPolling = false;
|
||||||
|
function _selectedProviderOption() {
|
||||||
|
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
|
||||||
|
}
|
||||||
|
function _selectedDeviceAuthProvider() {
|
||||||
|
const opt = _selectedProviderOption();
|
||||||
|
const flow = opt && opt.dataset ? opt.dataset.authFlow : '';
|
||||||
|
if (flow && DEVICE_AUTH_PROVIDER_VALUES.has(flow)) return flow;
|
||||||
|
return DEVICE_AUTH_PROVIDER_VALUES.has(provider.value) ? provider.value : '';
|
||||||
|
}
|
||||||
|
function _isDeviceAuthSelected() {
|
||||||
|
return !!_selectedDeviceAuthProvider();
|
||||||
|
}
|
||||||
|
function _setApiFormForProvider() {
|
||||||
|
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||||
|
const deviceAuthConfig = PROVIDER_DEVICE_FLOWS[deviceAuthProvider] || null;
|
||||||
|
const apiKey = el('adm-epApiKey');
|
||||||
|
const testBtn = el('adm-epApiTestBtn');
|
||||||
|
const addBtn = el('adm-epAddBtn');
|
||||||
|
const status = el('adm-deviceAuthStatus');
|
||||||
|
const msg = _endpointMsg('api');
|
||||||
|
if (deviceAuthConfig) {
|
||||||
|
urlInput.value = '';
|
||||||
|
urlInput.placeholder = deviceAuthProvider === 'copilot'
|
||||||
|
? 'GitHub Copilot uses GitHub account sign-in'
|
||||||
|
: 'ChatGPT Subscription uses OpenAI account sign-in';
|
||||||
|
urlInput.readOnly = true;
|
||||||
|
if (apiKey) {
|
||||||
|
apiKey.value = '';
|
||||||
|
apiKey.placeholder = 'No API key needed';
|
||||||
|
apiKey.disabled = true;
|
||||||
|
}
|
||||||
|
if (testBtn) {
|
||||||
|
testBtn.disabled = true;
|
||||||
|
testBtn.style.opacity = '0.45';
|
||||||
|
testBtn.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.disabled = false;
|
||||||
|
addBtn.textContent = 'Add';
|
||||||
|
addBtn.style.width = '55px';
|
||||||
|
addBtn.style.display = '';
|
||||||
|
}
|
||||||
|
if (kindSel) kindSel.value = 'api';
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = '';
|
||||||
|
msg.className = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
urlInput.placeholder = 'Base URL or pick provider';
|
||||||
|
urlInput.readOnly = false;
|
||||||
|
if (apiKey) {
|
||||||
|
apiKey.placeholder = 'API key';
|
||||||
|
apiKey.disabled = false;
|
||||||
|
}
|
||||||
|
if (testBtn) {
|
||||||
|
testBtn.disabled = false;
|
||||||
|
testBtn.style.opacity = '';
|
||||||
|
testBtn.style.cursor = '';
|
||||||
|
}
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.disabled = false;
|
||||||
|
addBtn.textContent = 'Add';
|
||||||
|
addBtn.style.width = '55px';
|
||||||
|
addBtn.style.display = '';
|
||||||
|
}
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = '';
|
||||||
|
msg.className = '';
|
||||||
|
}
|
||||||
|
if (!deviceAuthPolling && status) status.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
function _renderPickerMenu() {
|
function _renderPickerMenu() {
|
||||||
if (!pickerMenu) return;
|
if (!pickerMenu) return;
|
||||||
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
||||||
@@ -748,9 +823,16 @@ function initEndpointForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provider.addEventListener('change', () => {
|
provider.addEventListener('change', () => {
|
||||||
|
if (_isDeviceAuthSelected()) {
|
||||||
|
_setApiFormForProvider();
|
||||||
|
_renderPickerMenu();
|
||||||
|
_syncPickerCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (provider.value) urlInput.value = provider.value;
|
if (provider.value) urlInput.value = provider.value;
|
||||||
else urlInput.value = '';
|
else urlInput.value = '';
|
||||||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||||
|
_setApiFormForProvider();
|
||||||
});
|
});
|
||||||
urlInput.addEventListener('input', () => {
|
urlInput.addEventListener('input', () => {
|
||||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||||
@@ -838,6 +920,12 @@ function initEndpointForm() {
|
|||||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||||
if (apiTestBtn) {
|
if (apiTestBtn) {
|
||||||
apiTestBtn.addEventListener('click', async () => {
|
apiTestBtn.addEventListener('click', async () => {
|
||||||
|
if (_isDeviceAuthSelected()) {
|
||||||
|
const msg = _endpointMsg('api');
|
||||||
|
msg.textContent = '';
|
||||||
|
msg.className = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = _endpointMsg('api');
|
const msg = _endpointMsg('api');
|
||||||
msg.textContent = ''; msg.className = '';
|
msg.textContent = ''; msg.className = '';
|
||||||
const rawUrl = (urlInput.value || provider.value).trim();
|
const rawUrl = (urlInput.value || provider.value).trim();
|
||||||
@@ -885,6 +973,11 @@ function initEndpointForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||||
|
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||||
|
if (deviceAuthProvider) {
|
||||||
|
await _startProviderDeviceAuth(deviceAuthProvider, el('adm-epAddBtn'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = _endpointMsg('api');
|
const msg = _endpointMsg('api');
|
||||||
msg.textContent = ''; msg.className = '';
|
msg.textContent = ''; msg.className = '';
|
||||||
const rawUrl = (urlInput.value || provider.value).trim();
|
const rawUrl = (urlInput.value || provider.value).trim();
|
||||||
@@ -936,76 +1029,116 @@ function initEndpointForm() {
|
|||||||
btn.disabled = false; btn.textContent = 'Add';
|
btn.disabled = false; btn.textContent = 'Add';
|
||||||
});
|
});
|
||||||
|
|
||||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
async function _startProviderDeviceAuth(providerKey, triggerEl = null) {
|
||||||
// code + verification link, and polls until they authorise (or it expires).
|
if (deviceAuthPolling) return;
|
||||||
const copilotBtn = el('adm-copilotConnectBtn');
|
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||||
if (copilotBtn) {
|
if (!config) return;
|
||||||
let copilotPolling = false;
|
const status = el('adm-deviceAuthStatus') || _endpointMsg('api');
|
||||||
copilotBtn.addEventListener('click', async () => {
|
if (!status) return;
|
||||||
if (copilotPolling) return;
|
const triggerText = triggerEl ? triggerEl.textContent : '';
|
||||||
const status = el('adm-copilotStatus');
|
// Render an error with an inline "Try again" (the top button is hidden for
|
||||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
// device-auth providers, so retry lives here). Built with DOM methods, not
|
||||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
|
||||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
const showAuthError = (text) => {
|
||||||
copilotPolling = true;
|
status.className = 'admin-error';
|
||||||
let start;
|
status.textContent = text + ' ';
|
||||||
try {
|
const retry = document.createElement('button');
|
||||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
retry.type = 'button';
|
||||||
start = await res.json();
|
retry.className = 'admin-btn-sm';
|
||||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
retry.textContent = 'Try again';
|
||||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
retry.addEventListener('click', () => { _startProviderDeviceAuth(providerKey, triggerEl); });
|
||||||
|
status.appendChild(retry);
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
if (triggerEl) {
|
||||||
|
triggerEl.disabled = false;
|
||||||
|
triggerEl.textContent = triggerText || 'Add';
|
||||||
|
}
|
||||||
|
deviceAuthPolling = false;
|
||||||
|
_setApiFormForProvider();
|
||||||
|
};
|
||||||
|
status.textContent = '';
|
||||||
|
status.className = 'adm-ep-inline-msg';
|
||||||
|
if (triggerEl) {
|
||||||
|
triggerEl.disabled = true;
|
||||||
|
triggerEl.textContent = 'Starting...';
|
||||||
|
}
|
||||||
|
deviceAuthPolling = true;
|
||||||
|
_setApiFormForProvider();
|
||||||
|
status.textContent = `Starting ${config.label} sign-in...`;
|
||||||
|
|
||||||
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
|
try {
|
||||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
const result = await runProviderDeviceFlow(providerKey, {
|
||||||
// click "Authorize" (no manual code entry).
|
openWindow: () => {},
|
||||||
const authUrl = verification_uri_complete || verification_uri || '';
|
onStart: ({ start, authUrl }) => {
|
||||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
if (triggerEl) triggerEl.textContent = 'Waiting...';
|
||||||
copilotBtn.textContent = 'Waiting…';
|
status.className = '';
|
||||||
|
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
status.innerHTML =
|
||||||
status.className = '';
|
'<div class="adm-copilot-panel">' +
|
||||||
status.innerHTML =
|
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||||
'<div class="adm-copilot-panel">' +
|
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
'<div class="adm-copilot-coderow">' +
|
||||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
'<span class="adm-copilot-code-label">Code</span>' +
|
||||||
'<div class="adm-copilot-coderow">' +
|
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||||
'<span class="adm-copilot-code-label">Code</span>' +
|
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
'</div>' +
|
||||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||||
'</div>' +
|
'</div>';
|
||||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||||
'</div>';
|
const code = start.user_code || '';
|
||||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
let ok = false;
|
||||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
try {
|
||||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (!ok) {
|
||||||
|
// navigator.clipboard is unavailable in non-secure contexts (HTTP
|
||||||
|
// self-host over a LAN IP), so fall back to execCommand('copy').
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = code;
|
||||||
|
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
try { ta.setSelectionRange(0, code.length); } catch (e) {}
|
||||||
|
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
copyBtn.textContent = ok ? 'Copied' : 'Failed';
|
||||||
|
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
if (result.status === 'authorized') {
|
||||||
|
const endpoint = result.endpoint || {};
|
||||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
const n = ((endpoint && endpoint.models) || []).length;
|
||||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
status.className = 'admin-success';
|
||||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||||
const poll = async () => {
|
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
|
||||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
await loadEndpoints();
|
||||||
try {
|
await _selectAddedModelInChat(endpoint || {});
|
||||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
reset();
|
||||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
return;
|
||||||
const d = await r.json();
|
}
|
||||||
if (d.status === 'authorized') {
|
if (result.status === 'failed') {
|
||||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
reset();
|
||||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
showAuthError('Authorization failed (' + (result.error || 'denied') + ').');
|
||||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
return;
|
||||||
await loadEndpoints();
|
}
|
||||||
await _selectAddedModelInChat(d.endpoint || {});
|
if (result.status === 'expired') {
|
||||||
return;
|
reset();
|
||||||
}
|
showAuthError('Authorization expired.');
|
||||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
return;
|
||||||
} catch (e) { /* transient — keep polling */ }
|
}
|
||||||
setTimeout(poll, stepMs);
|
} catch (e) {
|
||||||
};
|
reset();
|
||||||
setTimeout(poll, stepMs);
|
showAuthError(formatDeviceFlowError(e));
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import codeRunnerModule from './codeRunner.js';
|
|||||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js';
|
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js';
|
||||||
import createResearchSynapse from './researchSynapse.js';
|
import createResearchSynapse from './researchSynapse.js';
|
||||||
import { createStreamRenderer } from './streamingRenderer.js';
|
import { createStreamRenderer } from './streamingRenderer.js';
|
||||||
|
import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js';
|
||||||
|
|
||||||
const RESEARCH_TIMEOUT_MS = 360000;
|
const RESEARCH_TIMEOUT_MS = 360000;
|
||||||
const DEFAULT_TIMEOUT_MS = 120000;
|
const DEFAULT_TIMEOUT_MS = 120000;
|
||||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||||
@@ -217,6 +219,19 @@ import { createStreamRenderer } from './streamingRenderer.js';
|
|||||||
const ta = document.getElementById('message');
|
const ta = document.getElementById('message');
|
||||||
if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
|
if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// ArrowUp on empty composer recalls last user message (like many chat apps).
|
||||||
|
const _wireArrowUpRecall = (composer) =>
|
||||||
|
wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(), {
|
||||||
|
autoResize: uiModule?.autoResize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = document.getElementById('message');
|
||||||
|
if (!_wireArrowUpRecall(composer)) {
|
||||||
|
// Init can run before #message exists (templated UI); short retries only.
|
||||||
|
try { requestAnimationFrame(() => _wireArrowUpRecall(document.getElementById('message'))); } catch (_) {}
|
||||||
|
setTimeout(() => _wireArrowUpRecall(document.getElementById('message')), 250);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
|
// addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen
|
||||||
|
|||||||
+39
-15
@@ -680,9 +680,11 @@ export function applyModelColor(roleEl, modelName) {
|
|||||||
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
|
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
|
if (isCostTrackedEndpoint(_epUrl)) {
|
||||||
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
|
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
|
||||||
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
|
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
|
||||||
|
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
|
||||||
|
}
|
||||||
popup.innerHTML = html;
|
popup.innerHTML = html;
|
||||||
const rect = roleEl.getBoundingClientRect();
|
const rect = roleEl.getBoundingClientRect();
|
||||||
popup.style.top = (rect.bottom + 4) + 'px';
|
popup.style.top = (rect.bottom + 4) + 'px';
|
||||||
@@ -735,11 +737,31 @@ export function isLocalEndpoint(url) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cost for the current turn, returning null (free) for local endpoints. */
|
export function isSubscriptionEndpoint(url) {
|
||||||
function _billableCost(model, inputTokens, outputTokens) {
|
if (!url) return false;
|
||||||
const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const path = parsed.pathname.replace(/\/+$/, '');
|
||||||
|
return parsed.hostname === 'chatgpt.com'
|
||||||
|
&& (path === '/backend-api/codex' || path.startsWith('/backend-api/codex/'));
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentEndpointUrl() {
|
||||||
|
return (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||||
if (isLocalEndpoint(url)) return null;
|
}
|
||||||
|
|
||||||
|
export function isCostTrackedEndpoint(url) {
|
||||||
|
return !isLocalEndpoint(url) && !isSubscriptionEndpoint(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||||
|
function _billableCost(model, inputTokens, outputTokens) {
|
||||||
|
const url = _currentEndpointUrl();
|
||||||
|
if (!isCostTrackedEndpoint(url)) return null;
|
||||||
return getModelCost(model, inputTokens, outputTokens);
|
return getModelCost(model, inputTokens, outputTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,11 +806,10 @@ export function resetSessionCost(sessionId) {
|
|||||||
export function updateSessionCostUI() {
|
export function updateSessionCostUI() {
|
||||||
const el = document.getElementById('session-cost-display');
|
const el = document.getElementById('session-cost-display');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// Local model? It's free — hide the badge and clear any stale cost that a
|
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
||||||
// previous (buggy) cloud-rate billing left in localStorage for this session.
|
// cloud-rate calculation may have left in localStorage for this session.
|
||||||
const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
const _url = _currentEndpointUrl();
|
||||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
if (!isCostTrackedEndpoint(_url)) {
|
||||||
if (isLocalEndpoint(_url)) {
|
|
||||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||||
if (sid && getSessionCost(sid) > 0) {
|
if (sid && getSessionCost(sid) > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -1708,7 +1729,8 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||||
|
|
||||||
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
|
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
|
||||||
|
const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
|
||||||
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
|
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
|
||||||
const totalTok = inputTokens + outputTokens;
|
const totalTok = inputTokens + outputTokens;
|
||||||
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
|
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
|
||||||
@@ -1722,7 +1744,7 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
// Session total cost
|
// Session total cost
|
||||||
let sessionCostStr = '';
|
let sessionCostStr = '';
|
||||||
const sc = getSessionCost();
|
const sc = getSessionCost();
|
||||||
if (sc > 0) {
|
if (costStr && sc > 0) {
|
||||||
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
|
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,7 +1760,7 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
<div><span class="ctx-label">Time</span> ${responseTime}s</div>
|
<div><span class="ctx-label">Time</span> ${responseTime}s</div>
|
||||||
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
|
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
|
||||||
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
|
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
|
||||||
<div><span class="ctx-label">Cost</span> ${costStr}</div>
|
${costRows}
|
||||||
${sessionCostStr}
|
${sessionCostStr}
|
||||||
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
||||||
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
||||||
@@ -2392,6 +2414,8 @@ const chatRenderer = {
|
|||||||
modelColor,
|
modelColor,
|
||||||
applyModelColor,
|
applyModelColor,
|
||||||
getModelCost,
|
getModelCost,
|
||||||
|
isCostTrackedEndpoint,
|
||||||
|
isSubscriptionEndpoint,
|
||||||
getImageCost,
|
getImageCost,
|
||||||
getSessionCost,
|
getSessionCost,
|
||||||
resetSessionCost,
|
resetSessionCost,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* ArrowUp on an empty composer recalls the last user message (chat-app convention).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last user bubble in the active chat surface (#chat-history), using dataset.raw
|
||||||
|
* (same source as resend/regenerate in chat.js).
|
||||||
|
*
|
||||||
|
* @param {Document | Element} [root=document]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getLastUserMessageFromChatHistory(root = document) {
|
||||||
|
const chatBox =
|
||||||
|
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
|
||||||
|
? root
|
||||||
|
: (root.getElementById ? root.getElementById('chat-history') : null);
|
||||||
|
if (!chatBox) return '';
|
||||||
|
|
||||||
|
const users = chatBox.querySelectorAll('.msg-user');
|
||||||
|
const last = users[users.length - 1];
|
||||||
|
if (!last) return '';
|
||||||
|
|
||||||
|
const bodyEl = last.querySelector('.body');
|
||||||
|
return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLTextAreaElement} composer
|
||||||
|
* @param {() => string} getLastUserMessage
|
||||||
|
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
|
||||||
|
* @returns {boolean} true when wired (or already wired)
|
||||||
|
*/
|
||||||
|
export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) {
|
||||||
|
if (!composer) return false;
|
||||||
|
if (composer._arrowUpRecallWired) return true;
|
||||||
|
composer._arrowUpRecallWired = true;
|
||||||
|
|
||||||
|
const { autoResize } = options;
|
||||||
|
|
||||||
|
composer.addEventListener('keydown', (e) => {
|
||||||
|
// Only ArrowUp, no modifier keys, no IME composition
|
||||||
|
if (e.key !== 'ArrowUp') return;
|
||||||
|
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||||
|
if (e.isComposing) return;
|
||||||
|
|
||||||
|
// Literal emptiness — intentional whitespace is not empty
|
||||||
|
if (composer.value !== '') return;
|
||||||
|
|
||||||
|
const recalled = getLastUserMessage();
|
||||||
|
if (!recalled) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
composer.value = recalled;
|
||||||
|
try {
|
||||||
|
composer.selectionStart = composer.selectionEnd = recalled.length;
|
||||||
|
} catch (_) {}
|
||||||
|
if (autoResize) autoResize(composer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Shared DOM-free provider device-flow runner.
|
||||||
|
|
||||||
|
export const PROVIDER_DEVICE_FLOWS = {
|
||||||
|
copilot: {
|
||||||
|
label: 'GitHub Copilot',
|
||||||
|
startUrl: '/api/copilot/device/start',
|
||||||
|
pollUrl: '/api/copilot/device/poll',
|
||||||
|
authUrl(start) {
|
||||||
|
return start?.verification_uri_complete || start?.verification_uri || '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'chatgpt-subscription': {
|
||||||
|
label: 'ChatGPT Subscription',
|
||||||
|
startUrl: '/api/chatgpt-subscription/device/start',
|
||||||
|
pollUrl: '/api/chatgpt-subscription/device/poll',
|
||||||
|
authUrl(start) {
|
||||||
|
return start?.verification_uri || '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function _formData() {
|
||||||
|
if (typeof FormData !== 'undefined') return new FormData();
|
||||||
|
return new URLSearchParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _jsonOrEmpty(response) {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _messageFromPayload(payload, fallback) {
|
||||||
|
if (payload && typeof payload.detail === 'string' && payload.detail.trim()) {
|
||||||
|
return payload.detail.trim();
|
||||||
|
}
|
||||||
|
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
return payload.error.trim();
|
||||||
|
}
|
||||||
|
if (payload && typeof payload.message === 'string' && payload.message.trim()) {
|
||||||
|
return payload.message.trim();
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeviceFlowError(error, fallback = 'Request failed') {
|
||||||
|
if (!error) return fallback;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error.detail) return String(error.detail);
|
||||||
|
if (error.message) return String(error.message);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchJson(fetchImpl, url, options, fallback) {
|
||||||
|
const response = await fetchImpl(url, options);
|
||||||
|
const payload = await _jsonOrEmpty(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(_messageFromPayload(payload, fallback || `Request failed (HTTP ${response.status})`));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _defaultSleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _callCallback(fn, payload) {
|
||||||
|
if (typeof fn === 'function') await fn(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProviderDeviceFlow(provider, options = {}) {
|
||||||
|
const cfg = PROVIDER_DEVICE_FLOWS[provider];
|
||||||
|
if (!cfg) throw new Error(`Unknown device-flow provider: ${provider}`);
|
||||||
|
|
||||||
|
const fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||||
|
if (!fetchImpl) throw new Error('Fetch API is unavailable');
|
||||||
|
|
||||||
|
const openWindow = options.openWindow || ((url) => {
|
||||||
|
if (globalThis.window && typeof globalThis.window.open === 'function') {
|
||||||
|
globalThis.window.open(url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sleep = options.sleep || _defaultSleep;
|
||||||
|
const now = options.now || (() => Date.now());
|
||||||
|
const formData = options.formData || _formData();
|
||||||
|
|
||||||
|
const start = await _fetchJson(fetchImpl, cfg.startUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}, `Failed to start ${cfg.label} sign-in`);
|
||||||
|
|
||||||
|
if (!start.poll_id) throw new Error(`${cfg.label} sign-in did not return a poll id`);
|
||||||
|
const authUrl = cfg.authUrl(start);
|
||||||
|
await _callCallback(options.onStart, { provider, config: cfg, start, authUrl });
|
||||||
|
if (authUrl) openWindow(authUrl);
|
||||||
|
|
||||||
|
const deadline = now() + Number(start.expires_in || 900) * 1000;
|
||||||
|
let stepMs = Math.max(Number(start.interval || 5), 2) * 1000;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (now() > deadline) return { status: 'expired' };
|
||||||
|
await _callCallback(options.onWaiting, { provider, config: cfg, start, authUrl });
|
||||||
|
await sleep(stepMs);
|
||||||
|
if (now() > deadline) return { status: 'expired' };
|
||||||
|
|
||||||
|
const fd = _formData();
|
||||||
|
fd.append('poll_id', start.poll_id);
|
||||||
|
const poll = await _fetchJson(fetchImpl, cfg.pollUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}, `${cfg.label} sign-in poll failed`);
|
||||||
|
await _callCallback(options.onPoll, { provider, config: cfg, start, poll });
|
||||||
|
|
||||||
|
if (poll.status === 'authorized') {
|
||||||
|
return { status: 'authorized', endpoint: poll.endpoint || {} };
|
||||||
|
}
|
||||||
|
if (poll.status === 'failed') {
|
||||||
|
return { status: 'failed', error: poll.error || 'denied' };
|
||||||
|
}
|
||||||
|
if (poll.interval) {
|
||||||
|
stepMs = Math.max(Number(poll.interval || 5), 2) * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ const _PROVIDERS = [
|
|||||||
[/opencode/i,
|
[/opencode/i,
|
||||||
'<svg viewBox="0 0 24 30" fill="currentColor"><path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z"/></svg>'],
|
'<svg viewBox="0 0 24 30" fill="currentColor"><path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z"/></svg>'],
|
||||||
|
|
||||||
|
// GitHub / Copilot
|
||||||
|
[/github|copilot/i,
|
||||||
|
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.8.1-.8.1-.8 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.3 3.6 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.3-3.2-.1-.3-.5-1.6.1-3.2 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0C15.3 4.7 16 5 16 5c.6 1.6.2 2.9.1 3.2.8.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>'],
|
||||||
|
|
||||||
// OpenRouter
|
// OpenRouter
|
||||||
[/openrouter|open router/i,
|
[/openrouter|open router/i,
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
|
||||||
@@ -102,6 +106,7 @@ export function providerLogo(modelId) {
|
|||||||
// doesn't match `x.ai`.
|
// doesn't match `x.ai`.
|
||||||
const _ENDPOINT_LABELS = [
|
const _ENDPOINT_LABELS = [
|
||||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||||
|
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
|
||||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
[/(^|\.)openai\.com$/i, "OpenAI"],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
|
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
|
||||||
|
|
||||||
const POPUP_ID = 'slash-autocomplete';
|
const POPUP_ID = 'slash-autocomplete';
|
||||||
const MAX_VISIBLE = 12;
|
const MAX_VISIBLE = 14;
|
||||||
|
|
||||||
// Flatten the registry into a searchable list of leaf entries. Each entry is
|
// Flatten the registry into a searchable list of leaf entries. Each entry is
|
||||||
// either a top-level command or a "cmd sub" pair (so subcommands get their
|
// either a top-level command or a "cmd sub" pair (so subcommands get their
|
||||||
@@ -81,6 +81,23 @@ function _flatten() {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _loadSkillEntries() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/skills/slash-catalog', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return (Array.isArray(data.skills) ? data.skills : []).map(s => ({
|
||||||
|
token: s.token || `/${s.name}`,
|
||||||
|
aliases: [],
|
||||||
|
category: s.category || 'Skills',
|
||||||
|
help: s.help || 'Run skill',
|
||||||
|
usage: s.usage || `${s.token || `/${s.name}`} <request>`,
|
||||||
|
})).filter(e => e.token && e.token.startsWith('/'));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _scoreMatch(entry, query) {
|
function _scoreMatch(entry, query) {
|
||||||
// query already starts with "/". Match against token + aliases. Prefix wins
|
// query already starts with "/". Match against token + aliases. Prefix wins
|
||||||
// over substring; alias match scores slightly lower than token match.
|
// over substring; alias match scores slightly lower than token match.
|
||||||
@@ -98,6 +115,17 @@ function _scoreMatch(entry, query) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _exactCommandGroupItems(all, query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
if (!/^\/[a-z0-9_-]+$/i.test(q)) return [];
|
||||||
|
const parent = all.find(entry => entry.token.toLowerCase() === q);
|
||||||
|
if (!parent) return [];
|
||||||
|
const prefix = q + ' ';
|
||||||
|
const children = all.filter(entry => entry.token.toLowerCase().startsWith(prefix));
|
||||||
|
if (!children.length) return [];
|
||||||
|
return children.concat(parent);
|
||||||
|
}
|
||||||
|
|
||||||
function _ensurePopup(textarea) {
|
function _ensurePopup(textarea) {
|
||||||
let el = document.getElementById(POPUP_ID);
|
let el = document.getElementById(POPUP_ID);
|
||||||
if (el) return el;
|
if (el) return el;
|
||||||
@@ -164,7 +192,7 @@ export function initSlashAutocomplete(textarea) {
|
|||||||
if (!textarea || textarea._slashAcWired) return;
|
if (!textarea || textarea._slashAcWired) return;
|
||||||
textarea._slashAcWired = true;
|
textarea._slashAcWired = true;
|
||||||
|
|
||||||
const all = _flatten();
|
let all = _flatten();
|
||||||
let popup = null;
|
let popup = null;
|
||||||
let visible = false;
|
let visible = false;
|
||||||
let items = [];
|
let items = [];
|
||||||
@@ -191,12 +219,17 @@ export function initSlashAutocomplete(textarea) {
|
|||||||
// the menu hides — we don't autocomplete mid-sentence.
|
// the menu hides — we don't autocomplete mid-sentence.
|
||||||
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
|
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
|
||||||
const query = v.trim();
|
const query = v.trim();
|
||||||
items = all
|
const groupItems = _exactCommandGroupItems(all, query);
|
||||||
|
if (groupItems.length) {
|
||||||
|
items = groupItems.slice(0, MAX_VISIBLE);
|
||||||
|
} else {
|
||||||
|
items = all
|
||||||
.map(e => ({ e, s: _scoreMatch(e, query) }))
|
.map(e => ({ e, s: _scoreMatch(e, query) }))
|
||||||
.filter(x => x.s > 0)
|
.filter(x => x.s > 0)
|
||||||
.sort((a, b) => b.s - a.s)
|
.sort((a, b) => b.s - a.s)
|
||||||
.slice(0, MAX_VISIBLE)
|
.slice(0, MAX_VISIBLE)
|
||||||
.map(x => x.e);
|
.map(x => x.e);
|
||||||
|
}
|
||||||
if (!items.length && query.length > 1) { hide(); return; }
|
if (!items.length && query.length > 1) { hide(); return; }
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
// Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
|
// Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
|
||||||
@@ -207,6 +240,19 @@ export function initSlashAutocomplete(textarea) {
|
|||||||
_render(popup, items, selectedIdx, query);
|
_render(popup, items, selectedIdx, query);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_loadSkillEntries().then(skillEntries => {
|
||||||
|
if (!skillEntries.length) return;
|
||||||
|
const seen = new Set(all.map(e => e.token));
|
||||||
|
const merged = all.slice();
|
||||||
|
for (const entry of skillEntries) {
|
||||||
|
if (seen.has(entry.token)) continue;
|
||||||
|
seen.add(entry.token);
|
||||||
|
merged.push(entry);
|
||||||
|
}
|
||||||
|
all = merged;
|
||||||
|
if (visible) refresh();
|
||||||
|
});
|
||||||
|
|
||||||
const insert = (token) => {
|
const insert = (token) => {
|
||||||
textarea.value = token + ' ';
|
textarea.value = token + ' ';
|
||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|||||||
+351
-71
@@ -21,6 +21,7 @@ import workspaceModule from './workspace.js';
|
|||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import cookbookModule from './cookbook.js';
|
import cookbookModule from './cookbook.js';
|
||||||
import { EVAL_PROMPTS } from './compare/index.js';
|
import { EVAL_PROMPTS } from './compare/index.js';
|
||||||
|
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||||
|
|
||||||
// ── Module state ──────────────────────────────────────────────────────
|
// ── Module state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -58,11 +59,28 @@ const SETUP_PROVIDER_URLS = {
|
|||||||
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
|
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
|
||||||
};
|
};
|
||||||
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
|
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
|
||||||
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_NAMES[SETUP_PROVIDER_NAMES.length - 1];
|
const SETUP_DEVICE_AUTH_PROVIDERS = [
|
||||||
|
{ key: 'copilot', name: 'GitHub Copilot', aliases: ['github'], command: '/setup copilot' },
|
||||||
|
{ key: 'chatgpt-subscription', name: 'ChatGPT Subscription', aliases: ['chatgptsubscription', 'chatgpt-sub', 'codex'], command: '/setup chatgpt-subscription' },
|
||||||
|
];
|
||||||
|
const SETUP_PROVIDER_HINT_NAMES = SETUP_PROVIDER_NAMES.concat(SETUP_DEVICE_AUTH_PROVIDERS.map(provider => provider.key));
|
||||||
|
const SETUP_PROVIDER_HINT = SETUP_PROVIDER_HINT_NAMES.slice(0, -1).join(', ') + ', or ' + SETUP_PROVIDER_HINT_NAMES[SETUP_PROVIDER_HINT_NAMES.length - 1];
|
||||||
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
const SETUP_LOCAL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>';
|
||||||
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
const SETUP_API_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||||
const SETUP_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
const SETUP_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||||
|
|
||||||
|
function _setupApiProviderChips() {
|
||||||
|
return SETUP_PROVIDER_NAMES.map(name =>
|
||||||
|
'<span class="setup-clickable-provider" data-setup-kind="api-key" data-setup-provider="' + name + '" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||||
|
).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setupDeviceAuthProviderChips() {
|
||||||
|
return SETUP_DEVICE_AUTH_PROVIDERS.map(provider =>
|
||||||
|
'<span class="setup-clickable-provider" data-setup-kind="device-auth" data-setup-provider="' + provider.key + '" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Run ' + provider.command + '">' + provider.name + '</span>'
|
||||||
|
).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function _setupProviderFromInput(input) {
|
function _setupProviderFromInput(input) {
|
||||||
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '');
|
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||||
const aliases = {
|
const aliases = {
|
||||||
@@ -84,6 +102,17 @@ function _setupProviderFromInput(input) {
|
|||||||
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setupDeviceAuthProviderFromInput(input) {
|
||||||
|
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '').replace(/_/g, '-');
|
||||||
|
if (!raw) return '';
|
||||||
|
for (const provider of SETUP_DEVICE_AUTH_PROVIDERS) {
|
||||||
|
const candidates = [provider.key, provider.name, ...(provider.aliases || [])]
|
||||||
|
.map(value => String(value || '').toLowerCase().replace(/\s+/g, '').replace(/_/g, '-'));
|
||||||
|
if (candidates.includes(raw)) return provider.key;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function _extractSetupProviderCredential(input) {
|
function _extractSetupProviderCredential(input) {
|
||||||
const raw = (input || '').trim();
|
const raw = (input || '').trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -158,9 +187,8 @@ function _setupReply(text, remember = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showSetupEndpointChoices() {
|
function _showSetupEndpointChoices() {
|
||||||
const providers = SETUP_PROVIDER_NAMES.map(name =>
|
const providers = _setupApiProviderChips();
|
||||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
const deviceAuthProviders = _setupDeviceAuthProviderChips();
|
||||||
).join(' ');
|
|
||||||
return slashReply(
|
return slashReply(
|
||||||
'<div class="setup-guide-no-censor" style="display:grid;gap:10px;">' +
|
'<div class="setup-guide-no-censor" style="display:grid;gap:10px;">' +
|
||||||
'<div>' +
|
'<div>' +
|
||||||
@@ -178,6 +206,7 @@ function _showSetupEndpointChoices() {
|
|||||||
'<div>Paste provider name then API key (example):</div>' +
|
'<div>Paste provider name then API key (example):</div>' +
|
||||||
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">deepseek sk-...</code></pre>' +
|
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">deepseek sk-...</code></pre>' +
|
||||||
'<div style="margin-top:8px;font-size:1em;"><span>Supported providers:</span><br>' + providers + '</div>' +
|
'<div style="margin-top:8px;font-size:1em;"><span>Supported providers:</span><br>' + providers + '</div>' +
|
||||||
|
'<div style="margin-top:8px;font-size:1em;"><span>Account sign-in:</span><br>' + deviceAuthProviders + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
@@ -208,9 +237,8 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
|||||||
text: 'deepseek sk-...',
|
text: 'deepseek sk-...',
|
||||||
copyText: 'deepseek sk-...',
|
copyText: 'deepseek sk-...',
|
||||||
},
|
},
|
||||||
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + SETUP_PROVIDER_NAMES.map(name =>
|
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + _setupApiProviderChips() },
|
||||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
{ kind: 'p', html: '<strong>Account sign-in:</strong><br>' + _setupDeviceAuthProviderChips() },
|
||||||
).join(' ') },
|
|
||||||
];
|
];
|
||||||
return typewriterBlocksReply(blocks, { gap: '4px', bodyClass: 'setup-guide-no-censor', interval: 3 });
|
return typewriterBlocksReply(blocks, { gap: '4px', bodyClass: 'setup-guide-no-censor', interval: 3 });
|
||||||
}
|
}
|
||||||
@@ -231,7 +259,7 @@ async function _hasConfiguredModels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _setupProviderPrompt() {
|
function _setupProviderPrompt() {
|
||||||
const chips = SETUP_PROVIDER_NAMES.map(name =>
|
const chips = SETUP_PROVIDER_HINT_NAMES.map(name =>
|
||||||
'<span style="font-weight:650;">' + name + '</span>'
|
'<span style="font-weight:650;">' + name + '</span>'
|
||||||
).join(' ');
|
).join(' ');
|
||||||
slashReply('<b>Supported providers:</b><br>' + chips);
|
slashReply('<b>Supported providers:</b><br>' + chips);
|
||||||
@@ -286,6 +314,53 @@ function slashReply(text) {
|
|||||||
return { el: div, body };
|
return { el: div, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _skillCatalogCache = { at: 0, items: [] };
|
||||||
|
|
||||||
|
async function _loadSkillSlashCatalog(force = false) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && (now - _skillCatalogCache.at) < 15000) return _skillCatalogCache.items;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/skills/slash-catalog`, { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) throw new Error('catalog unavailable');
|
||||||
|
const data = await res.json();
|
||||||
|
const items = Array.isArray(data.skills) ? data.skills : [];
|
||||||
|
_skillCatalogCache = { at: now, items };
|
||||||
|
return items;
|
||||||
|
} catch {
|
||||||
|
return _skillCatalogCache.items || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _submitComposedMessage(text) {
|
||||||
|
const msgInput = document.getElementById('message');
|
||||||
|
const form = document.getElementById('chat-form');
|
||||||
|
if (!msgInput || !form) return false;
|
||||||
|
msgInput.value = text;
|
||||||
|
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
||||||
|
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _invokeSkillByName(name, requestText, ctx) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(name)}/invoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ request: requestText || '' })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
slashReply(ctx?.esc ? ctx.esc(err?.detail || 'Skill is not available') : 'Skill is not available');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.message || !_submitComposedMessage(data.message)) {
|
||||||
|
slashReply('Could not start skill invocation.');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal footer for slash replies: copy + dismiss */
|
/** Minimal footer for slash replies: copy + dismiss */
|
||||||
function _slashFooter(msgEl) {
|
function _slashFooter(msgEl) {
|
||||||
const footer = document.createElement('div');
|
const footer = document.createElement('div');
|
||||||
@@ -681,6 +756,13 @@ async function handleSetupWizard(mode, input) {
|
|||||||
await _setupProviderPrompt();
|
await _setupProviderPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(input);
|
||||||
|
if (deviceAuthProvider) {
|
||||||
|
_addMessage('user', input);
|
||||||
|
setupMode = false;
|
||||||
|
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const paired = _extractSetupProviderCredential(input);
|
const paired = _extractSetupProviderCredential(input);
|
||||||
const provider = paired?.provider || _setupProviderFromInput(input);
|
const provider = paired?.provider || _setupProviderFromInput(input);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -1429,6 +1511,42 @@ async function _cmdModels(args, ctx) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _cmdModel(args, ctx) {
|
||||||
|
const sub = (args[0] || '').toLowerCase();
|
||||||
|
if (sub === 'list' || sub === 'ls') return _cmdModels(args.slice(1), ctx);
|
||||||
|
|
||||||
|
const model = sessionModule.getCurrentModel ? sessionModule.getCurrentModel() : '';
|
||||||
|
const endpoint = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : '';
|
||||||
|
slashReply(`<pre>${[
|
||||||
|
`Current model: ${ctx.esc(model || 'None selected')}`,
|
||||||
|
endpoint ? `Endpoint: ${ctx.esc(endpoint)}` : 'Endpoint: not available',
|
||||||
|
'',
|
||||||
|
'Usage: /model list to show all available models'
|
||||||
|
].join('\n')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _cmdMcp(args, ctx) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/mcp/servers`, { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) {
|
||||||
|
slashReply('MCP status is unavailable for this user.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const servers = await res.json();
|
||||||
|
if (!Array.isArray(servers) || !servers.length) {
|
||||||
|
slashReply('No MCP servers configured.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lines = servers.map(s => {
|
||||||
|
const status = s.status || (s.is_enabled ? 'enabled' : 'disabled');
|
||||||
|
const enabled = Number(s.enabled_tool_count ?? s.tool_count ?? 0);
|
||||||
|
const total = Number(s.tool_count ?? enabled);
|
||||||
|
return `${s.name || s.id || 'MCP server'} - ${status} (${enabled}/${total} tools)`;
|
||||||
|
});
|
||||||
|
slashReply(`<pre>${lines.map(line => ctx.esc(line)).join('\n')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Memory ──
|
// ── Memory ──
|
||||||
|
|
||||||
async function _cmdMemoryList(args, ctx) {
|
async function _cmdMemoryList(args, ctx) {
|
||||||
@@ -1507,6 +1625,73 @@ async function _cmdMemorySearch(args, ctx) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Skills ──
|
||||||
|
|
||||||
|
async function _cmdSkills(args, ctx) {
|
||||||
|
const sub = (args[0] || 'list').toLowerCase();
|
||||||
|
const rest = args.slice(1);
|
||||||
|
|
||||||
|
if (sub === 'list' || sub === 'ls') {
|
||||||
|
const skills = await _loadSkillSlashCatalog(true);
|
||||||
|
if (!skills.length) {
|
||||||
|
slashReply('No published skills available for slash commands.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lines = skills.map(s => {
|
||||||
|
const uses = Number(s.uses || 0);
|
||||||
|
const useText = uses > 0 ? ` uses:${uses}` : '';
|
||||||
|
return `${ctx.esc(String(s.token || '').padEnd(24))}${ctx.esc(s.help || '')}${useText}`;
|
||||||
|
});
|
||||||
|
slashReply(`<pre>${lines.join('\n')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'search' || sub === 'find') {
|
||||||
|
const query = rest.join(' ').trim();
|
||||||
|
if (!query) { slashReply('Usage: /skills search query'); return true; }
|
||||||
|
const res = await fetch(`${API_BASE}/api/skills/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query })
|
||||||
|
});
|
||||||
|
if (!res.ok) { slashReply('Skill search failed.'); return true; }
|
||||||
|
const data = await res.json();
|
||||||
|
const skills = Array.isArray(data.skills) ? data.skills : [];
|
||||||
|
if (!skills.length) { slashReply(`No skills found for "${ctx.esc(query)}".`); return true; }
|
||||||
|
const lines = skills.map(s =>
|
||||||
|
ctx.esc(`/${s.name || s.id || ''}`.padEnd(24)) + ctx.esc(s.description || '')
|
||||||
|
);
|
||||||
|
slashReply(`<pre>${lines.join('\n')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'view' || sub === 'cat' || sub === 'show') {
|
||||||
|
const name = (rest[0] || '').trim();
|
||||||
|
if (!name) { slashReply('Usage: /skills view name'); return true; }
|
||||||
|
const res = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(name)}/markdown`, { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) { slashReply(`Skill "${ctx.esc(name)}" was not found.`); return true; }
|
||||||
|
const data = await res.json();
|
||||||
|
slashReply(`<pre>${ctx.esc(data.markdown || '')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'use' || sub === 'run') {
|
||||||
|
const name = (rest[0] || '').trim();
|
||||||
|
if (!name) { slashReply('Usage: /skills use name request'); return true; }
|
||||||
|
return _invokeSkillByName(name, rest.slice(1).join(' ').trim(), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
slashReply('Usage: /skills list | search query | view name | use name request');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _cmdReloadSkills(args, ctx) {
|
||||||
|
const skills = await _loadSkillSlashCatalog(true);
|
||||||
|
slashReply(`Reloaded skills. ${skills.length} skill command${skills.length === 1 ? '' : 's'} available.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Note (quick Notes shortcut) ──
|
// ── Note (quick Notes shortcut) ──
|
||||||
|
|
||||||
async function _cmdNote(args, ctx) {
|
async function _cmdNote(args, ctx) {
|
||||||
@@ -1799,6 +1984,53 @@ Uploads: ${d.uploads || '?'}</pre>`);
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _cmdUsage(args, ctx) {
|
||||||
|
const sid = ctx.sid;
|
||||||
|
if (!sid) {
|
||||||
|
slashReply('No active session.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = null;
|
||||||
|
try {
|
||||||
|
const sessions = sessionModule.getSessions ? sessionModule.getSessions() : [];
|
||||||
|
session = (sessions || []).find(s => s.id === sid) || null;
|
||||||
|
if (!session) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/sessions`, { credentials: 'same-origin' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const items = Array.isArray(data) ? data : (data.sessions || data.items || []);
|
||||||
|
session = items.find(s => s.id === sid) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const model = session?.model || 'Unknown';
|
||||||
|
const endpointUrl = session?.endpoint_url || (
|
||||||
|
sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : ''
|
||||||
|
);
|
||||||
|
const messageCount = Number(session?.message_count || 0);
|
||||||
|
const totalTokens = Number(session?.total_tokens || 0);
|
||||||
|
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||||
|
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||||
|
const costLine = costTracked
|
||||||
|
? (cost > 0
|
||||||
|
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||||
|
: 'Estimated local cost: unavailable or zero')
|
||||||
|
: 'Estimated local cost: not tracked for this endpoint';
|
||||||
|
|
||||||
|
slashReply(`<pre>${[
|
||||||
|
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||||
|
`Model: ${ctx.esc(model)}`,
|
||||||
|
`Messages: ${messageCount.toLocaleString()}`,
|
||||||
|
`Recorded tokens: ${totalTokens.toLocaleString()}`,
|
||||||
|
costLine,
|
||||||
|
'',
|
||||||
|
'Provider account usage is not available from here; check the provider dashboard for account quota/billing.'
|
||||||
|
].join('\n')}</pre>`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Context compaction ──
|
// ── Context compaction ──
|
||||||
|
|
||||||
async function _cmdCompact(args, ctx) {
|
async function _cmdCompact(args, ctx) {
|
||||||
@@ -4783,39 +5015,53 @@ function _clearSetupCommandInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
|
async function _setupProviderDeviceFlow(providerKey) {
|
||||||
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
|
|
||||||
async function _setupCopilot() {
|
|
||||||
_clearSetupGuideMessages();
|
_clearSetupGuideMessages();
|
||||||
await _setupReply('Starting GitHub Copilot sign-in…');
|
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||||
let start;
|
if (!config) {
|
||||||
|
await _setupReply('Provider not recognised.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _setupReply(`Starting ${config.label} sign-in...`);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
const result = await runProviderDeviceFlow(providerKey, {
|
||||||
start = await r.json();
|
onStart: async ({ start, authUrl }) => {
|
||||||
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
|
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
|
||||||
} catch (e) { await _setupReply('Request failed.'); return; }
|
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
|
||||||
const authUrl = start.verification_uri_complete || start.verification_uri || '';
|
if (providerKey === 'chatgpt-subscription') {
|
||||||
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
|
slashReply(
|
||||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
'<div class="setup-guide-no-censor" style="display:grid;gap:6px;">' +
|
||||||
const deadline = Date.now() + (start.expires_in || 900) * 1000;
|
'<div>Open this URL in your browser, enter the code, then come back here. Waiting...</div>' +
|
||||||
const stepMs = Math.max((start.interval || 5), 2) * 1000;
|
'<div>Code: <code>' + uiModule.esc(start.user_code || '') + '</code></div>' +
|
||||||
const poll = async () => {
|
'<div><a href="' + uiModule.esc(authUrl || '') + '" target="_blank" rel="noopener noreferrer">' + uiModule.esc(authUrl || '') + '</a></div>' +
|
||||||
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
|
'</div>'
|
||||||
try {
|
);
|
||||||
const fd = new FormData(); fd.append('poll_id', start.poll_id);
|
return;
|
||||||
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
}
|
||||||
const d = await r.json();
|
await _setupReply(`Opening ${place} - ${action} (code ${start.user_code}). Waiting...`);
|
||||||
if (d.status === 'authorized') {
|
},
|
||||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
openWindow: (url) => {
|
||||||
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
|
if (providerKey === 'chatgpt-subscription') return;
|
||||||
if (modelsModule) modelsModule.refreshModels(true);
|
try { if (url) window.open(url, '_blank', 'noopener'); } catch (e) {}
|
||||||
return;
|
},
|
||||||
}
|
});
|
||||||
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
|
if (result.status === 'authorized') {
|
||||||
} catch (e) { /* transient — keep polling */ }
|
const n = ((result.endpoint && result.endpoint.models) || []).length;
|
||||||
setTimeout(poll, stepMs);
|
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
|
||||||
};
|
if (modelsModule) modelsModule.refreshModels(true);
|
||||||
setTimeout(poll, stepMs);
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
await _setupReply(`${config.label} sign-in failed (${result.error || 'denied'}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'expired') {
|
||||||
|
await _setupReply(`${config.label} sign-in expired - run /setup ${providerKey} again.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await _setupReply(formatDeviceFlowError(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _cmdSetup(args, ctx) {
|
async function _cmdSetup(args, ctx) {
|
||||||
@@ -4823,7 +5069,11 @@ async function _cmdSetup(args, ctx) {
|
|||||||
_clearSetupCommandInput();
|
_clearSetupCommandInput();
|
||||||
const topic = (args[0] || '').trim().toLowerCase();
|
const topic = (args[0] || '').trim().toLowerCase();
|
||||||
const topicArgs = args.slice(1);
|
const topicArgs = args.slice(1);
|
||||||
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
|
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
|
||||||
|
if (deviceAuthProvider) {
|
||||||
|
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const provider = _setupProviderFromInput(topic);
|
const provider = _setupProviderFromInput(topic);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
_clearSetupGuideMessages();
|
_clearSetupGuideMessages();
|
||||||
@@ -5463,8 +5713,20 @@ async function _cmdHelp(args, ctx) {
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const skillCommands = await _loadSkillSlashCatalog(false);
|
||||||
|
if (skillCommands.length) {
|
||||||
|
lines.push('Skills:');
|
||||||
|
for (const skill of skillCommands.slice(0, 20)) {
|
||||||
|
const token = String(skill.token || '').padEnd(21);
|
||||||
|
lines.push(` ${ctx.esc(token)}${ctx.esc(skill.help || '')}`);
|
||||||
|
}
|
||||||
|
if (skillCommands.length > 20) {
|
||||||
|
lines.push(` ... ${skillCommands.length - 20} more. Use /skills list`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
lines.push('Tip: /<command> --help for details');
|
lines.push('Tip: /<command> --help for details');
|
||||||
lines.push('Shortcuts: /new /rename /fork /web /bash /memories /forget');
|
lines.push('Shortcuts: /new /rename /fork /web /bash /memories /skills');
|
||||||
slashReply(`<pre style="line-height:1.7">${lines.join('\n')}</pre>`);
|
slashReply(`<pre style="line-height:1.7">${lines.join('\n')}</pre>`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -5539,6 +5801,20 @@ const COMMANDS = {
|
|||||||
'search': { handler: _cmdMemorySearch, alias: ['grep'], help: 'Search memories', usage: '/memory search q' }
|
'search': { handler: _cmdMemorySearch, alias: ['grep'], help: 'Search memories', usage: '/memory search q' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
skills: {
|
||||||
|
alias: ['skill'],
|
||||||
|
category: 'Memory',
|
||||||
|
help: 'List, search, inspect, or run skills',
|
||||||
|
handler: _cmdSkills,
|
||||||
|
usage: '/skills list | search query | view name | use name request',
|
||||||
|
},
|
||||||
|
'reload-skills': {
|
||||||
|
alias: ['reload_skills'],
|
||||||
|
category: 'Memory',
|
||||||
|
help: 'Refresh the slash skill catalog',
|
||||||
|
handler: _cmdReloadSkills,
|
||||||
|
usage: '/reload-skills',
|
||||||
|
},
|
||||||
rag: {
|
rag: {
|
||||||
alias: [],
|
alias: [],
|
||||||
category: 'RAG',
|
category: 'RAG',
|
||||||
@@ -5572,7 +5848,7 @@ const COMMANDS = {
|
|||||||
category: 'Getting started',
|
category: 'Getting started',
|
||||||
help: 'Add local or API model endpoints',
|
help: 'Add local or API model endpoints',
|
||||||
handler: _cmdSetup,
|
handler: _cmdSetup,
|
||||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint',
|
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup chatgpt-subscription',
|
||||||
// Provider subs so the autocomplete popup surfaces "/setup deepseek",
|
// Provider subs so the autocomplete popup surfaces "/setup deepseek",
|
||||||
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
||||||
// handler is a thin wrapper that re-prepends the sub name and
|
// handler is a thin wrapper that re-prepends the sub name and
|
||||||
@@ -5590,6 +5866,7 @@ const COMMANDS = {
|
|||||||
xai: { help: 'xAI (Grok)', alias: ['grok'], usage: '/setup xai xai-...', handler: (a, c) => _cmdSetup(['xai', ...a], c) },
|
xai: { help: 'xAI (Grok)', alias: ['grok'], usage: '/setup xai xai-...', handler: (a, c) => _cmdSetup(['xai', ...a], c) },
|
||||||
ollama: { help: 'Ollama Cloud', usage: '/setup ollama KEY', handler: (a, c) => _cmdSetup(['ollama', ...a], c) },
|
ollama: { help: 'Ollama Cloud', usage: '/setup ollama KEY', handler: (a, c) => _cmdSetup(['ollama', ...a], c) },
|
||||||
copilot: { help: 'GitHub Copilot', usage: '/setup copilot', handler: (a, c) => _cmdSetup(['copilot', ...a], c) },
|
copilot: { help: 'GitHub Copilot', usage: '/setup copilot', handler: (a, c) => _cmdSetup(['copilot', ...a], c) },
|
||||||
|
'chatgpt-subscription': { help: 'ChatGPT Subscription', alias: ['codex'], usage: '/setup chatgpt-subscription', handler: (a, c) => _cmdSetup(['chatgpt-subscription', ...a], c) },
|
||||||
local: { help: 'Local model server (vLLM / LM Studio / llama.cpp / Ollama)',
|
local: { help: 'Local model server (vLLM / LM Studio / llama.cpp / Ollama)',
|
||||||
usage: '/setup local http://localhost:8000/v1',
|
usage: '/setup local http://localhost:8000/v1',
|
||||||
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
||||||
@@ -5767,8 +6044,22 @@ const COMMANDS = {
|
|||||||
handler: (args, ctx) => _cmdToolPanel('compare', args, ctx),
|
handler: (args, ctx) => _cmdToolPanel('compare', args, ctx),
|
||||||
usage: '/compare'
|
usage: '/compare'
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
alias: [],
|
||||||
|
category: 'Tools',
|
||||||
|
help: 'Show MCP server status',
|
||||||
|
handler: _cmdMcp,
|
||||||
|
usage: '/mcp'
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
alias: [],
|
||||||
|
category: 'Settings',
|
||||||
|
help: 'Show current chat model',
|
||||||
|
handler: _cmdModel,
|
||||||
|
usage: '/model · /model list'
|
||||||
|
},
|
||||||
models: {
|
models: {
|
||||||
alias: ['model'],
|
alias: [],
|
||||||
category: 'Settings',
|
category: 'Settings',
|
||||||
help: 'List available models',
|
help: 'List available models',
|
||||||
handler: _cmdModels,
|
handler: _cmdModels,
|
||||||
@@ -5799,10 +6090,16 @@ const COMMANDS = {
|
|||||||
handler: _cmdStats,
|
handler: _cmdStats,
|
||||||
usage: '/stats'
|
usage: '/stats'
|
||||||
},
|
},
|
||||||
|
usage: {
|
||||||
|
alias: ['cost', 'tokens'],
|
||||||
|
category: 'Utility',
|
||||||
|
help: 'Show local usage for the current chat',
|
||||||
|
handler: _cmdUsage,
|
||||||
|
usage: '/usage'
|
||||||
|
},
|
||||||
compact: {
|
compact: {
|
||||||
alias: [],
|
alias: [],
|
||||||
category: 'Utility',
|
category: 'Utility',
|
||||||
hidden: true,
|
|
||||||
help: 'Compact older chat messages',
|
help: 'Compact older chat messages',
|
||||||
handler: _cmdCompact,
|
handler: _cmdCompact,
|
||||||
usage: '/compact'
|
usage: '/compact'
|
||||||
@@ -6075,33 +6372,13 @@ async function handleSlashCommand(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 4. Skill invocation: /<skill-name> [request] ---
|
// --- 4. Skill invocation: /<skill-name> [request] ---
|
||||||
// If `rawCmd` matches a published skill, pin its SKILL.md to the user's
|
// If `rawCmd` matches a published skill, the backend records usage and
|
||||||
// message and re-submit. Lets you fire a stored procedure on demand
|
// returns a skill-pinned message to submit as the next agent turn.
|
||||||
// without the model having to discover the skill itself.
|
|
||||||
try {
|
try {
|
||||||
const skillRes = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(rawCmd)}/markdown`, { credentials: 'same-origin' });
|
const catalog = await _loadSkillSlashCatalog(false);
|
||||||
if (skillRes.ok) {
|
if (catalog.some(s => s.name === rawCmd)) {
|
||||||
const skillData = await skillRes.json();
|
_showUser();
|
||||||
const md = skillData.markdown || '';
|
return await _invokeSkillByName(rawCmd, args.join(' ').trim(), ctx);
|
||||||
if (md) {
|
|
||||||
_showUser();
|
|
||||||
const request = args.join(' ').trim();
|
|
||||||
const msgInput = document.getElementById('message');
|
|
||||||
const composed =
|
|
||||||
`Apply the skill below to my request, following its Procedure / Pitfalls / Verification.\n\n` +
|
|
||||||
`--- BEGIN SKILL ---\n${md}\n--- END SKILL ---\n\n` +
|
|
||||||
(request ? `Request: ${request}` : `Request: (use the skill as appropriate)`);
|
|
||||||
if (msgInput) {
|
|
||||||
msgInput.value = composed;
|
|
||||||
const form = document.getElementById('chat-form');
|
|
||||||
if (form && typeof form.requestSubmit === 'function') {
|
|
||||||
form.requestSubmit();
|
|
||||||
} else if (form) {
|
|
||||||
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) { /* fall through to fuzzy match */ }
|
} catch (_) { /* fall through to fuzzy match */ }
|
||||||
|
|
||||||
@@ -6158,10 +6435,13 @@ export function initSlashCommands(deps) {
|
|||||||
const providerEl = e.target.closest('.setup-clickable-provider');
|
const providerEl = e.target.closest('.setup-clickable-provider');
|
||||||
if (providerEl) {
|
if (providerEl) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const providerKey = providerEl.dataset.setupProvider || providerEl.textContent.trim();
|
||||||
const providerName = providerEl.textContent.trim();
|
const providerName = providerEl.textContent.trim();
|
||||||
const messageInput = document.getElementById('message');
|
const messageInput = document.getElementById('message');
|
||||||
if (messageInput) {
|
if (messageInput) {
|
||||||
const text = providerName + ' sk-';
|
const text = providerEl.dataset.setupKind === 'device-auth'
|
||||||
|
? '/setup ' + providerKey
|
||||||
|
: providerName + ' sk-';
|
||||||
messageInput.value = text;
|
messageInput.value = text;
|
||||||
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
messageInput.focus();
|
messageInput.focus();
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Static regressions for Add Models provider device-flow UX."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_INDEX = (_REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
|
_ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _between(src: str, start: str, end: str) -> str:
|
||||||
|
start_idx = src.index(start)
|
||||||
|
end_idx = src.index(end, start_idx)
|
||||||
|
return src[start_idx:end_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def test_copilot_and_chatgpt_subscription_are_dropdown_device_auth_options():
|
||||||
|
assert 'value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot' in _INDEX
|
||||||
|
assert 'value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription' in _INDEX
|
||||||
|
assert 'id="adm-deviceAuthStatus"' in _INDEX
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_selection_is_inert_and_add_button_starts_device_flow():
|
||||||
|
change_block = _between(_ADMIN, "provider.addEventListener('change'", "urlInput.addEventListener('input'")
|
||||||
|
add_block = _between(_ADMIN, "el('adm-epAddBtn').addEventListener('click'", "async function _startProviderDeviceAuth")
|
||||||
|
|
||||||
|
assert "_startProviderDeviceAuth" not in change_block
|
||||||
|
assert "_startProviderDeviceAuth(deviceAuthProvider" in add_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_auth_selection_disables_and_dims_api_test_button():
|
||||||
|
form_block = _between(_ADMIN, "function _setApiFormForProvider()", "function _renderPickerMenu()")
|
||||||
|
|
||||||
|
assert "testBtn.disabled = true" in form_block
|
||||||
|
assert "testBtn.style.opacity = '0.45'" in form_block
|
||||||
|
assert "testBtn.style.cursor = 'not-allowed'" in form_block
|
||||||
|
assert "testBtn.disabled = false" in form_block
|
||||||
|
assert "testBtn.style.opacity = ''" in form_block
|
||||||
|
assert "testBtn.style.cursor = ''" in form_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_auth_keeps_manual_auth_button_without_auto_opening_tab():
|
||||||
|
auth_block = _between(_ADMIN, "async function _startProviderDeviceAuth", "// Local \"Add\" button")
|
||||||
|
|
||||||
|
assert "Authorize with OpenAI" in auth_block
|
||||||
|
assert "Authorize on GitHub" in auth_block
|
||||||
|
assert "adm-copilot-panel" in auth_block
|
||||||
|
assert "adm-device-auth-copy" in auth_block
|
||||||
|
assert "openWindow: () => {}" in auth_block
|
||||||
|
assert "A new tab opened" not in auth_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_loud_oauth_copy_and_removed_button_hooks_do_not_return():
|
||||||
|
forbidden = [
|
||||||
|
"Click Add to start",
|
||||||
|
"uses account sign-in",
|
||||||
|
"Uses ChatGPT/Codex OAuth, not an OpenAI API key.",
|
||||||
|
"adm-chatgptStatus",
|
||||||
|
"adm-chatgptConnectBtn",
|
||||||
|
"adm-copilotConnectBtn",
|
||||||
|
"adm-copilotStatus",
|
||||||
|
]
|
||||||
|
for needle in forbidden:
|
||||||
|
assert needle not in _INDEX
|
||||||
|
assert needle not in _ADMIN
|
||||||
@@ -64,4 +64,8 @@ def test_research_routes_fallbacks_are_owner_scoped():
|
|||||||
assert '_merge(*resolve_endpoint("utility", owner=user))' in src
|
assert '_merge(*resolve_endpoint("utility", owner=user))' in src
|
||||||
assert "ep = _owned_enabled_endpoint(db, user)" in src
|
assert "ep = _owned_enabled_endpoint(db, user)" in src
|
||||||
assert "db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).first()" not in src
|
assert "db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).first()" not in src
|
||||||
assert "owner = getattr(sess, \"owner\", None) or None" in src
|
# _resolve_research_endpoint derives the scope from the session owner. The
|
||||||
|
# rebased code generalized this to honor an explicit `owner` argument first
|
||||||
|
# (``owner = owner or getattr(sess, "owner", None) or None``), so assert on
|
||||||
|
# the stable session-derivation substring rather than the exact line.
|
||||||
|
assert 'getattr(sess, "owner", None) or None' in src
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""DB-backed ChatGPT Subscription endpoint provisioning tests."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from core.database import Base, ModelEndpoint, ProviderAuthSession
|
||||||
|
import routes.chatgpt_subscription_routes as csr
|
||||||
|
|
||||||
|
|
||||||
|
def _mem_db(monkeypatch):
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Match production (core.database SessionLocal is autoflush=False): a pending
|
||||||
|
# db.delete(ep) is NOT flushed before the orphan-auth reference-count SELECT,
|
||||||
|
# which is exactly why _delete_orphaned_provider_auth needs exclude_ep_id.
|
||||||
|
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
|
||||||
|
monkeypatch.setattr(csr, "SessionLocal", TestSessionLocal)
|
||||||
|
return TestSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch):
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5", "o4-mini"])
|
||||||
|
|
||||||
|
res = csr._provision_endpoint({"access_token": "AT", "refresh_token": "RT"}, "alice")
|
||||||
|
|
||||||
|
assert res["name"] == "ChatGPT Subscription"
|
||||||
|
assert res["base_url"] == csr.chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL
|
||||||
|
assert res["models"] == ["gpt-5.5", "o4-mini"]
|
||||||
|
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
auth = db.query(ProviderAuthSession).first()
|
||||||
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == res["id"]).first()
|
||||||
|
assert auth is not None
|
||||||
|
assert auth.owner == "alice"
|
||||||
|
assert auth.provider == csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER
|
||||||
|
assert auth.access_token == "AT"
|
||||||
|
assert auth.refresh_token == "RT"
|
||||||
|
assert auth.auth_mode == "chatgpt"
|
||||||
|
assert ep is not None
|
||||||
|
assert ep.owner == "alice"
|
||||||
|
assert ep.api_key is None
|
||||||
|
assert ep.provider_auth_id == auth.id
|
||||||
|
assert ep.endpoint_kind == "api"
|
||||||
|
assert ep.model_refresh_mode == "manual"
|
||||||
|
assert ep.supports_tools is False
|
||||||
|
assert json.loads(ep.cached_models) == ["gpt-5.5", "o4-mini"]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_refreshes_existing_auth_session_and_endpoint(monkeypatch):
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5"])
|
||||||
|
|
||||||
|
first = csr._provision_endpoint({"access_token": "OLD", "refresh_token": "OLD-RT"}, "bob")
|
||||||
|
second = csr._provision_endpoint({"access_token": "NEW", "refresh_token": "NEW-RT"}, "bob")
|
||||||
|
|
||||||
|
assert first["id"] == second["id"]
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
auth_rows = db.query(ProviderAuthSession).filter(ProviderAuthSession.owner == "bob").all()
|
||||||
|
ep_rows = db.query(ModelEndpoint).filter(ModelEndpoint.owner == "bob").all()
|
||||||
|
assert len(auth_rows) == 1
|
||||||
|
assert len(ep_rows) == 1
|
||||||
|
assert auth_rows[0].access_token == "NEW"
|
||||||
|
assert auth_rows[0].refresh_token == "NEW-RT"
|
||||||
|
assert ep_rows[0].provider_auth_id == auth_rows[0].id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_rejects_missing_tokens(monkeypatch):
|
||||||
|
_mem_db(monkeypatch)
|
||||||
|
with pytest.raises(ValueError, match="missing access_token or refresh_token"):
|
||||||
|
csr._provision_endpoint({"access_token": "AT"}, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_rejects_accounts_without_usable_models(monkeypatch):
|
||||||
|
_mem_db(monkeypatch)
|
||||||
|
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: [])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="no usable Codex models"):
|
||||||
|
csr._provision_endpoint({"access_token": "AT", "refresh_token": "RT"}, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_auth_and_endpoints(db, *, auth_id="auth1", ep_ids=("ep1",)):
|
||||||
|
db.add(ProviderAuthSession(
|
||||||
|
id=auth_id, provider=csr.chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||||
|
owner="alice", base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
refresh_token="RT", auth_mode="chatgpt",
|
||||||
|
))
|
||||||
|
for ep_id in ep_ids:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id=ep_id, name="ChatGPT Subscription",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
provider_auth_id=auth_id, owner="alice",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_revokes_when_last_endpoint_removed(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1",))
|
||||||
|
# Mirror the production delete route: db.delete(ep) is issued (but not yet
|
||||||
|
# flushed/committed) BEFORE the orphan check runs.
|
||||||
|
ep1 = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep1").first()
|
||||||
|
db.delete(ep1)
|
||||||
|
# ep1 (its only referencing endpoint) is being deleted, so the auth clears.
|
||||||
|
assert _delete_orphaned_provider_auth(db, "auth1", exclude_ep_id="ep1") is True
|
||||||
|
db.commit()
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_requires_exclude_ep_id_for_pending_delete(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1",))
|
||||||
|
ep1 = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep1").first()
|
||||||
|
db.delete(ep1)
|
||||||
|
# Without exclude_ep_id, the un-flushed pending delete leaves ep1 visible
|
||||||
|
# to the reference-count SELECT (autoflush=False), so the helper must
|
||||||
|
# conservatively KEEP the auth row. This is the bug exclude_ep_id fixes.
|
||||||
|
assert _delete_orphaned_provider_auth(db, "auth1") is False
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_keeps_auth_while_another_endpoint_uses_it(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1", "ep2"))
|
||||||
|
# ep2 still references auth1, so deleting ep1 must NOT revoke it.
|
||||||
|
assert _delete_orphaned_provider_auth(db, "auth1", exclude_ep_id="ep1") is False
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_noop_without_auth_id(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
assert _delete_orphaned_provider_auth(db, None, exclude_ep_id="ep1") is False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_noop_when_auth_row_missing(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
# Endpoint points at an auth_id whose ProviderAuthSession is already gone.
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id="ep1", name="ChatGPT Subscription",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
provider_auth_id="ghost", owner="alice",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
ep1 = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep1").first()
|
||||||
|
db.delete(ep1)
|
||||||
|
# No other endpoint references "ghost" and no auth row exists → no-op, no error.
|
||||||
|
assert _delete_orphaned_provider_auth(db, "ghost", exclude_ep_id="ep1") is False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_route(monkeypatch, TestSessionLocal):
|
||||||
|
"""Resolve the real DELETE /model-endpoints/{ep_id} route, wired to the test DB.
|
||||||
|
|
||||||
|
Neutralizes the route's unrelated cleanup side effects (settings/prefs files,
|
||||||
|
in-memory session manager) so the test stays hermetic and focuses on the
|
||||||
|
provider-auth revocation wiring.
|
||||||
|
"""
|
||||||
|
import routes.model_routes as mr
|
||||||
|
import routes.prefs_routes as prefs_routes
|
||||||
|
import src.ai_interaction as ai_interaction
|
||||||
|
|
||||||
|
monkeypatch.setattr(mr, "SessionLocal", TestSessionLocal)
|
||||||
|
monkeypatch.setattr(mr, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(mr, "_load_settings", lambda: {})
|
||||||
|
monkeypatch.setattr(mr, "_save_settings", lambda settings: None)
|
||||||
|
monkeypatch.setattr(prefs_routes, "_load", lambda: {})
|
||||||
|
monkeypatch.setattr(prefs_routes, "_save", lambda prefs: None)
|
||||||
|
monkeypatch.setattr(ai_interaction, "get_session_manager", lambda: None)
|
||||||
|
|
||||||
|
router = mr.setup_model_routes(model_discovery=None)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/model-endpoints/{ep_id}" and "DELETE" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("DELETE /api/model-endpoints/{ep_id} not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_endpoint_route_revokes_orphaned_provider_auth(monkeypatch):
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1",))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
|
||||||
|
result = delete_endpoint("ep1", object())
|
||||||
|
|
||||||
|
assert result["deleted"] is True
|
||||||
|
# The last (only) endpoint backed by auth1 is gone, so the route revokes it.
|
||||||
|
assert result["cleared_provider_auth"] is True
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None
|
||||||
|
assert db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep1").first() is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_endpoint_route_keeps_auth_when_shared(monkeypatch):
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1", "ep2"))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
delete_endpoint = _delete_route(monkeypatch, TestSessionLocal)
|
||||||
|
result = delete_endpoint("ep1", object())
|
||||||
|
|
||||||
|
assert result["deleted"] is True
|
||||||
|
# ep2 still references auth1, so deleting ep1 must NOT revoke the credentials.
|
||||||
|
assert result["cleared_provider_auth"] is False
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphaned_provider_auth_revokes_only_after_last_of_several(monkeypatch):
|
||||||
|
from routes.model_routes import _delete_orphaned_provider_auth
|
||||||
|
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
_add_auth_and_endpoints(db, auth_id="auth1", ep_ids=("ep1", "ep2"))
|
||||||
|
|
||||||
|
# Delete ep1 first: ep2 still references auth1, so the row survives.
|
||||||
|
ep1 = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep1").first()
|
||||||
|
db.delete(ep1)
|
||||||
|
assert _delete_orphaned_provider_auth(db, "auth1", exclude_ep_id="ep1") is False
|
||||||
|
db.commit()
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is not None
|
||||||
|
|
||||||
|
# Now delete the last endpoint ep2: the auth row is finally cleared.
|
||||||
|
ep2 = db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep2").first()
|
||||||
|
db.delete(ep2)
|
||||||
|
assert _delete_orphaned_provider_auth(db, "auth1", exclude_ep_id="ep2") is True
|
||||||
|
db.commit()
|
||||||
|
assert db.query(ProviderAuthSession).filter(ProviderAuthSession.id == "auth1").first() is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -118,10 +118,11 @@ def test_pairing_payload_shape():
|
|||||||
|
|
||||||
@pytest.mark.parametrize("payload", ["[]", '{"users": []}'])
|
@pytest.mark.parametrize("payload", ["[]", '{"users": []}'])
|
||||||
def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload):
|
def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload):
|
||||||
data_dir = tmp_path / "data"
|
auth_file = tmp_path / "auth.json"
|
||||||
data_dir.mkdir()
|
auth_file.write_text(payload)
|
||||||
(data_dir / "auth.json").write_text(payload)
|
# find_admin_user reads the import-time AUTH_FILE constant, so redirect that
|
||||||
monkeypatch.chdir(tmp_path)
|
# rather than relying on cwd.
|
||||||
|
monkeypatch.setattr(P, "AUTH_FILE", str(auth_file))
|
||||||
|
|
||||||
assert P.find_admin_user() is None
|
assert P.find_admin_user() is None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Pin ArrowUp recall on the chat composer (static/js/composerArrowUpRecall.js).
|
||||||
|
|
||||||
|
Driven through `node --input-type=module` so we exercise the real JS without a
|
||||||
|
full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips
|
||||||
|
when `node` is not installed rather than failing.
|
||||||
|
|
||||||
|
Locks in: empty composer recalls last user message; non-empty composer is
|
||||||
|
untouched; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp
|
||||||
|
are ignored; IME composition does not trigger recall; last message is read from
|
||||||
|
#chat-history (dataset.raw), not session sidebar metadata.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HELPER = _REPO / "static" / "js" / "composerArrowUpRecall.js"
|
||||||
|
_HELPER_URL = _HELPER.as_uri()
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
_HARNESS = r"""
|
||||||
|
import { wireArrowUpRecall } from 'HELPER_PATH';
|
||||||
|
|
||||||
|
function makeComposer(initial = '') {
|
||||||
|
const listeners = [];
|
||||||
|
const composer = {
|
||||||
|
value: initial,
|
||||||
|
selectionStart: initial.length,
|
||||||
|
selectionEnd: initial.length,
|
||||||
|
_arrowUpRecallWired: false,
|
||||||
|
addEventListener(type, fn) {
|
||||||
|
if (type === 'keydown') listeners.push(fn);
|
||||||
|
},
|
||||||
|
dispatchKey(opts = {}) {
|
||||||
|
let prevented = false;
|
||||||
|
const e = {
|
||||||
|
key: opts.key ?? 'ArrowUp',
|
||||||
|
shiftKey: !!opts.shiftKey,
|
||||||
|
altKey: !!opts.altKey,
|
||||||
|
ctrlKey: !!opts.ctrlKey,
|
||||||
|
metaKey: !!opts.metaKey,
|
||||||
|
isComposing: !!opts.isComposing,
|
||||||
|
preventDefault() { prevented = true; },
|
||||||
|
};
|
||||||
|
for (const fn of listeners) fn(e);
|
||||||
|
return prevented;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return composer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCase(body) {
|
||||||
|
const composer = makeComposer(body.initial ?? '');
|
||||||
|
if (body.caret != null) {
|
||||||
|
composer.selectionStart = body.caret;
|
||||||
|
composer.selectionEnd = body.caretEnd ?? body.caret;
|
||||||
|
}
|
||||||
|
const last = body.last ?? 'previous message';
|
||||||
|
let resized = false;
|
||||||
|
wireArrowUpRecall(composer, () => last, {
|
||||||
|
autoResize: () => { resized = true; },
|
||||||
|
});
|
||||||
|
const prevented = composer.dispatchKey(body.event ?? {});
|
||||||
|
return {
|
||||||
|
value: composer.value,
|
||||||
|
selectionStart: composer.selectionStart,
|
||||||
|
selectionEnd: composer.selectionEnd,
|
||||||
|
prevented,
|
||||||
|
resized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = CASES_JSON;
|
||||||
|
const results = cases.map(runCase);
|
||||||
|
console.log(JSON.stringify(results));
|
||||||
|
""".replace("HELPER_PATH", _HELPER_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cases: list) -> list:
|
||||||
|
js = _HARNESS.replace("CASES_JSON", json.dumps(cases))
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_empty_composer_recalls_last_user_message():
|
||||||
|
out = _run([{"initial": "", "last": "hello again"}])[0]
|
||||||
|
assert out["value"] == "hello again"
|
||||||
|
assert out["selectionStart"] == len("hello again")
|
||||||
|
assert out["selectionEnd"] == len("hello again")
|
||||||
|
assert out["prevented"] is True
|
||||||
|
assert out["resized"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_non_empty_composer_does_not_recall():
|
||||||
|
out = _run([{"initial": "draft in progress", "last": "ignored"}])[0]
|
||||||
|
assert out["value"] == "draft in progress"
|
||||||
|
assert out["prevented"] is False
|
||||||
|
assert out["resized"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_whitespace_only_composer_is_not_empty():
|
||||||
|
out = _run([{"initial": " ", "last": "ignored"}])[0]
|
||||||
|
assert out["value"] == " "
|
||||||
|
assert out["prevented"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_multiline_caret_navigation_preserved():
|
||||||
|
# Caret on line 2 — ArrowUp must not recall or preventDefault.
|
||||||
|
text = "line one\nline two"
|
||||||
|
out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0]
|
||||||
|
assert out["value"] == text
|
||||||
|
assert out["selectionStart"] == len(text)
|
||||||
|
assert out["prevented"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_modified_arrow_up_ignored():
|
||||||
|
cases = [
|
||||||
|
{"initial": "", "event": {"shiftKey": True}},
|
||||||
|
{"initial": "", "event": {"altKey": True}},
|
||||||
|
{"initial": "", "event": {"ctrlKey": True}},
|
||||||
|
{"initial": "", "event": {"metaKey": True}},
|
||||||
|
]
|
||||||
|
for out in _run(cases):
|
||||||
|
assert out["value"] == ""
|
||||||
|
assert out["prevented"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_ime_composition_does_not_trigger_recall():
|
||||||
|
out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0]
|
||||||
|
assert out["value"] == ""
|
||||||
|
assert out["prevented"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_no_recall_when_last_message_missing():
|
||||||
|
out = _run([{"initial": "", "last": ""}])[0]
|
||||||
|
assert out["value"] == ""
|
||||||
|
assert out["prevented"] is False
|
||||||
|
assert out["resized"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_wire_is_idempotent():
|
||||||
|
js = f"""
|
||||||
|
import {{ wireArrowUpRecall }} from '{_HELPER_URL}';
|
||||||
|
const composer = {{ _arrowUpRecallWired: false, addEventListener() {{}} }};
|
||||||
|
const ok1 = wireArrowUpRecall(composer, () => 'x');
|
||||||
|
const ok2 = wireArrowUpRecall(composer, () => 'y');
|
||||||
|
console.log(JSON.stringify({{ ok1, ok2, wired: composer._arrowUpRecallWired }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
assert json.loads(proc.stdout.strip()) == {"ok1": True, "ok2": True, "wired": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_get_last_user_message_from_chat_history():
|
||||||
|
js = f"""
|
||||||
|
import {{ getLastUserMessageFromChatHistory }} from '{_HELPER_URL}';
|
||||||
|
|
||||||
|
const chatBox = {{
|
||||||
|
id: 'chat-history',
|
||||||
|
querySelectorAll(sel) {{
|
||||||
|
if (sel !== '.msg-user') return [];
|
||||||
|
return [
|
||||||
|
{{ dataset: {{ raw: 'first' }}, querySelector: () => null }},
|
||||||
|
{{ dataset: {{ raw: 'last raw' }}, querySelector: () => null }},
|
||||||
|
];
|
||||||
|
}},
|
||||||
|
}};
|
||||||
|
|
||||||
|
const doc = {{
|
||||||
|
getElementById(id) {{ return id === 'chat-history' ? chatBox : null; }},
|
||||||
|
}};
|
||||||
|
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
fromChat: getLastUserMessageFromChatHistory(doc),
|
||||||
|
fromBox: getLastUserMessageFromChatHistory(chatBox),
|
||||||
|
empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}),
|
||||||
|
noUsers: getLastUserMessageFromChatHistory({{
|
||||||
|
getElementById: () => ({{ querySelectorAll: () => [] }}),
|
||||||
|
}}),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
assert json.loads(proc.stdout.strip()) == {
|
||||||
|
"fromChat": "last raw",
|
||||||
|
"fromBox": "last raw",
|
||||||
|
"empty": "",
|
||||||
|
"noUsers": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_integration_recalls_from_chat_history_dom():
|
||||||
|
js = f"""
|
||||||
|
import {{
|
||||||
|
wireArrowUpRecall,
|
||||||
|
getLastUserMessageFromChatHistory,
|
||||||
|
}} from '{_HELPER_URL}';
|
||||||
|
|
||||||
|
const chatBox = {{
|
||||||
|
id: 'chat-history',
|
||||||
|
querySelectorAll(sel) {{
|
||||||
|
if (sel !== '.msg-user') return [];
|
||||||
|
return [{{ dataset: {{ raw: 'stored prompt' }}, querySelector: () => null }}];
|
||||||
|
}},
|
||||||
|
}};
|
||||||
|
const doc = {{ getElementById: (id) => (id === 'chat-history' ? chatBox : null) }};
|
||||||
|
|
||||||
|
const listeners = [];
|
||||||
|
const composer = {{
|
||||||
|
value: '',
|
||||||
|
selectionStart: 0,
|
||||||
|
selectionEnd: 0,
|
||||||
|
_arrowUpRecallWired: false,
|
||||||
|
addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }},
|
||||||
|
}};
|
||||||
|
wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(doc));
|
||||||
|
let prevented = false;
|
||||||
|
listeners[0]({{
|
||||||
|
key: 'ArrowUp',
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
isComposing: false,
|
||||||
|
preventDefault() {{ prevented = true; }},
|
||||||
|
}});
|
||||||
|
console.log(JSON.stringify({{ value: composer.value, prevented }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Guard: cookbook_state.json must be located via DATA_DIR, not hardcoded /app/data
|
|
||||||
(which breaks native runs) or a relative os.environ fallback."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
||||||
FILES = [
|
|
||||||
"src/cookbook_serve_lifecycle.py",
|
|
||||||
"src/builtin_actions.py",
|
|
||||||
"routes/codex_routes.py",
|
|
||||||
"routes/cookbook_routes.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_hardcoded_app_data_cookbook_state():
|
|
||||||
for rel in FILES:
|
|
||||||
text = (ROOT / rel).read_text(encoding="utf-8")
|
|
||||||
for ln in text.splitlines():
|
|
||||||
if ln.strip().startswith("#"):
|
|
||||||
continue
|
|
||||||
assert "/app/data/cookbook_state" not in ln, f"{rel}: hardcoded /app/data: {ln.strip()}"
|
|
||||||
assert 'os.environ.get("DATA_DIR"' not in ln, f"{rel}: relative DATA_DIR env fallback: {ln.strip()}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cookbook_state_uses_datadir_constant():
|
|
||||||
# Each file that references cookbook_state.json should import the DATA_DIR constant.
|
|
||||||
for rel in FILES:
|
|
||||||
text = (ROOT / rel).read_text(encoding="utf-8")
|
|
||||||
if "cookbook_state.json" in text:
|
|
||||||
assert "from core.constants import DATA_DIR" in text, f"{rel}: missing DATA_DIR import"
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Shared device-flow route helper regressions."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from routes import device_flow
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch, now_ref, start_flow, poll_flow):
|
||||||
|
store = device_flow.PendingDeviceFlowStore(time_func=lambda: now_ref[0])
|
||||||
|
router = device_flow.create_device_flow_router(
|
||||||
|
prefix="/api/test-device",
|
||||||
|
tags=["test-device"],
|
||||||
|
store=store,
|
||||||
|
start_flow=start_flow,
|
||||||
|
poll_flow=poll_flow,
|
||||||
|
)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
monkeypatch.setattr(device_flow, "require_admin", lambda request: None)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _start(_request, _form):
|
||||||
|
return device_flow.DeviceFlowStart(
|
||||||
|
pending={"secret": "server-only", "owner": "alice"},
|
||||||
|
response={"user_code": "ABCD-EFGH", "verification_uri": "https://example.test/device"},
|
||||||
|
interval=5,
|
||||||
|
expires_in=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_poll_is_throttled_until_interval(monkeypatch):
|
||||||
|
now = [100.0]
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def poll(_request, pending):
|
||||||
|
calls.append(dict(pending))
|
||||||
|
return device_flow.DeviceFlowPoll.pending()
|
||||||
|
|
||||||
|
client = _client(monkeypatch, now, _start, poll)
|
||||||
|
start = client.post("/api/test-device/device/start").json()
|
||||||
|
|
||||||
|
first = client.post("/api/test-device/device/poll", data={"poll_id": start["poll_id"]})
|
||||||
|
assert first.json() == {"status": "pending"}
|
||||||
|
assert calls == [{"secret": "server-only", "owner": "alice"}]
|
||||||
|
|
||||||
|
second = client.post("/api/test-device/device/poll", data={"poll_id": start["poll_id"]})
|
||||||
|
assert second.json() == {"status": "pending"}
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
now[0] += 5
|
||||||
|
third = client.post("/api/test-device/device/poll", data={"poll_id": start["poll_id"]})
|
||||||
|
assert third.json() == {"status": "pending"}
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_down_updates_poll_interval(monkeypatch):
|
||||||
|
now = [100.0]
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def poll(_request, _pending):
|
||||||
|
calls.append(now[0])
|
||||||
|
if len(calls) == 1:
|
||||||
|
return device_flow.DeviceFlowPoll.slow_down(interval=10)
|
||||||
|
return device_flow.DeviceFlowPoll.authorized({"id": "ep1", "models": ["gpt-4o"]})
|
||||||
|
|
||||||
|
client = _client(monkeypatch, now, _start, poll)
|
||||||
|
poll_id = client.post("/api/test-device/device/start").json()["poll_id"]
|
||||||
|
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": poll_id}).json() == {"status": "pending"}
|
||||||
|
now[0] += 9
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": poll_id}).json() == {"status": "pending"}
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
now[0] += 1
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": poll_id}).json() == {
|
||||||
|
"status": "authorized",
|
||||||
|
"endpoint": {"id": "ep1", "models": ["gpt-4o"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorized_and_failed_polls_remove_pending_session(monkeypatch):
|
||||||
|
now = [100.0]
|
||||||
|
outcomes = [
|
||||||
|
device_flow.DeviceFlowPoll.authorized({"id": "ep1"}),
|
||||||
|
device_flow.DeviceFlowPoll.failed("access_denied"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def poll(_request, _pending):
|
||||||
|
return outcomes.pop(0)
|
||||||
|
|
||||||
|
client = _client(monkeypatch, now, _start, poll)
|
||||||
|
first = client.post("/api/test-device/device/start").json()["poll_id"]
|
||||||
|
second = client.post("/api/test-device/device/start").json()["poll_id"]
|
||||||
|
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": first}).json()["status"] == "authorized"
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": first}).status_code == 404
|
||||||
|
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": second}).json() == {
|
||||||
|
"status": "failed",
|
||||||
|
"error": "access_denied",
|
||||||
|
}
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": second}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_and_expiry_remove_pending_session(monkeypatch):
|
||||||
|
now = [100.0]
|
||||||
|
|
||||||
|
def poll(_request, _pending):
|
||||||
|
return device_flow.DeviceFlowPoll.pending()
|
||||||
|
|
||||||
|
client = _client(monkeypatch, now, _start, poll)
|
||||||
|
cancelled = client.post("/api/test-device/device/start").json()["poll_id"]
|
||||||
|
assert client.post("/api/test-device/device/cancel", data={"poll_id": cancelled}).json() == {"status": "cancelled"}
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": cancelled}).status_code == 404
|
||||||
|
|
||||||
|
expired = client.post("/api/test-device/device/start").json()["poll_id"]
|
||||||
|
now[0] += 21
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": expired}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_are_admin_gated(monkeypatch):
|
||||||
|
now = [100.0]
|
||||||
|
|
||||||
|
def poll(_request, _pending):
|
||||||
|
return device_flow.DeviceFlowPoll.pending()
|
||||||
|
|
||||||
|
client = _client(monkeypatch, now, _start, poll)
|
||||||
|
|
||||||
|
def deny(_request):
|
||||||
|
raise HTTPException(403, "admin required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(device_flow, "require_admin", deny)
|
||||||
|
assert client.post("/api/test-device/device/start").status_code == 403
|
||||||
|
assert client.post("/api/test-device/device/poll", data={"poll_id": "missing"}).status_code == 403
|
||||||
|
assert client.post("/api/test-device/device/cancel", data={"poll_id": "missing"}).status_code == 403
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Regression tests for endpoint owner scoping in secondary model routes."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_request(user="alice", is_admin=False):
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=user),
|
||||||
|
app=SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
auth_manager=SimpleNamespace(is_admin=lambda u: is_admin)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_start_route(session_manager):
|
||||||
|
from routes.compare_routes import setup_compare_routes
|
||||||
|
|
||||||
|
router = setup_compare_routes(session_manager)
|
||||||
|
# setup_compare_routes registers on a module-global router, so each call
|
||||||
|
# appends another /start route; take the most recently registered one so we
|
||||||
|
# get the handler bound to *this* session_manager.
|
||||||
|
return [
|
||||||
|
r.endpoint for r in router.routes
|
||||||
|
if getattr(r, "path", "") == "/api/compare/start"
|
||||||
|
][-1]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""The endpoint lookup is patched, so only the trailing Comparison insert
|
||||||
|
touches this — swallow add/commit/close so the test never hits a real DB."""
|
||||||
|
|
||||||
|
def add(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionStore:
|
||||||
|
def __init__(self, store):
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._store.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_rejects_unregistered_endpoint_for_non_admin(monkeypatch):
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
# Nothing visible to the caller matches the supplied URL → raw, unregistered.
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", lambda *a, **k: None)
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=lambda **_: None, sessions={})
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="http://127.0.0.1:8000/v1",
|
||||||
|
endpoint_b="http://127.0.0.1:8001/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_allows_owned_registered_endpoint_for_non_admin(monkeypatch):
|
||||||
|
# Regression: the followup must not blanket-reject non-admins. Compare
|
||||||
|
# resolves endpoints by URL (no endpoint_id), so a caller comparing a
|
||||||
|
# registered endpoint they own has to be allowed — only truly raw,
|
||||||
|
# unregistered URLs are rejected.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
owned = SimpleNamespace(id=7, api_key="sk-secret", base_url="http://127.0.0.1:8000/v1")
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", lambda *a, **k: owned)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **_):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
# Must complete without raising 403.
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="http://127.0.0.1:8000/v1",
|
||||||
|
endpoint_b="http://127.0.0.1:8000/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both [CMP] sessions created, each with the owned endpoint's key copied in.
|
||||||
|
assert len(created) == 2
|
||||||
|
for s in created.values():
|
||||||
|
assert s.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_rejects_another_users_private_endpoint(monkeypatch):
|
||||||
|
# bob owns the endpoint at this URL; alice supplying the same URL gets no
|
||||||
|
# match from the owner-scoped lookup (owner_filter drops bob's private row),
|
||||||
|
# so compare treats it exactly like a raw unregistered URL → 403. She can
|
||||||
|
# neither bind a session to his endpoint nor copy his key.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
|
||||||
|
def _scoped(db, base, owner):
|
||||||
|
# Only the owner ("bob") can see this private row; everyone else → None.
|
||||||
|
if owner == "bob":
|
||||||
|
return SimpleNamespace(id=9, api_key="sk-bob", base_url=base)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", _scoped)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **_):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
start(
|
||||||
|
_compare_request(user="alice"),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="http://10.0.0.5:9000/v1",
|
||||||
|
endpoint_b="http://10.0.0.5:9000/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
# Nothing was created → no session bound to bob's endpoint, no key copied.
|
||||||
|
assert created == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_rejects_before_creating_any_session_on_mixed_endpoints(monkeypatch):
|
||||||
|
# Mixed request: endpoint A is a registered endpoint the caller owns,
|
||||||
|
# endpoint B is a raw/unregistered URL. Both endpoints are resolved and
|
||||||
|
# validated up front, so the unregistered B makes the WHOLE request 403 with
|
||||||
|
# nothing created — no half-built [CMP] session for A, and therefore none of
|
||||||
|
# A's Authorization header left behind. Fails on the old interleaved loop
|
||||||
|
# that created A's session before reaching (and rejecting) B.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
from src.endpoint_resolver import normalize_base
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
owned = SimpleNamespace(id=7, api_key="sk-secret", base_url="http://127.0.0.1:8000/v1")
|
||||||
|
owned_base = normalize_base(owned.base_url)
|
||||||
|
|
||||||
|
def _scoped(db, base, owner):
|
||||||
|
# Only endpoint A's URL maps to a visible registered endpoint; B → None.
|
||||||
|
return owned if base == owned_base else None
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", _scoped)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **kw):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="http://127.0.0.1:8000/v1", # owned, registered
|
||||||
|
endpoint_b="http://203.0.113.9:9999/v1", # raw, unregistered
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
# No partial session survives the reject, so no copied header does either.
|
||||||
|
assert created == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_binds_session_to_registered_endpoint_url(monkeypatch):
|
||||||
|
# The session must dial the registered endpoint's OWN normalized base URL,
|
||||||
|
# never the raw caller-supplied string. Mint the owned row with a base URL
|
||||||
|
# that differs from the messy raw input so a regression to `endpoint_url=
|
||||||
|
# endpoint` would surface here.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
from src.endpoint_resolver import build_chat_url, normalize_base
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
owned = SimpleNamespace(id=7, api_key="sk-secret", base_url="http://127.0.0.1:8000/v1")
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", lambda *a, **k: owned)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **kw):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
captured[session_id] = kw
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
raw_url = "http://127.0.0.1:8000/v1/" # trailing slash → not byte-identical
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a=raw_url,
|
||||||
|
endpoint_b=raw_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = build_chat_url(normalize_base(owned.base_url))
|
||||||
|
assert captured and all(kw["endpoint_url"] == expected for kw in captured.values())
|
||||||
|
# The owned endpoint's key is copied into each session's headers.
|
||||||
|
for s in created.values():
|
||||||
|
assert s.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_admin_raw_endpoint_carries_no_borrowed_key(monkeypatch):
|
||||||
|
# Explicit admin/raw-endpoint behavior: an admin may pass a raw URL that
|
||||||
|
# matches no registered endpoint. It is allowed (the reject helper is a
|
||||||
|
# no-op for admins), the session keeps the raw URL, and — because nothing
|
||||||
|
# matched — no key/headers are inherited from any endpoint row.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", lambda *a, **k: None)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **kw):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
captured[session_id] = kw
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
raw_url = "http://198.51.100.7:1234/v1"
|
||||||
|
start(
|
||||||
|
_compare_request(user="root", is_admin=True),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a=raw_url,
|
||||||
|
endpoint_b=raw_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(created) == 2
|
||||||
|
for kw in captured.values():
|
||||||
|
assert kw["endpoint_url"] == raw_url # raw URL preserved for admins
|
||||||
|
for s in created.values():
|
||||||
|
assert s.headers == {} # no borrowed key/headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_prefers_endpoint_id_over_url(monkeypatch):
|
||||||
|
# Two endpoints visible to the caller share a base_url but hold DIFFERENT
|
||||||
|
# api_keys (e.g. two accounts on one provider). A base_url-only match returns
|
||||||
|
# whichever row sorts first, so it can copy the WRONG key. Passing the
|
||||||
|
# explicit id must pin the intended endpoint and copy ITS key.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
|
||||||
|
url = "http://127.0.0.1:8000/v1"
|
||||||
|
by_url = SimpleNamespace(id=1, api_key="sk-first", base_url=url) # URL match
|
||||||
|
by_id = SimpleNamespace(id=2, api_key="sk-second", base_url=url) # id match
|
||||||
|
|
||||||
|
# URL resolution would return the WRONG row; the id resolves the intended one.
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_url", lambda *a, **k: by_url)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cr, "_owned_endpoint_by_id", lambda db, eid, owner: by_id if eid == "2" else None
|
||||||
|
)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **kw):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
captured[session_id] = kw
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="",
|
||||||
|
endpoint_b="",
|
||||||
|
endpoint_a_id="2",
|
||||||
|
endpoint_b_id="2",
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_url = build_chat_url(normalize_base(url))
|
||||||
|
expected_headers = build_headers("sk-second", url)
|
||||||
|
assert captured and all(kw["endpoint_url"] == expected_url for kw in captured.values())
|
||||||
|
# The id's key is copied in, NOT the same-URL row's key.
|
||||||
|
for s in created.values():
|
||||||
|
assert s.headers == expected_headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_rejects_unowned_endpoint_id(monkeypatch):
|
||||||
|
# An id the caller can't see (wrong owner / deleted) must 404 and must NOT
|
||||||
|
# silently fall back to a same-URL row with a different key.
|
||||||
|
import routes.compare_routes as cr
|
||||||
|
|
||||||
|
monkeypatch.setattr(cr, "SessionLocal", lambda: _FakeDB())
|
||||||
|
# A same-URL row exists and would resolve, but the governing id is invisible.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cr,
|
||||||
|
"_owned_endpoint_by_url",
|
||||||
|
lambda *a, **k: SimpleNamespace(id=1, api_key="sk", base_url="http://127.0.0.1:8000/v1"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cr, "_owned_endpoint_by_id", lambda *a, **k: None)
|
||||||
|
|
||||||
|
created = {}
|
||||||
|
|
||||||
|
def _create_session(session_id, **_):
|
||||||
|
created[session_id] = SimpleNamespace(headers={})
|
||||||
|
|
||||||
|
start = _compare_start_route(
|
||||||
|
SimpleNamespace(create_session=_create_session, sessions=_SessionStore(created))
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
start(
|
||||||
|
_compare_request(),
|
||||||
|
prompt="p",
|
||||||
|
model_a="a",
|
||||||
|
model_b="b",
|
||||||
|
endpoint_a="",
|
||||||
|
endpoint_b="",
|
||||||
|
endpoint_a_id="999",
|
||||||
|
endpoint_b_id="999",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
assert created == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_endpoint_key_lookup_is_owner_scoped():
|
||||||
|
body = Path("routes/compare_routes.py").read_text(encoding="utf-8")
|
||||||
|
start_body = body.split("def start_comparison", 1)[1].split("# Store comparison record", 1)[0]
|
||||||
|
helper_body = body.split("def _owned_endpoint_by_url", 1)[1].split("class RecordVoteRequest", 1)[0]
|
||||||
|
id_helper_body = body.split("def _owned_endpoint_by_id", 1)[1].split("class RecordVoteRequest", 1)[0]
|
||||||
|
|
||||||
|
assert "_reject_raw_endpoint_url_for_non_admin" in start_body
|
||||||
|
assert "_owned_endpoint_by_url(db, base, user)" in start_body
|
||||||
|
# Credentials prefer an explicit endpoint id (pins the exact key) and only
|
||||||
|
# fall back to URL matching for legacy / admin raw-URL callers.
|
||||||
|
assert "_owned_endpoint_by_id(db, eid, user)" in start_body
|
||||||
|
# The session binds to the resolved endpoint's stored base URL, not the raw
|
||||||
|
# caller-supplied string (the reviewer's remaining compare blocker).
|
||||||
|
assert "build_chat_url(normalize_base(ep.base_url))" in start_body
|
||||||
|
assert "owner_filter(q, ModelEndpoint, owner)" in helper_body
|
||||||
|
# The id lookup is owner-scoped the same way the URL lookup is.
|
||||||
|
assert "owner_filter(q, ModelEndpoint, owner)" in id_helper_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_image_endpoint_lookups_are_owner_scoped():
|
||||||
|
body = Path("routes/gallery_routes.py").read_text(encoding="utf-8")
|
||||||
|
helper_body = body.split("def _visible_image_endpoint_query", 1)[1].split(
|
||||||
|
"def _first_visible_image_endpoint", 1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
assert "owner_filter(q, ModelEndpoint, owner)" in helper_body
|
||||||
|
assert body.count("_first_visible_image_endpoint(db, user)") >= 4
|
||||||
|
assert body.count("_visible_image_endpoint_for_base(db,") >= 2
|
||||||
|
assert "def _current_user_is_admin" in body
|
||||||
|
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 2
|
||||||
|
for marker in (
|
||||||
|
"async def gallery_ai_upscale",
|
||||||
|
"async def gallery_style_transfer",
|
||||||
|
"async def inpaint_proxy",
|
||||||
|
"async def harmonize_image",
|
||||||
|
):
|
||||||
|
section = body.split(marker, 1)[1].split("@router.", 1)[0]
|
||||||
|
assert "user = require_privilege(request, \"can_generate_images\")" in section
|
||||||
|
assert (
|
||||||
|
"_first_visible_image_endpoint(db, user)" in section
|
||||||
|
or "_visible_image_endpoint_for_base(db," in section
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_endpoint_resolution_passes_owner():
|
||||||
|
body = Path("routes/research_routes.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "def _resolve_research_endpoint(sess, owner:" in body
|
||||||
|
assert 'resolve_endpoint("research", owner=user)' in body
|
||||||
|
assert 'resolve_endpoint("utility", owner=user)' in body
|
||||||
|
assert 'resolve_endpoint("default", owner=user)' in body
|
||||||
|
assert 'resolve_endpoint("chat", owner=user)' in body
|
||||||
|
helper_body = body.split("def _owned_enabled_endpoint", 1)[1].split("def setup_research_routes", 1)[0]
|
||||||
|
assert "owner_filter(q, ModelEndpoint, owner)" in helper_body
|
||||||
|
assert body.count("_owned_enabled_endpoint(db, user") >= 2
|
||||||
@@ -25,32 +25,36 @@ from unittest.mock import MagicMock
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules
|
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
|
||||||
|
|
||||||
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
|
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
|
||||||
# during collection, drop the stub so the real URL helpers load here.
|
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
|
||||||
clear_fake_endpoint_resolver_modules()
|
# during collection, drop the stub so the real URL helpers load here.
|
||||||
|
clear_fake_endpoint_resolver_modules()
|
||||||
|
|
||||||
if "core.database" not in sys.modules:
|
if "core.database" not in sys.modules:
|
||||||
_core_db = types.ModuleType("core.database")
|
_core_db = types.ModuleType("core.database")
|
||||||
for _name in [
|
for _name in [
|
||||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||||
]:
|
"ProviderAuthSession", "Base",
|
||||||
setattr(_core_db, _name, MagicMock())
|
]:
|
||||||
sys.modules["core.database"] = _core_db
|
setattr(_core_db, _name, MagicMock())
|
||||||
|
_core_db.utcnow_naive = MagicMock()
|
||||||
|
sys.modules["core.database"] = _core_db
|
||||||
|
|
||||||
import routes.model_routes as model_routes
|
import routes.model_routes as model_routes
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
from routes.model_routes import (
|
from routes.model_routes import (
|
||||||
_probe_endpoint,
|
_probe_endpoint,
|
||||||
_ping_endpoint,
|
_ping_endpoint,
|
||||||
_probe_single_model,
|
_probe_single_model,
|
||||||
_classify_endpoint,
|
_resolve_probe_key,
|
||||||
_rewrite_loopback_for_docker,
|
_classify_endpoint,
|
||||||
_PROVIDER_CURATED,
|
_rewrite_loopback_for_docker,
|
||||||
)
|
_PROVIDER_CURATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _patch_resolve(monkeypatch):
|
def _patch_resolve(monkeypatch):
|
||||||
@@ -117,6 +121,26 @@ class TestProbeEndpointParsing:
|
|||||||
)
|
)
|
||||||
assert _probe_endpoint("https://api.example.com/v1") == []
|
assert _probe_endpoint("https://api.example.com/v1") == []
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_fetch(access_token, timeout=5):
|
||||||
|
calls.append((access_token, timeout))
|
||||||
|
return ["gpt-5.5"]
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", fake_fetch)
|
||||||
|
|
||||||
|
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS", timeout=7) == ["gpt-5.5"]
|
||||||
|
assert calls == [("ACCESS", 7)]
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_probe_without_discovery_returns_empty(self, monkeypatch):
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", lambda access_token, timeout=5: [])
|
||||||
|
|
||||||
|
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS") == []
|
||||||
|
assert _probe_endpoint("https://chatgpt.com/backend-api/codex") == []
|
||||||
|
|
||||||
|
|
||||||
# ── _ping_endpoint: reachability classification ──
|
# ── _ping_endpoint: reachability classification ──
|
||||||
|
|
||||||
@@ -321,6 +345,51 @@ class TestProbeSingleModel:
|
|||||||
_probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5", with_tools=True)
|
_probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5", with_tools=True)
|
||||||
assert "input_schema" in captured["payload"]["tools"][0]
|
assert "input_schema" in captured["payload"]["tools"][0]
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_skips_completion_probe(self, monkeypatch):
|
||||||
|
# This provider speaks the Responses/Codex API. A chat-completions probe
|
||||||
|
# would 400 and (via the re-probe flow) hide every model, so it must be
|
||||||
|
# short-circuited as discovery-only without any HTTP call.
|
||||||
|
_patch_resolve(monkeypatch)
|
||||||
|
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise AssertionError("must not send a completion probe for chatgpt-subscription")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes.httpx, "post", boom)
|
||||||
|
result = _probe_single_model("https://chatgpt.com/backend-api/codex", None, "gpt-5.1-codex")
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert result.get("skipped") is True
|
||||||
|
# Pin the full documented return shape — downstream JSON/UI reads latency_ms.
|
||||||
|
assert result["latency_ms"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── _resolve_probe_key: static key vs provider-auth runtime token ──
|
||||||
|
|
||||||
|
class TestResolveProbeKey:
|
||||||
|
def test_static_endpoint_uses_api_key(self):
|
||||||
|
ep = types.SimpleNamespace(id="e1", api_key="sk-static", provider_auth_id=None, owner=None)
|
||||||
|
assert _resolve_probe_key(ep) == "sk-static"
|
||||||
|
|
||||||
|
def test_provider_auth_endpoint_resolves_runtime_token(self, monkeypatch):
|
||||||
|
ep = types.SimpleNamespace(id="e2", api_key=None, provider_auth_id="auth123", owner="alice")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_runtime(endpoint, owner=None):
|
||||||
|
seen["owner"] = owner
|
||||||
|
return ("https://chatgpt.com/backend-api/codex", "live-bearer")
|
||||||
|
|
||||||
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime)
|
||||||
|
assert _resolve_probe_key(ep) == "live-bearer"
|
||||||
|
assert seen["owner"] == "alice"
|
||||||
|
|
||||||
|
def test_provider_auth_resolution_failure_returns_none(self, monkeypatch):
|
||||||
|
ep = types.SimpleNamespace(id="e3", api_key=None, provider_auth_id="auth123", owner=None)
|
||||||
|
|
||||||
|
def boom(endpoint, owner=None):
|
||||||
|
raise RuntimeError("reauth required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", boom)
|
||||||
|
assert _resolve_probe_key(ep) is None
|
||||||
|
|
||||||
|
|
||||||
# ── _classify_endpoint: Tailscale CGNAT range ──
|
# ── _classify_endpoint: Tailscale CGNAT range ──
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ def test_image_generation_endpoints_require_image_privilege():
|
|||||||
|
|
||||||
|
|
||||||
def test_gallery_routes_imports_privilege_helper():
|
def test_gallery_routes_imports_privilege_helper():
|
||||||
assert "from src.auth_helpers import get_current_user, require_privilege" in _gallery_source()
|
source = _gallery_source()
|
||||||
|
assert "get_current_user" in source
|
||||||
|
assert "require_privilege" in source
|
||||||
|
|||||||
@@ -75,6 +75,28 @@ def test_normal_model_payload_keeps_temperature_above_one(monkeypatch):
|
|||||||
assert payload["temperature"] == 1.2
|
assert payload["temperature"] == 1.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_payload_uses_max_output_tokens():
|
||||||
|
payload = llm_core._build_chatgpt_responses_payload(
|
||||||
|
"gpt-5.1-codex",
|
||||||
|
[{"role": "user", "content": "Say OK"}],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=37,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["max_output_tokens"] == 37
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_payload_omits_empty_max_output_tokens():
|
||||||
|
payload = llm_core._build_chatgpt_responses_payload(
|
||||||
|
"gpt-5.1-codex",
|
||||||
|
[{"role": "user", "content": "Say OK"}],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "max_output_tokens" not in payload
|
||||||
|
|
||||||
|
|
||||||
def _anthropic_payload(temperature):
|
def _anthropic_payload(temperature):
|
||||||
return llm_core._build_anthropic_payload(
|
return llm_core._build_anthropic_payload(
|
||||||
"claude-3-5-sonnet",
|
"claude-3-5-sonnet",
|
||||||
|
|||||||
+92
-42
@@ -11,49 +11,51 @@ from types import SimpleNamespace
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules
|
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
|
||||||
|
|
||||||
# Other tests stub this module during collection. These helper tests need
|
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
|
||||||
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
# Other tests stub this module during collection. These helper tests need
|
||||||
clear_fake_endpoint_resolver_modules()
|
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
||||||
|
clear_fake_endpoint_resolver_modules()
|
||||||
|
|
||||||
if "core.database" not in sys.modules:
|
if "core.database" not in sys.modules:
|
||||||
_core_db = types.ModuleType("core.database")
|
_core_db = types.ModuleType("core.database")
|
||||||
for _name in [
|
for _name in [
|
||||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun",
|
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun",
|
||||||
"McpServer",
|
"McpServer", "ProviderAuthSession", "Base",
|
||||||
]:
|
]:
|
||||||
setattr(_core_db, _name, MagicMock())
|
setattr(_core_db, _name, MagicMock())
|
||||||
sys.modules["core.database"] = _core_db
|
_core_db.utcnow_naive = MagicMock()
|
||||||
|
sys.modules["core.database"] = _core_db
|
||||||
|
|
||||||
import routes.model_routes as model_routes
|
import routes.model_routes as model_routes
|
||||||
import src.database as src_database
|
import src.database as src_database
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
import src.llm_core as llm_core
|
import src.llm_core as llm_core
|
||||||
from routes.model_routes import (
|
from routes.model_routes import (
|
||||||
_match_provider_curated,
|
_match_provider_curated,
|
||||||
_curate_models,
|
_curate_models,
|
||||||
_visible_models,
|
_visible_models,
|
||||||
_normalize_model_ids,
|
_normalize_model_ids,
|
||||||
_api_key_fingerprint,
|
_api_key_fingerprint,
|
||||||
_is_chat_model,
|
_is_chat_model,
|
||||||
_classify_endpoint,
|
_classify_endpoint,
|
||||||
_effective_endpoint_kind,
|
_effective_endpoint_kind,
|
||||||
_probe_endpoint,
|
_probe_endpoint,
|
||||||
_ping_endpoint,
|
_ping_endpoint,
|
||||||
_parse_model_list,
|
_parse_model_list,
|
||||||
_normalize_refresh_mode,
|
_normalize_refresh_mode,
|
||||||
_truthy,
|
_truthy,
|
||||||
_speech_settings_using_endpoint,
|
_speech_settings_using_endpoint,
|
||||||
_clear_speech_settings_for_endpoint,
|
_clear_speech_settings_for_endpoint,
|
||||||
_endpoint_settings_using_endpoint,
|
_endpoint_settings_using_endpoint,
|
||||||
_clear_endpoint_settings_for_endpoint,
|
_clear_endpoint_settings_for_endpoint,
|
||||||
_clear_user_pref_endpoint_refs,
|
_clear_user_pref_endpoint_refs,
|
||||||
_PROVIDER_CURATED,
|
_PROVIDER_CURATED,
|
||||||
)
|
)
|
||||||
from src.llm_core import ANTHROPIC_MODELS
|
from src.llm_core import ANTHROPIC_MODELS
|
||||||
|
|
||||||
|
|
||||||
# ── speech endpoint settings ──
|
# ── speech endpoint settings ──
|
||||||
@@ -687,8 +689,7 @@ class _PinnedFakeRequest:
|
|||||||
|
|
||||||
|
|
||||||
def _get_route(path, method):
|
def _get_route(path, method):
|
||||||
from routes.model_routes import setup_model_routes
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
router = setup_model_routes(model_discovery=None)
|
|
||||||
for route in router.routes:
|
for route in router.routes:
|
||||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||||
return route.endpoint
|
return route.endpoint
|
||||||
@@ -787,6 +788,55 @@ def test_reprobe_preserves_pinned_models(monkeypatch):
|
|||||||
assert json.loads(ep.cached_models) == ["m1"]
|
assert json.loads(ep.cached_models) == ["m1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reprobe_chatgpt_subscription_does_not_hide_models(monkeypatch):
|
||||||
|
# The whole point of the _probe_single_model short-circuit is that re-probing
|
||||||
|
# a chatgpt-subscription endpoint must NOT mark every (un-probeable) model as
|
||||||
|
# failed and write them all into hidden_models. Assert that end-to-end at the
|
||||||
|
# route level, with the REAL _probe_single_model doing the skip.
|
||||||
|
ep = _make_endpoint(
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
api_key=None,
|
||||||
|
hidden_models=json.dumps(["stale-hidden"]),
|
||||||
|
)
|
||||||
|
db = _PinnedFakeDb([ep])
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: ["gpt-5.1-codex", "gpt-5.1"])
|
||||||
|
monkeypatch.setattr(model_routes, "_is_chat_model", lambda m: True)
|
||||||
|
# Any completion probe would be a bug for this provider.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_routes.httpx, "post",
|
||||||
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not probe chatgpt-subscription")),
|
||||||
|
)
|
||||||
|
endpoint = _get_route("/api/model-endpoints/{ep_id}/probe", "GET")
|
||||||
|
|
||||||
|
response = endpoint("ep1", _PinnedFakeRequest())
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
async def _drain():
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
||||||
|
|
||||||
|
asyncio.run(_drain())
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for chunk in chunks:
|
||||||
|
for line in chunk.splitlines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
events.append(json.loads(line[len("data: "):]))
|
||||||
|
|
||||||
|
done = next(e for e in events if e.get("type") == "probe_done")
|
||||||
|
results = [e for e in events if e.get("type") == "probe_result"]
|
||||||
|
|
||||||
|
# Every model was skipped as ok; none failed → nothing hidden.
|
||||||
|
assert done["hidden"] == 0
|
||||||
|
assert done["ok"] == len(results) == 2
|
||||||
|
assert all(r["status"] == "ok" and r.get("skipped") is True for r in results)
|
||||||
|
# The stale hidden_models is cleared, not repopulated with every model.
|
||||||
|
assert ep.hidden_models is None
|
||||||
|
|
||||||
|
|
||||||
def test_visible_models_handles_malformed_strings():
|
def test_visible_models_handles_malformed_strings():
|
||||||
# Non-JSON cached/pinned strings are treated as comma/newline lists and
|
# Non-JSON cached/pinned strings are treated as comma/newline lists and
|
||||||
# never raise; a malformed hidden string is normalized too.
|
# never raise; a malformed hidden string is normalized too.
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class TestHostMatch:
|
|||||||
|
|
||||||
|
|
||||||
class TestDetectProviderRealHosts:
|
class TestDetectProviderRealHosts:
|
||||||
|
def test_chatgpt_subscription_codex_backend(self):
|
||||||
|
assert llm_core._detect_provider("https://chatgpt.com/backend-api/codex") == "chatgpt-subscription"
|
||||||
|
assert llm_core._detect_provider("https://chatgpt.com/backend-api/codex/responses") == "chatgpt-subscription"
|
||||||
|
|
||||||
def test_anthropic(self):
|
def test_anthropic(self):
|
||||||
assert llm_core._detect_provider("https://api.anthropic.com") == "anthropic"
|
assert llm_core._detect_provider("https://api.anthropic.com") == "anthropic"
|
||||||
|
|
||||||
@@ -93,6 +97,12 @@ class TestBuildersRejectLookalikeHosts:
|
|||||||
def test_real_anthropic_chat(self):
|
def test_real_anthropic_chat(self):
|
||||||
assert build_chat_url("https://api.anthropic.com") == "https://api.anthropic.com/v1/messages"
|
assert build_chat_url("https://api.anthropic.com") == "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_chat_uses_responses(self):
|
||||||
|
assert build_chat_url("https://chatgpt.com/backend-api/codex") == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_models_uses_no_live_probe(self):
|
||||||
|
assert build_models_url("https://chatgpt.com/backend-api/codex") is None
|
||||||
|
|
||||||
def test_lookalike_anthropic_chat_is_openai(self):
|
def test_lookalike_anthropic_chat_is_openai(self):
|
||||||
assert build_chat_url("https://notanthropic.com") == "https://notanthropic.com/chat/completions"
|
assert build_chat_url("https://notanthropic.com") == "https://notanthropic.com/chat/completions"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Node-driven tests for the shared provider device-flow runner."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HELPER = _REPO / "static" / "js" / "providerDeviceFlow.js"
|
||||||
|
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(script: str):
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_copilot_success_uses_complete_verification_uri():
|
||||||
|
js = f"""
|
||||||
|
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
|
||||||
|
const calls = [];
|
||||||
|
const opened = [];
|
||||||
|
let polls = 0;
|
||||||
|
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
|
||||||
|
const fetchImpl = async (url) => {{
|
||||||
|
calls.push(url);
|
||||||
|
if (url.endsWith('/device/start')) {{
|
||||||
|
return response(true, 200, {{
|
||||||
|
poll_id: 'poll-1',
|
||||||
|
user_code: 'GH-CODE',
|
||||||
|
verification_uri: 'https://github.com/login/device',
|
||||||
|
verification_uri_complete: 'https://github.com/login/device?user_code=GH-CODE',
|
||||||
|
interval: 2,
|
||||||
|
expires_in: 30,
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
polls += 1;
|
||||||
|
return response(true, 200, polls === 1
|
||||||
|
? {{ status: 'pending' }}
|
||||||
|
: {{ status: 'authorized', endpoint: {{ id: 'ep1', models: ['gpt-4o'] }} }}
|
||||||
|
);
|
||||||
|
}};
|
||||||
|
const result = await runProviderDeviceFlow('copilot', {{
|
||||||
|
fetchImpl,
|
||||||
|
openWindow: (url) => opened.push(url),
|
||||||
|
sleep: async () => {{}},
|
||||||
|
now: () => 0,
|
||||||
|
}});
|
||||||
|
console.log(JSON.stringify({{ result, calls, opened }}));
|
||||||
|
"""
|
||||||
|
out = _run_node(js)
|
||||||
|
assert out["result"]["status"] == "authorized"
|
||||||
|
assert out["result"]["endpoint"]["id"] == "ep1"
|
||||||
|
assert out["opened"] == ["https://github.com/login/device?user_code=GH-CODE"]
|
||||||
|
assert out["calls"] == ["/api/copilot/device/start", "/api/copilot/device/poll", "/api/copilot/device/poll"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_success_uses_plain_verification_uri():
|
||||||
|
js = f"""
|
||||||
|
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
|
||||||
|
const opened = [];
|
||||||
|
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
|
||||||
|
const fetchImpl = async (url) => {{
|
||||||
|
if (url.endsWith('/device/start')) {{
|
||||||
|
return response(true, 200, {{
|
||||||
|
poll_id: 'poll-1',
|
||||||
|
user_code: 'OA-CODE',
|
||||||
|
verification_uri: 'https://auth.openai.com/codex/device',
|
||||||
|
interval: 2,
|
||||||
|
expires_in: 30,
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
return response(true, 200, {{ status: 'authorized', endpoint: {{ id: 'chatgpt', models: ['gpt-5.5'] }} }});
|
||||||
|
}};
|
||||||
|
const result = await runProviderDeviceFlow('chatgpt-subscription', {{
|
||||||
|
fetchImpl,
|
||||||
|
openWindow: (url) => opened.push(url),
|
||||||
|
sleep: async () => {{}},
|
||||||
|
now: () => 0,
|
||||||
|
}});
|
||||||
|
console.log(JSON.stringify({{ result, opened }}));
|
||||||
|
"""
|
||||||
|
out = _run_node(js)
|
||||||
|
assert out["result"]["status"] == "authorized"
|
||||||
|
assert out["opened"] == ["https://auth.openai.com/codex/device"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_errors_surface_backend_detail():
|
||||||
|
js = f"""
|
||||||
|
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
|
||||||
|
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
|
||||||
|
try {{
|
||||||
|
await runProviderDeviceFlow('copilot', {{
|
||||||
|
fetchImpl: async () => response(false, 502, {{ detail: 'GitHub device-code request failed: upstream down' }}),
|
||||||
|
openWindow: () => {{}},
|
||||||
|
sleep: async () => {{}},
|
||||||
|
now: () => 0,
|
||||||
|
}});
|
||||||
|
}} catch (err) {{
|
||||||
|
console.log(JSON.stringify({{ message: err.message }}));
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
out = _run_node(js)
|
||||||
|
assert out["message"] == "GitHub device-code request failed: upstream down"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thrown_fetch_errors_are_preserved():
|
||||||
|
js = f"""
|
||||||
|
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
|
||||||
|
try {{
|
||||||
|
await runProviderDeviceFlow('chatgpt-subscription', {{
|
||||||
|
fetchImpl: async () => {{ throw new Error('network offline'); }},
|
||||||
|
openWindow: () => {{}},
|
||||||
|
sleep: async () => {{}},
|
||||||
|
now: () => 0,
|
||||||
|
}});
|
||||||
|
}} catch (err) {{
|
||||||
|
console.log(JSON.stringify({{ message: err.message }}));
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
out = _run_node(js)
|
||||||
|
assert out["message"] == "network offline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_flow_returns_expired_status():
|
||||||
|
js = f"""
|
||||||
|
import {{ runProviderDeviceFlow }} from '{_HELPER.as_posix()}';
|
||||||
|
let currentTime = 0;
|
||||||
|
const response = (ok, status, payload) => ({{ ok, status, async json() {{ return payload; }} }});
|
||||||
|
const result = await runProviderDeviceFlow('copilot', {{
|
||||||
|
fetchImpl: async (url) => url.endsWith('/device/start')
|
||||||
|
? response(true, 200, {{
|
||||||
|
poll_id: 'poll-1',
|
||||||
|
user_code: 'GH-CODE',
|
||||||
|
verification_uri: 'https://github.com/login/device',
|
||||||
|
interval: 2,
|
||||||
|
expires_in: 1,
|
||||||
|
}})
|
||||||
|
: response(true, 200, {{ status: 'pending' }}),
|
||||||
|
openWindow: () => {{}},
|
||||||
|
sleep: async () => {{ currentTime += 2000; }},
|
||||||
|
now: () => currentTime,
|
||||||
|
}});
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
"""
|
||||||
|
out = _run_node(js)
|
||||||
|
assert out == {"status": "expired"}
|
||||||
@@ -24,7 +24,7 @@ _sd = types.ModuleType("src.database")
|
|||||||
_sd.ModelEndpoint = MagicMock()
|
_sd.ModelEndpoint = MagicMock()
|
||||||
sys.modules.setdefault("src.database", _sd)
|
sys.modules.setdefault("src.database", _sd)
|
||||||
|
|
||||||
from routes.research_routes import _owned_enabled_endpoint # noqa: E402
|
from routes.research_routes import _owned_enabled_endpoint, _resolve_endpoint_runtime # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class _Predicate:
|
class _Predicate:
|
||||||
@@ -129,3 +129,29 @@ def test_null_owner_is_legacy_single_user_noop():
|
|||||||
rows = [_ep("ep-x", "bob"), _ep("ep-y", "alice")]
|
rows = [_ep("ep-x", "bob"), _ep("ep-y", "alice")]
|
||||||
ep = _resolve(rows, None, "ep-x")
|
ep = _resolve(rows, None, "ep-x")
|
||||||
assert ep is not None and ep.id == "ep-x"
|
assert ep is not None and ep.id == "ep-x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_resolution_uses_provider_auth_for_chatgpt_subscription(monkeypatch):
|
||||||
|
ep = SimpleNamespace(
|
||||||
|
id="ep-chatgpt",
|
||||||
|
owner="alice",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
api_key=None,
|
||||||
|
provider_auth_id="auth-1",
|
||||||
|
cached_models='["gpt-5.5"]',
|
||||||
|
hidden_models=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.chatgpt_subscription.resolve_runtime_credentials",
|
||||||
|
lambda auth_id, owner=None: {
|
||||||
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||||
|
"api_key": "fresh-access-token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
url, model, headers = _resolve_endpoint_runtime(ep, owner="alice", model="")
|
||||||
|
|
||||||
|
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
assert model == "gpt-5.5"
|
||||||
|
assert headers["Authorization"] == "Bearer fresh-access-token"
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ from fastapi import HTTPException
|
|||||||
from routes.research_routes import setup_research_routes
|
from routes.research_routes import setup_research_routes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _redirect_research_dir(tmp_path, monkeypatch):
|
||||||
|
# Deep-research paths are resolved from an import-time constant now, so chdir
|
||||||
|
# no longer redirects them. Point the constant the routes read at the temp dir.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routes.research_routes.DEEP_RESEARCH_DIR",
|
||||||
|
str(tmp_path / "data" / "deep_research"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _request(user: str):
|
def _request(user: str):
|
||||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""resolve_session_auth must not persist the ChatGPT Subscription bearer.
|
||||||
|
|
||||||
|
The ChatGPT Subscription access token is a short-lived OAuth bearer re-resolved
|
||||||
|
(and refreshed) on every request. resolve_session_auth() may set it on the
|
||||||
|
in-memory session for the current request, but it must never write it back into
|
||||||
|
the sessions table — otherwise the live token sits at rest as
|
||||||
|
"Authorization: Bearer ...". Only the encrypted refresh token in
|
||||||
|
ProviderAuthSession is allowed to persist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
import routes.chat_helpers as chat_helpers
|
||||||
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
|
from core.database import Base, ModelEndpoint, Session as DbSession
|
||||||
|
|
||||||
|
_CODEX_BASE = "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
|
|
||||||
|
def _mem_db(monkeypatch):
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
# Match production SessionLocal (core.database) which is autoflush=False.
|
||||||
|
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
|
||||||
|
monkeypatch.setattr(chat_helpers, "SessionLocal", TestSessionLocal)
|
||||||
|
return TestSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_auth_is_not_written_to_sessions_table(monkeypatch):
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
||||||
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
||||||
|
))
|
||||||
|
db.add(DbSession(
|
||||||
|
id="sess1", name="chat", endpoint_url=_CODEX_BASE,
|
||||||
|
model="gpt-5.1-codex", owner="alice", headers={},
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# A live access token is resolved at request time.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
endpoint_resolver, "resolve_endpoint_runtime",
|
||||||
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sess = types.SimpleNamespace(
|
||||||
|
id="sess1", endpoint_url=_CODEX_BASE, model="gpt-5.1-codex",
|
||||||
|
owner="alice", headers={},
|
||||||
|
)
|
||||||
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
||||||
|
|
||||||
|
# In-memory session got request-local auth for this request...
|
||||||
|
assert any(k.lower() == "authorization" for k in sess.headers)
|
||||||
|
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
||||||
|
|
||||||
|
# ...but the DB row must NOT have the bearer persisted.
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||||
|
stored = row.headers or {}
|
||||||
|
assert not any(k.lower() == "authorization" for k in stored), (
|
||||||
|
f"ChatGPT bearer leaked into sessions table: {stored}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_subscription_auth_is_still_persisted_to_sessions_table(monkeypatch):
|
||||||
|
"""The early-return must be scoped to ChatGPT Subscription only.
|
||||||
|
|
||||||
|
Ordinary endpoints rely on resolve_session_auth() persisting the resolved
|
||||||
|
headers into the sessions table so they aren't re-resolved on every request.
|
||||||
|
If the is_chatgpt_subscription guard ever widened, this would silently break;
|
||||||
|
this test pins the persistence path as still reached for normal endpoints.
|
||||||
|
"""
|
||||||
|
base = "https://api.example.com/v1"
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id="ep1", name="Generic", base_url=base,
|
||||||
|
owner="alice", is_enabled=True, api_key="sk-static",
|
||||||
|
))
|
||||||
|
db.add(DbSession(
|
||||||
|
id="sess1", name="chat", endpoint_url=base,
|
||||||
|
model="gpt-x", owner="alice", headers={},
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
endpoint_resolver, "resolve_endpoint_runtime",
|
||||||
|
lambda ep, owner=None: (base, "sk-static"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sess = types.SimpleNamespace(
|
||||||
|
id="sess1", endpoint_url=base, model="gpt-x", owner="alice", headers={},
|
||||||
|
)
|
||||||
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
||||||
|
|
||||||
|
# In-memory session got auth...
|
||||||
|
assert any(k.lower() in ("authorization", "x-api-key") for k in sess.headers)
|
||||||
|
|
||||||
|
# ...AND it was persisted to the DB row (the normal, non-subscription path).
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||||
|
stored = row.headers or {}
|
||||||
|
assert any(k.lower() in ("authorization", "x-api-key") for k in stored), (
|
||||||
|
f"non-subscription auth was not persisted: {stored}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
|
||||||
|
"""A bearer left at rest by an older code path is stripped on next resolve."""
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
||||||
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
||||||
|
))
|
||||||
|
# Simulate the leak: a stale bearer already sitting in the sessions table.
|
||||||
|
db.add(DbSession(
|
||||||
|
id="sess1", name="chat", endpoint_url=_CODEX_BASE,
|
||||||
|
model="gpt-5.1-codex", owner="alice",
|
||||||
|
headers={"Authorization": "Bearer stale-leaked-token"},
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
endpoint_resolver,
|
||||||
|
"resolve_endpoint_runtime",
|
||||||
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sess = types.SimpleNamespace(
|
||||||
|
id="sess1", endpoint_url=_CODEX_BASE, model="gpt-5.1-codex",
|
||||||
|
owner="alice", headers={},
|
||||||
|
)
|
||||||
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
||||||
|
|
||||||
|
# The stale bearer must have been stripped from the DB row.
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||||
|
stored = row.headers or {}
|
||||||
|
assert not any(k.lower() == "authorization" for k in stored), (
|
||||||
|
f"stale ChatGPT bearer was not cleared: {stored}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
|
||||||
|
"""Fallback endpoint selection must keep the resolved bearer request-local."""
|
||||||
|
TestSessionLocal = _mem_db(monkeypatch)
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
||||||
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
||||||
|
cached_models='["gpt-5.1-codex"]',
|
||||||
|
))
|
||||||
|
db.add(DbSession(
|
||||||
|
id="sess1", name="chat", endpoint_url="https://old.example/v1",
|
||||||
|
model="old-model", owner="alice", headers={},
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
endpoint_resolver,
|
||||||
|
"resolve_endpoint_runtime",
|
||||||
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sess = types.SimpleNamespace(
|
||||||
|
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
|
||||||
|
owner="alice", headers={},
|
||||||
|
)
|
||||||
|
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"model": "gpt-5.1-codex",
|
||||||
|
"endpoint_url": _CODEX_BASE + "/responses",
|
||||||
|
"endpoint_name": "ChatGPT Subscription",
|
||||||
|
}
|
||||||
|
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
||||||
|
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||||
|
assert row.model == "gpt-5.1-codex"
|
||||||
|
assert row.endpoint_url == _CODEX_BASE + "/responses"
|
||||||
|
stored = row.headers or {}
|
||||||
|
assert not any(k.lower() == "authorization" for k in stored), (
|
||||||
|
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -386,7 +386,7 @@ async def test_build_chat_context_incognito_does_not_duplicate_current_user_mess
|
|||||||
monkeypatch.setattr(chat_helpers, "add_user_message", fake_add_user_message)
|
monkeypatch.setattr(chat_helpers, "add_user_message", fake_add_user_message)
|
||||||
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
|
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda user: {})
|
||||||
monkeypatch.setattr(chat_helpers, "get_current_user", lambda request: "tester")
|
monkeypatch.setattr(chat_helpers, "get_current_user", lambda request: "tester")
|
||||||
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model: None)
|
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
|
||||||
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
|
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
|
||||||
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
|
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
|
||||||
|
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ def _import_mcp_routes():
|
|||||||
|
|
||||||
def test_mcp_oauth_paths_resolve_under_data_dir(tmp_path, monkeypatch):
|
def test_mcp_oauth_paths_resolve_under_data_dir(tmp_path, monkeypatch):
|
||||||
mcp_routes = _import_mcp_routes()
|
mcp_routes = _import_mcp_routes()
|
||||||
monkeypatch.setattr(mcp_routes, "DATA_DIR", str(tmp_path / "data"))
|
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", str(tmp_path / "data" / "mcp_oauth"))
|
||||||
|
|
||||||
resolved = Path(mcp_routes._resolve_mcp_oauth_path("gmail/credentials.json", "token_file"))
|
resolved = Path(mcp_routes._resolve_mcp_oauth_path("gmail/credentials.json", "token_file"))
|
||||||
|
|
||||||
@@ -963,7 +963,7 @@ def test_mcp_oauth_paths_reject_escapes(tmp_path, monkeypatch, raw_path):
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
mcp_routes = _import_mcp_routes()
|
mcp_routes = _import_mcp_routes()
|
||||||
monkeypatch.setattr(mcp_routes, "DATA_DIR", str(tmp_path / "data"))
|
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", str(tmp_path / "data" / "mcp_oauth"))
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
mcp_routes._resolve_mcp_oauth_path(raw_path, "token_file")
|
mcp_routes._resolve_mcp_oauth_path(raw_path, "token_file")
|
||||||
@@ -974,7 +974,7 @@ def test_mcp_oauth_filename_join_cannot_escape_base(tmp_path, monkeypatch):
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
mcp_routes = _import_mcp_routes()
|
mcp_routes = _import_mcp_routes()
|
||||||
monkeypatch.setattr(mcp_routes, "DATA_DIR", str(tmp_path / "data"))
|
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", str(tmp_path / "data" / "mcp_oauth"))
|
||||||
|
|
||||||
safe_dir = mcp_routes._resolve_mcp_oauth_path("gmail", "dir")
|
safe_dir = mcp_routes._resolve_mcp_oauth_path("gmail", "dir")
|
||||||
with pytest.raises(HTTPException):
|
with pytest.raises(HTTPException):
|
||||||
@@ -983,7 +983,7 @@ def test_mcp_oauth_filename_join_cannot_escape_base(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_mcp_oauth_config_sanitizes_paths_and_env(tmp_path, monkeypatch):
|
def test_mcp_oauth_config_sanitizes_paths_and_env(tmp_path, monkeypatch):
|
||||||
mcp_routes = _import_mcp_routes()
|
mcp_routes = _import_mcp_routes()
|
||||||
monkeypatch.setattr(mcp_routes, "DATA_DIR", str(tmp_path / "data"))
|
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", str(tmp_path / "data" / "mcp_oauth"))
|
||||||
|
|
||||||
cfg = mcp_routes._sanitize_mcp_oauth_config({
|
cfg = mcp_routes._sanitize_mcp_oauth_config({
|
||||||
"provider": "google",
|
"provider": "google",
|
||||||
|
|||||||
@@ -137,3 +137,12 @@ def test_unauthenticated_caller_rejected(monkeypatch):
|
|||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
SR._verify_session_owner(req, "sid")
|
SR._verify_session_owner(req, "sid")
|
||||||
assert exc.value.status_code == 401
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_disabled_allows_owner_stamped_session(monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
monkeypatch.setattr(SR, "SessionLocal", _session_local_returning("admin"))
|
||||||
|
req = _req(api_token=False, current_user=None)
|
||||||
|
|
||||||
|
# Single-user/auth-disabled mode should verify existence but not compare owner.
|
||||||
|
SR._verify_session_owner(req, "sid-owned-by-admin")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def _load_setup_module():
|
|||||||
|
|
||||||
def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
|
def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
|
||||||
setup_module = _load_setup_module()
|
setup_module = _load_setup_module()
|
||||||
monkeypatch.setattr(setup_module, "DATA_DIR", str(tmp_path))
|
monkeypatch.setattr(setup_module, "AUTH_FILE", str(tmp_path / "auth.json"))
|
||||||
monkeypatch.setenv("ODYSSEUS_ADMIN_USER", " AdminUser ")
|
monkeypatch.setenv("ODYSSEUS_ADMIN_USER", " AdminUser ")
|
||||||
monkeypatch.setenv("ODYSSEUS_ADMIN_PASSWORD", "temporary-password")
|
monkeypatch.setenv("ODYSSEUS_ADMIN_PASSWORD", "temporary-password")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Static regressions for `/setup` account sign-in providers."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_SLASH = (_REPO / "static" / "js" / "slashCommands.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _between(src: str, start: str, end: str) -> str:
|
||||||
|
start_idx = src.index(start)
|
||||||
|
end_idx = src.index(end, start_idx)
|
||||||
|
return src[start_idx:end_idx]
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_guide_lists_account_sign_in_providers():
|
||||||
|
guide_block = _between(_SLASH, "function _showSetupEndpointChoices", "async function _hasConfiguredModels")
|
||||||
|
|
||||||
|
assert 'data-setup-provider="' in _SLASH
|
||||||
|
assert "provider.key" in _SLASH
|
||||||
|
assert "'copilot'" in _SLASH
|
||||||
|
assert "'chatgpt-subscription'" in _SLASH
|
||||||
|
assert "/setup copilot" in _SLASH
|
||||||
|
assert "/setup chatgpt-subscription" in _SLASH
|
||||||
|
|
||||||
|
|
||||||
|
def test_clicking_account_sign_in_provider_prefills_setup_command_not_api_key():
|
||||||
|
click_block = _between(_SLASH, "const providerEl = e.target.closest('.setup-clickable-provider')", "// 3. Check")
|
||||||
|
|
||||||
|
assert "providerEl.dataset.setupProvider" in click_block
|
||||||
|
assert "providerEl.dataset.setupKind === 'device-auth'" in click_block
|
||||||
|
assert "'/setup ' + providerKey" in click_block
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_chatgpt_subscription_prints_auth_url_without_auto_opening_tab():
|
||||||
|
flow_block = _between(_SLASH, "async function _setupProviderDeviceFlow", "async function _cmdSetup")
|
||||||
|
|
||||||
|
assert "providerKey === 'chatgpt-subscription'" in flow_block
|
||||||
|
assert "Open this URL" in flow_block
|
||||||
|
assert "authUrl" in flow_block
|
||||||
|
assert 'href="\' + uiModule.esc(authUrl || \'\') + \'"' in flow_block
|
||||||
|
assert "if (providerKey === 'chatgpt-subscription') return;" in flow_block
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Static regressions for slash autocomplete command-group expansion."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_AC = (_REPO / "static" / "js" / "slashAutocomplete.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_parent_command_expands_subcommands_before_top_level_row_cap():
|
||||||
|
assert "function _exactCommandGroupItems" in _AC
|
||||||
|
assert "entry.token.toLowerCase().startsWith(prefix)" in _AC
|
||||||
|
assert "items = groupItems.slice(0, MAX_VISIBLE);" in _AC
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_group_has_room_for_chatgpt_subscription_suggestion():
|
||||||
|
assert "const MAX_VISIBLE = 14;" in _AC
|
||||||
Reference in New Issue
Block a user