mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 21:52:20 +01:00
Compare commits
8
Commits
| 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)
|
||||
# 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
|
||||
# ============================================================
|
||||
|
||||
@@ -23,7 +23,7 @@ body:
|
||||
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).)
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
- **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.
|
||||
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `core.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
|
||||
- **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 `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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
# Core imports
|
||||
from core.constants import (
|
||||
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.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
|
||||
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
|
||||
from routes.tts_routes import setup_tts_routes
|
||||
app.include_router(setup_tts_routes(tts_service))
|
||||
@@ -954,7 +958,7 @@ async def _startup_event():
|
||||
owners = set()
|
||||
try:
|
||||
import json as _json
|
||||
auth_path = "data/auth.json"
|
||||
auth_path = AUTH_FILE
|
||||
with open(auth_path, encoding="utf-8") as f:
|
||||
users = _json.load(f).get("users", {})
|
||||
owners.update(users.keys())
|
||||
@@ -1001,7 +1005,7 @@ async def _startup_event():
|
||||
# does not make an existing library look empty after auth/account changes.
|
||||
try:
|
||||
import json as _json
|
||||
auth_path = "data/auth.json"
|
||||
auth_path = AUTH_FILE
|
||||
with open(auth_path, encoding="utf-8") as f:
|
||||
users = _json.load(f).get("users", {})
|
||||
primary_owner = None
|
||||
|
||||
@@ -14,6 +14,8 @@ import uuid
|
||||
|
||||
import bcrypt
|
||||
|
||||
from src.constants import AUTH_FILE
|
||||
|
||||
PAIRING_VERSION = 1
|
||||
COMPANION_SCOPE = "chat"
|
||||
|
||||
@@ -61,7 +63,7 @@ def lan_ip_candidates() -> list[str]:
|
||||
def find_admin_user() -> str | None:
|
||||
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
||||
falling back to the first user."""
|
||||
auth_path = os.path.join("data", "auth.json")
|
||||
auth_path = AUTH_FILE
|
||||
try:
|
||||
with open(auth_path, "r", encoding="utf-8") as 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["allowed_models_restricted"] = False
|
||||
|
||||
DEFAULT_AUTH_PATH = os.path.join(
|
||||
Path(__file__).parent.parent, "data", "auth.json"
|
||||
)
|
||||
from src.constants import AUTH_FILE
|
||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||
|
||||
+11
-58
@@ -1,59 +1,12 @@
|
||||
# src/constants.py
|
||||
"""Application-wide constants and configuration values."""
|
||||
import os
|
||||
# core/constants.py
|
||||
"""Backward-compatible shim — the single source of truth is src/constants.py.
|
||||
|
||||
APP_VERSION = "0.9.1"
|
||||
|
||||
# Base paths
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
|
||||
# Data file paths
|
||||
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
||||
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')}"
|
||||
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
|
||||
kill the drift, this now simply re-exports everything from src.constants so
|
||||
there is exactly one place that defines paths and reads ODYSSEUS_DATA_DIR.
|
||||
internal_api_base() also lives in src.constants now and is re-exported here so
|
||||
existing `from core.constants import internal_api_base` callers keep working.
|
||||
"""
|
||||
from src.constants import * # noqa: F401,F403
|
||||
from src.constants import internal_api_base # noqa: F401 (explicit: functions aren't covered by some linters' * checks)
|
||||
|
||||
+46
-6
@@ -29,8 +29,9 @@ class TimestampMixin:
|
||||
def updated_at(cls):
|
||||
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
||||
|
||||
# Get database URL from environment, default to SQLite
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
||||
# Get database URL from environment, default to SQLite in DATA_DIR
|
||||
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
|
||||
engine = create_engine(
|
||||
@@ -360,6 +361,24 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
# is the historical default. When non-null, the model picker only shows
|
||||
# the endpoint to that user (admins always see everything).
|
||||
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):
|
||||
"""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}")
|
||||
|
||||
|
||||
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():
|
||||
"""Add model_type column to model_endpoints if it doesn't exist."""
|
||||
import sqlite3
|
||||
@@ -1065,7 +1104,7 @@ def _migrate_assign_legacy_owner():
|
||||
# fell through to "first user" every time.
|
||||
auth_path = os.path.join(os.path.dirname(DATABASE_URL.replace("sqlite:///", "")), "auth.json")
|
||||
if not os.path.isabs(auth_path):
|
||||
auth_path = os.path.join("data", "auth.json")
|
||||
auth_path = AUTH_FILE
|
||||
admin_user = None
|
||||
try:
|
||||
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}")
|
||||
|
||||
# Also migrate memory.json
|
||||
mem_path = os.path.join("data", "memory.json")
|
||||
mem_path = MEMORY_FILE
|
||||
try:
|
||||
if os.path.exists(mem_path):
|
||||
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}")
|
||||
|
||||
# Also migrate user_prefs.json to per-user format
|
||||
prefs_path = os.path.join("data", "user_prefs.json")
|
||||
prefs_path = USER_PREFS_FILE
|
||||
try:
|
||||
if os.path.exists(prefs_path):
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
@@ -1530,7 +1569,7 @@ def _migrate_seed_email_account():
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
settings_file = Path("data/settings.json")
|
||||
settings_file = Path(SETTINGS_FILE)
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
@@ -1598,6 +1637,7 @@ def init_db():
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_refresh_columns()
|
||||
_migrate_add_model_endpoint_owner_column()
|
||||
_migrate_add_provider_auth_id_column()
|
||||
_migrate_add_supports_tools_column()
|
||||
_migrate_add_task_run_model_column()
|
||||
_migrate_add_owner_column()
|
||||
|
||||
@@ -31,7 +31,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
server = Server("email")
|
||||
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:
|
||||
@@ -63,7 +64,7 @@ def _clean_header_value(value) -> str:
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return DATA_DIR / "app.db"
|
||||
return Path(APP_DB)
|
||||
|
||||
|
||||
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"),
|
||||
"cache_db": os.environ.get(
|
||||
"EMAIL_CACHE_DB",
|
||||
str(DATA_DIR / "email_cache.db"),
|
||||
EMAIL_CACHE_DB,
|
||||
),
|
||||
"account_id": None,
|
||||
"account_name": None,
|
||||
@@ -204,7 +205,7 @@ def _load_config(account: str | None = None) -> dict:
|
||||
else:
|
||||
# Legacy fallback: settings.json flat keys
|
||||
try:
|
||||
settings_path = Path(__file__).resolve().parent.parent / "data" / "settings.json"
|
||||
settings_path = Path(_SETTINGS_FILE)
|
||||
if settings_path.exists():
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
for key in (
|
||||
@@ -1061,7 +1062,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||
raw = msg_data[0][1]
|
||||
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)
|
||||
if not filepath:
|
||||
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))
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
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("/")
|
||||
|
||||
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)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
|
||||
@@ -31,7 +31,7 @@ from core.database import (
|
||||
CalendarEvent,
|
||||
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__)
|
||||
|
||||
@@ -107,7 +107,7 @@ def setup_admin_wipe_routes(session_manager):
|
||||
# Skills live as SKILL.md files under data/skills/. Drop
|
||||
# the entire directory; the SkillsManager re-creates the
|
||||
# tree on next write.
|
||||
skills_dir = os.path.join(DATA_DIR, "skills")
|
||||
skills_dir = SKILLS_DIR
|
||||
count = 0
|
||||
if os.path.isdir(skills_dir):
|
||||
# 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")
|
||||
_rmtree_quiet(skills_dir)
|
||||
# Legacy fallback file
|
||||
legacy = os.path.join(DATA_DIR, "skills.json")
|
||||
legacy = SKILLS_FILE
|
||||
if os.path.exists(legacy):
|
||||
try:
|
||||
os.remove(legacy)
|
||||
@@ -151,8 +151,8 @@ def setup_admin_wipe_routes(session_manager):
|
||||
db.query(GalleryAlbum).delete()
|
||||
db.commit()
|
||||
# Also drop the upload dir so disk doesn't keep orphans.
|
||||
_rmtree_quiet(os.path.join(DATA_DIR, "gallery"))
|
||||
_rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads"))
|
||||
_rmtree_quiet(GALLERY_DIR)
|
||||
_rmtree_quiet(GALLERY_UPLOADS_DIR)
|
||||
return {"status": "deleted", "kind": kind, "count": count}
|
||||
|
||||
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.
|
||||
"""
|
||||
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 ""
|
||||
owner = getattr(sess, "owner", None)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoints = db.query(ModelEndpoint).filter(
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True
|
||||
).all()
|
||||
)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -212,26 +224,33 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
||||
# Skip current endpoint
|
||||
if current_url and base in current_url:
|
||||
continue
|
||||
# Quick ping
|
||||
ping_url = build_models_url(base)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
try:
|
||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||
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")
|
||||
]
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
ping_url = build_models_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
try:
|
||||
if ping_url:
|
||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||
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:
|
||||
continue
|
||||
# Found a working endpoint — update session
|
||||
new_model = models[0]
|
||||
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.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({
|
||||
"model": new_model,
|
||||
"endpoint_url": chat_url,
|
||||
"headers": json.dumps(new_headers),
|
||||
"headers": persisted_headers,
|
||||
})
|
||||
_db.commit()
|
||||
finally:
|
||||
@@ -336,16 +355,26 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
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):
|
||||
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
||||
has_auth = sess.headers and isinstance(sess.headers, dict) and any(
|
||||
k.lower() in ('authorization', 'x-api-key') for k in sess.headers
|
||||
)
|
||||
if has_auth:
|
||||
try:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
||||
except Exception:
|
||||
is_chatgpt_subscription = False
|
||||
has_auth = _has_auth_keys(sess.headers)
|
||||
if has_auth and not is_chatgpt_subscription:
|
||||
return
|
||||
|
||||
try:
|
||||
from src.endpoint_resolver import build_headers, normalize_base
|
||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||
db = SessionLocal()
|
||||
try:
|
||||
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():
|
||||
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
||||
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
|
||||
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)
|
||||
if owner:
|
||||
update_q = update_q.filter(DBSession.owner == owner)
|
||||
@@ -408,7 +457,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
|
||||
db = SessionLocal()
|
||||
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:
|
||||
try:
|
||||
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
|
||||
# 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:
|
||||
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
|
||||
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).
|
||||
Without this, we'd POST the upstream with model="" and get a generic
|
||||
401/503 instead of using the model the user already picked.
|
||||
|
||||
Returns True iff sess.model was repaired.
|
||||
For ChatGPT Subscription, also repairs stale OpenAI API model names such as
|
||||
``gpt-5`` that are not accepted by the Codex-backed ChatGPT account route.
|
||||
"""
|
||||
if getattr(sess, "model", None):
|
||||
return False
|
||||
current_model = (getattr(sess, "model", "") or "").strip()
|
||||
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()
|
||||
try:
|
||||
# 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
|
||||
if not ep:
|
||||
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:
|
||||
cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or [])
|
||||
except Exception:
|
||||
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
|
||||
try:
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
if is_chatgpt_subscription:
|
||||
live_models = []
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
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:
|
||||
return False
|
||||
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
|
||||
# picks up the same model (we'd otherwise re-pick on every send
|
||||
# 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:
|
||||
db_session.model = model
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
sess.model = model
|
||||
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,
|
||||
)
|
||||
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.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"}
|
||||
@@ -425,8 +425,8 @@ def setup_codex_routes(
|
||||
|
||||
def _read_cookbook_state() -> dict:
|
||||
from pathlib import Path as _Path
|
||||
import os as _os, json as _json
|
||||
p = _Path(DATA_DIR) / "cookbook_state.json"
|
||||
import json as _json
|
||||
p = _Path(COOKBOOK_STATE_FILE)
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
@@ -734,7 +734,7 @@ def setup_codex_routes(
|
||||
import time as _t, json as _json
|
||||
from core.atomic_io import atomic_write_json
|
||||
from pathlib import Path as _Path
|
||||
cookbook_state_path = _Path(DATA_DIR) / "cookbook_state.json"
|
||||
cookbook_state_path = _Path(COOKBOOK_STATE_FILE)
|
||||
try:
|
||||
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
|
||||
+110
-22
@@ -12,6 +12,7 @@ import logging
|
||||
from core.database import Comparison, SessionLocal
|
||||
from core.session_manager import SessionManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +39,24 @@ def _owned_endpoint_by_url(db, base_url, owner):
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
def _owned_endpoint_by_id(db, endpoint_id, owner):
|
||||
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
|
||||
own rows + legacy null-owner "shared" rows); None otherwise.
|
||||
|
||||
Preferred over _owned_endpoint_by_url for credential resolution: two visible
|
||||
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
|
||||
accounts on the same provider). A base_url-only match returns whichever row
|
||||
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
|
||||
An id pins the exact registered endpoint, so /api/compare/start prefers it and
|
||||
only falls back to URL matching for legacy / admin raw-URL callers. Owner
|
||||
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
|
||||
"""
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
|
||||
return owner_filter(q, ModelEndpoint, owner).first()
|
||||
|
||||
|
||||
class RecordVoteRequest(BaseModel):
|
||||
prompt: str
|
||||
models: List[str]
|
||||
@@ -54,8 +73,10 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
prompt: str = Form(...),
|
||||
model_a: str = Form(...),
|
||||
model_b: str = Form(...),
|
||||
endpoint_a: str = Form(...),
|
||||
endpoint_b: str = Form(...),
|
||||
endpoint_a: str = Form(""),
|
||||
endpoint_b: str = Form(""),
|
||||
endpoint_a_id: str = Form(""),
|
||||
endpoint_b_id: str = Form(""),
|
||||
is_blind: str = Form("true"),
|
||||
):
|
||||
"""Create two ephemeral sessions and a comparison record.
|
||||
@@ -63,10 +84,10 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
Returns the comparison ID and the two session IDs so the client
|
||||
can fire two independent SSE streams to /api/chat_stream.
|
||||
"""
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
comp_id = str(uuid.uuid4())
|
||||
sid_a = str(uuid.uuid4())
|
||||
sid_b = str(uuid.uuid4())
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
|
||||
# Blind mapping: randomly assign left/right
|
||||
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).
|
||||
slot_name = {session_left: "Model A", session_right: "Model B"}
|
||||
|
||||
# Create ephemeral sessions (prefixed [CMP])
|
||||
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
|
||||
# SECURITY: resolve and validate BOTH endpoints before creating any
|
||||
# session. Compare copies a registered endpoint's Authorization header
|
||||
# into the [CMP] session, so validating one endpoint while creating its
|
||||
# session, then rejecting the other, would leave a partial compare
|
||||
# session behind with that header attached. Doing all the owner-scope
|
||||
# resolution + raw-URL rejection up front means a 403 on either endpoint
|
||||
# aborts the whole request with nothing created and no header copied.
|
||||
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
|
||||
resolved = []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for sid, model, endpoint, endpoint_id in [
|
||||
(sid_a, model_a, endpoint_a, endpoint_a_id),
|
||||
(sid_b, model_b, endpoint_b, endpoint_b_id),
|
||||
]:
|
||||
# Prefer an explicit endpoint id: it pins the EXACT registered
|
||||
# endpoint (and its api_key), even when two endpoints visible to
|
||||
# the caller share a base_url with different keys — a URL-only
|
||||
# match would copy whichever row sorts first, i.e. possibly the
|
||||
# wrong key. Fall back to URL resolution only for legacy / admin
|
||||
# raw-URL callers that don't send an id.
|
||||
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
|
||||
if eid:
|
||||
ep = _owned_endpoint_by_id(db, eid, user)
|
||||
if ep is None:
|
||||
# An id the caller can't see (wrong owner / deleted) must
|
||||
# NOT silently fall back to a same-URL row with a different
|
||||
# key — that's exactly the mix-up ids exist to prevent.
|
||||
raise HTTPException(404, "Model endpoint not found")
|
||||
# The id already resolved the endpoint; ignore any raw URL the
|
||||
# caller also sent and dial the stored config instead.
|
||||
endpoint = ep.base_url
|
||||
elif not endpoint:
|
||||
raise HTTPException(
|
||||
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
|
||||
)
|
||||
else:
|
||||
# Resolve the supplied URL to a ModelEndpoint the caller owns
|
||||
# (their own rows + legacy null-owner shared rows), scoped so a
|
||||
# comparison can't borrow another user's private endpoint key.
|
||||
base = normalize_base(endpoint)
|
||||
ep = _owned_endpoint_by_url(db, base, user)
|
||||
# Reject *unregistered* raw URLs for signed-in non-admins; a
|
||||
# matched registered endpoint supplies an id so the caller can
|
||||
# still compare endpoints they own. Blanket-rejecting here (the
|
||||
# earlier `endpoint_id=None` call) locked non-admins out of
|
||||
# compare entirely, since compare resolves endpoints by URL with
|
||||
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
|
||||
# Raised here (phase 1), before any session exists.
|
||||
_reject_raw_endpoint_url_for_non_admin(
|
||||
request, user, str(ep.id) if ep is not None else None, endpoint
|
||||
)
|
||||
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
||||
# caller-supplied string. When the URL matches a registered
|
||||
# endpoint visible to the caller, use that row's own normalized
|
||||
# base URL (the same value owner scoping + endpoint validation
|
||||
# already vetted) so the session dials exactly where the stored
|
||||
# config points. The raw `endpoint` only survives for callers
|
||||
# allowed to pass one — admins / single-user mode, where
|
||||
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
|
||||
# is None. Mirrors the registered-endpoint path in session_routes.
|
||||
session_endpoint_url = (
|
||||
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
|
||||
)
|
||||
# Headers come only from a matched endpoint's key; None when
|
||||
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||
# never inherit another user's key/headers.
|
||||
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||
resolved.append((sid, model, session_endpoint_url, headers))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Both endpoints validated — only now create the ephemeral [CMP]
|
||||
# sessions and copy any resolved headers.
|
||||
for sid, model, session_endpoint_url, headers in resolved:
|
||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||
session_manager.create_session(
|
||||
session_id=sid,
|
||||
name=name,
|
||||
endpoint_url=endpoint,
|
||||
endpoint_url=session_endpoint_url,
|
||||
model=model,
|
||||
rag=False,
|
||||
owner=user,
|
||||
)
|
||||
# Copy API key from endpoint config
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from src.endpoint_resolver import build_headers, normalize_base
|
||||
# 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()
|
||||
if headers:
|
||||
s = session_manager.sessions.get(sid)
|
||||
if s:
|
||||
s.headers = headers
|
||||
|
||||
# Store comparison record
|
||||
db = SessionLocal()
|
||||
@@ -121,8 +205,12 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
prompt=prompt,
|
||||
model_a=model_a,
|
||||
model_b=model_b,
|
||||
endpoint_a=endpoint_a,
|
||||
endpoint_b=endpoint_b,
|
||||
# Record the URL the session actually dials. For URL callers this
|
||||
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
||||
# fall back to the resolved endpoint URL so the column stays
|
||||
# meaningful and non-null. resolved is in [a, b] order.
|
||||
endpoint_a=endpoint_a or resolved[0][2],
|
||||
endpoint_b=endpoint_b or resolved[1][2],
|
||||
is_blind=blind,
|
||||
blind_mapping=json.dumps(mapping),
|
||||
owner=user,
|
||||
|
||||
@@ -25,9 +25,10 @@ from src.url_safety import check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||
LOCAL_CONTACTS_FILE = DATA_DIR / "contacts.json"
|
||||
from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE
|
||||
DATA_DIR = Path(_DATA_DIR)
|
||||
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||
LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE)
|
||||
|
||||
|
||||
def _load_settings():
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
|
||||
from src.auth_helpers import require_user
|
||||
from src.constants import COOKBOOK_STATE_FILE
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.middleware import require_admin
|
||||
@@ -33,7 +34,7 @@ from core.platform_compat import (
|
||||
get_wsl_windows_user_profile,
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -61,7 +62,7 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
|
||||
def setup_cookbook_routes() -> APIRouter:
|
||||
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:
|
||||
if not value:
|
||||
|
||||
+67
-117
@@ -20,39 +20,26 @@ All routes are admin-gated (endpoint/provider management is an admin action).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
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 import copilot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
|
||||
# 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)
|
||||
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def setup_copilot_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
|
||||
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
|
||||
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")
|
||||
def device_start(request: Request, enterprise_url: str = Form("")):
|
||||
require_admin(request)
|
||||
_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")
|
||||
if not device_code:
|
||||
raise HTTPException(502, "GitHub did not return a device code")
|
||||
|
||||
device_code = data.get("device_code")
|
||||
if not device_code:
|
||||
raise HTTPException(502, "GitHub did not return a device code")
|
||||
interval = int(data.get("interval") or 5)
|
||||
expires_in = int(data.get("expires_in") or 900)
|
||||
poll_id = uuid.uuid4().hex
|
||||
with _PENDING_LOCK:
|
||||
_PENDING[poll_id] = {
|
||||
"device_code": device_code,
|
||||
"host": host,
|
||||
"enterprise_url": ent,
|
||||
"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,
|
||||
# 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 DeviceFlowStart(
|
||||
pending={
|
||||
"device_code": device_code,
|
||||
"host": host,
|
||||
"enterprise_url": ent,
|
||||
"owner": get_current_user(request) or None,
|
||||
},
|
||||
response={
|
||||
"user_code": data.get("user_code"),
|
||||
"verification_uri": data.get("verification_uri"),
|
||||
"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
|
||||
# can't trip slow_down.
|
||||
now = time.time()
|
||||
if now < pending.get("next_poll_at", 0):
|
||||
return {"status": "pending"}
|
||||
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||
try:
|
||||
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
||||
except Exception as e:
|
||||
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:
|
||||
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
||||
result = _provision_endpoint(token, base, pending["owner"])
|
||||
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")
|
||||
if token:
|
||||
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
|
||||
try:
|
||||
result = _provision_endpoint(token, base, pending["owner"])
|
||||
except Exception as e:
|
||||
logger.exception("Copilot endpoint provisioning failed")
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
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":
|
||||
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"):
|
||||
return DeviceFlowPoll.failed(err)
|
||||
# Unknown error — surface but keep the session for another try.
|
||||
return DeviceFlowPoll.pending(err or "unknown")
|
||||
|
||||
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 device_cancel(request: Request, poll_id: str = Form(...)):
|
||||
require_admin(request)
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
return {"status": "cancelled"}
|
||||
|
||||
return router
|
||||
def setup_copilot_routes():
|
||||
return create_device_flow_router(
|
||||
prefix="/api/copilot",
|
||||
tags=["copilot"],
|
||||
store=_DEVICE_FLOW_STORE,
|
||||
start_flow=_start_device_flow,
|
||||
poll_flow=_poll_device_flow,
|
||||
)
|
||||
|
||||
@@ -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 Session as DbSession
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
|
||||
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
|
||||
# as email_routes (ODYSSEUS_MAIL_ATTACHMENTS_DIR).
|
||||
from pathlib import Path as _Path
|
||||
import os as _os
|
||||
_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 = _Path(MAIL_ATTACHMENTS_DIR) / "_compose"
|
||||
_COMPOSE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user = get_current_user(request)
|
||||
|
||||
@@ -254,16 +254,17 @@ def _cleanup_compose_uploads(tokens) -> None:
|
||||
pass
|
||||
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||
from src.constants import DATA_DIR as _DATA_DIR, MAIL_ATTACHMENTS_DIR, SETTINGS_FILE as _SETTINGS_FILE, SCHEDULED_EMAILS_DB
|
||||
DATA_DIR = Path(_DATA_DIR)
|
||||
SETTINGS_FILE = Path(_SETTINGS_FILE)
|
||||
# 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
|
||||
# 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)
|
||||
COMPOSE_UPLOADS_DIR = ATTACHMENTS_DIR / "_compose"
|
||||
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 = {
|
||||
|
||||
@@ -32,6 +32,7 @@ from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
from src.upload_limits import read_upload_limited
|
||||
@@ -2904,7 +2905,7 @@ def setup_email_routes():
|
||||
from pathlib import Path as _P
|
||||
import json as _json
|
||||
_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():
|
||||
return {"total_unread": 0, "total_urgent": 0, "max_score": 0, "per_uid": {}}
|
||||
try:
|
||||
|
||||
@@ -7,12 +7,12 @@ import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENDPOINT_FILE = os.path.join(BASE_DIR, "data", "embedding_endpoint.json")
|
||||
_ENDPOINT_FILE = EMBEDDING_ENDPOINT_FILE
|
||||
|
||||
# Track in-progress downloads
|
||||
_downloading: dict = {}
|
||||
@@ -35,13 +35,7 @@ def _cache_dir() -> str:
|
||||
default lived in /tmp, which many systems wipe on reboot — forcing a
|
||||
full re-download of the embedding model after every restart.
|
||||
"""
|
||||
env = os.environ.get("FASTEMBED_CACHE_PATH")
|
||||
if env:
|
||||
return env
|
||||
return os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "fastembed_cache",
|
||||
)
|
||||
return FASTEMBED_CACHE_DIR
|
||||
|
||||
|
||||
def _model_cache_name(hf_source: str) -> str:
|
||||
|
||||
@@ -18,9 +18,11 @@ import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import Response
|
||||
|
||||
from src.constants import EMOJI_CACHE_DIR
|
||||
|
||||
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
|
||||
# in UPPERCASE (FE0F dropped, same as we compute), '-' joined.
|
||||
_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 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.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
from routes.gallery_helpers import (
|
||||
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)))
|
||||
|
||||
|
||||
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:
|
||||
"""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]
|
||||
@@ -33,7 +47,7 @@ def _sanitize_gallery_filename(filename: str) -> str:
|
||||
return safe_name
|
||||
|
||||
|
||||
GALLERY_IMAGE_DIR = Path("data/generated_images")
|
||||
GALLERY_IMAGE_DIR = Path(GENERATED_IMAGES_DIR)
|
||||
|
||||
|
||||
def _gallery_image_path(filename: str) -> Path:
|
||||
@@ -133,7 +147,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"ok": False, "duplicate": True, "filename": existing.filename,
|
||||
"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)
|
||||
|
||||
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")
|
||||
|
||||
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_path = img_dir / _sanitize_gallery_filename(img.filename)
|
||||
img_path.write_bytes(content)
|
||||
@@ -1042,7 +1056,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, _target, user)
|
||||
if ep:
|
||||
base = (ep.base_url or base).rstrip("/")
|
||||
api_key = ep.api_key
|
||||
elif user and not _current_user_is_admin(request, user):
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1233,7 +1250,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, base, user)
|
||||
if ep:
|
||||
base = (ep.base_url or base).rstrip("/")
|
||||
api_key = ep.api_key
|
||||
elif user and not _current_user_is_admin(request, user):
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import httpx
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
def _mcp_oauth_base_dir() -> Path:
|
||||
"""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:
|
||||
|
||||
+107
-13
@@ -283,6 +283,7 @@ _HOST_TO_CURATED = (
|
||||
("fireworks.ai", "fireworks"),
|
||||
("googleapis.com", "google"),
|
||||
("x.ai", "xai"),
|
||||
|
||||
("openrouter.ai", "openrouter"),
|
||||
("ollama.com", "ollama"),
|
||||
("opencode.ai/zen/go", "opencode-go"),
|
||||
@@ -493,6 +494,8 @@ _NON_CHAT_EXACT_PREFIXES = (
|
||||
def _is_chat_model(model_id: str) -> bool:
|
||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||
mid = model_id.lower()
|
||||
if mid in {"gpt-5.1-codex"}:
|
||||
return True
|
||||
for prefix in _NON_CHAT_PREFIXES:
|
||||
if mid.startswith(prefix):
|
||||
return False
|
||||
@@ -505,9 +508,67 @@ def _is_chat_model(model_id: str) -> bool:
|
||||
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?}."""
|
||||
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 = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"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."""
|
||||
from src.endpoint_resolver import resolve_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":
|
||||
# Try Anthropic's /v1/models endpoint first
|
||||
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}")
|
||||
return list(ANTHROPIC_MODELS)
|
||||
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)
|
||||
try:
|
||||
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)
|
||||
if not ok:
|
||||
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"], {
|
||||
"base": info["base"],
|
||||
"api_key": info["api_key"],
|
||||
@@ -1266,12 +1347,20 @@ def setup_model_routes(model_discovery):
|
||||
"endpoint_kind": kind,
|
||||
}
|
||||
try:
|
||||
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)
|
||||
if _is_discovery_only_provider(provider):
|
||||
# No general health endpoint — an unauthenticated GET just
|
||||
# 401s. Report status from cached models instead of pinging.
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "online" if cached_count else "offline"
|
||||
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:
|
||||
entry["latency_ms"] = None
|
||||
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:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
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)
|
||||
if not ep_data:
|
||||
# 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,
|
||||
"name": ep.name,
|
||||
"base_url": ep.base_url,
|
||||
"api_key": ep.api_key,
|
||||
"api_key": _resolve_probe_key(ep),
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1432,12 +1521,14 @@ def setup_model_routes(model_discovery):
|
||||
# Endpoint counts as reachable if it has any model — including
|
||||
# admin-pinned IDs that a probe would never surface.
|
||||
status = "online" if (all_models or pinned) else "offline"
|
||||
base = _normalize_base(r.base_url)
|
||||
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)
|
||||
if ping.get("reachable"):
|
||||
status = "empty"
|
||||
base = _normalize_base(r.base_url)
|
||||
kind = _effective_endpoint_kind(r, base)
|
||||
results.append({
|
||||
"id": r.id,
|
||||
@@ -1713,7 +1804,7 @@ def setup_model_routes(model_discovery):
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
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:
|
||||
db.close()
|
||||
|
||||
@@ -1777,7 +1868,7 @@ def setup_model_routes(model_discovery):
|
||||
category = _classify_endpoint(base, kind)
|
||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||
try:
|
||||
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
||||
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||
probed = []
|
||||
@@ -2116,7 +2207,9 @@ def setup_model_routes(model_discovery):
|
||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, 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)
|
||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
_local_probe_cache["data"] = None
|
||||
@@ -2126,6 +2219,7 @@ def setup_model_routes(model_discovery):
|
||||
"cleared_user_preferences": cleared_user_preferences,
|
||||
"cleared_sessions": cleared_sessions,
|
||||
"cleared_loaded_sessions": cleared_loaded_sessions,
|
||||
"cleared_provider_auth": cleared_provider_auth,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -11,6 +11,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, Note
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.constants import DATA_DIR
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
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 pathlib import Path as _P
|
||||
_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():
|
||||
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
last = cache.get(cache_key)
|
||||
@@ -523,7 +524,7 @@ async def dispatch_reminder(
|
||||
_STATE = cache_path
|
||||
if _STATE is None:
|
||||
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||
_STATE = _P(f"data/note_pings_{_slug}.json")
|
||||
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
|
||||
_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
|
||||
|
||||
@@ -6,13 +6,13 @@ import uuid
|
||||
from typing import List, Tuple
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||
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.auth_helpers import require_privilege, require_user
|
||||
from core.middleware import require_admin
|
||||
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(
|
||||
os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024))
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@ import os
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
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():
|
||||
|
||||
+50
-36
@@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
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}$")
|
||||
|
||||
@@ -37,9 +38,9 @@ def _first_chat_model(models) -> str:
|
||||
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."""
|
||||
owner = getattr(sess, "owner", None) or None
|
||||
owner = owner or getattr(sess, "owner", None) or None
|
||||
url, model, headers = resolve_endpoint(
|
||||
"research",
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
router = APIRouter(tags=["research"])
|
||||
|
||||
@@ -100,7 +133,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if entry is not None:
|
||||
return entry.get("owner", "") == user
|
||||
# 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():
|
||||
return False
|
||||
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:
|
||||
"""404-not-403 ownership gate for a research session's on-disk JSON.
|
||||
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():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
@@ -227,7 +260,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
):
|
||||
user = _require_user(request)
|
||||
"""List all completed research for the Library panel."""
|
||||
data_dir = Path("data/deep_research")
|
||||
data_dir = Path(DEEP_RESEARCH_DIR)
|
||||
items = []
|
||||
for p in data_dir.glob("*.json"):
|
||||
try:
|
||||
@@ -277,7 +310,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
summary, stats — used by the Library preview panel."""
|
||||
user = _require_user(request)
|
||||
_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():
|
||||
raise HTTPException(404, "Research not found")
|
||||
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)."""
|
||||
user = _require_user(request)
|
||||
_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():
|
||||
raise HTTPException(404, "Research not found")
|
||||
try:
|
||||
@@ -314,7 +347,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
"""Delete a research result from disk."""
|
||||
user = _require_user(request)
|
||||
_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"
|
||||
deleted = False
|
||||
if json_path.exists():
|
||||
@@ -370,7 +403,6 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
|
||||
if body.endpoint_id:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 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)
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found or disabled")
|
||||
base = normalize_base(ep.base_url)
|
||||
ep_url = build_chat_url(base)
|
||||
ep_headers = build_headers(ep.api_key, base)
|
||||
ep_model = body.model or ""
|
||||
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
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user, model=body.model)
|
||||
if not resolved:
|
||||
raise HTTPException(400, "Endpoint is not configured with a usable model.")
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
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)
|
||||
if not ep_url:
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 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).
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
ep_url = build_chat_url(base)
|
||||
ep_headers = build_headers(ep.api_key, base)
|
||||
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
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
ep_url, ep_model, ep_headers = resolved
|
||||
finally:
|
||||
db.close()
|
||||
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")
|
||||
result = research_handler.get_result(session_id)
|
||||
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():
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
return {
|
||||
@@ -536,7 +550,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
sources = research_handler.get_sources(session_id) or []
|
||||
query = ""
|
||||
|
||||
path = Path("data/deep_research") / f"{session_id}.json"
|
||||
path = Path(DEEP_RESEARCH_DIR) / f"{session_id}.json"
|
||||
if path.exists():
|
||||
try:
|
||||
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):
|
||||
"""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
|
||||
there is no DB row but the caller owns an in-memory "ghost" session — one
|
||||
that lives only in ``session_manager`` because it was never persisted, or
|
||||
its DB row was removed out-of-band — fall back to the in-memory owner so the
|
||||
user can still manage and delete it. Without this fallback such sessions are
|
||||
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.
|
||||
Authenticated requests must match the stored DB or in-memory owner. When
|
||||
auth is disabled and no user is present, treat the app as single-user mode:
|
||||
verify that the session exists, but do not compare its stored owner. This
|
||||
keeps QA/dev instances with AUTH_ENABLED=false from rejecting owner-stamped
|
||||
rows created while auth was previously enabled.
|
||||
"""
|
||||
user = effective_user(request)
|
||||
if not user and not _auth_disabled():
|
||||
@@ -114,13 +109,13 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
|
||||
finally:
|
||||
db.close()
|
||||
if row is not None:
|
||||
if row.owner != user:
|
||||
if user and row.owner != user:
|
||||
raise HTTPException(404, f"Session {session_id} not found")
|
||||
return
|
||||
# No DB row — allow the caller to act on an in-memory ghost they own.
|
||||
if session_manager is not None:
|
||||
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
|
||||
raise HTTPException(404, f"Session {session_id} not found")
|
||||
|
||||
@@ -372,8 +367,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
pass
|
||||
elif not model_to_use:
|
||||
from src.llm_core import list_model_ids
|
||||
ids = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
|
||||
headers=validation_headers)
|
||||
ids = list_model_ids(
|
||||
endpoint_url,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
headers=validation_headers,
|
||||
owner=user,
|
||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||
)
|
||||
if not ids:
|
||||
raise HTTPException(400, "Cannot reach /v1/models")
|
||||
# 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
|
||||
import os as _os
|
||||
req_base = _os.path.basename(model_to_use.rstrip("/"))
|
||||
avail = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
|
||||
headers=validation_headers)
|
||||
avail = list_model_ids(
|
||||
endpoint_url,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
headers=validation_headers,
|
||||
owner=user,
|
||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||
)
|
||||
if not avail:
|
||||
raise HTTPException(400, "Cannot reach /v1/models")
|
||||
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)
|
||||
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")
|
||||
async def list_builtin_skills(request: Request):
|
||||
"""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)
|
||||
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}")
|
||||
async def get_skill(request: Request, skill_id: str):
|
||||
user = _owner(request)
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel
|
||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||
from core.constants import internal_api_base
|
||||
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 routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
@@ -621,7 +622,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
|
||||
removed_files = 0
|
||||
if action == "check_email_urgency":
|
||||
cache_dir = Path("data/email_urgency_cache")
|
||||
cache_dir = Path(EMAIL_URGENCY_CACHE_DIR)
|
||||
if cache_dir.exists():
|
||||
for child in cache_dir.glob("*.json"):
|
||||
try:
|
||||
@@ -630,7 +631,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
|
||||
@@ -17,10 +17,11 @@ from pydantic import BaseModel
|
||||
|
||||
from core.middleware import require_admin
|
||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VAULT_FILE = Path("data/vault.json")
|
||||
VAULT_FILE = Path(_VAULT_FILE)
|
||||
|
||||
|
||||
def _find_bw() -> str:
|
||||
|
||||
+21
-10
@@ -325,22 +325,33 @@ def setup_webhook_routes(
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
model = body.model or "auto"
|
||||
api_key = ep.api_key
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||
|
||||
if model == "auto":
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
models_url = build_models_url(base_url)
|
||||
hdrs = build_headers(api_key, base_url)
|
||||
resp = await client.get(models_url, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not ids:
|
||||
ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
if models_url:
|
||||
resp = await client.get(models_url, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not ids:
|
||||
ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
import json as _json
|
||||
ids = _json.loads(ep.cached_models or "[]")
|
||||
model = ids[0] if ids else "auto"
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not discover models from endpoint")
|
||||
|
||||
@@ -13,6 +13,8 @@ import json
|
||||
|
||||
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):
|
||||
count = 0
|
||||
@@ -35,8 +37,8 @@ def main():
|
||||
|
||||
# 1. Memories (JSON files)
|
||||
for label, path in [
|
||||
("memory.json", "data/memory.json"),
|
||||
("skills.json", "data/skills.json"),
|
||||
("memory.json", MEMORY_FILE),
|
||||
("skills.json", SKILLS_FILE),
|
||||
]:
|
||||
if not os.path.exists(path):
|
||||
print(f" {label}: not found, skipping")
|
||||
|
||||
@@ -19,6 +19,9 @@ import sys
|
||||
from pathlib import Path
|
||||
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
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -45,7 +48,7 @@ def main():
|
||||
rag_manager = RAGManager()
|
||||
|
||||
# Directory to scan
|
||||
docs_directory = "data/personal_docs"
|
||||
docs_directory = PERSONAL_DIR
|
||||
directory_path = Path(docs_directory)
|
||||
|
||||
# Check if directory exists
|
||||
|
||||
@@ -63,10 +63,10 @@ def migrate_memories():
|
||||
"""Migrate memory vectors from FAISS to ChromaDB."""
|
||||
from src.chroma_client import get_chroma_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")
|
||||
memory_path = os.path.join(DATA_DIR, "memory.json")
|
||||
ids_path = os.path.join(MEMORY_VECTORS_DIR, "ids.json")
|
||||
memory_path = MEMORY_FILE
|
||||
|
||||
if not os.path.exists(ids_path):
|
||||
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 src.rag_manager import RAGManager
|
||||
from src.constants import CHROMA_DIR
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -34,7 +35,7 @@ class DocsService:
|
||||
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)
|
||||
|
||||
async def query(self, query: str, top_k: int = 5) -> List[DocChunk]:
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from .memory import MemoryManager
|
||||
from .memory_vector import MemoryVectorStore
|
||||
from src.memory_provider import MemoryRecord, NativeMemoryProvider
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -38,7 +39,7 @@ class MemoryService:
|
||||
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.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
|
||||
os.path.join(data_dir, "memory_vectors")
|
||||
|
||||
@@ -15,10 +15,11 @@ from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from src.research_utils import is_low_quality
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RESEARCH_DATA_DIR = Path("data/deep_research")
|
||||
RESEARCH_DATA_DIR = Path(DEEP_RESEARCH_DIR)
|
||||
|
||||
|
||||
class ResearchHandler:
|
||||
|
||||
@@ -9,6 +9,8 @@ import httpx
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from src.constants import TTS_CACHE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -35,7 +37,7 @@ class TTSService:
|
||||
"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.mkdir(parents=True, exist_ok=True)
|
||||
self._kokoro = None # lazy-init
|
||||
|
||||
@@ -12,19 +12,24 @@ import subprocess
|
||||
import sys
|
||||
|
||||
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 = [
|
||||
DATA_DIR,
|
||||
os.path.join(DATA_DIR, "uploads"),
|
||||
os.path.join(DATA_DIR, "personal_docs"),
|
||||
os.path.join(DATA_DIR, "personal_uploads"),
|
||||
os.path.join(DATA_DIR, "tts_cache"),
|
||||
os.path.join(DATA_DIR, "generated_images"),
|
||||
os.path.join(DATA_DIR, "deep_research"),
|
||||
os.path.join(DATA_DIR, "chroma"),
|
||||
os.path.join(DATA_DIR, "rag"),
|
||||
os.path.join(DATA_DIR, "memory_vectors"),
|
||||
UPLOAD_DIR,
|
||||
PERSONAL_DIR,
|
||||
PERSONAL_UPLOADS_DIR,
|
||||
TTS_CACHE_DIR,
|
||||
GENERATED_IMAGES_DIR,
|
||||
DEEP_RESEARCH_DIR,
|
||||
CHROMA_DIR,
|
||||
RAG_DIR,
|
||||
MEMORY_VECTORS_DIR,
|
||||
os.path.join(BASE_DIR, "logs"),
|
||||
]
|
||||
|
||||
@@ -74,7 +79,7 @@ def _prompt_admin_credentials():
|
||||
|
||||
def create_default_admin():
|
||||
"""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):
|
||||
print(" [skip] auth.json already exists")
|
||||
return "exists"
|
||||
|
||||
+43
-27
@@ -14,6 +14,8 @@ import uuid
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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]:
|
||||
@@ -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 ""))
|
||||
|
||||
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)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
headers = build_headers(api_key, base)
|
||||
|
||||
if provider == "anthropic":
|
||||
# Anthropic: match against hardcoded model list
|
||||
@@ -112,16 +117,20 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
else:
|
||||
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
||||
try:
|
||||
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not model_ids:
|
||||
model_ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
models_url = build_models_url(base)
|
||||
if models_url:
|
||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not model_ids:
|
||||
model_ids = [
|
||||
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:
|
||||
model_ids = []
|
||||
|
||||
@@ -1119,25 +1128,32 @@ async def do_list_models(content: str, session_id: Optional[str] = None, owner:
|
||||
total_models = 0
|
||||
|
||||
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)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
headers = build_headers(api_key, base)
|
||||
|
||||
model_ids = []
|
||||
if provider == "anthropic":
|
||||
model_ids = list(ANTHROPIC_MODELS)
|
||||
else:
|
||||
try:
|
||||
r = httpx.get(build_models_url(base), headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not model_ids:
|
||||
model_ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
models_url = build_models_url(base)
|
||||
if models_url:
|
||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not model_ids:
|
||||
model_ids = [
|
||||
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:
|
||||
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
|
||||
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)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
@@ -1728,7 +1744,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
dl_resp = httpx.get(img["url"], timeout=60)
|
||||
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)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
|
||||
+4
-3
@@ -38,9 +38,10 @@ from core.platform_compat import (
|
||||
pid_alive,
|
||||
)
|
||||
|
||||
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
|
||||
_JOBS_DIR = _DATA_DIR / "bg_jobs"
|
||||
_STORE = _DATA_DIR / "bg_jobs.json"
|
||||
from src.constants import BG_JOBS_DIR, BG_JOBS_FILE
|
||||
|
||||
_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
|
||||
# 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 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__)
|
||||
|
||||
@@ -349,7 +350,7 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
try:
|
||||
from pathlib import Path
|
||||
import json as _json
|
||||
research_dir = Path("data/deep_research")
|
||||
research_dir = Path(DEEP_RESEARCH_DIR)
|
||||
if not research_dir.exists():
|
||||
raise TaskNoop("no research directory")
|
||||
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 sqlalchemy import func
|
||||
|
||||
STATE_FILE = Path("data/tidy_calendar_state.json")
|
||||
STATE_FILE = Path(TIDY_CALENDAR_STATE_FILE)
|
||||
last_watermark = None
|
||||
try:
|
||||
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
|
||||
# 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"))
|
||||
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)
|
||||
# One-time migration: if legacy global file exists and per-owner file
|
||||
# doesn't, seed from global (entries for OTHER owners still get pruned
|
||||
# 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():
|
||||
try:
|
||||
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
|
||||
# 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"))
|
||||
STATE_PATH = _P(f"data/email_urgency_state_{_owner_slug}.json")
|
||||
CACHE_DIR = _P("data/email_urgency_cache")
|
||||
STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json"
|
||||
CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
AGE_CUTOFF = _dt.utcnow() - _td(days=7)
|
||||
@@ -2043,7 +2044,7 @@ async def action_cookbook_serve(
|
||||
except Exception:
|
||||
end_after_min = 0
|
||||
|
||||
state_path = Path(DATA_DIR) / "cookbook_state.json"
|
||||
state_path = Path(COOKBOOK_STATE_FILE)
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
||||
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 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
|
||||
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
|
||||
# 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")
|
||||
|
||||
# Data paths
|
||||
data_dir: Path = Field(default=Path("data"), description="Main data directory")
|
||||
uploads_dir: Path = Field(default=Path("data/uploads"), description="Directory for uploaded files")
|
||||
sessions_file: Path = Field(default=Path("data/sessions.json"), description="Sessions storage file")
|
||||
memory_file: Path = Field(default=Path("data/memory.json"), description="Memory storage file")
|
||||
memory_doc: Path = Field(default=Path("data/memory_doc.md"), description="Memory document file")
|
||||
personal_dir: Path = Field(default=Path("data/personal_docs"), description="Personal documents directory")
|
||||
runbook_dir: Path = Field(default=Path("data/personal_docs/runbook"), description="Runbook directory")
|
||||
data_dir: Path = Field(default=Path(_DATA_DIR_CONST), description="Main data directory")
|
||||
uploads_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "uploads", description="Directory for uploaded files")
|
||||
sessions_file: Path = Field(default=Path(_DATA_DIR_CONST) / "sessions.json", description="Sessions storage file")
|
||||
memory_file: Path = Field(default=Path(_DATA_DIR_CONST) / "memory.json", description="Memory storage file")
|
||||
memory_doc: Path = Field(default=Path(_DATA_DIR_CONST) / "memory_doc.md", description="Memory document file")
|
||||
personal_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs", description="Personal documents directory")
|
||||
runbook_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs" / "runbook", description="Runbook directory")
|
||||
|
||||
# Upload settings
|
||||
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
|
||||
|
||||
# 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
|
||||
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_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
||||
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
|
||||
# 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")
|
||||
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
||||
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")
|
||||
FEATURES_FILE = os.path.join(DATA_DIR, "features.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,
|
||||
# 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_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')}"
|
||||
|
||||
@@ -19,7 +19,8 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
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__)
|
||||
|
||||
@@ -130,7 +131,7 @@ async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = ""
|
||||
|
||||
|
||||
async def _tick() -> None:
|
||||
state_path = Path(DATA_DIR) / "cookbook_state.json"
|
||||
state_path = Path(COOKBOOK_STATE_FILE)
|
||||
if not state_path.exists():
|
||||
return
|
||||
try:
|
||||
|
||||
+4
-8
@@ -14,6 +14,8 @@ Set EMBEDDING_URL in .env, e.g.:
|
||||
|
||||
import os
|
||||
|
||||
from src.constants import FASTEMBED_CACHE_DIR, EMBEDDING_ENDPOINT_FILE
|
||||
|
||||
# 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
|
||||
# ([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
|
||||
# the download lands exactly where the admin panel's _is_downloaded()
|
||||
# check looks (both default to this same path).
|
||||
cache_dir = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "fastembed_cache",
|
||||
)
|
||||
cache_dir = FASTEMBED_CACHE_DIR
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Windows self-heal: the HuggingFace-hub cache stores model files as
|
||||
# symlinks (snapshots/<rev>/model.onnx -> ../../blobs/<hash>). On a
|
||||
@@ -188,10 +187,7 @@ class FastEmbedClient:
|
||||
def _load_persisted_endpoint() -> dict:
|
||||
"""Load the custom embedding endpoint saved from the admin panel."""
|
||||
try:
|
||||
endpoint_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "embedding_endpoint.json",
|
||||
)
|
||||
endpoint_file = EMBEDDING_ENDPOINT_FILE
|
||||
if os.path.exists(endpoint_file):
|
||||
import json
|
||||
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]
|
||||
|
||||
|
||||
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
|
||||
_tailscale_cache: Dict[str, Optional[str]] = {}
|
||||
|
||||
@@ -133,7 +152,7 @@ def resolve_url(url: str) -> str:
|
||||
def normalize_base(url: str) -> str:
|
||||
"""Strip known API path suffixes from a base URL."""
|
||||
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):
|
||||
url = url[: -len(suffix)].rstrip("/")
|
||||
for suffix in ["/chat", "/tags", "/generate"]:
|
||||
@@ -158,10 +177,12 @@ def build_chat_url(base: str) -> str:
|
||||
return _anthropic_api_root(base) + "/v1/messages"
|
||||
if provider == "ollama":
|
||||
return _ollama_api_root(base) + "/chat"
|
||||
if provider == "chatgpt-subscription":
|
||||
return base.rstrip("/") + "/responses"
|
||||
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."""
|
||||
base = resolve_url(base)
|
||||
provider = _detect_provider(base)
|
||||
@@ -169,6 +190,8 @@ def build_models_url(base: str) -> str:
|
||||
return _anthropic_api_root(base) + "/v1/models"
|
||||
if provider == "ollama":
|
||||
return _ollama_api_root(base) + "/tags"
|
||||
if provider == "chatgpt-subscription":
|
||||
return None
|
||||
return base + "/models"
|
||||
|
||||
|
||||
@@ -184,6 +207,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||
if provider == "copilot":
|
||||
from src.copilot import copilot_headers
|
||||
return copilot_headers(api_key)
|
||||
if provider == "chatgpt-subscription":
|
||||
from src.chatgpt_subscription import chatgpt_headers
|
||||
return chatgpt_headers(api_key)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if provider == "openrouter":
|
||||
@@ -262,9 +288,13 @@ def resolve_endpoint(
|
||||
if not ep:
|
||||
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)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
headers = build_headers(api_key, base)
|
||||
|
||||
# Discard a configured model the user has since disabled on the
|
||||
# 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()
|
||||
if not ep:
|
||||
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)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
headers = build_headers(api_key, base)
|
||||
m = (model or "").strip()
|
||||
# Drop a model the user disabled on the endpoint, then pick the first
|
||||
# enabled chat model rather than a hidden one.
|
||||
|
||||
+3
-3
@@ -12,6 +12,8 @@ import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from src.constants import AUTH_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_task_scheduler = None
|
||||
@@ -54,9 +56,7 @@ def _resolve_event_owner(owner: Optional[str]) -> Optional[str]:
|
||||
return owner
|
||||
|
||||
try:
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
auth_path = os.path.join(DATA_DIR, "auth.json")
|
||||
auth_path = AUTH_FILE
|
||||
with open(auth_path, "r", encoding="utf-8") as f:
|
||||
users = (json.load(f).get("users") or {})
|
||||
for username, data in users.items():
|
||||
|
||||
@@ -4,8 +4,10 @@ from pathlib import Path
|
||||
|
||||
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(
|
||||
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.platform_compat import safe_chmod
|
||||
from src.secret_storage import decrypt, encrypt, is_encrypted
|
||||
from src.constants import DATA_DIR, INTEGRATIONS_FILE, SETTINGS_FILE
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json")
|
||||
DATA_FILE = INTEGRATIONS_FILE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets
|
||||
@@ -471,7 +472,7 @@ def get_integrations_prompt() -> str:
|
||||
def migrate_from_settings() -> None:
|
||||
"""If data/settings.json has miniflux_url and miniflux_api_key, create a
|
||||
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):
|
||||
return
|
||||
|
||||
|
||||
+217
-7
@@ -426,6 +426,9 @@ def _detect_provider(url: str) -> str:
|
||||
return "openrouter"
|
||||
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
|
||||
if is_copilot_base(url):
|
||||
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"): return "OpenCode Zen"
|
||||
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
|
||||
if is_copilot_base(url): return "GitHub Copilot"
|
||||
if _host_match(url, "mistral.ai"): return "Mistral"
|
||||
@@ -479,6 +484,77 @@ def _provider_label(url: str) -> str:
|
||||
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:
|
||||
"""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:
|
||||
"""Normalize model/chat URLs to the configured endpoint base."""
|
||||
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):
|
||||
base = base[: -len(suffix)].rstrip("/")
|
||||
for suffix in ("/chat", "/tags", "/generate"):
|
||||
@@ -903,7 +979,12 @@ def _parse_model_cache(raw) -> List[str]:
|
||||
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."""
|
||||
target = _model_list_base(endpoint_url)
|
||||
if not target:
|
||||
@@ -914,7 +995,13 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
||||
return []
|
||||
db = SessionLocal()
|
||||
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:
|
||||
if _model_list_base(getattr(ep, "base_url", "")) != target:
|
||||
continue
|
||||
@@ -933,9 +1020,16 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
||||
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."""
|
||||
cached = _configured_cached_model_ids(base_chat_url)
|
||||
cached = _configured_cached_model_ids(base_chat_url, owner=owner, endpoint_id=endpoint_id)
|
||||
if cached:
|
||||
return cached
|
||||
provider = _detect_provider(base_chat_url)
|
||||
@@ -971,9 +1065,16 @@ def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT,
|
||||
pass
|
||||
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."""
|
||||
avail = list_model_ids(endpoint_url, timeout)
|
||||
avail = list_model_ids(endpoint_url, timeout, owner=owner, endpoint_id=endpoint_id)
|
||||
if not avail:
|
||||
return None
|
||||
if requested in avail:
|
||||
@@ -1169,6 +1270,49 @@ async def llm_call_async(
|
||||
logger.debug(f"Returning cached response for key: {cache_key}")
|
||||
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":
|
||||
target_url = _normalize_anthropic_url(url)
|
||||
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,
|
||||
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:
|
||||
target_url = url
|
||||
payload = {
|
||||
@@ -1325,6 +1473,68 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
return
|
||||
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 ──
|
||||
if provider == "ollama":
|
||||
_ollama_tool_calls: List[Dict] = []
|
||||
|
||||
+3
-1
@@ -7,6 +7,8 @@ A thin wrapper around VectorRAG for backward compatibility and additional featur
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from src.constants import CHROMA_DIR
|
||||
|
||||
# Try to import from different possible locations
|
||||
try:
|
||||
from rag_vector import VectorRAG
|
||||
@@ -24,7 +26,7 @@ class RAGManager:
|
||||
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."""
|
||||
self.vector_rag = VectorRAG(persist_directory=persist_directory)
|
||||
logger.info("RAGManager initialized as wrapper for VectorRAG")
|
||||
|
||||
@@ -6,6 +6,8 @@ import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from src.constants import RAG_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
rag_instance = None
|
||||
@@ -41,8 +43,7 @@ def get_rag_manager():
|
||||
try:
|
||||
from src.rag_vector import VectorRAG
|
||||
|
||||
base_dir = Path(__file__).parent.parent
|
||||
persist_dir = os.path.join(base_dir, "data", "rag")
|
||||
persist_dir = RAG_DIR
|
||||
|
||||
rag_instance = VectorRAG(persist_directory=persist_dir)
|
||||
if not rag_instance.healthy:
|
||||
|
||||
+3
-1
@@ -12,6 +12,8 @@ import re
|
||||
import logging
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
|
||||
from src.constants import CHROMA_DIR
|
||||
from pathlib import Path
|
||||
|
||||
from src.embedding_lanes import (
|
||||
@@ -51,7 +53,7 @@ def _generate_doc_id(text: str, owner: str = "") -> str:
|
||||
class VectorRAG:
|
||||
"""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._collection = None
|
||||
self._model = None
|
||||
|
||||
@@ -16,10 +16,11 @@ from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from src.research_utils import strip_thinking, is_low_quality
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
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}$")
|
||||
|
||||
|
||||
|
||||
@@ -25,10 +25,11 @@ from pathlib import Path
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from core.platform_compat import safe_chmod
|
||||
from src.constants import APP_KEY_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEY_PATH = Path(__file__).resolve().parent.parent / "data" / ".app_key"
|
||||
_KEY_PATH = Path(APP_KEY_FILE)
|
||||
_PREFIX = "enc:"
|
||||
_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_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.
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||
# 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]]:
|
||||
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import re
|
||||
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
|
||||
|
||||
|
||||
@@ -4057,7 +4057,7 @@ async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
|
||||
args = {}
|
||||
action = (args.get("action") or "list").lower()
|
||||
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
|
||||
# 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:
|
||||
"""Load Vaultwarden config from data/vault.json."""
|
||||
from pathlib import Path
|
||||
p = Path("data/vault.json")
|
||||
p = Path(VAULT_FILE)
|
||||
if p.exists():
|
||||
try:
|
||||
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
|
||||
from pathlib import Path
|
||||
p = Path("data/vault.json")
|
||||
p = Path(VAULT_FILE)
|
||||
cfg = {}
|
||||
if p.exists():
|
||||
try:
|
||||
|
||||
+17
-1
@@ -1591,7 +1591,15 @@ function initializeEventListeners() {
|
||||
function applyModeToToggles(mode) {
|
||||
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
|
||||
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);
|
||||
btn.classList.toggle('active', on);
|
||||
if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
|
||||
@@ -1606,6 +1614,14 @@ function initializeEventListeners() {
|
||||
const state = loadToggleState();
|
||||
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) {
|
||||
currentMode = mode;
|
||||
const st = loadToggleState();
|
||||
|
||||
@@ -2108,6 +2108,8 @@
|
||||
<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.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://ollama.com/api" data-logo="ollama">Ollama Cloud</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>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+201
-68
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -707,6 +708,80 @@ function initEndpointForm() {
|
||||
const pickerBtn = el('adm-provider-btn');
|
||||
const pickerMenu = el('adm-provider-menu');
|
||||
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() {
|
||||
if (!pickerMenu) return;
|
||||
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
||||
@@ -748,9 +823,16 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
provider.addEventListener('change', () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
_setApiFormForProvider();
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
return;
|
||||
}
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||
_setApiFormForProvider();
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
@@ -838,6 +920,12 @@ function initEndpointForm() {
|
||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||
if (apiTestBtn) {
|
||||
apiTestBtn.addEventListener('click', async () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -885,6 +973,11 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||
if (deviceAuthProvider) {
|
||||
await _startProviderDeviceAuth(deviceAuthProvider, el('adm-epAddBtn'));
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -936,76 +1029,116 @@ function initEndpointForm() {
|
||||
btn.disabled = false; btn.textContent = 'Add';
|
||||
});
|
||||
|
||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
||||
// code + verification link, and polls until they authorise (or it expires).
|
||||
const copilotBtn = el('adm-copilotConnectBtn');
|
||||
if (copilotBtn) {
|
||||
let copilotPolling = false;
|
||||
copilotBtn.addEventListener('click', async () => {
|
||||
if (copilotPolling) return;
|
||||
const status = el('adm-copilotStatus');
|
||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
||||
copilotPolling = true;
|
||||
let start;
|
||||
try {
|
||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await res.json();
|
||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
||||
async function _startProviderDeviceAuth(providerKey, triggerEl = null) {
|
||||
if (deviceAuthPolling) return;
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) return;
|
||||
const status = el('adm-deviceAuthStatus') || _endpointMsg('api');
|
||||
if (!status) return;
|
||||
const triggerText = triggerEl ? triggerEl.textContent : '';
|
||||
// Render an error with an inline "Try again" (the top button is hidden for
|
||||
// device-auth providers, so retry lives here). Built with DOM methods, not
|
||||
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
|
||||
const showAuthError = (text) => {
|
||||
status.className = 'admin-error';
|
||||
status.textContent = text + ' ';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'admin-btn-sm';
|
||||
retry.textContent = 'Try again';
|
||||
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;
|
||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
||||
// click "Authorize" (no manual code entry).
|
||||
const authUrl = verification_uri_complete || verification_uri || '';
|
||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
copilotBtn.textContent = 'Waiting…';
|
||||
|
||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
||||
status.className = '';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
||||
try {
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
openWindow: () => {},
|
||||
onStart: ({ start, authUrl }) => {
|
||||
if (triggerEl) triggerEl.textContent = 'Waiting...';
|
||||
status.className = '';
|
||||
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const code = start.user_code || '';
|
||||
let ok = false;
|
||||
try {
|
||||
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) {}
|
||||
|
||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d.endpoint || {});
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const endpoint = result.endpoint || {};
|
||||
const n = ((endpoint && endpoint.models) || []).length;
|
||||
status.className = 'admin-success';
|
||||
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(endpoint || {});
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
reset();
|
||||
showAuthError('Authorization failed (' + (result.error || 'denied') + ').');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
reset();
|
||||
showAuthError('Authorization expired.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
reset();
|
||||
showAuthError(formatDeviceFlowError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 createResearchSynapse from './researchSynapse.js';
|
||||
import { createStreamRenderer } from './streamingRenderer.js';
|
||||
import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js';
|
||||
|
||||
const RESEARCH_TIMEOUT_MS = 360000;
|
||||
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>';
|
||||
@@ -217,6 +219,19 @@ import { createStreamRenderer } from './streamingRenderer.js';
|
||||
const ta = document.getElementById('message');
|
||||
if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta);
|
||||
}).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
|
||||
|
||||
+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>';
|
||||
}
|
||||
}
|
||||
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</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>';
|
||||
if (isCostTrackedEndpoint(_epUrl)) {
|
||||
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</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;
|
||||
const rect = roleEl.getBoundingClientRect();
|
||||
popup.style.top = (rect.bottom + 4) + 'px';
|
||||
@@ -735,11 +737,31 @@ export function isLocalEndpoint(url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cost for the current turn, returning null (free) for local endpoints. */
|
||||
function _billableCost(model, inputTokens, outputTokens) {
|
||||
const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
export function isSubscriptionEndpoint(url) {
|
||||
if (!url) return false;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -784,11 +806,10 @@ export function resetSessionCost(sessionId) {
|
||||
export function updateSessionCostUI() {
|
||||
const el = document.getElementById('session-cost-display');
|
||||
if (!el) return;
|
||||
// Local model? It's free — hide the badge and clear any stale cost that a
|
||||
// previous (buggy) cloud-rate billing left in localStorage for this session.
|
||||
const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||
if (isLocalEndpoint(_url)) {
|
||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
||||
// cloud-rate calculation may have left in localStorage for this session.
|
||||
const _url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(_url)) {
|
||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (sid && getSessionCost(sid) > 0) {
|
||||
try {
|
||||
@@ -1708,7 +1729,8 @@ export function displayMetrics(messageElement, metrics) {
|
||||
e.stopPropagation();
|
||||
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 totalTok = inputTokens + outputTokens;
|
||||
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
|
||||
let sessionCostStr = '';
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -1738,7 +1760,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
<div><span class="ctx-label">Time</span> ${responseTime}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>` : ''}
|
||||
<div><span class="ctx-label">Cost</span> ${costStr}</div>
|
||||
${costRows}
|
||||
${sessionCostStr}
|
||||
${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>
|
||||
@@ -2392,6 +2414,8 @@ const chatRenderer = {
|
||||
modelColor,
|
||||
applyModelColor,
|
||||
getModelCost,
|
||||
isCostTrackedEndpoint,
|
||||
isSubscriptionEndpoint,
|
||||
getImageCost,
|
||||
getSessionCost,
|
||||
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,
|
||||
'<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|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>'],
|
||||
@@ -102,6 +106,7 @@ export function providerLogo(modelId) {
|
||||
// doesn't match `x.ai`.
|
||||
const _ENDPOINT_LABELS = [
|
||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
|
||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
|
||||
|
||||
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
|
||||
// either a top-level command or a "cmd sub" pair (so subcommands get their
|
||||
@@ -81,6 +81,23 @@ function _flatten() {
|
||||
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) {
|
||||
// query already starts with "/". Match against token + aliases. Prefix wins
|
||||
// over substring; alias match scores slightly lower than token match.
|
||||
@@ -98,6 +115,17 @@ function _scoreMatch(entry, query) {
|
||||
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) {
|
||||
let el = document.getElementById(POPUP_ID);
|
||||
if (el) return el;
|
||||
@@ -164,7 +192,7 @@ export function initSlashAutocomplete(textarea) {
|
||||
if (!textarea || textarea._slashAcWired) return;
|
||||
textarea._slashAcWired = true;
|
||||
|
||||
const all = _flatten();
|
||||
let all = _flatten();
|
||||
let popup = null;
|
||||
let visible = false;
|
||||
let items = [];
|
||||
@@ -191,12 +219,17 @@ export function initSlashAutocomplete(textarea) {
|
||||
// the menu hides — we don't autocomplete mid-sentence.
|
||||
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
|
||||
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) }))
|
||||
.filter(x => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.slice(0, MAX_VISIBLE)
|
||||
.map(x => x.e);
|
||||
}
|
||||
if (!items.length && query.length > 1) { hide(); return; }
|
||||
if (!items.length) {
|
||||
// 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);
|
||||
};
|
||||
|
||||
_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) => {
|
||||
textarea.value = token + ' ';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
+351
-71
@@ -21,6 +21,7 @@ import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
// ── Module state ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,11 +59,28 @@ const SETUP_PROVIDER_URLS = {
|
||||
'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_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_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>';
|
||||
|
||||
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) {
|
||||
const raw = (input || '').trim().toLowerCase().replace(/\s+/g, '');
|
||||
const aliases = {
|
||||
@@ -84,6 +102,17 @@ function _setupProviderFromInput(input) {
|
||||
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) {
|
||||
const raw = (input || '').trim();
|
||||
if (!raw) return null;
|
||||
@@ -158,9 +187,8 @@ function _setupReply(text, remember = true) {
|
||||
}
|
||||
|
||||
function _showSetupEndpointChoices() {
|
||||
const providers = SETUP_PROVIDER_NAMES.map(name =>
|
||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||
).join(' ');
|
||||
const providers = _setupApiProviderChips();
|
||||
const deviceAuthProviders = _setupDeviceAuthProviderChips();
|
||||
return slashReply(
|
||||
'<div class="setup-guide-no-censor" style="display:grid;gap:10px;">' +
|
||||
'<div>' +
|
||||
@@ -178,6 +206,7 @@ function _showSetupEndpointChoices() {
|
||||
'<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>' +
|
||||
'<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>'
|
||||
);
|
||||
@@ -208,9 +237,8 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
||||
text: 'deepseek sk-...',
|
||||
copyText: 'deepseek sk-...',
|
||||
},
|
||||
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + SETUP_PROVIDER_NAMES.map(name =>
|
||||
'<span class="setup-clickable-provider" style="cursor:pointer;text-decoration:underline;margin-right:8px;" title="Click to setup ' + name + '">' + name + '</span>'
|
||||
).join(' ') },
|
||||
{ kind: 'p', html: '<strong>Supported providers:</strong><br>' + _setupApiProviderChips() },
|
||||
{ kind: 'p', html: '<strong>Account sign-in:</strong><br>' + _setupDeviceAuthProviderChips() },
|
||||
];
|
||||
return typewriterBlocksReply(blocks, { gap: '4px', bodyClass: 'setup-guide-no-censor', interval: 3 });
|
||||
}
|
||||
@@ -231,7 +259,7 @@ async function _hasConfiguredModels() {
|
||||
}
|
||||
|
||||
function _setupProviderPrompt() {
|
||||
const chips = SETUP_PROVIDER_NAMES.map(name =>
|
||||
const chips = SETUP_PROVIDER_HINT_NAMES.map(name =>
|
||||
'<span style="font-weight:650;">' + name + '</span>'
|
||||
).join(' ');
|
||||
slashReply('<b>Supported providers:</b><br>' + chips);
|
||||
@@ -286,6 +314,53 @@ function slashReply(text) {
|
||||
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 */
|
||||
function _slashFooter(msgEl) {
|
||||
const footer = document.createElement('div');
|
||||
@@ -681,6 +756,13 @@ async function handleSetupWizard(mode, input) {
|
||||
await _setupProviderPrompt();
|
||||
return;
|
||||
}
|
||||
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(input);
|
||||
if (deviceAuthProvider) {
|
||||
_addMessage('user', input);
|
||||
setupMode = false;
|
||||
await _setupProviderDeviceFlow(deviceAuthProvider);
|
||||
return;
|
||||
}
|
||||
const paired = _extractSetupProviderCredential(input);
|
||||
const provider = paired?.provider || _setupProviderFromInput(input);
|
||||
if (!provider) {
|
||||
@@ -1429,6 +1511,42 @@ async function _cmdModels(args, ctx) {
|
||||
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 ──
|
||||
|
||||
async function _cmdMemoryList(args, ctx) {
|
||||
@@ -1507,6 +1625,73 @@ async function _cmdMemorySearch(args, ctx) {
|
||||
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) ──
|
||||
|
||||
async function _cmdNote(args, ctx) {
|
||||
@@ -1799,6 +1984,53 @@ Uploads: ${d.uploads || '?'}</pre>`);
|
||||
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 ──
|
||||
|
||||
async function _cmdCompact(args, ctx) {
|
||||
@@ -4783,39 +5015,53 @@ function _clearSetupCommandInput() {
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
|
||||
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
|
||||
async function _setupCopilot() {
|
||||
async function _setupProviderDeviceFlow(providerKey) {
|
||||
_clearSetupGuideMessages();
|
||||
await _setupReply('Starting GitHub Copilot sign-in…');
|
||||
let start;
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) {
|
||||
await _setupReply('Provider not recognised.');
|
||||
return;
|
||||
}
|
||||
await _setupReply(`Starting ${config.label} sign-in...`);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await r.json();
|
||||
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
|
||||
} catch (e) { await _setupReply('Request failed.'); return; }
|
||||
const authUrl = start.verification_uri_complete || start.verification_uri || '';
|
||||
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
const deadline = Date.now() + (start.expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((start.interval || 5), 2) * 1000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', start.poll_id);
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
onStart: async ({ start, authUrl }) => {
|
||||
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
|
||||
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
|
||||
if (providerKey === 'chatgpt-subscription') {
|
||||
slashReply(
|
||||
'<div class="setup-guide-no-censor" style="display:grid;gap:6px;">' +
|
||||
'<div>Open this URL in your browser, enter the code, then come back here. Waiting...</div>' +
|
||||
'<div>Code: <code>' + uiModule.esc(start.user_code || '') + '</code></div>' +
|
||||
'<div><a href="' + uiModule.esc(authUrl || '') + '" target="_blank" rel="noopener noreferrer">' + uiModule.esc(authUrl || '') + '</a></div>' +
|
||||
'</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _setupReply(`Opening ${place} - ${action} (code ${start.user_code}). Waiting...`);
|
||||
},
|
||||
openWindow: (url) => {
|
||||
if (providerKey === 'chatgpt-subscription') return;
|
||||
try { if (url) window.open(url, '_blank', 'noopener'); } catch (e) {}
|
||||
},
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const n = ((result.endpoint && result.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
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) {
|
||||
@@ -4823,7 +5069,11 @@ async function _cmdSetup(args, ctx) {
|
||||
_clearSetupCommandInput();
|
||||
const topic = (args[0] || '').trim().toLowerCase();
|
||||
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);
|
||||
if (provider) {
|
||||
_clearSetupGuideMessages();
|
||||
@@ -5463,8 +5713,20 @@ async function _cmdHelp(args, ctx) {
|
||||
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('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>`);
|
||||
return true;
|
||||
}
|
||||
@@ -5539,6 +5801,20 @@ const COMMANDS = {
|
||||
'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: {
|
||||
alias: [],
|
||||
category: 'RAG',
|
||||
@@ -5572,7 +5848,7 @@ const COMMANDS = {
|
||||
category: 'Getting started',
|
||||
help: 'Add local or API model endpoints',
|
||||
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",
|
||||
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
||||
// 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) },
|
||||
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) },
|
||||
'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)',
|
||||
usage: '/setup local http://localhost:8000/v1',
|
||||
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
||||
@@ -5767,8 +6044,22 @@ const COMMANDS = {
|
||||
handler: (args, ctx) => _cmdToolPanel('compare', args, ctx),
|
||||
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: {
|
||||
alias: ['model'],
|
||||
alias: [],
|
||||
category: 'Settings',
|
||||
help: 'List available models',
|
||||
handler: _cmdModels,
|
||||
@@ -5799,10 +6090,16 @@ const COMMANDS = {
|
||||
handler: _cmdStats,
|
||||
usage: '/stats'
|
||||
},
|
||||
usage: {
|
||||
alias: ['cost', 'tokens'],
|
||||
category: 'Utility',
|
||||
help: 'Show local usage for the current chat',
|
||||
handler: _cmdUsage,
|
||||
usage: '/usage'
|
||||
},
|
||||
compact: {
|
||||
alias: [],
|
||||
category: 'Utility',
|
||||
hidden: true,
|
||||
help: 'Compact older chat messages',
|
||||
handler: _cmdCompact,
|
||||
usage: '/compact'
|
||||
@@ -6075,33 +6372,13 @@ async function handleSlashCommand(input) {
|
||||
}
|
||||
|
||||
// --- 4. Skill invocation: /<skill-name> [request] ---
|
||||
// If `rawCmd` matches a published skill, pin its SKILL.md to the user's
|
||||
// message and re-submit. Lets you fire a stored procedure on demand
|
||||
// without the model having to discover the skill itself.
|
||||
// If `rawCmd` matches a published skill, the backend records usage and
|
||||
// returns a skill-pinned message to submit as the next agent turn.
|
||||
try {
|
||||
const skillRes = await fetch(`${API_BASE}/api/skills/${encodeURIComponent(rawCmd)}/markdown`, { credentials: 'same-origin' });
|
||||
if (skillRes.ok) {
|
||||
const skillData = await skillRes.json();
|
||||
const md = skillData.markdown || '';
|
||||
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;
|
||||
}
|
||||
const catalog = await _loadSkillSlashCatalog(false);
|
||||
if (catalog.some(s => s.name === rawCmd)) {
|
||||
_showUser();
|
||||
return await _invokeSkillByName(rawCmd, args.join(' ').trim(), ctx);
|
||||
}
|
||||
} catch (_) { /* fall through to fuzzy match */ }
|
||||
|
||||
@@ -6158,10 +6435,13 @@ export function initSlashCommands(deps) {
|
||||
const providerEl = e.target.closest('.setup-clickable-provider');
|
||||
if (providerEl) {
|
||||
e.preventDefault();
|
||||
const providerKey = providerEl.dataset.setupProvider || providerEl.textContent.trim();
|
||||
const providerName = providerEl.textContent.trim();
|
||||
const messageInput = document.getElementById('message');
|
||||
if (messageInput) {
|
||||
const text = providerName + ' sk-';
|
||||
const text = providerEl.dataset.setupKind === 'device-auth'
|
||||
? '/setup ' + providerKey
|
||||
: providerName + ' sk-';
|
||||
messageInput.value = text;
|
||||
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
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 "ep = _owned_enabled_endpoint(db, user)" 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": []}'])
|
||||
def test_find_admin_user_ignores_invalid_auth_shape(tmp_path, monkeypatch, payload):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "auth.json").write_text(payload)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text(payload)
|
||||
# find_admin_user reads the import-time AUTH_FILE constant, so redirect that
|
||||
# rather than relying on cwd.
|
||||
monkeypatch.setattr(P, "AUTH_FILE", str(auth_file))
|
||||
|
||||
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 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
|
||||
# during collection, drop the stub so the real URL helpers load here.
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
|
||||
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
|
||||
# during collection, drop the stub so the real URL helpers load here.
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
|
||||
if "core.database" not in sys.modules:
|
||||
_core_db = types.ModuleType("core.database")
|
||||
for _name in [
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||
]:
|
||||
setattr(_core_db, _name, MagicMock())
|
||||
sys.modules["core.database"] = _core_db
|
||||
if "core.database" not in sys.modules:
|
||||
_core_db = types.ModuleType("core.database")
|
||||
for _name in [
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||
"ProviderAuthSession", "Base",
|
||||
]:
|
||||
setattr(_core_db, _name, MagicMock())
|
||||
_core_db.utcnow_naive = MagicMock()
|
||||
sys.modules["core.database"] = _core_db
|
||||
|
||||
import routes.model_routes as model_routes
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
from routes.model_routes import (
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_probe_single_model,
|
||||
_classify_endpoint,
|
||||
_rewrite_loopback_for_docker,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
import routes.model_routes as model_routes
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
from routes.model_routes import (
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_probe_single_model,
|
||||
_resolve_probe_key,
|
||||
_classify_endpoint,
|
||||
_rewrite_loopback_for_docker,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
|
||||
|
||||
def _patch_resolve(monkeypatch):
|
||||
@@ -117,6 +121,26 @@ class TestProbeEndpointParsing:
|
||||
)
|
||||
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 ──
|
||||
|
||||
@@ -321,6 +345,51 @@ class TestProbeSingleModel:
|
||||
_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]
|
||||
|
||||
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 ──
|
||||
|
||||
|
||||
@@ -37,4 +37,6 @@ def test_image_generation_endpoints_require_image_privilege():
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
return llm_core._build_anthropic_payload(
|
||||
"claude-3-5-sonnet",
|
||||
|
||||
+92
-42
@@ -11,49 +11,51 @@ from types import SimpleNamespace
|
||||
import httpx
|
||||
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
|
||||
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
|
||||
# Other tests stub this module during collection. These helper tests need
|
||||
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
|
||||
if "core.database" not in sys.modules:
|
||||
_core_db = types.ModuleType("core.database")
|
||||
for _name in [
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun",
|
||||
"McpServer",
|
||||
]:
|
||||
setattr(_core_db, _name, MagicMock())
|
||||
sys.modules["core.database"] = _core_db
|
||||
if "core.database" not in sys.modules:
|
||||
_core_db = types.ModuleType("core.database")
|
||||
for _name in [
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun",
|
||||
"McpServer", "ProviderAuthSession", "Base",
|
||||
]:
|
||||
setattr(_core_db, _name, MagicMock())
|
||||
_core_db.utcnow_naive = MagicMock()
|
||||
sys.modules["core.database"] = _core_db
|
||||
|
||||
import routes.model_routes as model_routes
|
||||
import src.database as src_database
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
import src.llm_core as llm_core
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
_visible_models,
|
||||
_normalize_model_ids,
|
||||
_api_key_fingerprint,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_effective_endpoint_kind,
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
_endpoint_settings_using_endpoint,
|
||||
_clear_endpoint_settings_for_endpoint,
|
||||
_clear_user_pref_endpoint_refs,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
from src.llm_core import ANTHROPIC_MODELS
|
||||
import routes.model_routes as model_routes
|
||||
import src.database as src_database
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
import src.llm_core as llm_core
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
_visible_models,
|
||||
_normalize_model_ids,
|
||||
_api_key_fingerprint,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_effective_endpoint_kind,
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
_endpoint_settings_using_endpoint,
|
||||
_clear_endpoint_settings_for_endpoint,
|
||||
_clear_user_pref_endpoint_refs,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
from src.llm_core import ANTHROPIC_MODELS
|
||||
|
||||
|
||||
# ── speech endpoint settings ──
|
||||
@@ -687,8 +689,7 @@ class _PinnedFakeRequest:
|
||||
|
||||
|
||||
def _get_route(path, method):
|
||||
from routes.model_routes import setup_model_routes
|
||||
router = setup_model_routes(model_discovery=None)
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
@@ -787,6 +788,55 @@ def test_reprobe_preserves_pinned_models(monkeypatch):
|
||||
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():
|
||||
# Non-JSON cached/pinned strings are treated as comma/newline lists and
|
||||
# never raise; a malformed hidden string is normalized too.
|
||||
|
||||
@@ -42,6 +42,10 @@ class TestHostMatch:
|
||||
|
||||
|
||||
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):
|
||||
assert llm_core._detect_provider("https://api.anthropic.com") == "anthropic"
|
||||
|
||||
@@ -93,6 +97,12 @@ class TestBuildersRejectLookalikeHosts:
|
||||
def test_real_anthropic_chat(self):
|
||||
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):
|
||||
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()
|
||||
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:
|
||||
@@ -129,3 +129,29 @@ def test_null_owner_is_legacy_single_user_noop():
|
||||
rows = [_ep("ep-x", "bob"), _ep("ep-y", "alice")]
|
||||
ep = _resolve(rows, None, "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
|
||||
|
||||
|
||||
@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):
|
||||
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, "load_prefs_for_user", lambda user: {})
|
||||
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, "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):
|
||||
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"))
|
||||
|
||||
@@ -963,7 +963,7 @@ def test_mcp_oauth_paths_reject_escapes(tmp_path, monkeypatch, raw_path):
|
||||
from fastapi import HTTPException
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
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")
|
||||
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):
|
||||
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({
|
||||
"provider": "google",
|
||||
|
||||
@@ -137,3 +137,12 @@ def test_unauthenticated_caller_rejected(monkeypatch):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
SR._verify_session_owner(req, "sid")
|
||||
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):
|
||||
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_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