Spaces:
Running
Running
Upload patch_frontend.py
#3
by bep40 - opened
- .gitignore +0 -2
- BUILD_TRIGGER +0 -1
- Dockerfile +33 -79
- README.md +8 -42
- backend/document_parser.py +0 -319
- backend/session_logger.py +0 -467
- configs/frontend_agent_config.json +1 -4
- fix_space_error_report.json +5 -10
- patch_agent_backend.py +20 -18
- patch_apodex_ling_beatapi.py +0 -145
- patch_beatapi_routing.py +0 -182
- patch_catch_up_dataset_fallback.py +0 -102
- patch_catch_up_fix_backend.py +0 -220
- patch_catch_up_fix_frontend.py +0 -289
- patch_document_upload.py +0 -225
- patch_fix_static_404.py +0 -105
- patch_frontend.py +9 -24
- patch_frontend_apodex_ling_beatapi.py +0 -110
- patch_frontend_multi.py +0 -120
- patch_frontend_new_models.py +0 -71
- patch_frontend_permanent_sessions.py +0 -45
- patch_frontend_uploads.py +0 -81
- patch_frontend_xing_nemotron_vl.py +0 -63
- patch_lossless_context.py +0 -365
- patch_models.py +80 -118
- patch_multi_upload.py +0 -183
- patch_new_models.py +0 -88
- patch_openrouter_fix.py +0 -92
- patch_permanent_session_logging.py +0 -358
- patch_restore_full_app.py +0 -84
- patch_space_bunny.py +0 -56
- patch_xing_nemotron_vl.py +0 -93
- scripts/build_kpis.py +0 -917
.gitignore
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.pyc
|
|
|
|
|
|
|
|
|
BUILD_TRIGGER
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
catch-up-message-loss-fix-v4-rebuild-xing-nemotron-vl-dockerfile
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,103 +1,57 @@
|
|
|
|
|
| 1 |
FROM node:20-alpine AS frontend-builder
|
| 2 |
RUN apk add --no-cache git python3
|
| 3 |
-
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source
|
| 4 |
-
|
| 5 |
-
|
| 6 |
COPY patch_frontend.py /tmp/patch_frontend.py
|
| 7 |
-
RUN python3 /tmp/patch_frontend.py
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
COPY patch_frontend_uploads.py /tmp/patch_frontend_uploads.py
|
| 11 |
-
RUN python3 /tmp/patch_frontend_uploads.py || true
|
| 12 |
-
COPY patch_frontend_multi.py /tmp/patch_frontend_multi.py
|
| 13 |
-
RUN python3 /tmp/patch_frontend_multi.py || true
|
| 14 |
-
COPY patch_frontend_new_models.py /tmp/patch_frontend_new_models.py
|
| 15 |
-
RUN python3 /tmp/patch_frontend_new_models.py || true
|
| 16 |
-
COPY patch_frontend_apodex_ling_beatapi.py /tmp/patch_frontend_apodex_ling_beatapi.py
|
| 17 |
-
RUN python3 /tmp/patch_frontend_apodex_ling_beatapi.py || true
|
| 18 |
-
COPY patch_frontend_xing_nemotron_vl.py /tmp/patch_frontend_xing_nemotron_vl.py
|
| 19 |
-
RUN python3 /tmp/patch_frontend_xing_nemotron_vl.py || true
|
| 20 |
-
COPY patch_catch_up_dataset_fallback.py /tmp/patch_catch_up_dataset_fallback.py
|
| 21 |
-
RUN python3 /tmp/patch_catch_up_dataset_fallback.py || true
|
| 22 |
-
# Run auto_continue patch BEFORE our frontend fix, since it also patches useAgentChat.ts
|
| 23 |
COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py
|
| 24 |
-
RUN python3 /tmp/patch_auto_continue_v17.py
|
| 25 |
-
# Run our frontend catch-up fix AFTER all other frontend patches
|
| 26 |
-
COPY patch_catch_up_fix_frontend.py /tmp/patch_catch_up_fix_frontend.py
|
| 27 |
-
RUN python3 /tmp/patch_catch_up_fix_frontend.py || true
|
| 28 |
|
| 29 |
WORKDIR /source/frontend
|
| 30 |
-
RUN npm config set fetch-timeout 120000 &&
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
|
|
|
| 34 |
FROM python:3.12-slim
|
| 35 |
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 36 |
RUN useradd -m -u 1000 user
|
| 37 |
WORKDIR /app
|
| 38 |
-
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates
|
| 39 |
-
|
| 40 |
-
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && cp -r /tmp/source/agent ./agent && cp -r /tmp/source/backend ./backend && (cp /tmp/source/backend/document_parser.py ./backend/document_parser.py 2>/dev/null || true) && cp -r /tmp/source/configs ./configs && cp -r /tmp/source/scripts ./scripts && rm -rf /tmp/source
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
RUN python3 /tmp/patch_restore_full_app.py || true
|
| 47 |
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
COPY backend/document_parser.py /app/backend/document_parser.py
|
| 52 |
COPY patch_models.py /tmp/patch_models.py
|
| 53 |
-
RUN python /tmp/patch_models.py
|
| 54 |
-
COPY patch_new_models.py /tmp/patch_new_models.py
|
| 55 |
-
RUN python /tmp/patch_new_models.py || true
|
| 56 |
-
COPY patch_apodex_ling_beatapi.py /tmp/patch_apodex_ling_beatapi.py
|
| 57 |
-
RUN python3 /tmp/patch_apodex_ling_beatapi.py || true
|
| 58 |
-
COPY patch_xing_nemotron_vl.py /tmp/patch_xing_nemotron_vl.py
|
| 59 |
-
RUN python3 /tmp/patch_xing_nemotron_vl.py || true
|
| 60 |
-
COPY patch_openrouter_fix.py /tmp/patch_openrouter_fix.py
|
| 61 |
-
RUN python3 /tmp/patch_openrouter_fix.py || true
|
| 62 |
-
COPY patch_beatapi_routing.py /tmp/patch_beatapi_routing.py
|
| 63 |
-
RUN python3 /tmp/patch_beatapi_routing.py || true
|
| 64 |
-
COPY patch_agent_backend.py /tmp/patch_agent_backend.py
|
| 65 |
-
RUN python3 /tmp/patch_agent_backend.py || true
|
| 66 |
-
COPY patch_document_upload.py /tmp/patch_document_upload.py
|
| 67 |
-
RUN python3 /tmp/patch_document_upload.py || true
|
| 68 |
-
COPY patch_multi_upload.py /tmp/patch_multi_upload.py
|
| 69 |
-
RUN python3 /tmp/patch_multi_upload.py || true
|
| 70 |
-
COPY patch_permanent_session_logging.py /tmp/patch_permanent_session_logging.py
|
| 71 |
-
RUN python3 /tmp/patch_permanent_session_logging.py || true
|
| 72 |
-
COPY patch_catch_up_fix_backend.py /tmp/patch_catch_up_fix_backend.py
|
| 73 |
-
RUN python3 /tmp/patch_catch_up_fix_backend.py || true
|
| 74 |
-
|
| 75 |
-
# === PATCH: fix 404 on root and /api when frontend build is missing ===
|
| 76 |
-
# Root cause: mkdir -p /app/static creates an empty dir; main.py mounts
|
| 77 |
-
# StaticFiles on the empty dir, shadowing ALL API routes with 404s.
|
| 78 |
-
COPY patch_fix_static_404.py /tmp/patch_fix_static_404.py
|
| 79 |
-
RUN python3 /tmp/patch_fix_static_404.py || echo "STATIC FIX PATCH WARN (BUILD CONTINUES)"
|
| 80 |
|
| 81 |
-
#
|
| 82 |
-
COPY
|
| 83 |
-
RUN python /tmp/
|
| 84 |
-
RUN python3 -c "import ast; [ast.parse(open(f).read()) for f in ['/app/agent/context_manager/manager.py','/app/agent/core/agent_loop.py','/app/agent/core/session.py','/app/agent/core/session_resume.py','/app/agent/config.py']]; print('ALL PATCHED FILES SYNTAX OK')" || echo "SYNTAX CHECK WARN"
|
| 85 |
-
|
| 86 |
-
# Copy frontend dist from builder stage using COPY --from (multi-stage)
|
| 87 |
-
# The frontend-builder stage guarantees /source/frontend/dist exists (mkdir -p)
|
| 88 |
-
COPY --from=frontend-builder /source/frontend/dist /app/static
|
| 89 |
-
RUN echo "Static dir ready: $(ls /app/static 2>/dev/null | wc -l) files"
|
| 90 |
-
|
| 91 |
-
RUN mkdir -p /app/configs
|
| 92 |
-
RUN cp configs/frontend_agent_config.json /app/configs/frontend_agent_config.json 2>/dev/null || echo "No frontend config"
|
| 93 |
-
|
| 94 |
-
RUN grep -rl "where we here now" /app/static/ 2>/dev/null | xargs sed -i "s/where we here now//g" 2>/dev/null || true
|
| 95 |
|
| 96 |
RUN mkdir -p /app/session_logs && chown -R user:user /app
|
| 97 |
|
| 98 |
USER user
|
| 99 |
-
ENV HOME=/home/user
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
EXPOSE 7860
|
| 102 |
WORKDIR /app/backend
|
| 103 |
-
CMD ["
|
|
|
|
| 1 |
+
# Restore + patch frontend + backend models + auto-continue V17 + auto-approve
|
| 2 |
FROM node:20-alpine AS frontend-builder
|
| 3 |
RUN apk add --no-cache git python3
|
| 4 |
+
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /source
|
| 5 |
+
|
| 6 |
+
# Patch frontend: add OpenRouter models
|
| 7 |
COPY patch_frontend.py /tmp/patch_frontend.py
|
| 8 |
+
RUN python3 /tmp/patch_frontend.py
|
| 9 |
+
|
| 10 |
+
# Patch auto-continue V17
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
COPY patch_auto_continue_v17.py /tmp/patch_auto_continue_v17.py
|
| 12 |
+
RUN python3 /tmp/patch_auto_continue_v17.py
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
WORKDIR /source/frontend
|
| 15 |
+
RUN npm config set fetch-timeout 120000 && \
|
| 16 |
+
npm config set fetch-retries 3 && \
|
| 17 |
+
npm install && \
|
| 18 |
+
npm run build
|
| 19 |
|
| 20 |
+
# Stage 2: Production
|
| 21 |
FROM python:3.12-slim
|
| 22 |
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 23 |
RUN useradd -m -u 1000 user
|
| 24 |
WORKDIR /app
|
| 25 |
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
RUN git clone --depth 1 https://huggingface.co/spaces/smolagents/ml-intern /tmp/source && \
|
| 28 |
+
cp /tmp/source/pyproject.toml /tmp/source/uv.lock ./ && \
|
| 29 |
+
cp -r /tmp/source/agent ./agent && \
|
| 30 |
+
cp -r /tmp/source/backend ./backend && \
|
| 31 |
+
cp -r /tmp/source/configs ./configs && \
|
| 32 |
+
rm -rf /tmp/source
|
| 33 |
|
| 34 |
+
RUN uv sync --no-dev --frozen
|
|
|
|
| 35 |
|
| 36 |
+
COPY --from=frontend-builder /source/frontend/dist ./static/
|
| 37 |
+
COPY configs/frontend_agent_config.json ./configs/frontend_agent_config.json
|
| 38 |
|
| 39 |
+
# Patch backend: accept new model IDs
|
|
|
|
| 40 |
COPY patch_models.py /tmp/patch_models.py
|
| 41 |
+
RUN python /tmp/patch_models.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
# Patch backend: auto-continue detection + auto-approve
|
| 44 |
+
COPY patch_agent_backend.py /tmp/patch_agent_backend.py
|
| 45 |
+
RUN python /tmp/patch_agent_backend.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
RUN mkdir -p /app/session_logs && chown -R user:user /app
|
| 48 |
|
| 49 |
USER user
|
| 50 |
+
ENV HOME=/home/user \
|
| 51 |
+
PYTHONUNBUFFERED=1 \
|
| 52 |
+
PYTHONPATH=/app \
|
| 53 |
+
PATH="/app/.venv/bin:$PATH"
|
| 54 |
|
| 55 |
EXPOSE 7860
|
| 56 |
WORKDIR /app/backend
|
| 57 |
+
CMD ["sh", "start.sh"]
|
README.md
CHANGED
|
@@ -5,7 +5,7 @@ colorFrom: yellow
|
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
-
short_description: ML Intern V2 (FREE & SAVE MODEL
|
| 9 |
hf_oauth: true
|
| 10 |
hf_oauth_expiration_minutes: 43200
|
| 11 |
hf_oauth_scopes:
|
|
@@ -19,48 +19,14 @@ hf_oauth_scopes:
|
|
| 19 |
- write-discussions
|
| 20 |
tags:
|
| 21 |
- ml-intern
|
| 22 |
-
- permanent-session-logging
|
| 23 |
-
- persistent-context
|
| 24 |
---
|
| 25 |
|
| 26 |
-
# Space Multiverse — ML Intern V2
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
- Mỗi model (dù là model nào) đều được lưu session vĩnh viễn
|
| 36 |
-
- **Không có thông báo "where we here now?"** — giảm thiểu thông báo không cần thiết
|
| 37 |
-
|
| 38 |
-
### Cách hoạt động
|
| 39 |
-
|
| 40 |
-
1. Khi session được tạo → log ngay vào dataset với event_type=session_created
|
| 41 |
-
2. User gửi tin nhắn → log ngay role=user, event_type=message
|
| 42 |
-
3. Agent phản hồi → log ngay role=assistant, event_type=message
|
| 43 |
-
4. Tool call/result → log ngay event_type=tool_call/tool_result
|
| 44 |
-
5. Session kết thúc → log trajectory đầy đủ
|
| 45 |
-
|
| 46 |
-
### Truy cập lịch sử
|
| 47 |
-
|
| 48 |
-
- `GET /api/sessions-history` — lấy tất cả session của user
|
| 49 |
-
- `GET /api/session-history/{session_id}` — lấy full lịch sử một session
|
| 50 |
-
- `GET /api/dataset-info` — thông tin dataset lưu trữ
|
| 51 |
-
|
| 52 |
-
Dataset: [bep40/ml-intern-sessions](https://huggingface.co/datasets/bep40/ml-intern-sessions)
|
| 53 |
-
|
| 54 |
-
## Models
|
| 55 |
-
|
| 56 |
-
- **Default**: `openai/tencent/hy3:free`
|
| 57 |
-
- OpenRouter models: Tencent HY3, Gemma 4 31B, Llama 3.3 70B, DeepSeek V4, Laguna S 2.1
|
| 58 |
-
- BeatAPI: `beatapi/space-bunny-alpha` (provider riêng, cần BEATAPI_API_KEY)
|
| 59 |
-
- Cục bản chất, mọi model đều được lưu session vĩnh viễn
|
| 60 |
-
|
| 61 |
-
## Quick Start
|
| 62 |
-
|
| 63 |
-
1. Chọn model (dùng bất kỳ model nào — đều được lưu)
|
| 64 |
-
2. Chat bình thường
|
| 65 |
-
3. Session được lưu vĩnh viễn vào dataset
|
| 66 |
-
4. Sau Space restart, frontend tự động restore từ dataset
|
|
|
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
+
short_description: ML Intern V2 (FREE & SAVE MODEL)
|
| 9 |
hf_oauth: true
|
| 10 |
hf_oauth_expiration_minutes: 43200
|
| 11 |
hf_oauth_scopes:
|
|
|
|
| 19 |
- write-discussions
|
| 20 |
tags:
|
| 21 |
- ml-intern
|
|
|
|
|
|
|
| 22 |
---
|
| 23 |
|
| 24 |
+
# Space Multiverse — ML Intern V2 (FREE & SAVE MODEL)
|
| 25 |
|
| 26 |
+
<!-- rebuild: add-laguna-s21-2026-07-22T03:00:00Z -->
|
| 27 |
|
| 28 |
+
- Replaced GPT-5.5 model with **Laguna S 2.1:free** (path: `openai/poolside/laguna-s-2.1:free`)
|
| 29 |
+
- Added OpenRouter routing (catch-all `openai/` prefix → OpenRouter API)
|
| 30 |
+
- Default: `openai/tencent/hy3:free`
|
| 31 |
+
- Models: Tencent HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, **Laguna M.1:free**, **Laguna S 2.1:free**, Llama 3.1 8B
|
| 32 |
+
- Ollama: không thể dùng cho HF Space (local inference, không có cloud API free cho use case này)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/document_parser.py
DELETED
|
@@ -1,319 +0,0 @@
|
|
| 1 |
-
"""Extract plain-text context from uploaded office/binary documents.
|
| 2 |
-
|
| 3 |
-
The ML Intern agent runtime is a coding agent that mostly runs on text-only
|
| 4 |
-
LLM backends (many of the free OpenRouter models have no vision). So instead
|
| 5 |
-
of just handing the raw binary file to the model, we extract the meaningful
|
| 6 |
-
text out of it and present it as a compact, readable context block.
|
| 7 |
-
|
| 8 |
-
Supported formats:
|
| 9 |
-
PDF (.pdf)
|
| 10 |
-
Images (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .gif)
|
| 11 |
-
Excel (.xlsx, .xlsm, .xls)
|
| 12 |
-
Word (.docx)
|
| 13 |
-
PowerPoint (.pptx)
|
| 14 |
-
OpenDocument (.odt, .ods, .odp) (via LibreOffice)
|
| 15 |
-
Plain text (.txt, .md, .csv, .json, .jsonl, .rtf, .log)
|
| 16 |
-
|
| 17 |
-
IMPORTANT: For OCR we rely on pytesseract + a system tesseract binary.
|
| 18 |
-
For legacy binary office formats (.xls, .doc, .rtf, .odt/.ods/.odp) we rely
|
| 19 |
-
on LibreOffice headless (`libreoffice --headless --convert-to txt`). If those
|
| 20 |
-
system binaries are not installed, extraction degrades gracefully: the file is
|
| 21 |
-
still uploaded and referenced, but with a warning that the raw content could
|
| 22 |
-
not be parsed.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
from __future__ import annotations
|
| 26 |
-
|
| 27 |
-
import io
|
| 28 |
-
import os
|
| 29 |
-
import re
|
| 30 |
-
import shutil
|
| 31 |
-
import subprocess
|
| 32 |
-
import tempfile
|
| 33 |
-
from typing import Optional
|
| 34 |
-
|
| 35 |
-
# --- Optional heavy imports, guarded --------------------------------
|
| 36 |
-
try:
|
| 37 |
-
from PIL import Image
|
| 38 |
-
except Exception: # pragma: no cover
|
| 39 |
-
Image = None
|
| 40 |
-
|
| 41 |
-
try:
|
| 42 |
-
from pypdf import PdfReader
|
| 43 |
-
except Exception: # pragma: no cover
|
| 44 |
-
PdfReader = None
|
| 45 |
-
|
| 46 |
-
try:
|
| 47 |
-
import docx # python-docx
|
| 48 |
-
except Exception: # pragma: no cover
|
| 49 |
-
docx = None
|
| 50 |
-
|
| 51 |
-
try:
|
| 52 |
-
import openpyxl
|
| 53 |
-
except Exception: # pragma: no cover
|
| 54 |
-
openpyxl = None
|
| 55 |
-
|
| 56 |
-
try:
|
| 57 |
-
import pptx # python-pptx
|
| 58 |
-
except Exception: # pragma: no cover
|
| 59 |
-
pptx = None
|
| 60 |
-
|
| 61 |
-
MAX_CHARS = 60_000 # cap extracted text so the context block stays small
|
| 62 |
-
_PAGE_BREAK = "\n\n--- PAGE BREAK ---\n\n"
|
| 63 |
-
|
| 64 |
-
# Map extension -> parser kind
|
| 65 |
-
IMAGE_EXTS = {"png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif"}
|
| 66 |
-
TEXT_EXTS = {"txt", "md", "csv", "json", "jsonl", "log", "rtf"}
|
| 67 |
-
_LO_TEXT_FORMATS = {".xls", ".doc", ".odt", ".ods", ".odp"}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def _snippet(text: str, limit: int = 120) -> str:
|
| 71 |
-
text = " ".join(text.split())
|
| 72 |
-
return text[:limit] + ("…" if len(text) > limit else "")
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# --- Individual extractors -------------------------------------------
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _extract_pdf(data: bytes) -> str:
|
| 79 |
-
if PdfReader is None:
|
| 80 |
-
raise RuntimeError("pypdf not installed")
|
| 81 |
-
reader = PdfReader(io.BytesIO(data))
|
| 82 |
-
pages = []
|
| 83 |
-
for page in reader.pages:
|
| 84 |
-
try:
|
| 85 |
-
pages.append(page.extract_text() or "")
|
| 86 |
-
except Exception:
|
| 87 |
-
pages.append("")
|
| 88 |
-
return _PAGE_BREAK.join(pages)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def _extract_docx(data: bytes) -> str:
|
| 92 |
-
if docx is None:
|
| 93 |
-
raise RuntimeError("python-docx not installed")
|
| 94 |
-
document = docx.Document(io.BytesIO(data))
|
| 95 |
-
parts = []
|
| 96 |
-
|
| 97 |
-
# Tables first (structure is usually important)
|
| 98 |
-
for table in document.tables:
|
| 99 |
-
for row in table.rows:
|
| 100 |
-
cells = [c.text.strip() for c in row.cells]
|
| 101 |
-
parts.append(" | ".join(cells))
|
| 102 |
-
parts.append("")
|
| 103 |
-
|
| 104 |
-
for para in document.paragraphs:
|
| 105 |
-
t = para.text.strip()
|
| 106 |
-
if t:
|
| 107 |
-
parts.append(t)
|
| 108 |
-
return "\n".join(parts)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def _extract_xlsx(data: bytes) -> str:
|
| 112 |
-
if openpyxl is None:
|
| 113 |
-
raise RuntimeError("openpyxl not installed")
|
| 114 |
-
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
| 115 |
-
parts = []
|
| 116 |
-
for ws in wb.worksheets:
|
| 117 |
-
parts.append(f"### Sheet: {ws.title}")
|
| 118 |
-
for row in ws.iter_rows(values_only=True):
|
| 119 |
-
vals = ["" if v is None else str(v) for v in row]
|
| 120 |
-
line = " | ".join(vals).strip()
|
| 121 |
-
if line:
|
| 122 |
-
parts.append(line)
|
| 123 |
-
parts.append("")
|
| 124 |
-
return "\n".join(parts)
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _extract_pptx(data: bytes) -> str:
|
| 128 |
-
if pptx is None:
|
| 129 |
-
raise RuntimeError("python-pptx not installed")
|
| 130 |
-
prs = pptx.Presentation(io.BytesIO(data))
|
| 131 |
-
parts = []
|
| 132 |
-
for idx, slide in enumerate(prs.slides, start=1):
|
| 133 |
-
parts.append(f"### Slide {idx}")
|
| 134 |
-
for shape in slide.shapes:
|
| 135 |
-
if hasattr(shape, "text") and shape.text and shape.text.strip():
|
| 136 |
-
parts.append(shape.text.strip())
|
| 137 |
-
if shape.has_table if hasattr(shape, "has_table") else False:
|
| 138 |
-
for row in shape.table.rows:
|
| 139 |
-
parts.append(" | ".join(c.text.strip() for c in row.cells))
|
| 140 |
-
parts.append("")
|
| 141 |
-
return "\n".join(parts)
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def _extract_image(data: bytes) -> str:
|
| 145 |
-
"""OCR an image. Returns combined text + a note about the image."""
|
| 146 |
-
if Image is None:
|
| 147 |
-
raise RuntimeError("Pillow not installed")
|
| 148 |
-
tesseract = shutil.which("tesseract")
|
| 149 |
-
if not tesseract:
|
| 150 |
-
raise RuntimeError(
|
| 151 |
-
"OCR unavailable: tesseract binary not found on this runtime. "
|
| 152 |
-
"The image was still uploaded and attached by reference."
|
| 153 |
-
)
|
| 154 |
-
try:
|
| 155 |
-
import pytesseract
|
| 156 |
-
except Exception as exc: # pragma: no cover
|
| 157 |
-
raise RuntimeError(f"OCR unavailable: pytesseract import failed: {exc}")
|
| 158 |
-
|
| 159 |
-
image = Image.open(io.BytesIO(data))
|
| 160 |
-
try:
|
| 161 |
-
text = pytesseract.image_to_string(image)
|
| 162 |
-
finally:
|
| 163 |
-
try:
|
| 164 |
-
image.close()
|
| 165 |
-
except Exception:
|
| 166 |
-
pass
|
| 167 |
-
return text
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def _extract_rtf(data: bytes) -> str:
|
| 171 |
-
"""RTF is too messy to hand-parse well; route through LibreOffice if present."""
|
| 172 |
-
return _lo_convert_to_text(data, ".rtf")
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
def _extract_legacy_office(data: bytes, ext: str) -> str:
|
| 176 |
-
"""Legacy binary office formats (.xls, .doc) via LibreOffice headless."""
|
| 177 |
-
return _lo_convert_to_text(data, ext)
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def _lo_convert_to_text(data: bytes, ext: str) -> str:
|
| 181 |
-
"""Use LibreOffice headless to convert a document to plain text."""
|
| 182 |
-
soffice = shutil.which("libreoffice") or shutil.which("soffice")
|
| 183 |
-
if not soffice:
|
| 184 |
-
raise RuntimeError(
|
| 185 |
-
"LibreOffice not installed on this runtime; cannot parse this "
|
| 186 |
-
"format here. The file was still uploaded and attached by reference."
|
| 187 |
-
)
|
| 188 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 189 |
-
in_path = os.path.join(tmp, f"input{ext}")
|
| 190 |
-
out_dir = os.path.join(tmp, "out")
|
| 191 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 192 |
-
with open(in_path, "wb") as f:
|
| 193 |
-
f.write(data)
|
| 194 |
-
result = subprocess.run(
|
| 195 |
-
[soffice, "--headless", "--convert-to", "txt:Text (encoded):UTF8",
|
| 196 |
-
"--outdir", out_dir, in_path],
|
| 197 |
-
capture_output=True,
|
| 198 |
-
timeout=120,
|
| 199 |
-
)
|
| 200 |
-
if result.returncode != 0:
|
| 201 |
-
raise RuntimeError(
|
| 202 |
-
f"LibreOffice failed to convert this file (rc={result.returncode})."
|
| 203 |
-
)
|
| 204 |
-
out_files = os.listdir(out_dir)
|
| 205 |
-
if not out_files:
|
| 206 |
-
raise RuntimeError("LibreOffice produced no output for this file.")
|
| 207 |
-
with open(os.path.join(out_dir, out_files[0]), "r", encoding="utf-8", errors="ignore") as f:
|
| 208 |
-
return f.read()
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
# --- Public dispatcher -------------------------------------------------
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
def extract_document_text(filename: str, data: bytes) -> tuple[str, str]:
|
| 215 |
-
"""Extract text from an uploaded document.
|
| 216 |
-
|
| 217 |
-
Returns:
|
| 218 |
-
(extracted_text, warning) — `warning` is a non-empty string when the
|
| 219 |
-
extraction is a degraded fallback, otherwise empty.
|
| 220 |
-
"""
|
| 221 |
-
ext = os.path.splitext(filename or "")[1].lower().lstrip(".")
|
| 222 |
-
try:
|
| 223 |
-
if ext == "pdf":
|
| 224 |
-
text = _extract_pdf(data)
|
| 225 |
-
elif ext in IMAGE_EXTS:
|
| 226 |
-
text = _extract_image(data)
|
| 227 |
-
elif ext in {"xlsx", "xlsm"}:
|
| 228 |
-
text = _extract_xlsx(data)
|
| 229 |
-
elif ext == "xls":
|
| 230 |
-
text = _extract_legacy_office(data, ".xls")
|
| 231 |
-
elif ext == "docx":
|
| 232 |
-
text = _extract_docx(data)
|
| 233 |
-
elif ext == "doc":
|
| 234 |
-
text = _extract_legacy_office(data, ".doc")
|
| 235 |
-
elif ext == "pptx":
|
| 236 |
-
text = _extract_pptx(data)
|
| 237 |
-
elif ext in {"odt", "ods", "odp"}:
|
| 238 |
-
text = _extract_legacy_office(data, f".{ext}")
|
| 239 |
-
elif ext == "rtf":
|
| 240 |
-
text = _extract_rtf(data)
|
| 241 |
-
elif ext in TEXT_EXTS:
|
| 242 |
-
text = data.decode("utf-8", errors="replace")
|
| 243 |
-
else:
|
| 244 |
-
return "", f"Unsupported format '.{ext}' — file uploaded but no text extracted."
|
| 245 |
-
except RuntimeError as e:
|
| 246 |
-
return "", str(e)
|
| 247 |
-
except Exception as e: # pragma: no cover
|
| 248 |
-
return "", f"Failed to extract text from this file: {e}"
|
| 249 |
-
|
| 250 |
-
cleaned = "\n".join(line.rstrip() for line in text.splitlines())
|
| 251 |
-
cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned).strip()
|
| 252 |
-
|
| 253 |
-
if ext in IMAGE_EXTS:
|
| 254 |
-
note = (
|
| 255 |
-
f"[The image is {len(data):,} bytes. The text below is OCR output. "
|
| 256 |
-
"For layout/diagram questions, refer to the uploaded image file.]\n\n"
|
| 257 |
-
)
|
| 258 |
-
cleaned = note + cleaned
|
| 259 |
-
|
| 260 |
-
if len(cleaned) > MAX_CHARS:
|
| 261 |
-
cleaned = cleaned[:MAX_CHARS] + "\n\n... [TRUNCATED — file content exceeds context limit] ..."
|
| 262 |
-
|
| 263 |
-
return cleaned, ""
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
# --- Dispatch helper for the upload note -------------------------------
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
def format_uploaded_document_context(
|
| 270 |
-
*,
|
| 271 |
-
filename: str,
|
| 272 |
-
stored_filename: str,
|
| 273 |
-
repo_id: str,
|
| 274 |
-
path_in_repo: str,
|
| 275 |
-
hub_url: str,
|
| 276 |
-
size_bytes: int,
|
| 277 |
-
file_format: str,
|
| 278 |
-
extracted_text: str,
|
| 279 |
-
warning: str,
|
| 280 |
-
) -> str:
|
| 281 |
-
"""Build the SYSTEM context note injected into the session for the AI."""
|
| 282 |
-
lines = [
|
| 283 |
-
"[SYSTEM: The user uploaded a document/file for this session.",
|
| 284 |
-
"",
|
| 285 |
-
"A text extraction was performed below so you can understand the file's",
|
| 286 |
-
"content directly. If the text is truncated, empty, or you need the",
|
| 287 |
-
"original bytes/format, the original file is preserved on the Hub:",
|
| 288 |
-
"",
|
| 289 |
-
f"- Repo ID: {repo_id}",
|
| 290 |
-
f"- Repo type: dataset",
|
| 291 |
-
f"- File in repo: {path_in_repo}",
|
| 292 |
-
f"- Original filename: {filename}",
|
| 293 |
-
f"- Stored filename: {stored_filename}",
|
| 294 |
-
f"- Format: {file_format}",
|
| 295 |
-
f"- Size: {size_bytes} bytes",
|
| 296 |
-
f"- Hub URL: {hub_url}",
|
| 297 |
-
"",
|
| 298 |
-
]
|
| 299 |
-
if warning:
|
| 300 |
-
lines.extend(
|
| 301 |
-
[
|
| 302 |
-
"WARNING (extraction):",
|
| 303 |
-
warning,
|
| 304 |
-
"",
|
| 305 |
-
"You can still try to load/read the original file from the Hub URL",
|
| 306 |
-
"above, but understand the plain-text extraction did not succeed.",
|
| 307 |
-
"",
|
| 308 |
-
]
|
| 309 |
-
)
|
| 310 |
-
lines.extend(
|
| 311 |
-
[
|
| 312 |
-
"Extracted text content:",
|
| 313 |
-
"```",
|
| 314 |
-
extracted_text if extracted_text else "(no text could be extracted)",
|
| 315 |
-
"```",
|
| 316 |
-
"]",
|
| 317 |
-
]
|
| 318 |
-
)
|
| 319 |
-
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/session_logger.py
DELETED
|
@@ -1,467 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Permanent session & user context logger.
|
| 3 |
-
|
| 4 |
-
Lưu vĩnh viễn ngữ cảnh từng phiên và từng user vào dataset HuggingFace ngay khi
|
| 5 |
-
có message/event mới — fire-and-forget, robust, không bao giờ block agent.
|
| 6 |
-
|
| 7 |
-
Lưu vào:
|
| 8 |
-
- ``bep40/ml-intern-sessions`` : dataset chung, lưu tất cả session
|
| 9 |
-
- ``{hf_user}/ml-intern-sessions``: dataset cá nhân mỗi user
|
| 10 |
-
|
| 11 |
-
Mỗi message được lưu ngay lập tức qua subprocess (detached) để tránh blocking.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import asyncio
|
| 17 |
-
import json
|
| 18 |
-
import logging
|
| 19 |
-
import os
|
| 20 |
-
import subprocess
|
| 21 |
-
import sys
|
| 22 |
-
import time
|
| 23 |
-
from datetime import datetime
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
from typing import Optional
|
| 26 |
-
|
| 27 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 28 |
-
|
| 29 |
-
logger = logging.getLogger(__name__)
|
| 30 |
-
|
| 31 |
-
PUBLIC_SESSIONS_DATASET = os.environ.get(
|
| 32 |
-
"ML_INTERN_SESSIONS_DATASET", "bep40/ml-intern-sessions"
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
DEFAULT_LOG_DIR = Path(os.environ.get("ML_INTERN_SESSION_LOG_DIR", "/tmp/session_logs"))
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def _now_iso() -> str:
|
| 39 |
-
return datetime.now().astimezone().isoformat()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _get_hf_token() -> Optional[str]:
|
| 43 |
-
return (
|
| 44 |
-
os.environ.get("HF_TOKEN")
|
| 45 |
-
or os.environ.get("_ML_INTERN_PERSONAL_TOKEN")
|
| 46 |
-
or os.environ.get("HF_SESSION_UPLOAD_TOKEN")
|
| 47 |
-
or os.environ.get("HF_ADMIN_TOKEN")
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _dataset_card_readme() -> str:
|
| 52 |
-
return """---
|
| 53 |
-
pretty_name: "ML Intern Session Context"
|
| 54 |
-
language:
|
| 55 |
-
- en
|
| 56 |
-
license: other
|
| 57 |
-
task_categories:
|
| 58 |
-
- text-generation
|
| 59 |
-
tags:
|
| 60 |
-
- ml-intern
|
| 61 |
-
- session-traces
|
| 62 |
-
- conversation
|
| 63 |
-
- agent-traces
|
| 64 |
-
configs:
|
| 65 |
-
- config_name: default
|
| 66 |
-
data_files:
|
| 67 |
-
- split: train
|
| 68 |
-
path: "sessions/**/*.jsonl"
|
| 69 |
-
---
|
| 70 |
-
|
| 71 |
-
# ML Intern Session Context
|
| 72 |
-
|
| 73 |
-
**Lưu trữ vĩnh viễn ngữ cảnh từng phiên và từng user.**
|
| 74 |
-
|
| 75 |
-
Mỗi message, tool call, event được lưu ngay khi xảy ra để đảm bảo không bao giờ mất dữ liệu.
|
| 76 |
-
|
| 77 |
-
## Schema
|
| 78 |
-
|
| 79 |
-
| Column | Type | Mô tả |
|
| 80 |
-
|----------------|--------|------------------------------------------|
|
| 81 |
-
| session_id | string | UUID của session |
|
| 82 |
-
| user_id | string | ID người dùng |
|
| 83 |
-
| model_name | string | Model đang dùng |
|
| 84 |
-
| timestamp | string | ISO timestamp |
|
| 85 |
-
| role | string | user / assistant / tool / system / event |
|
| 86 |
-
| content | string | Nội dung message |
|
| 87 |
-
| event_type | string | Loại event |
|
| 88 |
-
| seq | int | Số thứ tự trong session |
|
| 89 |
-
| extra | string | JSON thông tin bổ sung |
|
| 90 |
-
"""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _append_to_local_log(
|
| 94 |
-
session_id: str,
|
| 95 |
-
user_id: str,
|
| 96 |
-
model_name: str,
|
| 97 |
-
role: str,
|
| 98 |
-
content: str,
|
| 99 |
-
event_type: str,
|
| 100 |
-
seq: int,
|
| 101 |
-
extra: dict | None = None,
|
| 102 |
-
directory: str = str(DEFAULT_LOG_DIR),
|
| 103 |
-
) -> Optional[str]:
|
| 104 |
-
try:
|
| 105 |
-
log_dir = Path(directory)
|
| 106 |
-
log_dir.mkdir(parents=True, exist_ok=True)
|
| 107 |
-
row = {
|
| 108 |
-
"session_id": session_id,
|
| 109 |
-
"user_id": user_id,
|
| 110 |
-
"model_name": model_name,
|
| 111 |
-
"timestamp": _now_iso(),
|
| 112 |
-
"role": role,
|
| 113 |
-
"content": content,
|
| 114 |
-
"event_type": event_type,
|
| 115 |
-
"seq": seq,
|
| 116 |
-
"extra": json.dumps(extra) if extra else "{}",
|
| 117 |
-
}
|
| 118 |
-
date_str = datetime.now().strftime("%Y-%m-%d")
|
| 119 |
-
filename = f"session_{session_id}_{date_str}.jsonl"
|
| 120 |
-
filepath = log_dir / filename
|
| 121 |
-
with open(filepath, "a") as f:
|
| 122 |
-
f.write(json.dumps(row, default=str) + "\n")
|
| 123 |
-
return str(filepath)
|
| 124 |
-
except Exception as e:
|
| 125 |
-
logger.debug("Local log write failed: %s", e)
|
| 126 |
-
return None
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
# ─── Dataset reader: per-file scan (never loads 410MB into memory) ──────────
|
| 130 |
-
|
| 131 |
-
_file_list_cache: list[str] | None = None
|
| 132 |
-
_file_list_cache_time: float = 0
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def _list_dataset_files() -> list[str]:
|
| 136 |
-
"""Return all .jsonl file paths in the sessions dataset (cached, 60s TTL)."""
|
| 137 |
-
global _file_list_cache, _file_list_cache_time
|
| 138 |
-
now = time.time()
|
| 139 |
-
if _file_list_cache is not None and (now - _file_list_cache_time) < 60:
|
| 140 |
-
return _file_list_cache
|
| 141 |
-
try:
|
| 142 |
-
api = HfApi(token=_get_hf_token())
|
| 143 |
-
files = api.list_repo_files(PUBLIC_SESSIONS_DATASET, repo_type="dataset")
|
| 144 |
-
_file_list_cache = [f for f in files if f.endswith(".jsonl")]
|
| 145 |
-
_file_list_cache_time = now
|
| 146 |
-
except Exception as e:
|
| 147 |
-
logger.warning("Failed to list dataset files: %s", e)
|
| 148 |
-
_file_list_cache = []
|
| 149 |
-
_file_list_cache_time = now
|
| 150 |
-
return _file_list_cache
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
def _normalize_row(row: dict) -> dict | None:
|
| 154 |
-
"""Normalize a row to the standard schema regardless of source format.
|
| 155 |
-
|
| 156 |
-
The dataset contains two incompatible formats:
|
| 157 |
-
- *New* (session_logger.py): session_id, user_id, role, content, event_type, seq
|
| 158 |
-
- *Old* (Claude Code trace format): type, message, uuid, parentUuid, sessionId, timestamp
|
| 159 |
-
"""
|
| 160 |
-
if not isinstance(row, dict):
|
| 161 |
-
return None
|
| 162 |
-
|
| 163 |
-
# Already in new format — has session_id + role
|
| 164 |
-
if "session_id" in row and "role" in row:
|
| 165 |
-
return row
|
| 166 |
-
|
| 167 |
-
# Old Claude Code format: type + message
|
| 168 |
-
if "type" in row and "message" in row:
|
| 169 |
-
session_id = row.get("sessionId") or row.get("session_id") or ""
|
| 170 |
-
if not session_id:
|
| 171 |
-
return None
|
| 172 |
-
msg = row.get("message")
|
| 173 |
-
role = row.get("type", "unknown")
|
| 174 |
-
content_text = ""
|
| 175 |
-
model_name = ""
|
| 176 |
-
if isinstance(msg, dict):
|
| 177 |
-
role = msg.get("role", role)
|
| 178 |
-
content_val = msg.get("content", "")
|
| 179 |
-
model_name = msg.get("model", "")
|
| 180 |
-
if isinstance(content_val, str):
|
| 181 |
-
content_text = content_val
|
| 182 |
-
elif isinstance(content_val, list):
|
| 183 |
-
parts = []
|
| 184 |
-
for block in content_val:
|
| 185 |
-
if isinstance(block, dict):
|
| 186 |
-
t = block.get("text") or block.get("content")
|
| 187 |
-
if isinstance(t, str):
|
| 188 |
-
parts.append(t)
|
| 189 |
-
elif isinstance(block, str):
|
| 190 |
-
parts.append(block)
|
| 191 |
-
content_text = " ".join(parts)
|
| 192 |
-
elif content_val is None:
|
| 193 |
-
content_text = ""
|
| 194 |
-
return {
|
| 195 |
-
"session_id": session_id,
|
| 196 |
-
"user_id": "",
|
| 197 |
-
"model_name": model_name,
|
| 198 |
-
"timestamp": row.get("timestamp", ""),
|
| 199 |
-
"role": role,
|
| 200 |
-
"content": content_text,
|
| 201 |
-
"event_type": row.get("type", "message"),
|
| 202 |
-
"seq": 0,
|
| 203 |
-
"extra": "{}",
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
return None
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def _read_jsonl_file(file_path: str) -> list[dict]:
|
| 210 |
-
"""Download a single JSONL file from the dataset and parse its lines.
|
| 211 |
-
|
| 212 |
-
Robust to: empty files, malformed JSON lines, old/new format mismatch.
|
| 213 |
-
"""
|
| 214 |
-
try:
|
| 215 |
-
local = hf_hub_download(
|
| 216 |
-
PUBLIC_SESSIONS_DATASET,
|
| 217 |
-
file_path,
|
| 218 |
-
repo_type="dataset",
|
| 219 |
-
token=_get_hf_token(),
|
| 220 |
-
)
|
| 221 |
-
rows: list[dict] = []
|
| 222 |
-
with open(local, encoding="utf-8", errors="replace") as fh:
|
| 223 |
-
for line in fh:
|
| 224 |
-
line = line.strip()
|
| 225 |
-
if not line:
|
| 226 |
-
continue
|
| 227 |
-
try:
|
| 228 |
-
row = json.loads(line)
|
| 229 |
-
normalized = _normalize_row(row)
|
| 230 |
-
if normalized is not None:
|
| 231 |
-
rows.append(normalized)
|
| 232 |
-
except json.JSONDecodeError:
|
| 233 |
-
continue
|
| 234 |
-
return rows
|
| 235 |
-
except Exception as e:
|
| 236 |
-
logger.debug("Failed to read %s: %s", file_path, e)
|
| 237 |
-
return []
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def load_session_history(session_id: str) -> list[dict]:
|
| 241 |
-
"""Load all messages/events for a session from the dataset.
|
| 242 |
-
|
| 243 |
-
Reads only the files whose name contains ``session_id`` instead of
|
| 244 |
-
loading the entire 400MB dataset into memory on every call.
|
| 245 |
-
"""
|
| 246 |
-
try:
|
| 247 |
-
all_files = _list_dataset_files()
|
| 248 |
-
matching = [
|
| 249 |
-
f for f in all_files if session_id in os.path.basename(f)
|
| 250 |
-
]
|
| 251 |
-
rows: list[dict] = []
|
| 252 |
-
for f in matching:
|
| 253 |
-
for row in _read_jsonl_file(f):
|
| 254 |
-
if row.get("session_id") == session_id:
|
| 255 |
-
rows.append(row)
|
| 256 |
-
rows.sort(key=lambda r: (r.get("seq", 0), r.get("timestamp", "")))
|
| 257 |
-
return rows
|
| 258 |
-
except Exception as e:
|
| 259 |
-
import traceback
|
| 260 |
-
logger.warning(
|
| 261 |
-
"Failed to load session history: %s\n%s",
|
| 262 |
-
e,
|
| 263 |
-
traceback.format_exc(),
|
| 264 |
-
)
|
| 265 |
-
return []
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def get_user_sessions(user_id: str, limit: int = 50) -> list[dict]:
|
| 269 |
-
"""Get all sessions for a user from the dataset.
|
| 270 |
-
|
| 271 |
-
Uses per-file scanning exclusively (never calls load_dataset which can
|
| 272 |
-
fail when the dataset has incompatible/mixed schemas). Cached file list
|
| 273 |
-
(60s TTL) avoids repeated list_repo_files calls.
|
| 274 |
-
"""
|
| 275 |
-
sessions: dict[str, dict] = {}
|
| 276 |
-
for f in _list_dataset_files():
|
| 277 |
-
for row in _read_jsonl_file(f):
|
| 278 |
-
if row.get("user_id") == user_id:
|
| 279 |
-
sid = row.get("session_id")
|
| 280 |
-
if sid and sid not in sessions:
|
| 281 |
-
sessions[sid] = {
|
| 282 |
-
"session_id": sid,
|
| 283 |
-
"user_id": user_id,
|
| 284 |
-
"model_name": row.get("model_name"),
|
| 285 |
-
"last_timestamp": row.get("timestamp"),
|
| 286 |
-
"message_count": 0,
|
| 287 |
-
}
|
| 288 |
-
sessions[sid]["message_count"] += 1
|
| 289 |
-
|
| 290 |
-
result = list(sessions.values())
|
| 291 |
-
result.sort(key=lambda s: s.get("last_timestamp", ""), reverse=True)
|
| 292 |
-
return result[:limit]
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
# ─── Upload/subprocess functions (unchanged from original) ──────────────────
|
| 296 |
-
|
| 297 |
-
def _spawn_uploader(
|
| 298 |
-
row_data: dict,
|
| 299 |
-
repo_id: str,
|
| 300 |
-
token: str,
|
| 301 |
-
private: bool = False,
|
| 302 |
-
) -> None:
|
| 303 |
-
"""Spawn detached subprocess to upload a single row to HF dataset."""
|
| 304 |
-
try:
|
| 305 |
-
script = f"""
|
| 306 |
-
import json, sys, time, os
|
| 307 |
-
from huggingface_hub import HfApi
|
| 308 |
-
|
| 309 |
-
row_data = {json.dumps(row_data)}
|
| 310 |
-
repo_id = "{repo_id}"
|
| 311 |
-
token = "{token}"
|
| 312 |
-
private = {private}
|
| 313 |
-
|
| 314 |
-
api = HfApi()
|
| 315 |
-
try:
|
| 316 |
-
api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, token=token, exist_ok=True)
|
| 317 |
-
except: pass
|
| 318 |
-
|
| 319 |
-
# Upload README
|
| 320 |
-
try:
|
| 321 |
-
api.upload_file(path_or_fileobj={_dataset_card_readme()!r}.encode(), path_in_repo="README.md", repo_id=repo_id, repo_type="dataset", token=token)
|
| 322 |
-
except: pass
|
| 323 |
-
|
| 324 |
-
date_str = row_data.get("timestamp", "")[:10]
|
| 325 |
-
session_id = row_data.get("session_id", "unknown")
|
| 326 |
-
seq = row_data.get("seq", 0)
|
| 327 |
-
path_in_repo = f"sessions/{{date_str}}/{{session_id}}_{{seq}}.jsonl"
|
| 328 |
-
payload = json.dumps(row_data)
|
| 329 |
-
|
| 330 |
-
for attempt in range(5):
|
| 331 |
-
try:
|
| 332 |
-
api.upload_file(path_or_fileobj=payload.encode(), path_in_repo=path_in_repo, repo_id=repo_id, repo_type="dataset", token=token, commit_message=f"Add session {{session_id[:8]}} seq {{seq}}")
|
| 333 |
-
break
|
| 334 |
-
except Exception as e:
|
| 335 |
-
if attempt < 4:
|
| 336 |
-
time.sleep(2 ** attempt)
|
| 337 |
-
else:
|
| 338 |
-
sys.exit(1)
|
| 339 |
-
"""
|
| 340 |
-
subprocess.Popen(
|
| 341 |
-
[sys.executable, "-c", script],
|
| 342 |
-
stdin=subprocess.DEVNULL,
|
| 343 |
-
stdout=subprocess.DEVNULL,
|
| 344 |
-
stderr=subprocess.DEVNULL,
|
| 345 |
-
env=os.environ.copy(),
|
| 346 |
-
start_new_session=True,
|
| 347 |
-
)
|
| 348 |
-
except Exception as e:
|
| 349 |
-
logger.debug("Upload spawn failed: %s", e)
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def log_message(
|
| 353 |
-
session_id: str,
|
| 354 |
-
user_id: str,
|
| 355 |
-
model_name: str,
|
| 356 |
-
role: str,
|
| 357 |
-
content: str,
|
| 358 |
-
event_type: str = "message",
|
| 359 |
-
seq: int = 0,
|
| 360 |
-
extra: dict | None = None,
|
| 361 |
-
hf_user: str | None = None,
|
| 362 |
-
) -> None:
|
| 363 |
-
"""Log a single message/event permanently to dataset.
|
| 364 |
-
|
| 365 |
-
Fire-and-forget — all I/O is offloaded to a detached subprocess.
|
| 366 |
-
"""
|
| 367 |
-
token = _get_hf_token()
|
| 368 |
-
|
| 369 |
-
# Always write to local log first (backup)
|
| 370 |
-
_append_to_local_log(
|
| 371 |
-
session_id=session_id,
|
| 372 |
-
user_id=user_id,
|
| 373 |
-
model_name=model_name,
|
| 374 |
-
role=role,
|
| 375 |
-
content=content,
|
| 376 |
-
event_type=event_type,
|
| 377 |
-
seq=seq,
|
| 378 |
-
extra=extra,
|
| 379 |
-
)
|
| 380 |
-
|
| 381 |
-
if not token:
|
| 382 |
-
logger.debug("No HF token, skipping dataset upload")
|
| 383 |
-
return
|
| 384 |
-
|
| 385 |
-
row_data = {
|
| 386 |
-
"session_id": session_id,
|
| 387 |
-
"user_id": user_id,
|
| 388 |
-
"model_name": model_name,
|
| 389 |
-
"timestamp": _now_iso(),
|
| 390 |
-
"role": role,
|
| 391 |
-
"content": content,
|
| 392 |
-
"event_type": event_type,
|
| 393 |
-
"seq": seq,
|
| 394 |
-
"extra": json.dumps(extra) if extra else "{}",
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
# Upload to public/shared dataset
|
| 398 |
-
_spawn_uploader(row_data, PUBLIC_SESSIONS_DATASET, token, private=False)
|
| 399 |
-
|
| 400 |
-
# Upload to personal dataset if available
|
| 401 |
-
if hf_user and hf_user != "dev":
|
| 402 |
-
_spawn_uploader(row_data, f"{hf_user}/ml-intern-sessions", token, private=True)
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
def log_trajectory(
|
| 406 |
-
trajectory: dict,
|
| 407 |
-
user_id: str,
|
| 408 |
-
hf_user: str | None = None,
|
| 409 |
-
hf_token: str | None = None,
|
| 410 |
-
private: bool = False,
|
| 411 |
-
) -> Optional[str]:
|
| 412 |
-
"""Upload complete session trajectory to dataset (final flush)."""
|
| 413 |
-
session_id = trajectory.get("session_id", "unknown")
|
| 414 |
-
model_name = trajectory.get("model_name", "")
|
| 415 |
-
messages = trajectory.get("messages", [])
|
| 416 |
-
|
| 417 |
-
log_dir = Path(str(DEFAULT_LOG_DIR))
|
| 418 |
-
log_dir.mkdir(parents=True, exist_ok=True)
|
| 419 |
-
date_str = datetime.now().strftime("%Y-%m-%d")
|
| 420 |
-
filepath = log_dir / f"session_{session_id}_{date_str}.jsonl"
|
| 421 |
-
|
| 422 |
-
with open(filepath, "w") as f:
|
| 423 |
-
for msg in messages:
|
| 424 |
-
row = {
|
| 425 |
-
"session_id": session_id,
|
| 426 |
-
"user_id": user_id,
|
| 427 |
-
"model_name": model_name,
|
| 428 |
-
"timestamp": msg.get("timestamp") or _now_iso(),
|
| 429 |
-
"role": msg.get("role", "unknown"),
|
| 430 |
-
"content": msg.get("content", ""),
|
| 431 |
-
"event_type": "message",
|
| 432 |
-
"seq": msg.get("seq", 0),
|
| 433 |
-
"extra": json.dumps({
|
| 434 |
-
"tool_calls": msg.get("tool_calls"),
|
| 435 |
-
"tool_call_id": msg.get("tool_call_id"),
|
| 436 |
-
}) if any(k in msg for k in ("tool_calls", "tool_call_id")) else "{}",
|
| 437 |
-
}
|
| 438 |
-
f.write(json.dumps(row, default=str) + "\n")
|
| 439 |
-
|
| 440 |
-
events = trajectory.get("events", [])
|
| 441 |
-
for event in events:
|
| 442 |
-
row = {
|
| 443 |
-
"session_id": session_id,
|
| 444 |
-
"user_id": user_id,
|
| 445 |
-
"model_name": model_name,
|
| 446 |
-
"timestamp": event.get("timestamp", _now_iso()),
|
| 447 |
-
"role": "event",
|
| 448 |
-
"content": json.dumps(event.get("data", {})),
|
| 449 |
-
"event_type": event.get("event_type", "unknown"),
|
| 450 |
-
"seq": event.get("seq", 0),
|
| 451 |
-
"extra": "{}",
|
| 452 |
-
}
|
| 453 |
-
f.write(json.dumps(row, default=str) + "\n")
|
| 454 |
-
|
| 455 |
-
token = hf_token or _get_hf_token()
|
| 456 |
-
if token:
|
| 457 |
-
with open(filepath, "r") as f:
|
| 458 |
-
for line in f:
|
| 459 |
-
try:
|
| 460 |
-
row = json.loads(line.strip())
|
| 461 |
-
_spawn_uploader(row, PUBLIC_SESSIONS_DATASET, token, private)
|
| 462 |
-
if hf_user and hf_user != "dev":
|
| 463 |
-
_spawn_uploader(row, f"{hf_user}/ml-intern-sessions", token, True)
|
| 464 |
-
except Exception:
|
| 465 |
-
continue
|
| 466 |
-
|
| 467 |
-
return str(filepath)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
configs/frontend_agent_config.json
CHANGED
|
@@ -7,13 +7,10 @@
|
|
| 7 |
"yolo_mode": false,
|
| 8 |
"confirm_cpu_jobs": true,
|
| 9 |
"auto_file_upload": true,
|
| 10 |
-
"auto_save_interval": 1,
|
| 11 |
-
"heartbeat_interval_s": 30,
|
| 12 |
-
"max_iterations": 300,
|
| 13 |
"mcpServers": {
|
| 14 |
"hf-mcp-server": {
|
| 15 |
"transport": "http",
|
| 16 |
"url": "https://openrouter.ai/mcp"
|
| 17 |
}
|
| 18 |
}
|
| 19 |
-
}
|
|
|
|
| 7 |
"yolo_mode": false,
|
| 8 |
"confirm_cpu_jobs": true,
|
| 9 |
"auto_file_upload": true,
|
|
|
|
|
|
|
|
|
|
| 10 |
"mcpServers": {
|
| 11 |
"hf-mcp-server": {
|
| 12 |
"transport": "http",
|
| 13 |
"url": "https://openrouter.ai/mcp"
|
| 14 |
}
|
| 15 |
}
|
| 16 |
+
}
|
fix_space_error_report.json
CHANGED
|
@@ -1,14 +1,9 @@
|
|
| 1 |
{
|
| 2 |
-
"status": "
|
| 3 |
"space": "bep40/ml-intern",
|
| 4 |
"space_url": "https://huggingface.co/spaces/bep40/ml-intern",
|
| 5 |
-
"root_cause": "
|
| 6 |
-
"fix": "Changed
|
| 7 |
-
"files_changed": ["
|
| 8 |
-
"
|
| 9 |
-
"deepseek-ai/DeepSeek-V4-Flash": "HF Inference Router (3 providers: novita, fireworks-ai, deepinfra) - all support tools, 1M context",
|
| 10 |
-
"deepseek-ai/DeepSeek-V4-Flash-0731": "HF Inference Router (5 providers) - all support tools, 1M context",
|
| 11 |
-
"deepseek/deepseek-v4-flash-0731": "OpenRouter - available via openrouter/deepseek/deepseek-v4-flash-0731"
|
| 12 |
-
},
|
| 13 |
-
"verification": "Space rebuild triggered. Monitor Space build logs. Test model selection for DeepSeek V4 Flash after rebuild completes."
|
| 14 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"status": "fixed_uploaded",
|
| 3 |
"space": "bep40/ml-intern",
|
| 4 |
"space_url": "https://huggingface.co/spaces/bep40/ml-intern",
|
| 5 |
+
"root_cause": "Dockerfile runtime stage used CMD [\"bash\", \"start.sh\"] on python:3.12-slim, which does not include bash by default. This causes container startup failure and Space error after a successful build.",
|
| 6 |
+
"fix": "Changed Dockerfile CMD to [\"sh\", \"start.sh\"] and added ca-certificates to apt packages. start.sh is POSIX-compatible for this use.",
|
| 7 |
+
"files_changed": ["Dockerfile", "README.md"],
|
| 8 |
+
"verification": "Repo files updated and rebuild triggered. Live runtime verification via HF Jobs/sandbox was blocked by namespace credit/duplication limits in this session; check Space build logs if HF still reports error."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
patch_agent_backend.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
"""Patch: agent_loop.py task detection + auto-approve for free models."""
|
| 2 |
|
| 3 |
import os
|
| 4 |
-
import re
|
| 5 |
import ast
|
| 6 |
|
| 7 |
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
|
@@ -46,11 +45,13 @@ except SyntaxError as e:
|
|
| 46 |
print(f"FAIL: agent_loop.py syntax: {e}")
|
| 47 |
|
| 48 |
# ===== PATCH 2: Enable auto_approval for ALL sessions by default =====
|
| 49 |
-
# So free models don't block on user approval
|
| 50 |
with open(SESSION_FILE) as f:
|
| 51 |
c = f.read()
|
| 52 |
|
| 53 |
-
if "AUTO_APPROVE" not in c
|
|
|
|
|
|
|
| 54 |
auto_patch = (
|
| 55 |
"\n # === PATCH: Auto-approve for free models ===\n"
|
| 56 |
" self.auto_approval = {\n"
|
|
@@ -60,28 +61,29 @@ if "AUTO_APPROVE" not in c and "self.auto_approval" not in c:
|
|
| 60 |
' "remaining_usd": None,\n'
|
| 61 |
" }\n"
|
| 62 |
)
|
| 63 |
-
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 76 |
try:
|
| 77 |
ast.parse(c)
|
| 78 |
with open(SESSION_FILE, "w") as f:
|
| 79 |
f.write(c)
|
| 80 |
-
print("OK: session.py patched
|
| 81 |
except SyntaxError as e:
|
| 82 |
print(f"FAIL: session.py syntax: {e}")
|
| 83 |
# Don't block on this patch
|
| 84 |
else:
|
| 85 |
-
print("OK: session.py already has
|
| 86 |
|
| 87 |
print("DONE: Backend patches applied")
|
|
|
|
| 1 |
"""Patch: agent_loop.py task detection + auto-approve for free models."""
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
import ast
|
| 5 |
|
| 6 |
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
|
|
|
| 45 |
print(f"FAIL: agent_loop.py syntax: {e}")
|
| 46 |
|
| 47 |
# ===== PATCH 2: Enable auto_approval for ALL sessions by default =====
|
| 48 |
+
# So free models don't block on user approval
|
| 49 |
with open(SESSION_FILE) as f:
|
| 50 |
c = f.read()
|
| 51 |
|
| 52 |
+
if "AUTO_APPROVE" not in c:
|
| 53 |
+
# Find where auto_approval is set on the session object
|
| 54 |
+
# Patch: when creating a new session, set auto_approval.enabled = True
|
| 55 |
auto_patch = (
|
| 56 |
"\n # === PATCH: Auto-approve for free models ===\n"
|
| 57 |
" self.auto_approval = {\n"
|
|
|
|
| 61 |
' "remaining_usd": None,\n'
|
| 62 |
" }\n"
|
| 63 |
)
|
| 64 |
+
|
| 65 |
+
# Find session init method
|
| 66 |
+
init_match = "class Session:"
|
| 67 |
+
if init_match in c:
|
| 68 |
+
# Insert after the class definition, find __init__
|
| 69 |
+
init_found = "def __init__"
|
| 70 |
+
if init_found in c:
|
| 71 |
+
# Find first line inside __init__ that's an assignment
|
| 72 |
+
init_start = c.find(init_found)
|
| 73 |
+
body_start = c.find("\n", init_start) + 1
|
| 74 |
+
body_start = c.find(" ", body_start) # first indented line
|
| 75 |
+
c = c[:body_start] + auto_patch + c[body_start:]
|
| 76 |
+
print("OK: Added auto_approve default")
|
| 77 |
+
|
| 78 |
try:
|
| 79 |
ast.parse(c)
|
| 80 |
with open(SESSION_FILE, "w") as f:
|
| 81 |
f.write(c)
|
| 82 |
+
print("OK: session.py patched")
|
| 83 |
except SyntaxError as e:
|
| 84 |
print(f"FAIL: session.py syntax: {e}")
|
| 85 |
# Don't block on this patch
|
| 86 |
else:
|
| 87 |
+
print("OK: session.py already has auto_approve")
|
| 88 |
|
| 89 |
print("DONE: Backend patches applied")
|
patch_apodex_ling_beatapi.py
DELETED
|
@@ -1,145 +0,0 @@
|
|
| 1 |
-
"""Patch backend: add apodex-1.1-mini:free, ling-3.1-flash (OpenRouter) + BeatAPI space-bunny-alpha.
|
| 2 |
-
|
| 3 |
-
Two OpenRouter models (same `openai/` catch-all routing as the rest of the app):
|
| 4 |
-
- openai/apodex/apodex-1.1-mini:free
|
| 5 |
-
- openai/inclusionai/ling-3.1-flash
|
| 6 |
-
|
| 7 |
-
One BEATAPI model routed to a *different* provider entirely:
|
| 8 |
-
- beatapi/space-bunny-alpha
|
| 9 |
-
|
| 10 |
-
The BeatAPI id deliberately uses a `beatapi/` prefix (NOT `openai/`) so it can
|
| 11 |
-
never be captured by the OpenRouter catch-all branch in _resolve_llm_params.
|
| 12 |
-
See patch_beatapi_routing.py for the dedicated routing branch.
|
| 13 |
-
|
| 14 |
-
This runs AFTER patch_models.py / patch_new_models.py, which already injected
|
| 15 |
-
the `openai/`-prefixed constants and the LING_3_0_FLASH_FREE_MODEL_ID anchor.
|
| 16 |
-
Idempotent: re-running is a no-op.
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import ast
|
| 20 |
-
import re
|
| 21 |
-
|
| 22 |
-
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 23 |
-
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 24 |
-
|
| 25 |
-
# ---------- 1. model_ids.py ----------
|
| 26 |
-
with open(IDS_FILE) as f:
|
| 27 |
-
ids = f.read()
|
| 28 |
-
|
| 29 |
-
new_constants = (
|
| 30 |
-
'APODEX_1_1_MINI_FREE_MODEL_ID = "openai/apodex/apodex-1.1-mini:free"\n'
|
| 31 |
-
'LING_3_1_FLASH_MODEL_ID = "openai/inclusionai/ling-3.1-flash"\n'
|
| 32 |
-
'# NOTE: BeatAPI model. NOT an OpenRouter id: `beatapi/` routes to\n'
|
| 33 |
-
'# https://api.beatapi.io/v1 and is authenticated with BEATAPI_API_KEY\n'
|
| 34 |
-
'# (see patch_beatapi_routing.py). Keep this prefix exclusive to BeatAPI.\n'
|
| 35 |
-
'BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID = "beatapi/space-bunny-alpha"\n'
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
-
missing = [
|
| 39 |
-
name
|
| 40 |
-
for name in (
|
| 41 |
-
"APODEX_1_1_MINI_FREE_MODEL_ID",
|
| 42 |
-
"LING_3_1_FLASH_MODEL_ID",
|
| 43 |
-
"BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID",
|
| 44 |
-
)
|
| 45 |
-
if name not in ids
|
| 46 |
-
]
|
| 47 |
-
|
| 48 |
-
if not missing:
|
| 49 |
-
print("OK: model_ids.py already has all 3 new constants (idempotent)")
|
| 50 |
-
else:
|
| 51 |
-
# Anchor: the LING_3_0_FLASH line injected by patch_new_models.py.
|
| 52 |
-
# Fall back to a generic OpenRouter constant line from patch_models.py.
|
| 53 |
-
anchors = [
|
| 54 |
-
'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"',
|
| 55 |
-
'NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"',
|
| 56 |
-
]
|
| 57 |
-
inserted = False
|
| 58 |
-
for anchor in anchors:
|
| 59 |
-
if anchor in ids:
|
| 60 |
-
ids = ids.replace(anchor, anchor + "\n" + new_constants, 1)
|
| 61 |
-
print(f"OK: added constants to model_ids.py after {anchor.split(' =')[0]}")
|
| 62 |
-
inserted = True
|
| 63 |
-
break
|
| 64 |
-
if not inserted:
|
| 65 |
-
# Last resort: append right after the HOSTED_MODEL_IDS set closes, which
|
| 66 |
-
# is a stable upstream anchor present in every revision.
|
| 67 |
-
m = re.search(r"HOSTED_MODEL_IDS\s*=\s*\{[^}]*\}\n", ids)
|
| 68 |
-
if m:
|
| 69 |
-
ids = ids[: m.end()] + "\n" + new_constants + ids[m.end() :]
|
| 70 |
-
print("OK: added constants to model_ids.py after HOSTED_MODEL_IDS")
|
| 71 |
-
else:
|
| 72 |
-
print("FAIL: no anchor found in model_ids.py")
|
| 73 |
-
raise SystemExit(1)
|
| 74 |
-
|
| 75 |
-
with open(IDS_FILE, "w") as f:
|
| 76 |
-
f.write(ids)
|
| 77 |
-
ast.parse(ids)
|
| 78 |
-
print("OK: model_ids.py written & syntax OK")
|
| 79 |
-
|
| 80 |
-
# ---------- 2. agent.py : imports ----------
|
| 81 |
-
with open(AGENT_FILE) as f:
|
| 82 |
-
ac = f.read()
|
| 83 |
-
|
| 84 |
-
if "APODEX_1_1_MINI_FREE_MODEL_ID" not in ac:
|
| 85 |
-
# patch_models.py rewrote the import block to list every OpenRouter constant.
|
| 86 |
-
imp_anchor = " LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 87 |
-
if imp_anchor in ac:
|
| 88 |
-
ac = ac.replace(
|
| 89 |
-
imp_anchor,
|
| 90 |
-
imp_anchor
|
| 91 |
-
+ " APODEX_1_1_MINI_FREE_MODEL_ID,\n"
|
| 92 |
-
+ " LING_3_1_FLASH_MODEL_ID,\n"
|
| 93 |
-
+ " BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID,\n",
|
| 94 |
-
1,
|
| 95 |
-
)
|
| 96 |
-
print("OK: added 3 model imports to agent.py")
|
| 97 |
-
else:
|
| 98 |
-
print("FAIL: import anchor not found in agent.py")
|
| 99 |
-
raise SystemExit(1)
|
| 100 |
-
|
| 101 |
-
# ---------- 3. agent.py : _available_models ----------
|
| 102 |
-
line_anchor = (
|
| 103 |
-
' {"id": SPACE_BUNNY_ALPHA_MODEL_ID, "label": "stealth/space-bunny-alpha", "recommended": True},\n'
|
| 104 |
-
)
|
| 105 |
-
new_lines = line_anchor + (
|
| 106 |
-
' # OpenRouter models (openai/ prefix -> OpenRouter)\n'
|
| 107 |
-
' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
|
| 108 |
-
' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
|
| 109 |
-
' # BeatAPI model (beatapi/ prefix -> https://api.beatapi.io/v1)\n'
|
| 110 |
-
' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n'
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
-
if line_anchor in ac:
|
| 114 |
-
ac = ac.replace(line_anchor, new_lines, 1)
|
| 115 |
-
print("OK: added 3 new models to _available_models()")
|
| 116 |
-
else:
|
| 117 |
-
# The _available_models body was fully rewritten by patch_models.py; match
|
| 118 |
-
# on the generic recommended-Ling line so this stays robust.
|
| 119 |
-
alt = (
|
| 120 |
-
' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": '
|
| 121 |
-
'"inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 122 |
-
)
|
| 123 |
-
if alt in ac:
|
| 124 |
-
ac = ac.replace(
|
| 125 |
-
alt,
|
| 126 |
-
alt
|
| 127 |
-
+ ' {"id": APODEX_1_1_MINI_FREE_MODEL_ID, "label": "apodex/apodex-1.1-mini:free", "recommended": True},\n'
|
| 128 |
-
+ ' {"id": LING_3_1_FLASH_MODEL_ID, "label": "inclusionai/ling-3.1-flash", "recommended": True},\n'
|
| 129 |
-
+ ' {"id": BEATAPI_SPACE_BUNNY_ALPHA_MODEL_ID, "label": "BeatAPI space-bunny-alpha", "recommended": True},\n',
|
| 130 |
-
1,
|
| 131 |
-
)
|
| 132 |
-
print("OK: added 3 new models to _available_models() (fallback anchor)")
|
| 133 |
-
else:
|
| 134 |
-
print("FAIL: _available_models() anchor not found in agent.py")
|
| 135 |
-
raise SystemExit(1)
|
| 136 |
-
|
| 137 |
-
with open(AGENT_FILE, "w") as f:
|
| 138 |
-
f.write(ac)
|
| 139 |
-
ast.parse(ac)
|
| 140 |
-
print("OK: agent.py written & syntax OK")
|
| 141 |
-
|
| 142 |
-
else:
|
| 143 |
-
print("OK: agent.py already patched (idempotent)")
|
| 144 |
-
|
| 145 |
-
print("DONE: apodex-1.1-mini:free, ling-3.1-flash, BeatAPI space-bunny-alpha registered")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_beatapi_routing.py
DELETED
|
@@ -1,182 +0,0 @@
|
|
| 1 |
-
"""Patch llm_params.py: add a DEDICATED BeatAPI provider branch.
|
| 2 |
-
|
| 3 |
-
BEATAPI vs OpenRouter -- why this branch is separate and must stay separate
|
| 4 |
-
=========================================================================
|
| 5 |
-
Every other model in this app except the HF Router / local-ollama ones is an
|
| 6 |
-
*OpenRouter* model. They are all tagged with an `openai/` prefix, and
|
| 7 |
-
_resolve_llm_params has a single catch-all that:
|
| 8 |
-
|
| 9 |
-
1. strips the `openai/` prefix,
|
| 10 |
-
2. points api_base at https://openrouter.ai/api/v1,
|
| 11 |
-
3. authenticates with OPENROUTER_API_KEY.
|
| 12 |
-
|
| 13 |
-
BeatAPI (https://api.beatapi.io) is a *different provider* that happens to
|
| 14 |
-
expose an OpenAI-compatible surface. It must therefore NOT reuse that branch:
|
| 15 |
-
|
| 16 |
-
provider OpenRouter BeatAPI
|
| 17 |
-
----------- ----------------------------- ------------------------------
|
| 18 |
-
api_base https://openrouter.ai/api/v1 https://api.beatapi.io/v1
|
| 19 |
-
key env OPENROUTER_API_KEY BEATAPI_API_KEY
|
| 20 |
-
model id vendor/model space-bunny-alpha (bare, no vendor prefix)
|
| 21 |
-
effort unsupported (omitted) low/medium/high/xhigh/max via extra_body
|
| 22 |
-
|
| 23 |
-
BeatAPI additionally:
|
| 24 |
-
- does not understand the `openai/` prefix at all, so sending
|
| 25 |
-
"openai/space-bunny-alpha" or "openai/stealth/space-bunny-alpha" 404s;
|
| 26 |
-
- uses `space-bunny-alpha` as a *flat* model id, so we must strip our own
|
| 27 |
-
`beatapi/` routing prefix before the request leaves the app;
|
| 28 |
-
- rate-limits the free tier (1 req/min before first top-up, 10/min after,
|
| 29 |
-
1000/day) and returns HTTP 429 with Retry-After.
|
| 30 |
-
|
| 31 |
-
Therefore BeatAPI models use the `beatapi/` prefix -- a namespace nothing else
|
| 32 |
-
uses -- so this branch can never be shadowed by the `openai/` catch-all, and
|
| 33 |
-
the OpenRouter branch can never accidentally strip a BeatAPI model id.
|
| 34 |
-
|
| 35 |
-
If BEATAPI_API_KEY is unset we raise a clear, actionable error instead of
|
| 36 |
-
silently sending an empty key (which BeatAPI answers with a bare 401
|
| 37 |
-
missing_api_key).
|
| 38 |
-
|
| 39 |
-
Runs AFTER patch_models.py and patch_openrouter_fix.py. Idempotent.
|
| 40 |
-
"""
|
| 41 |
-
|
| 42 |
-
import ast
|
| 43 |
-
|
| 44 |
-
FILE = "/app/agent/core/llm_params.py"
|
| 45 |
-
|
| 46 |
-
BEATAPI_BASE_URL = "https://api.beatapi.io/v1"
|
| 47 |
-
BEATAPI_MODEL_PREFIX = "beatapi/"
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
with open(FILE) as f:
|
| 51 |
-
c = f.read()
|
| 52 |
-
|
| 53 |
-
if "BeatApiKeyMissingError" in c:
|
| 54 |
-
print("OK: llm_params.py already contains BeatAPI routing (idempotent)")
|
| 55 |
-
raise SystemExit(0)
|
| 56 |
-
|
| 57 |
-
if "\nimport os\n" not in c:
|
| 58 |
-
print("FAIL: expected `import os` in llm_params.py")
|
| 59 |
-
raise SystemExit(1)
|
| 60 |
-
|
| 61 |
-
# --- Insert the BeatAPI block before _resolve_llm_params ---
|
| 62 |
-
anchor = "def _resolve_llm_params(\n"
|
| 63 |
-
if anchor not in c:
|
| 64 |
-
print("FAIL: could not find _resolve_llm_params definition")
|
| 65 |
-
raise SystemExit(1)
|
| 66 |
-
|
| 67 |
-
beatapi_block = f'''BEATAPI_BASE_URL = "{BEATAPI_BASE_URL}"
|
| 68 |
-
BEATAPI_MODEL_PREFIX = "{BEATAPI_MODEL_PREFIX}"
|
| 69 |
-
# BeatAPI accepts low/medium/high/xhigh/max (HF Router only low/medium/high).
|
| 70 |
-
_BEATAPI_EFFORTS = {{"low", "medium", "high", "xhigh", "max"}}
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
class BeatApiKeyMissingError(ValueError):
|
| 74 |
-
"""BEATAPI_API_KEY is not configured, so BeatAPI cannot be called.
|
| 75 |
-
|
| 76 |
-
Raised before any network call so the UI shows an actionable message
|
| 77 |
-
("set BEATAPI_API_KEY") instead of an opaque 401 missing_api_key.
|
| 78 |
-
"""
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _beatapi_base_model(model_name: str) -> str:
|
| 82 |
-
"""Return the wire model id for a BeatAPI request.
|
| 83 |
-
|
| 84 |
-
``beatapi/space-bunny-alpha`` -> ``space-bunny-alpha``
|
| 85 |
-
BeatAPI model ids are flat; they must never carry our routing prefix.
|
| 86 |
-
"""
|
| 87 |
-
return model_name[len(BEATAPI_MODEL_PREFIX):].strip()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _resolve_beatapi_params(
|
| 91 |
-
model_name: str,
|
| 92 |
-
reasoning_effort: str | None = None,
|
| 93 |
-
strict: bool = False,
|
| 94 |
-
) -> dict:
|
| 95 |
-
"""Build LiteLLM kwargs for a BeatAPI (``beatapi/``-prefixed) model.
|
| 96 |
-
|
| 97 |
-
Mirrors the OpenRouter branch but targets api.beatapi.io, uses
|
| 98 |
-
BEATAPI_API_KEY, forces LiteLLM's OpenAI-compatible client, and forwards
|
| 99 |
-
BeatAPI's own reasoning_effort levels (it accepts xhigh and max, which the
|
| 100 |
-
HF Router does not).
|
| 101 |
-
"""
|
| 102 |
-
base_model = _beatapi_base_model(model_name)
|
| 103 |
-
if not base_model:
|
| 104 |
-
raise ValueError(f"Missing BeatAPI model id: {{model_name!r}}")
|
| 105 |
-
|
| 106 |
-
api_key = os.environ.get("BEATAPI_API_KEY", "").strip()
|
| 107 |
-
if not api_key:
|
| 108 |
-
raise BeatApiKeyMissingError(
|
| 109 |
-
"BEATAPI_API_KEY is not set. BeatAPI models (beatapi/...) require a "
|
| 110 |
-
"BeatAPI key from https://beatapi.io (Dashboard -> API Keys); "
|
| 111 |
-
"set it as a Space secret named BEATAPI_API_KEY."
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
params: dict = {{
|
| 115 |
-
"model": f"openai/{{base_model}}",
|
| 116 |
-
"api_base": BEATAPI_BASE_URL,
|
| 117 |
-
"api_key": api_key,
|
| 118 |
-
# Force LiteLLM's OpenAI-compatible client. Without this, LiteLLM would
|
| 119 |
-
# try to resolve a provider from the model string and raise
|
| 120 |
-
# "LLM Provider NOT provided" (same class of bug as the OpenRouter fix).
|
| 121 |
-
"custom_llm_provider": "openai",
|
| 122 |
-
}}
|
| 123 |
-
|
| 124 |
-
if reasoning_effort:
|
| 125 |
-
level = reasoning_effort
|
| 126 |
-
if level not in _BEATAPI_EFFORTS:
|
| 127 |
-
if strict:
|
| 128 |
-
raise UnsupportedEffortError(
|
| 129 |
-
f"BeatAPI doesn't accept effort={{level!r}}"
|
| 130 |
-
)
|
| 131 |
-
else:
|
| 132 |
-
# BeatAPI reads reasoning_effort from the request body directly.
|
| 133 |
-
params["extra_body"] = {{"reasoning_effort": level}}
|
| 134 |
-
return params
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
'''
|
| 138 |
-
|
| 139 |
-
c = c.replace(anchor, beatapi_block + anchor, 1)
|
| 140 |
-
|
| 141 |
-
# --- Add the dispatch inside _resolve_llm_params, BEFORE the openai/ catch-all ---
|
| 142 |
-
# The dispatch must sit after the local-model checks and before the OpenRouter
|
| 143 |
-
# branch so `beatapi/` can never be swallowed by `openai/`.
|
| 144 |
-
dispatch_anchor = " hf_model = normalized_model\n"
|
| 145 |
-
if dispatch_anchor not in c:
|
| 146 |
-
print("FAIL: could not find hf_model assignment in _resolve_llm_params")
|
| 147 |
-
raise SystemExit(1)
|
| 148 |
-
|
| 149 |
-
dispatch = """ # --- BeatAPI provider (beatapi/ prefix) -------------------------------
|
| 150 |
-
# NOTE: distinct from the OpenRouter branch below on api_base, API key,
|
| 151 |
-
# model-id shape and reasoning_effort levels. Keep this check above the
|
| 152 |
-
# openai/ catch-all so the two providers can never collide.
|
| 153 |
-
if normalized_model.startswith(BEATAPI_MODEL_PREFIX):
|
| 154 |
-
return _resolve_beatapi_params(
|
| 155 |
-
normalized_model, reasoning_effort, strict
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
"""
|
| 159 |
-
|
| 160 |
-
c = c.replace(dispatch_anchor, dispatch + dispatch_anchor, 1)
|
| 161 |
-
|
| 162 |
-
with open(FILE, "w") as f:
|
| 163 |
-
f.write(c)
|
| 164 |
-
|
| 165 |
-
try:
|
| 166 |
-
ast.parse(c)
|
| 167 |
-
print("OK: llm_params.py patched & syntax valid")
|
| 168 |
-
except SyntaxError as e:
|
| 169 |
-
print(f"FAIL: llm_params.py syntax error: {e}")
|
| 170 |
-
raise
|
| 171 |
-
|
| 172 |
-
# Sanity: the BeatAPI branch must appear before the openai/ catch-all.
|
| 173 |
-
beat_i = c.find("startswith(BEATAPI_MODEL_PREFIX)")
|
| 174 |
-
openai_i = c.find('startswith("openai/")')
|
| 175 |
-
if beat_i != -1 and openai_i != -1:
|
| 176 |
-
if beat_i < openai_i:
|
| 177 |
-
print("OK: BeatAPI dispatch precedes the openai/ catch-all")
|
| 178 |
-
else:
|
| 179 |
-
print("FAIL: BeatAPI dispatch is AFTER the openai/ catch-all")
|
| 180 |
-
raise SystemExit(1)
|
| 181 |
-
|
| 182 |
-
print("DONE: BeatAPI routing branch added to llm_params.py")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_catch_up_dataset_fallback.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Patch frontend: Add dataset fallback to ExpiredBanner handleCatchUp.
|
| 4 |
-
|
| 5 |
-
When localStorage caches (backend-messages, ui-messages) are empty
|
| 6 |
-
(user cleared cache, new browser, Space restarted), the catch-up flow
|
| 7 |
-
shows "Nothing to summarize from this chat.". This patch adds a fallback
|
| 8 |
-
that fetches messages from /api/session-history/{session_id} — which reads
|
| 9 |
-
from the permanent HF dataset (bep40/ml-intern-sessions).
|
| 10 |
-
|
| 11 |
-
This ensures the "Catch me up" button always works as long as the
|
| 12 |
-
session was previously logged to the permanent dataset.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
import os
|
| 16 |
-
import sys
|
| 17 |
-
|
| 18 |
-
PATCHED_MARKER = "// === PATCH: catch-up dataset fallback ==="
|
| 19 |
-
TARGET_FILE = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def patch_expired_banner(content: str) -> tuple[str, bool]:
|
| 23 |
-
changed = False
|
| 24 |
-
|
| 25 |
-
if PATCHED_MARKER in content:
|
| 26 |
-
print(f" SKIP: already patched")
|
| 27 |
-
return content, False
|
| 28 |
-
|
| 29 |
-
# Add a fallback to /api/session-history/{session_id} before the error.
|
| 30 |
-
# The old block checks if messages array is empty and bails with error.
|
| 31 |
-
old_block = (
|
| 32 |
-
" if (!messages || messages.length === 0) {\n"
|
| 33 |
-
" setError('Nothing to summarize from this chat.');\n"
|
| 34 |
-
" setBusy(null);\n"
|
| 35 |
-
" return;\n"
|
| 36 |
-
" }\n"
|
| 37 |
-
)
|
| 38 |
-
new_block = (
|
| 39 |
-
PATCHED_MARKER + "\n"
|
| 40 |
-
" // Fallback: when localStorage caches are empty (Space restart,\n"
|
| 41 |
-
" // new browser, cache cleared), read from the permanent HF dataset\n"
|
| 42 |
-
" // via /api/session-history/{session_id}.\n"
|
| 43 |
-
" if (!messages || messages.length === 0) {\n"
|
| 44 |
-
" try {\n"
|
| 45 |
-
" const res = await apiFetch(`/api/session-history/${sessionId}`);\n"
|
| 46 |
-
" if (res.ok) {\n"
|
| 47 |
-
" const history = await res.json();\n"
|
| 48 |
-
" if (Array.isArray(history) && history.length > 0) {\n"
|
| 49 |
-
" messages = history.map((row: any) => ({\n"
|
| 50 |
-
" role: row.role || 'assistant',\n"
|
| 51 |
-
" content: row.content || '',\n"
|
| 52 |
-
" }));\n"
|
| 53 |
-
" }\n"
|
| 54 |
-
" }\n"
|
| 55 |
-
" } catch {\n"
|
| 56 |
-
" /* dataset fetch failed — continue to error below */\n"
|
| 57 |
-
" }\n"
|
| 58 |
-
" }\n"
|
| 59 |
-
" if (!messages || messages.length === 0) {\n"
|
| 60 |
-
" setError('Nothing to summarize from this chat.');\n"
|
| 61 |
-
" setBusy(null);\n"
|
| 62 |
-
" return;\n"
|
| 63 |
-
" }\n"
|
| 64 |
-
)
|
| 65 |
-
if old_block in content:
|
| 66 |
-
content = content.replace(old_block, new_block, 1)
|
| 67 |
-
changed = True
|
| 68 |
-
print(" + Added dataset fallback to handleCatchUp")
|
| 69 |
-
else:
|
| 70 |
-
print(" SKIP: target block not found (pattern may have changed)")
|
| 71 |
-
|
| 72 |
-
return content, changed
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def main():
|
| 76 |
-
if not os.path.exists(TARGET_FILE):
|
| 77 |
-
print(f"SKIP: {TARGET_FILE} not found — frontend source not available")
|
| 78 |
-
return
|
| 79 |
-
|
| 80 |
-
with open(TARGET_FILE, "r", encoding="utf-8") as f:
|
| 81 |
-
content = f.read()
|
| 82 |
-
|
| 83 |
-
if PATCHED_MARKER in content:
|
| 84 |
-
print(f"SKIP: {TARGET_FILE} already patched")
|
| 85 |
-
return
|
| 86 |
-
|
| 87 |
-
content, changed = patch_expired_banner(content)
|
| 88 |
-
if changed:
|
| 89 |
-
with open(TARGET_FILE, "w", encoding="utf-8") as f:
|
| 90 |
-
f.write(content)
|
| 91 |
-
print(f"OK: {TARGET_FILE} patched with dataset fallback")
|
| 92 |
-
else:
|
| 93 |
-
print(f"SKIP: {TARGET_FILE} — no changes applied")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
if __name__ == "__main__":
|
| 97 |
-
try:
|
| 98 |
-
main()
|
| 99 |
-
except Exception as e:
|
| 100 |
-
print(f"WARN: patch_catch_up_dataset_fallback.py error: {e}")
|
| 101 |
-
# Never fail the Docker build
|
| 102 |
-
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_catch_up_fix_backend.py
DELETED
|
@@ -1,220 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Patch: Fix catch-up backend robustness.
|
| 4 |
-
|
| 5 |
-
Issues fixed:
|
| 6 |
-
1. /api/session-history/{session_id} returns ALL rows including 'event' type
|
| 7 |
-
rows (role='event'). These cause Message.model_validate to silently drop them.
|
| 8 |
-
Fix: Filter out non-message roles (event, system) from history endpoint.
|
| 9 |
-
|
| 10 |
-
2. seed_from_summary raises if summarization fails (API error, rate limit, credits),
|
| 11 |
-
causing restore-summary to return 500 and the frontend to show "Couldn't catch up."
|
| 12 |
-
Fix: Fall back to raw messages if summarization fails.
|
| 13 |
-
|
| 14 |
-
3. Event rows with role='event' fail Message.model_validate.
|
| 15 |
-
Fix: Skip event rows explicitly in seed_from_summary.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import ast
|
| 19 |
-
import os
|
| 20 |
-
import sys
|
| 21 |
-
|
| 22 |
-
PATCHED_MARKER = "# === PATCH: catch-up fix (backend) ==="
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _safe_replace(content, old, new, count=1):
|
| 26 |
-
if old in content:
|
| 27 |
-
content = content.replace(old, new, count)
|
| 28 |
-
return content, True
|
| 29 |
-
return content, False
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def patch_file(path, patch_fn):
|
| 33 |
-
if not os.path.exists(path):
|
| 34 |
-
print(f"SKIP: {path} not found")
|
| 35 |
-
return False
|
| 36 |
-
try:
|
| 37 |
-
with open(path, "r") as f:
|
| 38 |
-
content = f.read()
|
| 39 |
-
content, changed = patch_fn(content)
|
| 40 |
-
if not changed:
|
| 41 |
-
print(f"SKIP: {path} — no changes needed")
|
| 42 |
-
return False
|
| 43 |
-
try:
|
| 44 |
-
ast.parse(content)
|
| 45 |
-
except SyntaxError as e:
|
| 46 |
-
print(f"FAIL: {path} syntax: {e}")
|
| 47 |
-
return False
|
| 48 |
-
with open(path, "w") as f:
|
| 49 |
-
f.write(content)
|
| 50 |
-
print(f"OK: {path} patched (catch-up fix)")
|
| 51 |
-
return True
|
| 52 |
-
except Exception as e:
|
| 53 |
-
print(f"WARN: {path} patch error: {e}")
|
| 54 |
-
return False
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# ─── Patch routes/agent.py: filter event rows from session-history ─────────
|
| 58 |
-
print("=== Patching routes/agent.py (session-history filter) ===")
|
| 59 |
-
|
| 60 |
-
ROUTES_AGENT = "/app/backend/routes/agent.py"
|
| 61 |
-
|
| 62 |
-
def _patch_routes(content):
|
| 63 |
-
changed = False
|
| 64 |
-
|
| 65 |
-
if PATCHED_MARKER in content:
|
| 66 |
-
print(" SKIP: already patched")
|
| 67 |
-
return content, False
|
| 68 |
-
|
| 69 |
-
# Patch the get_session_history endpoint to filter out event rows
|
| 70 |
-
old_endpoint = (
|
| 71 |
-
'@router.get("/session-history/{session_id}")\n'
|
| 72 |
-
'async def get_session_history(\n'
|
| 73 |
-
' session_id: str, user: dict = Depends(get_current_user)\n'
|
| 74 |
-
') -> list:\n'
|
| 75 |
-
' """Get full message history for a session from the permanent dataset."""\n'
|
| 76 |
-
' from backend.session_logger import load_session_history\n'
|
| 77 |
-
' return load_session_history(session_id)'
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
new_endpoint = (
|
| 81 |
-
'@router.get("/session-history/{session_id}")\n'
|
| 82 |
-
'async def get_session_history(\n'
|
| 83 |
-
' session_id: str, user: dict = Depends(get_current_user)\n'
|
| 84 |
-
') -> list:\n'
|
| 85 |
-
' """Get full message history for a session from the permanent dataset.\n'
|
| 86 |
-
'\n'
|
| 87 |
-
' Returns only valid conversation messages (user/assistant/tool roles).\n'
|
| 88 |
-
" Event rows (role='event') are filtered out because they are internal\n"
|
| 89 |
-
' tracing data that cannot be parsed as Message objects and would be\n'
|
| 90 |
-
' silently dropped during catch-up, causing message loss.\n'
|
| 91 |
-
' """\n'
|
| 92 |
-
' from backend.session_logger import load_session_history\n'
|
| 93 |
-
' rows = load_session_history(session_id)\n'
|
| 94 |
-
' # Filter out event rows and any rows without valid roles\n'
|
| 95 |
-
' valid_roles = {"user", "assistant", "tool"}\n'
|
| 96 |
-
' return [r for r in rows if r.get("role") in valid_roles]'
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
content, ok = _safe_replace(content, old_endpoint, new_endpoint)
|
| 100 |
-
if ok:
|
| 101 |
-
print(" + Filtered event rows from session-history endpoint")
|
| 102 |
-
changed = True
|
| 103 |
-
else:
|
| 104 |
-
print(" SKIP: target block not found")
|
| 105 |
-
|
| 106 |
-
# Add marker
|
| 107 |
-
content = content.rstrip() + "\n\n" + PATCHED_MARKER + "\n"
|
| 108 |
-
changed = True
|
| 109 |
-
|
| 110 |
-
return content, changed
|
| 111 |
-
|
| 112 |
-
patch_file(ROUTES_AGENT, _patch_routes)
|
| 113 |
-
|
| 114 |
-
# ─── Patch session_manager.py: robust seed_from_summary ─────────────────────
|
| 115 |
-
print("\n=== Patching session_manager.py (seed_from_summary robustness) ===")
|
| 116 |
-
|
| 117 |
-
SESSION_MANAGER = "/app/backend/session_manager.py"
|
| 118 |
-
|
| 119 |
-
def _patch_seed_from_summary(content):
|
| 120 |
-
changed = False
|
| 121 |
-
|
| 122 |
-
# Check if already patched
|
| 123 |
-
if PATCHED_MARKER in content:
|
| 124 |
-
print(" SKIP: already patched")
|
| 125 |
-
return content, False
|
| 126 |
-
|
| 127 |
-
# Patch 1: Skip event rows explicitly in seed_from_summary
|
| 128 |
-
old_parse = (
|
| 129 |
-
' # Parse into Message objects, tolerating malformed entries.\n'
|
| 130 |
-
' parsed: list[Message] = []\n'
|
| 131 |
-
' for raw in messages:\n'
|
| 132 |
-
' if raw.get("role") == "system":\n'
|
| 133 |
-
' continue # the new session has its own system prompt\n'
|
| 134 |
-
' try:\n'
|
| 135 |
-
' parsed.append(Message.model_validate(raw))\n'
|
| 136 |
-
' except Exception as e:\n'
|
| 137 |
-
' logger.warning("Dropping malformed message during seed: %s", e)'
|
| 138 |
-
)
|
| 139 |
-
|
| 140 |
-
new_parse = (
|
| 141 |
-
' # Parse into Message objects, tolerating malformed entries.\n'
|
| 142 |
-
' # === PATCH: skip event rows (not just system) ===\n'
|
| 143 |
-
' parsed: list[Message] = []\n'
|
| 144 |
-
' for raw in messages:\n'
|
| 145 |
-
' role = raw.get("role", "")\n'
|
| 146 |
-
' if role in ("system", "event"):\n'
|
| 147 |
-
' continue # not conversation messages\n'
|
| 148 |
-
' try:\n'
|
| 149 |
-
' parsed.append(Message.model_validate(raw))\n'
|
| 150 |
-
' except Exception as e:\n'
|
| 151 |
-
' logger.warning("Dropping malformed message during seed: %s", e)'
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
content, ok = _safe_replace(content, old_parse, new_parse)
|
| 155 |
-
if ok:
|
| 156 |
-
print(" + Skip event rows explicitly in seed_from_summary")
|
| 157 |
-
changed = True
|
| 158 |
-
|
| 159 |
-
# Patch 2: Fall back to raw messages if summarization fails
|
| 160 |
-
old_try = (
|
| 161 |
-
' try:\n'
|
| 162 |
-
' summary, _ = await summarize_messages(\n'
|
| 163 |
-
' parsed,\n'
|
| 164 |
-
' model_name=session.config.model_name,\n'
|
| 165 |
-
' hf_token=session.hf_token,\n'
|
| 166 |
-
' max_tokens=4000,\n'
|
| 167 |
-
' prompt=_RESTORE_PROMPT,\n'
|
| 168 |
-
' tool_specs=tool_specs,\n'
|
| 169 |
-
' session=session,\n'
|
| 170 |
-
' kind="restore",\n'
|
| 171 |
-
' )\n'
|
| 172 |
-
' except Exception as e:\n'
|
| 173 |
-
' logger.error("Summary call failed during seed: %s", e)\n'
|
| 174 |
-
' raise\n'
|
| 175 |
-
'\n'
|
| 176 |
-
' seed = Message('
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
new_try = (
|
| 180 |
-
' # === PATCH: fall back to raw messages if summarization fails ===\n'
|
| 181 |
-
' summary = None\n'
|
| 182 |
-
' try:\n'
|
| 183 |
-
' summary, _ = await summarize_messages(\n'
|
| 184 |
-
' parsed,\n'
|
| 185 |
-
' model_name=session.config.model_name,\n'
|
| 186 |
-
' hf_token=session.hf_token,\n'
|
| 187 |
-
' max_tokens=4000,\n'
|
| 188 |
-
' prompt=_RESTORE_PROMPT,\n'
|
| 189 |
-
' tool_specs=tool_specs,\n'
|
| 190 |
-
' session=session,\n'
|
| 191 |
-
' kind="restore",\n'
|
| 192 |
-
' )\n'
|
| 193 |
-
' except Exception as e:\n'
|
| 194 |
-
' logger.error("Summary call failed during seed, falling back to raw messages: %s", e)\n'
|
| 195 |
-
' # Fall back: inject raw messages directly into the new session context\n'
|
| 196 |
-
' # This preserves the full conversation instead of losing it\n'
|
| 197 |
-
' for msg in parsed:\n'
|
| 198 |
-
' session.context_manager.items.append(msg)\n'
|
| 199 |
-
' self._touch(agent_session)\n'
|
| 200 |
-
' await self.persist_session_snapshot(agent_session, runtime_state="idle")\n'
|
| 201 |
-
' return len(parsed)\n'
|
| 202 |
-
'\n'
|
| 203 |
-
' seed = Message('
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
content, ok = _safe_replace(content, old_try, new_try)
|
| 207 |
-
if ok:
|
| 208 |
-
print(" + Fall back to raw messages if summarization fails")
|
| 209 |
-
changed = True
|
| 210 |
-
|
| 211 |
-
# Add marker
|
| 212 |
-
if changed:
|
| 213 |
-
content = content + "\n" + PATCHED_MARKER + "\n"
|
| 214 |
-
changed = True
|
| 215 |
-
|
| 216 |
-
return content, changed
|
| 217 |
-
|
| 218 |
-
patch_file(SESSION_MANAGER, _patch_seed_from_summary)
|
| 219 |
-
|
| 220 |
-
print("\n=== Backend catch-up fixes applied ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_catch_up_fix_frontend.py
DELETED
|
@@ -1,289 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Patch: Fix frontend catch-up message loss.
|
| 4 |
-
|
| 5 |
-
Issue: After catch-up succeeds, the new session's backend hydration
|
| 6 |
-
fetches /api/session/{newId}/messages from the backend, which only
|
| 7 |
-
contains the summary message. It then calls setMessages() and
|
| 8 |
-
saveMessages(), OVERWRITING the old full messages that were moved to
|
| 9 |
-
the new ID via renameSession -> moveMessages.
|
| 10 |
-
|
| 11 |
-
Fix: In each hydration code path, only overwrite frontend messages if
|
| 12 |
-
the backend returns MORE non-system messages than the frontend already has.
|
| 13 |
-
If the frontend already has more messages (from localStorage), keep them.
|
| 14 |
-
|
| 15 |
-
Also: Fix the catch-up dataset fallback in ExpiredBanner to properly
|
| 16 |
-
filter event rows and handle malformed data.
|
| 17 |
-
|
| 18 |
-
Runs during Docker build — never fails the build.
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
import ast
|
| 22 |
-
import os
|
| 23 |
-
import sys
|
| 24 |
-
|
| 25 |
-
PATCHED_MARKER = "// PATCH: catch-up fix (frontend)"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _safe_replace(content, old, new, count=1):
|
| 29 |
-
if old in content:
|
| 30 |
-
content = content.replace(old, new, count)
|
| 31 |
-
return content, True
|
| 32 |
-
return content, False
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _is_python_file(path):
|
| 36 |
-
return path.endswith(".py")
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def patch_file(path, patch_fn):
|
| 40 |
-
if not os.path.exists(path):
|
| 41 |
-
print(f"SKIP: {path} not found")
|
| 42 |
-
return False
|
| 43 |
-
try:
|
| 44 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 45 |
-
content = f.read()
|
| 46 |
-
content, changed = patch_fn(content)
|
| 47 |
-
if not changed:
|
| 48 |
-
print(f"SKIP: {path} — no changes needed")
|
| 49 |
-
return False
|
| 50 |
-
# Only validate Python syntax for .py files
|
| 51 |
-
if _is_python_file(path):
|
| 52 |
-
try:
|
| 53 |
-
ast.parse(content)
|
| 54 |
-
except SyntaxError as e:
|
| 55 |
-
print(f"FAIL: {path} syntax: {e}")
|
| 56 |
-
return False
|
| 57 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 58 |
-
f.write(content)
|
| 59 |
-
print(f"OK: {path} patched (catch-up fix)")
|
| 60 |
-
return True
|
| 61 |
-
except Exception as e:
|
| 62 |
-
print(f"WARN: {path} patch error: {e}")
|
| 63 |
-
return False
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
# ─── Patch useAgentChat.ts ──────────────────────────────────────────────────
|
| 67 |
-
print("=== Patching useAgentChat.ts (prevent message overwrite) ===")
|
| 68 |
-
|
| 69 |
-
USE_AGENT_CHAT = "/source/frontend/src/hooks/useAgentChat.ts"
|
| 70 |
-
|
| 71 |
-
def _patch_use_agent_chat(content):
|
| 72 |
-
changed = False
|
| 73 |
-
|
| 74 |
-
if PATCHED_MARKER in content:
|
| 75 |
-
print(" SKIP: already patched")
|
| 76 |
-
return content, False
|
| 77 |
-
|
| 78 |
-
# Patch 1: onRecoverMessages
|
| 79 |
-
# The pattern uses 10-space indentation (inside a nested callback)
|
| 80 |
-
old_pattern1 = (
|
| 81 |
-
" const setMsgs = chatActionsRef.current.setMessages;\n"
|
| 82 |
-
" if (setMsgs && uiMsgs.length >= currentMessageCount) {\n"
|
| 83 |
-
" setMsgs(uiMsgs);\n"
|
| 84 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 85 |
-
" }"
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
new_pattern1 = (
|
| 89 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 90 |
-
" // After catch-up, the backend only has a summary message but\n"
|
| 91 |
-
" // the frontend (via localStorage moveMessages) has the full\n"
|
| 92 |
-
" // old conversation. Don't overwrite if backend has fewer.\n"
|
| 93 |
-
" const _currentNonSystem = chatActionsRef.current.messages.filter(\n"
|
| 94 |
-
" (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
|
| 95 |
-
" );\n"
|
| 96 |
-
" const _shouldOverwrite = uiMsgs.length > _currentNonSystem.length || _currentNonSystem.length === 0;\n"
|
| 97 |
-
" const setMsgs = chatActionsRef.current.setMessages;\n"
|
| 98 |
-
" if (setMsgs && _shouldOverwrite) {\n"
|
| 99 |
-
" setMsgs(uiMsgs);\n"
|
| 100 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 101 |
-
" }"
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
content, ok = _safe_replace(content, old_pattern1, new_pattern1)
|
| 105 |
-
if ok:
|
| 106 |
-
print(" + Prevented backend overwrite in onRecoverMessages")
|
| 107 |
-
changed = True
|
| 108 |
-
else:
|
| 109 |
-
print(" INFO: onRecoverMessages pattern not found")
|
| 110 |
-
|
| 111 |
-
# Patch 2: refreshMessages
|
| 112 |
-
# The pattern uses 6-space indentation and ends with 'return true;'
|
| 113 |
-
old_pattern2 = (
|
| 114 |
-
" const setMsgs = chatActionsRef.current.setMessages;\n"
|
| 115 |
-
" if (setMsgs && uiMsgs.length > 0) {\n"
|
| 116 |
-
" setMsgs(uiMsgs);\n"
|
| 117 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 118 |
-
" }\n"
|
| 119 |
-
" return true;"
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
new_pattern2 = (
|
| 123 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 124 |
-
" const _currentNonSystem2 = chatActionsRef.current.messages.filter(\n"
|
| 125 |
-
" (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
|
| 126 |
-
" );\n"
|
| 127 |
-
" const _shouldOverwrite2 = uiMsgs.length > _currentNonSystem2.length || _currentNonSystem2.length === 0;\n"
|
| 128 |
-
" const setMsgs = chatActionsRef.current.setMessages;\n"
|
| 129 |
-
" if (setMsgs && _shouldOverwrite2) {\n"
|
| 130 |
-
" setMsgs(uiMsgs);\n"
|
| 131 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 132 |
-
" }\n"
|
| 133 |
-
" return true;"
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
content, ok2 = _safe_replace(content, old_pattern2, new_pattern2)
|
| 137 |
-
if ok2:
|
| 138 |
-
print(" + Prevented backend overwrite in refreshMessages")
|
| 139 |
-
changed = True
|
| 140 |
-
else:
|
| 141 |
-
print(" INFO: refreshMessages pattern not found")
|
| 142 |
-
|
| 143 |
-
# Patch 3: Main hydration useEffect (uses chat.setMessages)
|
| 144 |
-
# There are multiple code paths with different indentation levels:
|
| 145 |
-
# - 6-space indent: in some code paths
|
| 146 |
-
# - 10-space indent: main hydration useEffect
|
| 147 |
-
# - 14-space indent: hydrateMessages helper (appears twice)
|
| 148 |
-
# We patch each indentation level separately.
|
| 149 |
-
|
| 150 |
-
old_pattern3a = (
|
| 151 |
-
" if (uiMsgs.length > 0) {\n"
|
| 152 |
-
" chat.setMessages(uiMsgs);\n"
|
| 153 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 154 |
-
" }"
|
| 155 |
-
)
|
| 156 |
-
|
| 157 |
-
new_pattern3a = (
|
| 158 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 159 |
-
" // Don't overwrite frontend messages with backend's fewer messages\n"
|
| 160 |
-
" const _currentMsgs = (chatActionsRef.current.messages || []).filter(\n"
|
| 161 |
-
" (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
|
| 162 |
-
" );\n"
|
| 163 |
-
" const _shouldSet = uiMsgs.length > _currentMsgs.length || _currentMsgs.length === 0;\n"
|
| 164 |
-
" if (_shouldSet) {\n"
|
| 165 |
-
" chat.setMessages(uiMsgs);\n"
|
| 166 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 167 |
-
" }"
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
# 10-space indentation variant
|
| 171 |
-
old_pattern3b = (
|
| 172 |
-
" if (uiMsgs.length > 0) {\n"
|
| 173 |
-
" chat.setMessages(uiMsgs);\n"
|
| 174 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 175 |
-
" }"
|
| 176 |
-
)
|
| 177 |
-
|
| 178 |
-
new_pattern3b = (
|
| 179 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 180 |
-
" const _currentMsgsB = (chatActionsRef.current.messages || []).filter(\n"
|
| 181 |
-
" (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
|
| 182 |
-
" );\n"
|
| 183 |
-
" const _shouldSetB = uiMsgs.length > _currentMsgsB.length || _currentMsgsB.length === 0;\n"
|
| 184 |
-
" if (_shouldSetB) {\n"
|
| 185 |
-
" chat.setMessages(uiMsgs);\n"
|
| 186 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 187 |
-
" }"
|
| 188 |
-
)
|
| 189 |
-
|
| 190 |
-
# 14-space indentation variant
|
| 191 |
-
old_pattern3c = (
|
| 192 |
-
" if (uiMsgs.length > 0) {\n"
|
| 193 |
-
" chat.setMessages(uiMsgs);\n"
|
| 194 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 195 |
-
" }"
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
new_pattern3c = (
|
| 199 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 200 |
-
" const _currentMsgsC = (chatActionsRef.current.messages || []).filter(\n"
|
| 201 |
-
" (m) => !(m.parts?.[0]?.type === 'text' && typeof m.parts?.[0]?.text === 'string' && m.parts?.[0].text.trimStart().startsWith('[SYSTEM:'))\n"
|
| 202 |
-
" );\n"
|
| 203 |
-
" const _shouldSetC = uiMsgs.length > _currentMsgsC.length || _currentMsgsC.length === 0;\n"
|
| 204 |
-
" if (_shouldSetC) {\n"
|
| 205 |
-
" chat.setMessages(uiMsgs);\n"
|
| 206 |
-
" saveMessages(sessionId, uiMsgs);\n"
|
| 207 |
-
" }"
|
| 208 |
-
)
|
| 209 |
-
|
| 210 |
-
count = 0
|
| 211 |
-
for old_p, new_p in [(old_pattern3a, new_pattern3a), (old_pattern3b, new_pattern3b), (old_pattern3c, new_pattern3c)]:
|
| 212 |
-
while True:
|
| 213 |
-
content, ok3 = _safe_replace(content, old_p, new_p)
|
| 214 |
-
if ok3:
|
| 215 |
-
count += 1
|
| 216 |
-
changed = True
|
| 217 |
-
else:
|
| 218 |
-
break
|
| 219 |
-
if count > 0:
|
| 220 |
-
print(f" + Prevented backend overwrite in {count} hydration code paths")
|
| 221 |
-
|
| 222 |
-
if changed:
|
| 223 |
-
content = content.replace(
|
| 224 |
-
"import { logger } from '@/utils/logger';",
|
| 225 |
-
"import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
|
| 226 |
-
1
|
| 227 |
-
)
|
| 228 |
-
|
| 229 |
-
return content, changed
|
| 230 |
-
|
| 231 |
-
patch_file(USE_AGENT_CHAT, _patch_use_agent_chat)
|
| 232 |
-
|
| 233 |
-
# ─── Patch ExpiredBanner.tsx: filter event rows in dataset fallback ─────────
|
| 234 |
-
print("\n=== Patching ExpiredBanner.tsx (dataset fallback filter) ===")
|
| 235 |
-
|
| 236 |
-
EXPIRED_BANNER = "/source/frontend/src/components/Chat/ExpiredBanner.tsx"
|
| 237 |
-
|
| 238 |
-
def _patch_expired_banner(content):
|
| 239 |
-
changed = False
|
| 240 |
-
|
| 241 |
-
if PATCHED_MARKER in content:
|
| 242 |
-
print(" SKIP: already patched")
|
| 243 |
-
return content, False
|
| 244 |
-
|
| 245 |
-
# Check if the dataset fallback is present (from patch_catch_up_dataset_fallback.py)
|
| 246 |
-
if "/api/session-history/" not in content:
|
| 247 |
-
print(" SKIP: no dataset fallback found in ExpiredBanner.tsx")
|
| 248 |
-
return content, False
|
| 249 |
-
|
| 250 |
-
# Patch the dataset fallback mapping to filter event rows
|
| 251 |
-
old_mapping = (
|
| 252 |
-
" messages = history.map((row: any) => ({\n"
|
| 253 |
-
" role: row.role || 'assistant',\n"
|
| 254 |
-
" content: row.content || '',\n"
|
| 255 |
-
" }));"
|
| 256 |
-
)
|
| 257 |
-
|
| 258 |
-
new_mapping = (
|
| 259 |
-
" // === PATCH: catch-up fix (frontend) ===\n"
|
| 260 |
-
" // Filter out 'event' rows — they are internal tracing\n"
|
| 261 |
-
" // data that cannot be parsed as Message objects\n"
|
| 262 |
-
" const validRoles = new Set(['user', 'assistant', 'tool']);\n"
|
| 263 |
-
" messages = history\n"
|
| 264 |
-
" .filter((row: any) => validRoles.has(row.role))\n"
|
| 265 |
-
" .map((row: any) => ({\n"
|
| 266 |
-
" role: row.role,\n"
|
| 267 |
-
" content: row.content || '',\n"
|
| 268 |
-
" }));"
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
content, ok = _safe_replace(content, old_mapping, new_mapping)
|
| 272 |
-
if ok:
|
| 273 |
-
print(" + Filtered event rows in dataset fallback")
|
| 274 |
-
changed = True
|
| 275 |
-
else:
|
| 276 |
-
print(" INFO: dataset fallback mapping pattern not found")
|
| 277 |
-
|
| 278 |
-
if changed:
|
| 279 |
-
content = content.replace(
|
| 280 |
-
"import { logger } from '@/utils/logger';",
|
| 281 |
-
"import { logger } from '@/utils/logger';\n// " + PATCHED_MARKER + " applied",
|
| 282 |
-
1
|
| 283 |
-
)
|
| 284 |
-
|
| 285 |
-
return content, changed
|
| 286 |
-
|
| 287 |
-
patch_file(EXPIRED_BANNER, _patch_expired_banner)
|
| 288 |
-
|
| 289 |
-
print("\n=== Frontend catch-up fixes applied ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_document_upload.py
DELETED
|
@@ -1,225 +0,0 @@
|
|
| 1 |
-
"""Patch: extend dataset uploads to support office/binary documents
|
| 2 |
-
(PDF, images, Excel, Word, PPT, OpenDocument, text) and extract their text
|
| 3 |
-
so the agent can understand the file content even on text-only LLM backends.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import ast
|
| 7 |
-
import os
|
| 8 |
-
|
| 9 |
-
FILE = "/app/backend/dataset_uploads.py"
|
| 10 |
-
|
| 11 |
-
with open(FILE) as f:
|
| 12 |
-
c = f.read()
|
| 13 |
-
|
| 14 |
-
orig = c
|
| 15 |
-
|
| 16 |
-
# ---- 1. Expand allowed extensions ----
|
| 17 |
-
old_ext = 'ALLOWED_DATASET_EXTENSIONS = {"csv", "json", "jsonl"}'
|
| 18 |
-
new_ext = (
|
| 19 |
-
'ALLOWED_DATASET_EXTENSIONS = {\n'
|
| 20 |
-
' "csv", "json", "jsonl",\n'
|
| 21 |
-
' "pdf",\n'
|
| 22 |
-
' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
|
| 23 |
-
' "xlsx", "xlsm", "xls",\n'
|
| 24 |
-
' "docx", "doc",\n'
|
| 25 |
-
' "pptx",\n'
|
| 26 |
-
' "odt", "ods", "odp",\n'
|
| 27 |
-
' "txt", "md", "rtf", "log",\n'
|
| 28 |
-
'}\n'
|
| 29 |
-
'BINARY_EXTRACTION_EXTS = {\n'
|
| 30 |
-
' "pdf",\n'
|
| 31 |
-
' "png", "jpg", "jpeg", "webp", "bmp", "tiff", "tif", "gif",\n'
|
| 32 |
-
' "xlsx", "xlsm", "xls",\n'
|
| 33 |
-
' "docx", "doc",\n'
|
| 34 |
-
' "pptx",\n'
|
| 35 |
-
' "odt", "ods", "odp",\n'
|
| 36 |
-
' "rtf",\n'
|
| 37 |
-
'}\n'
|
| 38 |
-
)
|
| 39 |
-
if old_ext in c:
|
| 40 |
-
c = c.replace(old_ext, new_ext)
|
| 41 |
-
print("OK: expanded ALLOWED_DATASET_EXTENSIONS")
|
| 42 |
-
|
| 43 |
-
else:
|
| 44 |
-
print("WARN: extension line not found")
|
| 45 |
-
|
| 46 |
-
# ---- 1b. Update the legacy error message so no stale message remains ----
|
| 47 |
-
old_msg = 'detail="Only .csv, .json, and .jsonl dataset files are supported."'
|
| 48 |
-
new_msg = 'detail="Không hỗ trợ định dạng này. Các định dạng hỗ trợ: CSV, JSON, JSONL, PDF, ảnh, Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG)."'
|
| 49 |
-
if old_msg in c:
|
| 50 |
-
c = c.replace(old_msg, new_msg)
|
| 51 |
-
print("OK: updated legacy error message")
|
| 52 |
-
else:
|
| 53 |
-
print("WARN: legacy dataset message not found (already updated?)")
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
# ---- 2. Import the document parser ----
|
| 57 |
-
if "document_parser" not in c:
|
| 58 |
-
c = c.replace(
|
| 59 |
-
"from huggingface_hub import HfApi\n",
|
| 60 |
-
"from huggingface_hub import HfApi\n"
|
| 61 |
-
"from document_parser import (\n"
|
| 62 |
-
" extract_document_text,\n"
|
| 63 |
-
" format_uploaded_document_context,\n"
|
| 64 |
-
")\n",
|
| 65 |
-
)
|
| 66 |
-
print("OK: imported document_parser")
|
| 67 |
-
else:
|
| 68 |
-
print("OK: document_parser already imported")
|
| 69 |
-
|
| 70 |
-
# ---- 3. Extract text inside push_dataset_upload_to_hub ----
|
| 71 |
-
# Find the return statement that constructs the DatasetUpload and inject
|
| 72 |
-
# extraction before it.
|
| 73 |
-
marker = " return DatasetUpload(\n"
|
| 74 |
-
if marker in c and "extract_document_text" in c and "extracted_text" not in c:
|
| 75 |
-
extraction_block = (
|
| 76 |
-
" # Extract plain-text content so the agent can understand the file\n"
|
| 77 |
-
" # even when the LLM backend has no native attachment support.\n"
|
| 78 |
-
" extracted_text, extraction_warning = \"\", \"\"\n"
|
| 79 |
-
" if dataset_format in BINARY_EXTRACTION_EXTS:\n"
|
| 80 |
-
" try:\n"
|
| 81 |
-
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 82 |
-
" raw_bytes = await asyncio.to_thread(upload.file.read)\n"
|
| 83 |
-
" extracted_text, extraction_warning = await asyncio.to_thread(\n"
|
| 84 |
-
" extract_document_text, safe_filename, raw_bytes\n"
|
| 85 |
-
" )\n"
|
| 86 |
-
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 87 |
-
" except Exception as exc: # never let extraction break the upload\n"
|
| 88 |
-
" extraction_warning = f\"Could not extract text: {exc}\"\n"
|
| 89 |
-
" extracted_text = \"\"\n"
|
| 90 |
-
" await asyncio.to_thread(upload.file.seek, 0)\n"
|
| 91 |
-
"\n"
|
| 92 |
-
)
|
| 93 |
-
c = c.replace(marker, extraction_block + marker)
|
| 94 |
-
print("OK: inserted extraction block before DatasetUpload return")
|
| 95 |
-
else:
|
| 96 |
-
print("WARN: could not insert extraction block (marker or state issue)")
|
| 97 |
-
|
| 98 |
-
# ---- 4. Thread the extracted fields into the DatasetUpload dataclass ----
|
| 99 |
-
# Add fields to the dataclass
|
| 100 |
-
if "extracted_text: str" not in c:
|
| 101 |
-
c = c.replace(
|
| 102 |
-
" load_dataset_snippet: str\n",
|
| 103 |
-
" load_dataset_snippet: str\n"
|
| 104 |
-
" extracted_text: str = \"\"\n"
|
| 105 |
-
" extraction_warning: str = \"\"\n",
|
| 106 |
-
)
|
| 107 |
-
print("OK: added dataclass fields")
|
| 108 |
-
else:
|
| 109 |
-
print("OK: dataclass fields present")
|
| 110 |
-
|
| 111 |
-
# Add to response_payload
|
| 112 |
-
if '"extracted_text": self.extracted_text' not in c:
|
| 113 |
-
c = c.replace(
|
| 114 |
-
' "load_dataset_snippet": self.load_dataset_snippet,\n',
|
| 115 |
-
' "load_dataset_snippet": self.load_dataset_snippet,\n'
|
| 116 |
-
' "extracted_text": self.extracted_text,\n'
|
| 117 |
-
' "extraction_warning": self.extraction_warning,\n',
|
| 118 |
-
)
|
| 119 |
-
print("OK: response_payload includes extracted_text")
|
| 120 |
-
else:
|
| 121 |
-
print("OK: response_payload present")
|
| 122 |
-
|
| 123 |
-
# Add to the final constructor call
|
| 124 |
-
if "extracted_text=extracted_text" not in c:
|
| 125 |
-
c = c.replace(
|
| 126 |
-
" load_dataset_snippet=snippet,\n"
|
| 127 |
-
" )",
|
| 128 |
-
" load_dataset_snippet=snippet,\n"
|
| 129 |
-
" extracted_text=extracted_text,\n"
|
| 130 |
-
" extraction_warning=extraction_warning,\n"
|
| 131 |
-
" )",
|
| 132 |
-
)
|
| 133 |
-
print("OK: constructor passes extracted fields")
|
| 134 |
-
else:
|
| 135 |
-
print("OK: constructor present")
|
| 136 |
-
|
| 137 |
-
try:
|
| 138 |
-
ast.parse(c)
|
| 139 |
-
with open(FILE, "w") as f:
|
| 140 |
-
f.write(c)
|
| 141 |
-
print("OK: dataset_uploads.py written & syntax valid")
|
| 142 |
-
except SyntaxError as e:
|
| 143 |
-
print(f"FAIL: dataset_uploads.py syntax error: {e}")
|
| 144 |
-
|
| 145 |
-
# ---- Also patch models.py DatasetUploadResponse.format literal ----
|
| 146 |
-
MODELS_FILE = "/app/backend/models.py"
|
| 147 |
-
with open(MODELS_FILE) as f:
|
| 148 |
-
mc = f.read()
|
| 149 |
-
|
| 150 |
-
if 'Literal["csv", "json", "jsonl"]' in mc:
|
| 151 |
-
mc = mc.replace(
|
| 152 |
-
'format: Literal["csv", "json", "jsonl"]',
|
| 153 |
-
'format: str',
|
| 154 |
-
)
|
| 155 |
-
# add new fields
|
| 156 |
-
mc = mc.replace(
|
| 157 |
-
" load_dataset_snippet: str\n",
|
| 158 |
-
" load_dataset_snippet: str\n"
|
| 159 |
-
" extracted_text: str = \"\"\n"
|
| 160 |
-
" extraction_warning: str = \"\"\n",
|
| 161 |
-
)
|
| 162 |
-
with open(MODELS_FILE, "w") as f:
|
| 163 |
-
f.write(mc)
|
| 164 |
-
print("OK: models.py DatasetUploadResponse widened")
|
| 165 |
-
else:
|
| 166 |
-
print("OK: models.py already patched")
|
| 167 |
-
|
| 168 |
-
# ---- Patch routes/agent.py to use the richer doc context note ----
|
| 169 |
-
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 170 |
-
with open(AGENT_FILE) as f:
|
| 171 |
-
ac = f.read()
|
| 172 |
-
|
| 173 |
-
if "format_uploaded_document_context" not in ac:
|
| 174 |
-
# Update import
|
| 175 |
-
old_imp = " dataset_context_note,\n push_dataset_upload_to_hub,\n"
|
| 176 |
-
new_imp = (
|
| 177 |
-
" format_uploaded_document_context,\n"
|
| 178 |
-
" push_dataset_upload_to_hub,\n"
|
| 179 |
-
)
|
| 180 |
-
if old_imp in ac:
|
| 181 |
-
ac = ac.replace(old_imp, new_imp)
|
| 182 |
-
print("OK: agent.py import updated")
|
| 183 |
-
else:
|
| 184 |
-
# Try removing the dataset_context_note import line entirely
|
| 185 |
-
ac = ac.replace(" dataset_context_note,\n", "")
|
| 186 |
-
ac = ac.replace(
|
| 187 |
-
"from dataset_uploads import (\n push_dataset_upload_to_hub,\n)",
|
| 188 |
-
"from dataset_uploads import (\n"
|
| 189 |
-
" format_uploaded_document_context,\n"
|
| 190 |
-
" push_dataset_upload_to_hub,\n)",
|
| 191 |
-
)
|
| 192 |
-
print("OK: agent.py import swapped (fallback)")
|
| 193 |
-
|
| 194 |
-
# Update usage
|
| 195 |
-
old_use = 'Message(role="user", content=dataset_context_note(uploaded))'
|
| 196 |
-
new_use = (
|
| 197 |
-
'Message(role="user", content=format_uploaded_document_context(\n'
|
| 198 |
-
" filename=uploaded.original_filename,\n"
|
| 199 |
-
" stored_filename=uploaded.filename,\n"
|
| 200 |
-
" repo_id=uploaded.repo_id,\n"
|
| 201 |
-
" path_in_repo=uploaded.path_in_repo,\n"
|
| 202 |
-
" hub_url=uploaded.hub_url,\n"
|
| 203 |
-
" size_bytes=uploaded.size_bytes,\n"
|
| 204 |
-
" file_format=uploaded.format,\n"
|
| 205 |
-
" extracted_text=uploaded.extracted_text,\n"
|
| 206 |
-
" warning=uploaded.extraction_warning,\n"
|
| 207 |
-
" ))"
|
| 208 |
-
)
|
| 209 |
-
if old_use in ac:
|
| 210 |
-
ac = ac.replace(old_use, new_use)
|
| 211 |
-
print("OK: agent.py usage updated")
|
| 212 |
-
else:
|
| 213 |
-
print("WARN: agent.py usage line not found")
|
| 214 |
-
|
| 215 |
-
try:
|
| 216 |
-
ast.parse(ac)
|
| 217 |
-
with open(AGENT_FILE, "w") as f:
|
| 218 |
-
f.write(ac)
|
| 219 |
-
print("OK: agent.py written & syntax valid")
|
| 220 |
-
except SyntaxError as e:
|
| 221 |
-
print(f"FAIL: agent.py syntax error: {e}")
|
| 222 |
-
else:
|
| 223 |
-
print("OK: agent.py already patched")
|
| 224 |
-
|
| 225 |
-
print("DONE: document upload backend patch complete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_fix_static_404.py
DELETED
|
@@ -1,105 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Patch: Fix 404 on root and /api when frontend build is missing.
|
| 4 |
-
|
| 5 |
-
Root cause: The Dockerfile creates /app/static with `mkdir -p` but the
|
| 6 |
-
frontend build may fail, leaving the directory empty. main.py checks
|
| 7 |
-
`if static_path.exists()` which returns True for an empty directory,
|
| 8 |
-
causing StaticFiles to mount at "/" and shadow ALL API routes with 404s.
|
| 9 |
-
|
| 10 |
-
Fix: Patch main.py to check that the static directory is non-empty
|
| 11 |
-
before mounting StaticFiles.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
import os
|
| 15 |
-
import sys
|
| 16 |
-
|
| 17 |
-
TARGET_FILE = "/app/backend/main.py"
|
| 18 |
-
PATCHED_MARKER = "# === PATCH: fix static 404 on empty dir ==="
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def patch_main_py(content: str) -> tuple[str, bool]:
|
| 22 |
-
changed = False
|
| 23 |
-
|
| 24 |
-
old_block = (
|
| 25 |
-
"# Serve static files (frontend build) in production\n"
|
| 26 |
-
"static_path = Path(__file__).parent.parent / \"static\"\n"
|
| 27 |
-
"if static_path.exists():\n"
|
| 28 |
-
" app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
|
| 29 |
-
" logger.info(f\"Serving static files from {static_path}\")\n"
|
| 30 |
-
"else:\n"
|
| 31 |
-
" logger.info(\"No static directory found, running in API-only mode\")"
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
new_block = (
|
| 35 |
-
"# Serve static files (frontend build) in production\n"
|
| 36 |
-
"static_path = Path(__file__).parent.parent / \"static\"\n"
|
| 37 |
-
f"{PATCHED_MARKER}\n"
|
| 38 |
-
"# Only mount if the directory exists AND has content — an empty\n"
|
| 39 |
-
"# directory would shadow all API routes with 404s.\n"
|
| 40 |
-
"if static_path.exists() and any(static_path.iterdir()):\n"
|
| 41 |
-
" app.mount(\"/\", StaticFiles(directory=str(static_path), html=True), name=\"static\")\n"
|
| 42 |
-
" logger.info(f\"Serving static files from {static_path}\")\n"
|
| 43 |
-
"else:\n"
|
| 44 |
-
" logger.info(\"No static files found, running in API-only mode\")"
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
if old_block in content:
|
| 48 |
-
content = content.replace(old_block, new_block, 1)
|
| 49 |
-
changed = True
|
| 50 |
-
print(" + Fixed static file mounting to check for non-empty directory")
|
| 51 |
-
else:
|
| 52 |
-
# Try a more flexible match
|
| 53 |
-
if "static_path.exists()" in content and PATCHED_MARKER not in content:
|
| 54 |
-
import re
|
| 55 |
-
# Replace the if condition
|
| 56 |
-
pattern = r'if static_path\.exists\(\):'
|
| 57 |
-
replacement = f'if static_path.exists() and any(static_path.iterdir()):\n {PATCHED_MARKER}'
|
| 58 |
-
content = re.sub(pattern, replacement, content, count=1)
|
| 59 |
-
changed = True
|
| 60 |
-
print(" + Fixed static file mounting (regex fallback)")
|
| 61 |
-
else:
|
| 62 |
-
print(f" SKIP: pattern already patched or not found")
|
| 63 |
-
|
| 64 |
-
return content, changed
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def main():
|
| 68 |
-
if not os.path.exists(TARGET_FILE):
|
| 69 |
-
print(f"SKIP: {TARGET_FILE} not found")
|
| 70 |
-
return
|
| 71 |
-
|
| 72 |
-
with open(TARGET_FILE, "r", encoding="utf-8") as f:
|
| 73 |
-
content = f.read()
|
| 74 |
-
|
| 75 |
-
if PATCHED_MARKER in content:
|
| 76 |
-
print(f"SKIP: {TARGET_FILE} already patched")
|
| 77 |
-
return
|
| 78 |
-
|
| 79 |
-
# Syntax-check by parsing
|
| 80 |
-
import ast
|
| 81 |
-
try:
|
| 82 |
-
ast.parse(content)
|
| 83 |
-
except SyntaxError as e:
|
| 84 |
-
print(f"WARN: {TARGET_FILE} has syntax errors before patching: {e}")
|
| 85 |
-
return
|
| 86 |
-
|
| 87 |
-
content, changed = patch_main_py(content)
|
| 88 |
-
if changed:
|
| 89 |
-
try:
|
| 90 |
-
ast.parse(content)
|
| 91 |
-
with open(TARGET_FILE, "w", encoding="utf-8") as f:
|
| 92 |
-
f.write(content)
|
| 93 |
-
print(f"OK: {TARGET_FILE} patched")
|
| 94 |
-
except SyntaxError as e:
|
| 95 |
-
print(f"FAIL: patched {TARGET_FILE} has syntax error: {e}")
|
| 96 |
-
else:
|
| 97 |
-
print(f"SKIP: {TARGET_FILE} — no changes applied")
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
if __name__ == "__main__":
|
| 101 |
-
try:
|
| 102 |
-
main()
|
| 103 |
-
except Exception as e:
|
| 104 |
-
print(f"WARN: patch_fix_static_404.py error: {e}")
|
| 105 |
-
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend.py
CHANGED
|
@@ -20,7 +20,7 @@ model_content = model_content.replace(
|
|
| 20 |
)
|
| 21 |
model_content = model_content.replace(
|
| 22 |
"export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';",
|
| 23 |
-
"export const GLM_52_MODEL_PATH = 'deepseek/deepseek-v4-flash';"
|
| 24 |
)
|
| 25 |
model_content = model_content.replace(
|
| 26 |
"export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';",
|
|
@@ -31,7 +31,7 @@ model_content = model_content.replace(
|
|
| 31 |
if "DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH" not in model_content:
|
| 32 |
model_content = model_content.replace(
|
| 33 |
"export const LAGUNA_S21_MODEL_PATH",
|
| 34 |
-
"export const DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH = '
|
| 35 |
)
|
| 36 |
print("OK: Added DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH")
|
| 37 |
|
|
@@ -43,14 +43,6 @@ if "LAGUNA_S21_MODEL_PATH" not in model_content:
|
|
| 43 |
)
|
| 44 |
print("OK: Added LAGUNA_S21_MODEL_PATH")
|
| 45 |
|
| 46 |
-
# === PATCH: Add Space Bunny Alpha model path ===
|
| 47 |
-
if "SPACE_BUNNY_ALPHA_MODEL_PATH" not in model_content:
|
| 48 |
-
model_content = model_content.replace(
|
| 49 |
-
"export const LAGUNA_S21_MODEL_PATH",
|
| 50 |
-
"export const SPACE_BUNNY_ALPHA_MODEL_PATH = 'openai/stealth/space-bunny-alpha';\n\nexport const LAGUNA_S21_MODEL_PATH"
|
| 51 |
-
)
|
| 52 |
-
print("OK: Added SPACE_BUNNY_ALPHA_MODEL_PATH")
|
| 53 |
-
|
| 54 |
with open(MODEL_FILE, "w") as f:
|
| 55 |
f.write(model_content)
|
| 56 |
print("OK: model.ts constants updated")
|
|
@@ -100,9 +92,9 @@ if not match:
|
|
| 100 |
|
| 101 |
NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
| 102 |
{
|
| 103 |
-
id: '
|
| 104 |
-
name: '
|
| 105 |
-
modelPath: 'openai/
|
| 106 |
avatarUrl: 'https://huggingface.co/api/avatars/tencent',
|
| 107 |
recommended: true,
|
| 108 |
},
|
|
@@ -134,13 +126,6 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
|
| 134 |
avatarUrl: 'https://huggingface.co/api/avatars/poolside',
|
| 135 |
recommended: true,
|
| 136 |
},
|
| 137 |
-
{
|
| 138 |
-
id: 'space-bunny-alpha',
|
| 139 |
-
name: 'stealth/space-bunny-alpha',
|
| 140 |
-
modelPath: 'openai/stealth/space-bunny-alpha',
|
| 141 |
-
avatarUrl: 'https://huggingface.co/api/avatars/stealth',
|
| 142 |
-
recommended: true,
|
| 143 |
-
},
|
| 144 |
{
|
| 145 |
id: 'nex-n2-mini',
|
| 146 |
name: 'nex-agi/nex-n2-mini',
|
|
@@ -176,7 +161,7 @@ NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
|
| 176 |
{
|
| 177 |
id: 'deepseek-v4-flash-latest',
|
| 178 |
name: 'DeepSeek V4 Flash latest',
|
| 179 |
-
modelPath: '
|
| 180 |
avatarUrl: 'https://huggingface.co/api/avatars/deepseek',
|
| 181 |
recommended: true,
|
| 182 |
},
|
|
@@ -187,7 +172,7 @@ content = content[:match.start()] + NEW_MODELS + content[match.end():]
|
|
| 187 |
# Update DEFAULT_MODEL_PATH
|
| 188 |
dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content)
|
| 189 |
if dm_path:
|
| 190 |
-
content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/
|
| 191 |
print("OK: Replaced DEFAULT_MODEL_PATH")
|
| 192 |
else:
|
| 193 |
print("OK: DEFAULT_MODEL_PATH not found")
|
|
@@ -195,5 +180,5 @@ else:
|
|
| 195 |
with open(FILE, "w", encoding="utf-8") as f:
|
| 196 |
f.write(content)
|
| 197 |
|
| 198 |
-
print("OK: Frontend patched -
|
| 199 |
-
print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free,
|
|
|
|
| 20 |
)
|
| 21 |
model_content = model_content.replace(
|
| 22 |
"export const GLM_52_MODEL_PATH = 'zai-org/GLM-5.2:novita';",
|
| 23 |
+
"export const GLM_52_MODEL_PATH = 'openai/deepseek/deepseek-v4-flash';"
|
| 24 |
)
|
| 25 |
model_content = model_content.replace(
|
| 26 |
"export const DEEPSEEK_V4_PRO_MODEL_PATH = 'deepseek-ai/DeepSeek-V4-Pro:novita';",
|
|
|
|
| 31 |
if "DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH" not in model_content:
|
| 32 |
model_content = model_content.replace(
|
| 33 |
"export const LAGUNA_S21_MODEL_PATH",
|
| 34 |
+
"export const DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH = 'openai/deepseek/deepseek-v4-flash-latest';\n\nexport const LAGUNA_S21_MODEL_PATH"
|
| 35 |
)
|
| 36 |
print("OK: Added DEEPSEEK_V4_FLASH_LATEST_MODEL_PATH")
|
| 37 |
|
|
|
|
| 43 |
)
|
| 44 |
print("OK: Added LAGUNA_S21_MODEL_PATH")
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
with open(MODEL_FILE, "w") as f:
|
| 47 |
f.write(model_content)
|
| 48 |
print("OK: model.ts constants updated")
|
|
|
|
| 92 |
|
| 93 |
NEW_MODELS = """const DEFAULT_MODEL_OPTIONS: ModelOption[] = [
|
| 94 |
{
|
| 95 |
+
id: 'tencent-hy3',
|
| 96 |
+
name: 'Tencent HY3:free',
|
| 97 |
+
modelPath: 'openai/tencent/hy3:free',
|
| 98 |
avatarUrl: 'https://huggingface.co/api/avatars/tencent',
|
| 99 |
recommended: true,
|
| 100 |
},
|
|
|
|
| 126 |
avatarUrl: 'https://huggingface.co/api/avatars/poolside',
|
| 127 |
recommended: true,
|
| 128 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
{
|
| 130 |
id: 'nex-n2-mini',
|
| 131 |
name: 'nex-agi/nex-n2-mini',
|
|
|
|
| 161 |
{
|
| 162 |
id: 'deepseek-v4-flash-latest',
|
| 163 |
name: 'DeepSeek V4 Flash latest',
|
| 164 |
+
modelPath: 'openai/deepseek/deepseek-v4-flash-latest',
|
| 165 |
avatarUrl: 'https://huggingface.co/api/avatars/deepseek',
|
| 166 |
recommended: true,
|
| 167 |
},
|
|
|
|
| 172 |
# Update DEFAULT_MODEL_PATH
|
| 173 |
dm_path = re.search(r'const DEFAULT_MODEL_PATH\s*=.*?;', content)
|
| 174 |
if dm_path:
|
| 175 |
+
content = content.replace(dm_path.group(0), "const DEFAULT_MODEL_PATH = 'openai/tencent/hy3:free';")
|
| 176 |
print("OK: Replaced DEFAULT_MODEL_PATH")
|
| 177 |
else:
|
| 178 |
print("OK: DEFAULT_MODEL_PATH not found")
|
|
|
|
| 180 |
with open(FILE, "w", encoding="utf-8") as f:
|
| 181 |
f.write(content)
|
| 182 |
|
| 183 |
+
print("OK: Frontend patched - Tencent HY3:free DEFAULT")
|
| 184 |
+
print(" Models: HY3:free, Gemma 4 31B:free, Llama 3.3 70B:free, Laguna M.1:free, Laguna S 2.1:free, Nex N2 Mini, Ling 3.0 Flash:free, Llama 3.1 8B, DeepSeek V4 Flash latest")
|
patch_frontend_apodex_ling_beatapi.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
"""Patch frontend ChatInput.tsx: add apodex-1.1-mini:free, ling-3.1-flash and
|
| 2 |
-
the BeatAPI space-bunny-alpha entry to the model picker.
|
| 3 |
-
|
| 4 |
-
- apodex/apodex-1.1-mini:free path: openai/apodex/apodex-1.1-mini:free
|
| 5 |
-
- inclusionai/ling-3.1-flash path: openai/inclusionai/ling-3.1-flash
|
| 6 |
-
- BeatAPI space-bunny-alpha path: beatapi/space-bunny-alpha
|
| 7 |
-
|
| 8 |
-
The first two follow the same OpenRouter `openai/` convention as the existing
|
| 9 |
-
models (including stealth/space-bunny-alpha). The third is a BEATAPI model:
|
| 10 |
-
its path starts with `beatapi/`, NOT `openai/`, so the backend routes it to
|
| 11 |
-
https://api.beatapi.io/v1 with BEATAPI_API_KEY instead of OpenRouter. The name
|
| 12 |
-
is prefixed "BeatAPI" so the picker makes the provider difference visible.
|
| 13 |
-
|
| 14 |
-
Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
|
| 15 |
-
replaced DEFAULT_MODEL_OPTIONS). Idempotent.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import os
|
| 19 |
-
import re
|
| 20 |
-
|
| 21 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 22 |
-
|
| 23 |
-
NEW_ENTRIES = """ {
|
| 24 |
-
id: 'apodex-1.1-mini-free',
|
| 25 |
-
name: 'apodex/apodex-1.1-mini:free',
|
| 26 |
-
modelPath: 'openai/apodex/apodex-1.1-mini:free',
|
| 27 |
-
avatarUrl: 'https://huggingface.co/api/avatars/apodex',
|
| 28 |
-
recommended: true,
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
id: 'ling-3.1-flash',
|
| 32 |
-
name: 'inclusionai/ling-3.1-flash',
|
| 33 |
-
modelPath: 'openai/inclusionai/ling-3.1-flash',
|
| 34 |
-
avatarUrl: 'https://huggingface.co/api/avatars/inclusionai',
|
| 35 |
-
recommended: true,
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
id: 'beatapi-space-bunny-alpha',
|
| 39 |
-
name: 'BeatAPI space-bunny-alpha',
|
| 40 |
-
modelPath: 'beatapi/space-bunny-alpha',
|
| 41 |
-
avatarUrl: 'https://beatapi.io/favicon.ico',
|
| 42 |
-
recommended: true,
|
| 43 |
-
},
|
| 44 |
-
"""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def patch():
|
| 48 |
-
if not os.path.exists(FILE):
|
| 49 |
-
print(f"SKIP: {FILE} not found")
|
| 50 |
-
return
|
| 51 |
-
|
| 52 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 53 |
-
content = f.read()
|
| 54 |
-
|
| 55 |
-
if (
|
| 56 |
-
"apodex/apodex-1.1-mini:free" in content
|
| 57 |
-
and "beatapi/space-bunny-alpha" in content
|
| 58 |
-
):
|
| 59 |
-
print("OK: new models already present in ChatInput.tsx")
|
| 60 |
-
return
|
| 61 |
-
|
| 62 |
-
# Insert right before the stealth/space-bunny-alpha entry so the three new
|
| 63 |
-
# models sit next to the existing free OpenRouter models.
|
| 64 |
-
anchor = " {\n id: 'space-bunny-alpha',\n"
|
| 65 |
-
if anchor in content:
|
| 66 |
-
content = content.replace(anchor, NEW_ENTRIES + anchor, 1)
|
| 67 |
-
print("OK: added 3 models before the stealth/space-bunny-alpha entry")
|
| 68 |
-
else:
|
| 69 |
-
# Fallback: insert before deepseek-v4-flash-latest, the other stable anchor.
|
| 70 |
-
alt = " {\n id: 'deepseek-v4-flash-latest',\n"
|
| 71 |
-
if alt in content:
|
| 72 |
-
content = content.replace(alt, NEW_ENTRIES + alt, 1)
|
| 73 |
-
print("OK: added 3 models before deepseek-v4-flash-latest (fallback)")
|
| 74 |
-
else:
|
| 75 |
-
m = re.search(
|
| 76 |
-
r"const DEFAULT_MODEL_OPTIONS: ModelOption\[\] = \[.*?\];",
|
| 77 |
-
content,
|
| 78 |
-
re.DOTALL,
|
| 79 |
-
)
|
| 80 |
-
if not m:
|
| 81 |
-
print("FAIL: could not find any model-list anchor")
|
| 82 |
-
return
|
| 83 |
-
head = m.group(0)[: m.group(0).rindex("[") + 1]
|
| 84 |
-
content = (
|
| 85 |
-
content[: m.start()]
|
| 86 |
-
+ head
|
| 87 |
-
+ "\n"
|
| 88 |
-
+ NEW_ENTRIES
|
| 89 |
-
+ content[m.start() + len(head) :]
|
| 90 |
-
)
|
| 91 |
-
print("OK: added 3 models at top of DEFAULT_MODEL_OPTIONS (fallback)")
|
| 92 |
-
|
| 93 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 94 |
-
f.write(content)
|
| 95 |
-
print("OK: ChatInput.tsx patched")
|
| 96 |
-
|
| 97 |
-
# Verification: all three paths must be present.
|
| 98 |
-
for needle in (
|
| 99 |
-
"openai/apodex/apodex-1.1-mini:free",
|
| 100 |
-
"openai/inclusionai/ling-3.1-flash",
|
| 101 |
-
"beatapi/space-bunny-alpha",
|
| 102 |
-
):
|
| 103 |
-
if needle not in content:
|
| 104 |
-
print(f"FAIL: missing {needle}")
|
| 105 |
-
raise SystemExit(1)
|
| 106 |
-
print("OK: verified all 3 new model paths present")
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
if __name__ == "__main__":
|
| 110 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend_multi.py
DELETED
|
@@ -1,120 +0,0 @@
|
|
| 1 |
-
"""Patch ChatInput.tsx: support uploading multiple files at once.
|
| 2 |
-
|
| 3 |
-
Changes:
|
| 4 |
-
1. Add `multiple` to the <input type="file"> so the user can select many
|
| 5 |
-
files/images in one pick.
|
| 6 |
-
2. Rewrite handleDatasetFileChange to send ALL selected files in a single
|
| 7 |
-
multipart request (backend returns list[DatasetUploadResponse]).
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import os
|
| 11 |
-
|
| 12 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 13 |
-
|
| 14 |
-
def patch():
|
| 15 |
-
if not os.path.exists(FILE):
|
| 16 |
-
print(f"SKIP: {FILE} not found")
|
| 17 |
-
return
|
| 18 |
-
|
| 19 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 20 |
-
content = f.read()
|
| 21 |
-
|
| 22 |
-
# ---- 1. Add `multiple` to the file input ----
|
| 23 |
-
old_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
|
| 24 |
-
new_input = '<input\n ref={fileInputRef}\n type="file"\n accept={DATASET_UPLOAD_ACCEPT}\n multiple\n onChange={handleDatasetFileChange}\n style={{ display: \'none\' }}\n />'
|
| 25 |
-
if old_input in content:
|
| 26 |
-
content = content.replace(old_input, new_input)
|
| 27 |
-
print("OK: added multiple to file input")
|
| 28 |
-
else:
|
| 29 |
-
print("WARN: file input block not matched exactly")
|
| 30 |
-
|
| 31 |
-
# ---- 2. Rewrite handleDatasetFileChange ----
|
| 32 |
-
old_handler_start = " const handleDatasetFileChange = useCallback("
|
| 33 |
-
start = content.find(old_handler_start)
|
| 34 |
-
if start == -1:
|
| 35 |
-
print("FAIL: handleDatasetFileChange not found")
|
| 36 |
-
return
|
| 37 |
-
# End of the handler: the " );" right after the deps line.
|
| 38 |
-
deps = " [sessionId, onDatasetUploaded],\n );"
|
| 39 |
-
end = content.find(deps, start)
|
| 40 |
-
if end != -1:
|
| 41 |
-
end += len(deps)
|
| 42 |
-
else:
|
| 43 |
-
print("FAIL: could not find end of handler")
|
| 44 |
-
return
|
| 45 |
-
|
| 46 |
-
new_handler = ''' const handleDatasetFileChange = useCallback(
|
| 47 |
-
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 48 |
-
const files = Array.from(event.target.files ?? []);
|
| 49 |
-
event.target.value = '';
|
| 50 |
-
if (!files.length) return;
|
| 51 |
-
|
| 52 |
-
if (!sessionId) {
|
| 53 |
-
setDatasetUploadError('Start a session before uploading files.');
|
| 54 |
-
return;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
// Validate each file before uploading the batch.
|
| 58 |
-
for (const file of files) {
|
| 59 |
-
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
| 60 |
-
if (!DATASET_UPLOAD_EXTENSIONS.has(ext)) {
|
| 61 |
-
setDatasetUploadError('Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), và văn bản (TXT/MD/RTF/LOG).');
|
| 62 |
-
return;
|
| 63 |
-
}
|
| 64 |
-
if (file.size > MAX_DATASET_UPLOAD_BYTES) {
|
| 65 |
-
setDatasetUploadError(`File must be 100 MB or smaller. ${file.name} is ${formatBytes(file.size)}.`);
|
| 66 |
-
return;
|
| 67 |
-
}
|
| 68 |
-
if (file.size === 0) {
|
| 69 |
-
setDatasetUploadError(`Uploaded file is empty: ${file.name}`);
|
| 70 |
-
return;
|
| 71 |
-
}
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
// Send all files in one request; backend returns a list.
|
| 75 |
-
const formData = new FormData();
|
| 76 |
-
for (const file of files) {
|
| 77 |
-
formData.append('file', file);
|
| 78 |
-
}
|
| 79 |
-
setIsUploadingDataset(true);
|
| 80 |
-
setDatasetUploadProgress(0);
|
| 81 |
-
setDatasetUploadError(null);
|
| 82 |
-
setDatasetUploadSuccess(null);
|
| 83 |
-
try {
|
| 84 |
-
const res = await apiUpload(`/api/session/${sessionId}/datasets`, formData, {
|
| 85 |
-
onProgress: ({ percent }) => {
|
| 86 |
-
setDatasetUploadProgress(percent !== null && percent < 100 ? percent : null);
|
| 87 |
-
},
|
| 88 |
-
});
|
| 89 |
-
if (!res.ok) {
|
| 90 |
-
setDatasetUploadError(await readApiErrorMessage(res, 'File upload failed.'));
|
| 91 |
-
return;
|
| 92 |
-
}
|
| 93 |
-
const payload = await res.json() as DatasetUploadResponse[];
|
| 94 |
-
if (payload.length) {
|
| 95 |
-
setUploadedDatasets((previous) => [...payload, ...previous]);
|
| 96 |
-
const names = payload.map((u) => u.filename).join(', ');
|
| 97 |
-
setDatasetUploadSuccess(`Đã tải lên ${payload.length} file: ${names}`);
|
| 98 |
-
}
|
| 99 |
-
await onDatasetUploaded?.();
|
| 100 |
-
} catch (error) {
|
| 101 |
-
setDatasetUploadError(
|
| 102 |
-
error instanceof Error ? error.message : 'File upload failed.'
|
| 103 |
-
);
|
| 104 |
-
} finally {
|
| 105 |
-
setIsUploadingDataset(false);
|
| 106 |
-
setDatasetUploadProgress(null);
|
| 107 |
-
}
|
| 108 |
-
},
|
| 109 |
-
[sessionId, onDatasetUploaded],
|
| 110 |
-
);'''
|
| 111 |
-
|
| 112 |
-
content = content[:start] + new_handler + content[end:]
|
| 113 |
-
|
| 114 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 115 |
-
f.write(content)
|
| 116 |
-
print("OK: ChatInput.tsx multi-file upload patched")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
if __name__ == "__main__":
|
| 120 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend_new_models.py
DELETED
|
@@ -1,71 +0,0 @@
|
|
| 1 |
-
"""Patch frontend ChatInput.tsx: add 3 new OpenRouter models to the model picker.
|
| 2 |
-
|
| 3 |
-
New models (openai/ prefix routes to OpenRouter):
|
| 4 |
-
- UPSTAGE Solar Pro 4 (openai/upstage/solar-pro4)
|
| 5 |
-
- NVIDIA Nemotron 3.5 Lightning:free (openai/nvidia/nemotron-3.5-lightning:free)
|
| 6 |
-
- Liquid LFM 2.5 2.6B:free (openai/liquid/lfm-2.5-2.6b:free)
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
import os
|
| 10 |
-
|
| 11 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 12 |
-
|
| 13 |
-
def patch():
|
| 14 |
-
if not os.path.exists(FILE):
|
| 15 |
-
print(f"SKIP: {FILE} not found")
|
| 16 |
-
return
|
| 17 |
-
|
| 18 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 19 |
-
content = f.read()
|
| 20 |
-
|
| 21 |
-
if "upstage-solar-pro4" in content:
|
| 22 |
-
print("OK: models already present")
|
| 23 |
-
return
|
| 24 |
-
|
| 25 |
-
# Unique anchor: the deepseek-v4-flash-latest entry inside DEFAULT_MODEL_OPTIONS.
|
| 26 |
-
# Insert the 3 new model objects immediately BEFORE that entry.
|
| 27 |
-
anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
|
| 28 |
-
new_entries = (
|
| 29 |
-
" {\n"
|
| 30 |
-
" id: 'upstage-solar-pro4',\n"
|
| 31 |
-
" name: 'UPSTAGE Solar Pro 4',\n"
|
| 32 |
-
" modelPath: 'openai/upstage/solar-pro4',\n"
|
| 33 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/upstage',\n"
|
| 34 |
-
" recommended: true,\n"
|
| 35 |
-
" },\n"
|
| 36 |
-
" {\n"
|
| 37 |
-
" id: 'nemotron-3.5-lightning',\n"
|
| 38 |
-
" name: 'NVIDIA Nemotron 3.5 Lightning:free',\n"
|
| 39 |
-
" modelPath: 'openai/nvidia/nemotron-3.5-lightning:free',\n"
|
| 40 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
|
| 41 |
-
" recommended: true,\n"
|
| 42 |
-
" },\n"
|
| 43 |
-
" {\n"
|
| 44 |
-
" id: 'liquid-lfm-2.5-2.6b',\n"
|
| 45 |
-
" name: 'Liquid LFM 2.5 2.6B:free',\n"
|
| 46 |
-
" modelPath: 'openai/liquid/lfm-2.5-2.6b:free',\n"
|
| 47 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/liquid',\n"
|
| 48 |
-
" recommended: true,\n"
|
| 49 |
-
" },\n"
|
| 50 |
-
)
|
| 51 |
-
|
| 52 |
-
if anchor in content:
|
| 53 |
-
content = content.replace(anchor, new_entries + anchor, 1)
|
| 54 |
-
print("OK: added 3 new models before deepseek-v4-flash-latest entry")
|
| 55 |
-
else:
|
| 56 |
-
# Fallback: find the deepseek id line by itself.
|
| 57 |
-
alt = " id: 'deepseek-v4-flash-latest',\n"
|
| 58 |
-
if alt in content:
|
| 59 |
-
content = content.replace(alt, new_entries + " {\n" + alt, 1)
|
| 60 |
-
print("OK: added 3 new models (fallback anchor)")
|
| 61 |
-
else:
|
| 62 |
-
print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 66 |
-
f.write(content)
|
| 67 |
-
print("OK: ChatInput.tsx new models patched")
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if __name__ == "__main__":
|
| 71 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend_permanent_sessions.py
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Patch frontend: Disable 'where we here now?' notification.
|
| 3 |
-
|
| 4 |
-
Chỉ tìm và xóa bất kỳ chuỗi 'where we here now' nào trong frontend source.
|
| 5 |
-
An toàn — không thêm code TypeScript mới để tránh lỗi build.
|
| 6 |
-
"""
|
| 7 |
-
import os
|
| 8 |
-
import sys
|
| 9 |
-
|
| 10 |
-
FRONTEND_DIR = "/source/frontend/src"
|
| 11 |
-
|
| 12 |
-
patch_count = 0
|
| 13 |
-
|
| 14 |
-
# Scan all .ts/.tsx files for 'where we here now' and remove the notification
|
| 15 |
-
if os.path.exists(FRONTEND_DIR):
|
| 16 |
-
for root, dirs, files in os.walk(FRONTEND_DIR):
|
| 17 |
-
for fname in files:
|
| 18 |
-
if fname.endswith((".ts", ".tsx")):
|
| 19 |
-
fpath = os.path.join(root, fname)
|
| 20 |
-
try:
|
| 21 |
-
with open(fpath, "r", encoding="utf-8") as f:
|
| 22 |
-
content = f.read()
|
| 23 |
-
if "where we here now" in content.lower():
|
| 24 |
-
# Remove the entire line containing the notification text
|
| 25 |
-
lines = content.split("\n")
|
| 26 |
-
new_lines = []
|
| 27 |
-
removed = False
|
| 28 |
-
for line in lines:
|
| 29 |
-
if "where we here now" in line.lower():
|
| 30 |
-
removed = True
|
| 31 |
-
continue # Skip this line
|
| 32 |
-
new_lines.append(line)
|
| 33 |
-
if removed:
|
| 34 |
-
with open(fpath, "w", encoding="utf-8") as f:
|
| 35 |
-
f.write("\n".join(new_lines))
|
| 36 |
-
patch_count += 1
|
| 37 |
-
print(f" Removed 'where we here now?' from {fpath}")
|
| 38 |
-
except Exception as e:
|
| 39 |
-
print(f" WARN: Could not patch {fpath}: {e}")
|
| 40 |
-
|
| 41 |
-
if patch_count == 0:
|
| 42 |
-
print(" No 'where we here now?' notifications found (already clean)")
|
| 43 |
-
|
| 44 |
-
print(f"Done: patched {patch_count} frontend files")
|
| 45 |
-
sys.exit(0) # Always exit 0 — never fail build
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend_uploads.py
DELETED
|
@@ -1,81 +0,0 @@
|
|
| 1 |
-
"""Patch ChatInput.tsx: allow uploading PDF, images, Excel, Word, PPT,
|
| 2 |
-
OpenDocument and text files in addition to CSV/JSON/JSONL, and surface the
|
| 3 |
-
extracted-text fields returned by the backend.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import os
|
| 7 |
-
|
| 8 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 9 |
-
|
| 10 |
-
ACCEPT = (
|
| 11 |
-
".csv,.json,.jsonl,.pdf,"
|
| 12 |
-
".png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,"
|
| 13 |
-
".xlsx,.xlsm,.xls,"
|
| 14 |
-
".docx,.doc,"
|
| 15 |
-
".pptx,"
|
| 16 |
-
".odt,.ods,.odp,"
|
| 17 |
-
".txt,.md,.rtf,.log"
|
| 18 |
-
)
|
| 19 |
-
|
| 20 |
-
EXTENSIONS_SET = (
|
| 21 |
-
"['csv', 'json', 'jsonl', 'pdf', "
|
| 22 |
-
"'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff', 'tif', 'gif', "
|
| 23 |
-
"'xlsx', 'xlsm', 'xls', 'docx', 'doc', 'pptx', "
|
| 24 |
-
"'odt', 'ods', 'odp', 'txt', 'md', 'rtf', 'log']"
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def patch():
|
| 29 |
-
if not os.path.exists(FILE):
|
| 30 |
-
print(f"SKIP: {FILE} not found")
|
| 31 |
-
return
|
| 32 |
-
|
| 33 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 34 |
-
content = f.read()
|
| 35 |
-
|
| 36 |
-
# 1. Widen the accept string
|
| 37 |
-
old_accept = "const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl';"
|
| 38 |
-
if old_accept in content:
|
| 39 |
-
content = content.replace(old_accept, f"const DATASET_UPLOAD_ACCEPT = `{ACCEPT}`;")
|
| 40 |
-
print("OK: widened DATASET_UPLOAD_ACCEPT")
|
| 41 |
-
|
| 42 |
-
# 2. Widen the allowed extension set
|
| 43 |
-
old_ext = "const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']);"
|
| 44 |
-
if old_ext in content:
|
| 45 |
-
content = content.replace(old_ext, f"const DATASET_UPLOAD_EXTENSIONS = new Set({EXTENSIONS_SET});")
|
| 46 |
-
print("OK: widened DATASET_UPLOAD_EXTENSIONS")
|
| 47 |
-
|
| 48 |
-
# 3. Update the error message
|
| 49 |
-
old_err = "'Only CSV, JSON, and JSONL dataset files are supported.'"
|
| 50 |
-
new_err = (
|
| 51 |
-
"'Không hỗ trợ định dạng này. Các định dạng được hỗ trợ: CSV, JSON, JSONL, "
|
| 52 |
-
"PDF, ảnh (PNG/JPG/JPEG/WebP/BMP/TIFF/GIF), Excel (XLSX/XLSM/XLS), "
|
| 53 |
-
"Word (DOCX/DOC), PowerPoint (PPTX), OpenDocument (ODT/ODS/ODP), "
|
| 54 |
-
"và văn bản (TXT/MD/RTF/LOG).'"
|
| 55 |
-
)
|
| 56 |
-
if old_err in content:
|
| 57 |
-
content = content.replace(old_err, new_err)
|
| 58 |
-
print("OK: updated unsupported-format error message")
|
| 59 |
-
|
| 60 |
-
# 4. Widen the DatasetUploadResponse interface format + add extracted fields
|
| 61 |
-
old_fmt = " format: 'csv' | 'json' | 'jsonl';"
|
| 62 |
-
if old_fmt in content:
|
| 63 |
-
content = content.replace(old_fmt, " format: string;")
|
| 64 |
-
print("OK: widened interface format type")
|
| 65 |
-
|
| 66 |
-
if "extracted_text?: string" not in content:
|
| 67 |
-
content = content.replace(
|
| 68 |
-
" load_dataset_snippet: string;\n",
|
| 69 |
-
" load_dataset_snippet: string;\n"
|
| 70 |
-
" extracted_text?: string;\n"
|
| 71 |
-
" extraction_warning?: string;\n",
|
| 72 |
-
)
|
| 73 |
-
print("OK: added extracted_text/extraction_warning to interface")
|
| 74 |
-
|
| 75 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 76 |
-
f.write(content)
|
| 77 |
-
print("OK: ChatInput.tsx patched for multi-format uploads")
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
if __name__ == "__main__":
|
| 81 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_frontend_xing_nemotron_vl.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
"""Patch frontend ChatInput.tsx: add xingchen-agi/xing4.0-29b-a4b:free and
|
| 2 |
-
nvidia/nemotron-3.5-super-vl:free with the same openai/ path convention as
|
| 3 |
-
Laguna S 2.1:free.
|
| 4 |
-
|
| 5 |
-
New models (openai/ prefix -> OpenRouter):
|
| 6 |
-
- xingchen-agi/xing4.0-29b-a4b:free modelPath: openai/xingchen-agi/xing4.0-29b-a4b:free
|
| 7 |
-
- nvidia/nemotron-3.5-super-vl:free modelPath: openai/nvidia/nemotron-3.5-super-vl:free
|
| 8 |
-
|
| 9 |
-
Runs AFTER patch_frontend.py and patch_frontend_new_models.py (which already
|
| 10 |
-
replaced DEFAULT_MODEL_OPTIONS). Idempotent.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import os
|
| 14 |
-
|
| 15 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def patch():
|
| 19 |
-
if not os.path.exists(FILE):
|
| 20 |
-
print(f"SKIP: {FILE} not found")
|
| 21 |
-
return
|
| 22 |
-
|
| 23 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 24 |
-
content = f.read()
|
| 25 |
-
|
| 26 |
-
if "xingchen-agi/xing4.0-29b-a4b" in content:
|
| 27 |
-
print("OK: models already present in ChatInput.tsx")
|
| 28 |
-
return
|
| 29 |
-
|
| 30 |
-
# Insert right before the deepseek-v4-flash-latest entry — a stable anchor
|
| 31 |
-
# that exists after patch_frontend.py rewrites DEFAULT_MODEL_OPTIONS.
|
| 32 |
-
anchor = " {\n id: 'deepseek-v4-flash-latest',\n"
|
| 33 |
-
new_entries = (
|
| 34 |
-
" {\n"
|
| 35 |
-
" id: 'xingchen-xing4-0-29b',\n"
|
| 36 |
-
" name: 'xingchen-agi/xing4.0-29b-a4b:free',\n"
|
| 37 |
-
" modelPath: 'openai/xingchen-agi/xing4.0-29b-a4b:free',\n"
|
| 38 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/xingchen-agi',\n"
|
| 39 |
-
" recommended: true,\n"
|
| 40 |
-
" },\n"
|
| 41 |
-
" {\n"
|
| 42 |
-
" id: 'nemotron-3.5-super-vl',\n"
|
| 43 |
-
" name: 'nvidia/nemotron-3.5-super-vl:free',\n"
|
| 44 |
-
" modelPath: 'openai/nvidia/nemotron-3.5-super-vl:free',\n"
|
| 45 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/nvidia',\n"
|
| 46 |
-
" recommended: true,\n"
|
| 47 |
-
" },\n"
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
if anchor in content:
|
| 51 |
-
content = content.replace(anchor, new_entries + anchor, 1)
|
| 52 |
-
print("OK: added 2 models before deepseek-v4-flash-latest entry")
|
| 53 |
-
else:
|
| 54 |
-
print("FAIL: could not find insertion anchor in DEFAULT_MODEL_OPTIONS")
|
| 55 |
-
return
|
| 56 |
-
|
| 57 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 58 |
-
f.write(content)
|
| 59 |
-
print("OK: ChatInput.tsx patched")
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
if __name__ == "__main__":
|
| 63 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lossless_context.py
DELETED
|
@@ -1,365 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""Patch: lossless long-context memory — fix context loss in long sessions/jobs.
|
| 3 |
-
|
| 4 |
-
Applied during Docker build. Idempotent. NEVER fails the build.
|
| 5 |
-
|
| 6 |
-
Root causes fixed:
|
| 7 |
-
1. compact() deleted the middle incl. ALL tool results (model continued a long
|
| 8 |
-
job from a terse paraphrase)
|
| 9 |
-
2. CompactionFailedError TERMINATED the session mid-job (_compact_and_notify)
|
| 10 |
-
3. restore_session_from_log reloaded history VERBATIM -> next compaction
|
| 11 |
-
nuked the tool trail or killed the session
|
| 12 |
-
4. max_iterations=300 silently stopped long jobs
|
| 13 |
-
|
| 14 |
-
Fixes:
|
| 15 |
-
A. compact(): snapshot removed middle (raw tool calls/results) into
|
| 16 |
-
cm.compaction_archive before rewriting items — the raw trail survives.
|
| 17 |
-
B. untouched_messages default 5 -> 24 (keep more of the recent working set).
|
| 18 |
-
C. cm.prune_to_fit(): drop oldest tool outputs (markers) until under
|
| 19 |
-
threshold. agent_loop _compact_and_notify calls it on CompactionFailedError
|
| 20 |
-
instead of terminating — degrade, never kill.
|
| 21 |
-
D. restore_session_from_log: normalize restored history to 70% headroom
|
| 22 |
-
(trim oldest tool outputs, keep system/first_user/tail).
|
| 23 |
-
E. config max_iterations default -1 (unlimited) + untouched_messages=24;
|
| 24 |
-
session.py reads config.untouched_messages.
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
import ast
|
| 28 |
-
import os
|
| 29 |
-
|
| 30 |
-
MARKER = "# === PATCH: lossless long-context memory ==="
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _apply(path, patch_fn, label):
|
| 34 |
-
if not os.path.exists(path):
|
| 35 |
-
print(f"SKIP: {path} not found")
|
| 36 |
-
return False
|
| 37 |
-
try:
|
| 38 |
-
with open(path, "r") as f:
|
| 39 |
-
c = f.read()
|
| 40 |
-
if MARKER in c:
|
| 41 |
-
print(f"SKIP: {path} already patched")
|
| 42 |
-
return True
|
| 43 |
-
new_c, changed = patch_fn(c)
|
| 44 |
-
if not changed:
|
| 45 |
-
print(f"SKIP: {path} — no changes needed")
|
| 46 |
-
return False
|
| 47 |
-
try:
|
| 48 |
-
ast.parse(new_c)
|
| 49 |
-
except SyntaxError as e:
|
| 50 |
-
print(f"FAIL: {path} syntax: {e}")
|
| 51 |
-
return False
|
| 52 |
-
with open(path, "w") as f:
|
| 53 |
-
f.write(new_c)
|
| 54 |
-
print(f"OK: {path} {label}")
|
| 55 |
-
return True
|
| 56 |
-
except Exception as e:
|
| 57 |
-
print(f"WARN: {path} patch error: {e}")
|
| 58 |
-
return False
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
# ── 1. manager.py — lossless compaction + prune_to_fit ──────────────────────
|
| 62 |
-
MANAGER = "/app/agent/context_manager/manager.py"
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def _patch_manager(c):
|
| 66 |
-
changed = False
|
| 67 |
-
|
| 68 |
-
if "untouched_messages: int = 5" in c:
|
| 69 |
-
c = c.replace("untouched_messages: int = 5", "untouched_messages: int = 24")
|
| 70 |
-
print(" + untouched_messages default 5 -> 24")
|
| 71 |
-
changed = True
|
| 72 |
-
|
| 73 |
-
archive_code = (
|
| 74 |
-
" # === PATCH: lossless long-context memory ===\n"
|
| 75 |
-
" # Never destroy the working state: keep the full removed middle\n"
|
| 76 |
-
" # (tool calls + results + user prompts) in memory on the summary,\n"
|
| 77 |
-
" # so a follow-up question or a continued job can be answered from\n"
|
| 78 |
-
" # the raw trail, not from a paraphrase.\n"
|
| 79 |
-
" try:\n"
|
| 80 |
-
" snapshot = [\n"
|
| 81 |
-
" m.model_dump() if hasattr(m, \"model_dump\") else dict(m)\n"
|
| 82 |
-
" for m in messages_to_summarize\n"
|
| 83 |
-
" ]\n"
|
| 84 |
-
" if not hasattr(self, \"compaction_archive\"):\n"
|
| 85 |
-
" self.compaction_archive = []\n"
|
| 86 |
-
" self.compaction_archive.extend(snapshot)\n"
|
| 87 |
-
" # Cap the archive so we don't grow unbounded: keep last 400 msgs.\n"
|
| 88 |
-
" if len(self.compaction_archive) > 400:\n"
|
| 89 |
-
" self.compaction_archive = self.compaction_archive[-400:]\n"
|
| 90 |
-
" except Exception:\n"
|
| 91 |
-
" self.compaction_archive = getattr(self, \"compaction_archive\", [])\n"
|
| 92 |
-
)
|
| 93 |
-
anchor = " self.items = head + [summarized_message] + recent_messages"
|
| 94 |
-
if anchor in c and "compaction_archive" not in c:
|
| 95 |
-
c = c.replace(anchor, archive_code + anchor, 1)
|
| 96 |
-
print(" + compact() snapshots removed middle to compaction_archive")
|
| 97 |
-
changed = True
|
| 98 |
-
|
| 99 |
-
if "def prune_to_fit" not in c:
|
| 100 |
-
prune = '''
|
| 101 |
-
|
| 102 |
-
def prune_to_fit(
|
| 103 |
-
self, model_name: str, *, max_messages: int = 200
|
| 104 |
-
) -> bool:
|
| 105 |
-
"""Drop oldest non-first-user tool outputs until under threshold.
|
| 106 |
-
|
| 107 |
-
Degrade, don't kill. Returns True if context is now under threshold.
|
| 108 |
-
Preserves: system prompt, first user message, assistant reasoning,
|
| 109 |
-
and enough of the tail to continue. Drops the OLDEST tool outputs
|
| 110 |
-
(replaced with a short marker) so a long job can keep running.
|
| 111 |
-
"""
|
| 112 |
-
from litellm import token_counter
|
| 113 |
-
|
| 114 |
-
def _usage(messages):
|
| 115 |
-
try:
|
| 116 |
-
return token_counter(
|
| 117 |
-
model=model_name,
|
| 118 |
-
messages=[m.model_dump() for m in messages],
|
| 119 |
-
)
|
| 120 |
-
except Exception:
|
| 121 |
-
return (
|
| 122 |
-
sum(len(getattr(m, "content", "") or "") for m in messages) // 4
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
items = list(self.items)
|
| 126 |
-
# Find first user message index (skip system at 0).
|
| 127 |
-
first_user_idx = 1
|
| 128 |
-
for i in range(1, len(items)):
|
| 129 |
-
if getattr(items[i], "role", None) == "user":
|
| 130 |
-
first_user_idx = i
|
| 131 |
-
break
|
| 132 |
-
# Trim tool messages from oldest to newest (skip system/first-user).
|
| 133 |
-
trimmed = 0
|
| 134 |
-
for i in range(first_user_idx + 1, len(items)):
|
| 135 |
-
if _usage(items) <= self.compaction_threshold:
|
| 136 |
-
break
|
| 137 |
-
msg = items[i]
|
| 138 |
-
if getattr(msg, "role", None) != "tool":
|
| 139 |
-
continue
|
| 140 |
-
old = items[i]
|
| 141 |
-
items[i] = Message(
|
| 142 |
-
role="tool",
|
| 143 |
-
content=(
|
| 144 |
-
"[tool output pruned to keep the session running — "
|
| 145 |
-
f"original was {len(getattr(old, 'content', '') or '')} chars; "
|
| 146 |
-
"stored in compaction_archive]"
|
| 147 |
-
),
|
| 148 |
-
tool_call_id=getattr(old, "tool_call_id", None),
|
| 149 |
-
name=getattr(old, "name", None),
|
| 150 |
-
)
|
| 151 |
-
trimmed += 1
|
| 152 |
-
self.items = items
|
| 153 |
-
self._recompute_usage(model_name)
|
| 154 |
-
return self.running_context_usage <= self.compaction_threshold
|
| 155 |
-
'''
|
| 156 |
-
c += prune
|
| 157 |
-
print(" + Added prune_to_fit() (degrade instead of kill)")
|
| 158 |
-
changed = True
|
| 159 |
-
|
| 160 |
-
return c, changed
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
_apply(MANAGER, _patch_manager, "lossless compaction patched")
|
| 164 |
-
|
| 165 |
-
# ── 2. agent_loop.py — never kill on compaction failure ─────────────────────
|
| 166 |
-
AGENT_LOOP = "/app/agent/core/agent_loop.py"
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def _patch_agent_loop(c):
|
| 170 |
-
changed = False
|
| 171 |
-
|
| 172 |
-
old_fail = (
|
| 173 |
-
" await session.send_event(\n"
|
| 174 |
-
" Event(\n"
|
| 175 |
-
" event_type=\"session_terminated\",\n"
|
| 176 |
-
" data={\n"
|
| 177 |
-
" \"reason\": \"compaction_failed\",\n"
|
| 178 |
-
" \"context_usage\": cm.running_context_usage,\n"
|
| 179 |
-
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 180 |
-
" \"error\": str(e)[:300],\n"
|
| 181 |
-
" \"user_message\": (\n"
|
| 182 |
-
" \"Your conversation has grown too large to continue. \"\n"
|
| 183 |
-
" \"The work you've done is saved — start a new session to keep going.\"\n"
|
| 184 |
-
" ),\n"
|
| 185 |
-
" },\n"
|
| 186 |
-
" )\n"
|
| 187 |
-
" )\n"
|
| 188 |
-
" # Stop the agent loop; the finally in _run_session will fire\n"
|
| 189 |
-
" # cleanup_sandbox + save_trajectory so the dataset captures\n"
|
| 190 |
-
" # everything that did happen.\n"
|
| 191 |
-
" session.is_running = False\n"
|
| 192 |
-
" return"
|
| 193 |
-
)
|
| 194 |
-
new_fail = (
|
| 195 |
-
" # === PATCH: lossless long-context memory ===\n"
|
| 196 |
-
" # Never kill the session on compaction failure — degrade instead:\n"
|
| 197 |
-
" # prune the oldest tool outputs to fit, then keep working.\n"
|
| 198 |
-
" try:\n"
|
| 199 |
-
" ok = cm.prune_to_fit(session.config.model_name)\n"
|
| 200 |
-
" if ok:\n"
|
| 201 |
-
" await session.send_event(\n"
|
| 202 |
-
" Event(\n"
|
| 203 |
-
" event_type=\"compacted\",\n"
|
| 204 |
-
" data={\n"
|
| 205 |
-
" \"reason\": \"pruned_to_fit\",\n"
|
| 206 |
-
" \"context_usage\": cm.running_context_usage,\n"
|
| 207 |
-
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 208 |
-
" \"user_message\": (\n"
|
| 209 |
-
" \"Context was getting full, so the agent \"\n"
|
| 210 |
-
" \"trimmed some old tool outputs to keep working. \"\n"
|
| 211 |
-
" \"The full raw history stays in the session archive.\"\n"
|
| 212 |
-
" ),\n"
|
| 213 |
-
" },\n"
|
| 214 |
-
" )\n"
|
| 215 |
-
" )\n"
|
| 216 |
-
" return\n"
|
| 217 |
-
" except Exception as _prune_exc:\n"
|
| 218 |
-
" logger.error(\"prune_to_fit failed: %s\", _prune_exc)\n"
|
| 219 |
-
" await session.send_event(\n"
|
| 220 |
-
" Event(\n"
|
| 221 |
-
" event_type=\"session_terminated\",\n"
|
| 222 |
-
" data={\n"
|
| 223 |
-
" \"reason\": \"compaction_failed\",\n"
|
| 224 |
-
" \"context_usage\": cm.running_context_usage,\n"
|
| 225 |
-
" \"context_threshold\": cm.compaction_threshold,\n"
|
| 226 |
-
" \"error\": str(e)[:300],\n"
|
| 227 |
-
" \"user_message\": (\n"
|
| 228 |
-
" \"The conversation context is full and could not be \"\n"
|
| 229 |
-
" \"reduced further. The work done so far is saved — \"\n"
|
| 230 |
-
" \"start a new session to keep going.\"\n"
|
| 231 |
-
" ),\n"
|
| 232 |
-
" },\n"
|
| 233 |
-
" )\n"
|
| 234 |
-
" )\n"
|
| 235 |
-
" # Stop the agent loop; the finally in _run_session will fire\n"
|
| 236 |
-
" # cleanup_sandbox + save_trajectory so the dataset captures\n"
|
| 237 |
-
" # everything that did happen.\n"
|
| 238 |
-
" session.is_running = False\n"
|
| 239 |
-
" return"
|
| 240 |
-
)
|
| 241 |
-
if old_fail in c and "prune_to_fit" not in c:
|
| 242 |
-
c = c.replace(old_fail, new_fail, 1)
|
| 243 |
-
print(" + _compact_and_notify degrades via prune_to_fit instead of kill")
|
| 244 |
-
changed = True
|
| 245 |
-
|
| 246 |
-
return c, changed
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
_apply(AGENT_LOOP, _patch_agent_loop, "agent_loop degrades instead of killing")
|
| 250 |
-
|
| 251 |
-
# ── 3. session.py — read config.untouched_messages ──────────────────────────
|
| 252 |
-
SESSION = "/app/agent/core/session.py"
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
def _patch_session(c):
|
| 256 |
-
changed = False
|
| 257 |
-
old = " compact_size=0.1,\n untouched_messages=5,"
|
| 258 |
-
new = (
|
| 259 |
-
" compact_size=0.1,\n"
|
| 260 |
-
" untouched_messages=getattr(config, 'untouched_messages', 24),"
|
| 261 |
-
)
|
| 262 |
-
if old in c:
|
| 263 |
-
c = c.replace(old, new, 1)
|
| 264 |
-
print(" + session.py reads config.untouched_messages (default 24)")
|
| 265 |
-
changed = True
|
| 266 |
-
return c, changed
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
_apply(SESSION, _patch_session, "session.py untouched_messages override")
|
| 270 |
-
|
| 271 |
-
# ── 4. config.py — max_iterations unlimited + untouched_messages=24 ─────────
|
| 272 |
-
CONFIG = "/app/agent/config.py"
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
def _patch_config(c):
|
| 276 |
-
changed = False
|
| 277 |
-
old = " max_iterations: int = 300 # Max LLM calls per agent turn (-1 = unlimited)"
|
| 278 |
-
new = (
|
| 279 |
-
" max_iterations: int = -1 # Max LLM calls per agent turn (-1 = unlimited)\n"
|
| 280 |
-
" untouched_messages: int = 24 # PATCH: keep more recent tool trail before compaction"
|
| 281 |
-
)
|
| 282 |
-
if old in c:
|
| 283 |
-
c = c.replace(old, new, 1)
|
| 284 |
-
print(" + config.py max_iterations default -1 (unlimited), untouched_messages=24")
|
| 285 |
-
changed = True
|
| 286 |
-
return c, changed
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
_apply(CONFIG, _patch_config, "config.py max_iterations unlimited")
|
| 290 |
-
|
| 291 |
-
# ── 5. session_resume.py — normalize restored history to headroom ───────────
|
| 292 |
-
RESUME = "/app/agent/core/session_resume.py"
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def _patch_resume(c):
|
| 296 |
-
changed = False
|
| 297 |
-
anchor = " cm = session.context_manager\n"
|
| 298 |
-
insert = (
|
| 299 |
-
" # === PATCH: lossless long-context memory ===\n"
|
| 300 |
-
" # Normalize restored history to fit model headroom instead of restoring\n"
|
| 301 |
-
" # everything verbatim and letting the first compaction nuke the tool\n"
|
| 302 |
-
" # trail or kill the session. Keep system + first user + tail verbatim;\n"
|
| 303 |
-
" # replace oversize tool outputs in the middle with markers (full payloads\n"
|
| 304 |
-
" # still exist on-disk in the original log).\n"
|
| 305 |
-
" try:\n"
|
| 306 |
-
" from litellm import token_counter as _tc\n"
|
| 307 |
-
" _budget = int(session.context_manager.model_max_tokens * 0.70)\n"
|
| 308 |
-
" try:\n"
|
| 309 |
-
" _use = _tc(\n"
|
| 310 |
-
" model=session.config.model_name,\n"
|
| 311 |
-
" messages=[m.model_dump() for m in restored_messages],\n"
|
| 312 |
-
" )\n"
|
| 313 |
-
" except Exception:\n"
|
| 314 |
-
" _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
|
| 315 |
-
" if _use > _budget:\n"
|
| 316 |
-
" # Trim the oldest tool outputs (keep system + first user + tail).\n"
|
| 317 |
-
" _first_user_idx = 0\n"
|
| 318 |
-
" for _i, _m in enumerate(restored_messages):\n"
|
| 319 |
-
" if getattr(_m, \"role\", None) == \"user\":\n"
|
| 320 |
-
" _first_user_idx = _i\n"
|
| 321 |
-
" break\n"
|
| 322 |
-
" _trimmed = 0\n"
|
| 323 |
-
" for _i in range(_first_user_idx + 1, len(restored_messages)):\n"
|
| 324 |
-
" if _use <= _budget:\n"
|
| 325 |
-
" break\n"
|
| 326 |
-
" _m = restored_messages[_i]\n"
|
| 327 |
-
" if getattr(_m, \"role\", None) != \"tool\":\n"
|
| 328 |
-
" continue\n"
|
| 329 |
-
" _sz = len(getattr(_m, \"content\", \"\") or \"\")\n"
|
| 330 |
-
" restored_messages[_i] = type(_m)(\n"
|
| 331 |
-
" role=\"tool\",\n"
|
| 332 |
-
" content=(\n"
|
| 333 |
-
" \"[tool output trimmed on restore to fit context — \"\n"
|
| 334 |
-
" f\"original {_sz} chars retained in the on-disk log]\"\n"
|
| 335 |
-
" ),\n"
|
| 336 |
-
" tool_call_id=getattr(_m, \"tool_call_id\", None),\n"
|
| 337 |
-
" name=getattr(_m, \"name\", None),\n"
|
| 338 |
-
" )\n"
|
| 339 |
-
" _trimmed += 1\n"
|
| 340 |
-
" try:\n"
|
| 341 |
-
" _use = _tc(\n"
|
| 342 |
-
" model=session.config.model_name,\n"
|
| 343 |
-
" messages=[m.model_dump() for m in restored_messages],\n"
|
| 344 |
-
" )\n"
|
| 345 |
-
" except Exception:\n"
|
| 346 |
-
" _use = sum(len(getattr(m, \"content\", \"\") or \"\") for m in restored_messages) // 4\n"
|
| 347 |
-
" logger.warning(\n"
|
| 348 |
-
" \"Restored session %s was over budget (%d/%d); trimmed %d tool outputs\",\n"
|
| 349 |
-
" session.session_id if hasattr(session, \"session_id\") else \"?\",\n"
|
| 350 |
-
" _use, _budget, _trimmed,\n"
|
| 351 |
-
" )\n"
|
| 352 |
-
" except Exception as _exc:\n"
|
| 353 |
-
" logger.warning(\"Restore normalization skipped: %s\", _exc)\n"
|
| 354 |
-
"\n"
|
| 355 |
-
)
|
| 356 |
-
if anchor in c and "Restore normalization skipped" not in c:
|
| 357 |
-
c = c.replace(anchor, anchor + insert, 1)
|
| 358 |
-
print(" + restore_session_from_log normalizes history to 70% headroom")
|
| 359 |
-
changed = True
|
| 360 |
-
return c, changed
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
_apply(RESUME, _patch_resume, "session_resume normalized restore")
|
| 364 |
-
|
| 365 |
-
print("\n=== All long-context patches applied (build will not fail regardless) ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_models.py
CHANGED
|
@@ -1,18 +1,4 @@
|
|
| 1 |
-
"""Patch backend:
|
| 2 |
-
|
| 3 |
-
FIX: GLM_52_MODEL_ID was set to "openai/deepseek/deepseek-v4-flash" which routes
|
| 4 |
-
to OpenRouter via the openai/ prefix catch-all. However, OpenRouter's model ID
|
| 5 |
-
is "deepseek/deepseek-v4-flash" (without the openai/ prefix). When LiteLLM
|
| 6 |
-
sends the full "openai/deepseek/deepseek-v4-flash" as the model name to
|
| 7 |
-
OpenRouter's API, it gets a 404 because OpenRouter doesn't recognize the
|
| 8 |
-
"openai/" prefix.
|
| 9 |
-
|
| 10 |
-
Solution:
|
| 11 |
-
1. GLM_52_MODEL_ID -> "deepseek-ai/DeepSeek-V4-Flash:deepinfra" (HF Router path, model exists there, cheapest provider)
|
| 12 |
-
2. OpenRouter routing strips the "openai/" prefix before sending to OpenRouter
|
| 13 |
-
3. Add :novita tag to DeepSeek V4 Pro for deterministic routing via HF Router
|
| 14 |
-
4. Add stealth/space-bunny-alpha using the same OpenRouter-style prefix.
|
| 15 |
-
"""
|
| 16 |
|
| 17 |
import ast
|
| 18 |
import os
|
|
@@ -20,95 +6,77 @@ import re
|
|
| 20 |
|
| 21 |
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 22 |
LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
|
| 23 |
-
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 24 |
|
|
|
|
|
|
|
| 25 |
with open(IDS_FILE) as f:
|
| 26 |
content = f.read()
|
| 27 |
|
| 28 |
-
#
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
('DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"',
|
| 49 |
-
'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'),
|
| 50 |
-
]
|
| 51 |
-
for old, new in replacements:
|
| 52 |
-
content = content.replace(old, new)
|
| 53 |
-
print("OK: Model ID replacements applied")
|
| 54 |
|
| 55 |
-
# Add
|
| 56 |
-
|
| 57 |
-
target_line = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
|
| 58 |
-
insert_block = '''TENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"
|
| 59 |
GEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"
|
|
|
|
| 60 |
LLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"
|
| 61 |
LLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"
|
| 62 |
LAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"
|
| 63 |
LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"
|
| 64 |
NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"
|
| 65 |
LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
# Add Space Bunny independently so this remains idempotent when the base
|
| 75 |
-
# model_ids.py already contains the earlier OpenRouter constants.
|
| 76 |
-
if "SPACE_BUNNY_ALPHA_MODEL_ID" not in content:
|
| 77 |
-
anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
|
| 78 |
-
if anchor in content:
|
| 79 |
-
content = content.replace(
|
| 80 |
-
anchor,
|
| 81 |
-
anchor + '\nSPACE_BUNNY_ALPHA_MODEL_ID = "openai/stealth/space-bunny-alpha"\n',
|
| 82 |
-
1,
|
| 83 |
-
)
|
| 84 |
-
print("OK: Added SPACE_BUNNY_ALPHA_MODEL_ID")
|
| 85 |
-
else:
|
| 86 |
-
print("WARN: Could not find LING_3_0_FLASH_FREE_MODEL_ID anchor")
|
| 87 |
|
| 88 |
-
#
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
with open(IDS_FILE, "w") as f:
|
| 93 |
f.write(content)
|
| 94 |
-
print("OK: model_ids.py
|
| 95 |
-
|
| 96 |
-
ast.parse(content) # validate syntax
|
| 97 |
-
print("OK: model_ids.py syntax OK")
|
| 98 |
|
|
|
|
| 99 |
with open(AGENT_FILE) as f:
|
| 100 |
content = f.read()
|
| 101 |
|
| 102 |
content = content.replace(
|
| 103 |
"DEFAULT_MODEL_ID = GLM_52_MODEL_ID",
|
| 104 |
-
'DEFAULT_MODEL_ID =
|
| 105 |
)
|
| 106 |
content = content.replace(
|
| 107 |
"DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID",
|
| 108 |
"DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID"
|
| 109 |
)
|
| 110 |
|
| 111 |
-
#
|
| 112 |
old_import = (
|
| 113 |
"from agent.core.model_ids import (\n"
|
| 114 |
" CLAUDE_OPUS_48_MODEL_ID,\n"
|
|
@@ -127,48 +95,51 @@ new_import = (
|
|
| 127 |
" GPT_55_MODEL_ID,\n"
|
| 128 |
" KIMI_K27_CODE_MODEL_ID,\n"
|
| 129 |
" MINIMAX_M3_MODEL_ID,\n"
|
| 130 |
-
" TENCENT_HY3_FREE_MODEL_ID,\n"
|
| 131 |
" GEMMA_4_31B_FREE_MODEL_ID,\n"
|
|
|
|
| 132 |
" LLAMA_3_3_70B_FREE_MODEL_ID,\n"
|
| 133 |
" LLAMA_3_1_8B_MODEL_ID,\n"
|
| 134 |
" LAGUNA_M1_FREE_MODEL_ID,\n"
|
| 135 |
" LAGUNA_S21_FREE_MODEL_ID,\n"
|
| 136 |
" NEX_N2_MINI_MODEL_ID,\n"
|
| 137 |
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 138 |
-
"
|
| 139 |
" strip_huggingface_model_prefix,\n"
|
| 140 |
")"
|
| 141 |
)
|
| 142 |
content = content.replace(old_import, new_import)
|
| 143 |
|
| 144 |
-
#
|
| 145 |
-
new_func =
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
{"id":
|
| 149 |
-
{"id":
|
| 150 |
-
{"id":
|
| 151 |
-
{"id":
|
| 152 |
-
{"id":
|
| 153 |
-
{"id": NEX_N2_MINI_MODEL_ID, "label": "nex-agi/nex-n2-mini", "recommended": True},
|
| 154 |
-
{"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},
|
| 155 |
-
{"id": LLAMA_3_1_8B_MODEL_ID, "label": "Llama 3.1 8B"},
|
| 156 |
-
{"id": KIMI_K27_CODE_MODEL_ID, "label": "DeepSeek V4 Pro"},
|
| 157 |
-
{"id": MINIMAX_M3_MODEL_ID, "label": "DeepSeek V4 Flash"},
|
| 158 |
-
{"id": DEFAULT_MODEL_ID, "label": "
|
| 159 |
-
{"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "Nemotron 3 Super 120B"},
|
| 160 |
-
{"id":
|
| 161 |
-
]
|
| 162 |
-
return models
|
|
|
|
| 163 |
|
|
|
|
| 164 |
func_match = re.search(
|
| 165 |
r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models",
|
| 166 |
content, re.DOTALL
|
| 167 |
)
|
| 168 |
if func_match:
|
| 169 |
content = content[:func_match.start()] + new_func + content[func_match.end():]
|
| 170 |
-
print("OK: Replaced _available_models()
|
| 171 |
else:
|
|
|
|
| 172 |
old_func = (
|
| 173 |
"def _available_models() -> list[dict[str, Any]]:\n"
|
| 174 |
" models = [\n"
|
|
@@ -185,16 +156,20 @@ else:
|
|
| 185 |
content = content.replace(old_func, new_func)
|
| 186 |
print("OK: String replace of _available_models()")
|
| 187 |
else:
|
| 188 |
-
print("
|
|
|
|
| 189 |
idx = content.find("def _available_models")
|
| 190 |
if idx >= 0:
|
| 191 |
-
print(
|
|
|
|
| 192 |
|
|
|
|
| 193 |
content = content.replace(
|
| 194 |
'"openai/gpt-oss-120b:cerebras",',
|
| 195 |
'"huggingface/deepseek-ai/DeepSeek-V4-Pro",'
|
| 196 |
)
|
| 197 |
|
|
|
|
| 198 |
try:
|
| 199 |
ast.parse(content)
|
| 200 |
print("OK: agent.py syntax OK")
|
|
@@ -204,11 +179,9 @@ except SyntaxError as e:
|
|
| 204 |
|
| 205 |
with open(AGENT_FILE, "w") as f:
|
| 206 |
f.write(content)
|
|
|
|
| 207 |
|
| 208 |
-
#
|
| 209 |
-
# FIX: Strip the "openai/" prefix before sending model name to OpenRouter
|
| 210 |
-
# OpenRouter model IDs do not include the "openai/" prefix.
|
| 211 |
-
# e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"
|
| 212 |
with open(LLM_PARAMS_FILE) as f:
|
| 213 |
llm_content = f.read()
|
| 214 |
|
|
@@ -217,27 +190,16 @@ if "normalized_model.startswith" not in llm_content:
|
|
| 217 |
if api_key_find in llm_content:
|
| 218 |
line_end = llm_content.find("\n", llm_content.find(api_key_find) + len(api_key_find)) + 1
|
| 219 |
routing_insert = (
|
| 220 |
-
' # Route deepseek/ models to OpenRouter\n'
|
| 221 |
-
' if normalized_model.startswith("deepseek/"):\n'
|
| 222 |
-
' return {\n'
|
| 223 |
-
' "model": normalized_model,\n'
|
| 224 |
-
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 225 |
-
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 226 |
-
' }\n\n'
|
| 227 |
' # Route openai/-prefixed models to OpenRouter\n'
|
| 228 |
-
' # FIX: Strip the "openai/" prefix - OpenRouter model IDs do not include it.\n'
|
| 229 |
-
' # e.g. "openai/deepseek/deepseek-v4-flash" -> "deepseek/deepseek-v4-flash"\n'
|
| 230 |
-
' # e.g. "openai/tencent/hy3:free" -> "tencent/hy3:free"\n'
|
| 231 |
' if normalized_model.startswith("openai/"):\n'
|
| 232 |
-
' or_model = normalized_model[len("openai/"):]\n'
|
| 233 |
' return {\n'
|
| 234 |
-
' "model":
|
| 235 |
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 236 |
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 237 |
' }\n\n'
|
| 238 |
)
|
| 239 |
llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:]
|
| 240 |
-
print("OK: Patched _resolve_llm_params
|
| 241 |
else:
|
| 242 |
print("WARN: Could not find api_key line")
|
| 243 |
else:
|
|
@@ -253,4 +215,4 @@ except SyntaxError as e:
|
|
| 253 |
with open(LLM_PARAMS_FILE, "w") as f:
|
| 254 |
f.write(llm_content)
|
| 255 |
|
| 256 |
-
print("OK: ALL BACKEND PATCHES APPLIED
|
|
|
|
| 1 |
+
"""Patch backend: replace AVAILABLE_MODELS with OpenRouter models + routing."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import ast
|
| 4 |
import os
|
|
|
|
| 6 |
|
| 7 |
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 8 |
LLM_PARAMS_FILE = "/app/agent/core/llm_params.py"
|
|
|
|
| 9 |
|
| 10 |
+
# === Step 1: Patch model_ids.py ===
|
| 11 |
+
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 12 |
with open(IDS_FILE) as f:
|
| 13 |
content = f.read()
|
| 14 |
|
| 15 |
+
# Swap out old model definitions for new ones
|
| 16 |
+
content = content.replace(
|
| 17 |
+
"CLAUDE_OPUS_48_MODEL_ID", "__DELETED_CLAUDE"
|
| 18 |
+
)
|
| 19 |
+
content = content.replace(
|
| 20 |
+
'KIMI_K27_CODE_MODEL_ID = "moonshotai/Kimi-K2.7-Code:novita"',
|
| 21 |
+
'KIMI_K27_CODE_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro"',
|
| 22 |
+
)
|
| 23 |
+
content = content.replace(
|
| 24 |
+
'MINIMAX_M3_MODEL_ID = "MiniMaxAI/MiniMax-M3:novita"',
|
| 25 |
+
'MINIMAX_M3_MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash"',
|
| 26 |
+
)
|
| 27 |
+
content = content.replace(
|
| 28 |
+
'GLM_52_MODEL_ID = "zai-org/GLM-5.2:novita"',
|
| 29 |
+
'GLM_52_MODEL_ID = "openai/deepseek/deepseek-v4-flash"',
|
| 30 |
+
)
|
| 31 |
+
content = content.replace(
|
| 32 |
+
'DEEPSEEK_V4_PRO_MODEL_ID = "deepseek-ai/DeepSeek-V4-Pro:novita"',
|
| 33 |
+
'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"',
|
| 34 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
# Add new model IDs
|
| 37 |
+
new_ids = """
|
|
|
|
|
|
|
| 38 |
GEMMA_4_31B_FREE_MODEL_ID = "openai/google/gemma-4-31b-it:free"
|
| 39 |
+
TENCENT_HY3_FREE_MODEL_ID = "openai/tencent/hy3:free"
|
| 40 |
LLAMA_3_3_70B_FREE_MODEL_ID = "openai/meta-llama/llama-3.3-70b-instruct:free"
|
| 41 |
LLAMA_3_1_8B_MODEL_ID = "openai/meta-llama/llama-3.1-8b-instruct"
|
| 42 |
LAGUNA_M1_FREE_MODEL_ID = "openai/poolside/laguna-m.1:free"
|
| 43 |
LAGUNA_S21_FREE_MODEL_ID = "openai/poolside/laguna-s-2.1:free"
|
| 44 |
NEX_N2_MINI_MODEL_ID = "openai/nex-agi/nex-n2-mini"
|
| 45 |
LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"
|
| 46 |
+
DEEPSEEK_V4_FLASH_LATEST_MODEL_ID = "openai/deepseek/deepseek-v4-flash-latest"
|
| 47 |
+
"""
|
| 48 |
+
if "GEMMA_4_31B_FREE_MODEL_ID" not in content:
|
| 49 |
+
content = content.replace(
|
| 50 |
+
"DEEPSEEK_V4_PRO_MODEL_ID",
|
| 51 |
+
new_ids + "\nDEEPSEEK_V4_PRO_MODEL_ID"
|
| 52 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
# Update HOSTED_MODEL_IDS
|
| 55 |
+
hosted_start = content.find("HOSTED_MODEL_IDS = {")
|
| 56 |
+
if hosted_start >= 0:
|
| 57 |
+
hosted_end = content.find("}", hosted_start) + 1
|
| 58 |
+
hosted_block = content[hosted_start:hosted_end]
|
| 59 |
+
new_hosted = "HOSTED_MODEL_IDS = {\n GPT_55_MODEL_ID,\n KIMI_K27_CODE_MODEL_ID,\n MINIMAX_M3_MODEL_ID,\n GLM_52_MODEL_ID,\n DEEPSEEK_V4_PRO_MODEL_ID,\n GEMMA_4_31B_FREE_MODEL_ID,\n TENCENT_HY3_FREE_MODEL_ID,\n LLAMA_3_3_70B_FREE_MODEL_ID,\n LLAMA_3_1_8B_MODEL_ID,\n LAGUNA_M1_FREE_MODEL_ID,\n LAGUNA_S21_FREE_MODEL_ID,\n NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n DEEPSEEK_V4_FLASH_LATEST_MODEL_ID,\n}"
|
| 60 |
+
content = content.replace(hosted_block, new_hosted)
|
| 61 |
|
| 62 |
with open(IDS_FILE, "w") as f:
|
| 63 |
f.write(content)
|
| 64 |
+
print("OK: model_ids.py patched")
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
# === Step 2: Patch agent.py ===
|
| 67 |
with open(AGENT_FILE) as f:
|
| 68 |
content = f.read()
|
| 69 |
|
| 70 |
content = content.replace(
|
| 71 |
"DEFAULT_MODEL_ID = GLM_52_MODEL_ID",
|
| 72 |
+
'DEFAULT_MODEL_ID = "openai/tencent/hy3:free"'
|
| 73 |
)
|
| 74 |
content = content.replace(
|
| 75 |
"DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID",
|
| 76 |
"DEFAULT_GPT_MODEL_ID = LAGUNA_S21_FREE_MODEL_ID"
|
| 77 |
)
|
| 78 |
|
| 79 |
+
# Replace import block
|
| 80 |
old_import = (
|
| 81 |
"from agent.core.model_ids import (\n"
|
| 82 |
" CLAUDE_OPUS_48_MODEL_ID,\n"
|
|
|
|
| 95 |
" GPT_55_MODEL_ID,\n"
|
| 96 |
" KIMI_K27_CODE_MODEL_ID,\n"
|
| 97 |
" MINIMAX_M3_MODEL_ID,\n"
|
|
|
|
| 98 |
" GEMMA_4_31B_FREE_MODEL_ID,\n"
|
| 99 |
+
" TENCENT_HY3_FREE_MODEL_ID,\n"
|
| 100 |
" LLAMA_3_3_70B_FREE_MODEL_ID,\n"
|
| 101 |
" LLAMA_3_1_8B_MODEL_ID,\n"
|
| 102 |
" LAGUNA_M1_FREE_MODEL_ID,\n"
|
| 103 |
" LAGUNA_S21_FREE_MODEL_ID,\n"
|
| 104 |
" NEX_N2_MINI_MODEL_ID,\n"
|
| 105 |
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 106 |
+
" DEEPSEEK_V4_FLASH_LATEST_MODEL_ID,\n"
|
| 107 |
" strip_huggingface_model_prefix,\n"
|
| 108 |
")"
|
| 109 |
)
|
| 110 |
content = content.replace(old_import, new_import)
|
| 111 |
|
| 112 |
+
# Replace _available_models function
|
| 113 |
+
new_func = (
|
| 114 |
+
"def _available_models() -> list[dict[str, Any]]:\n"
|
| 115 |
+
" models = [\n"
|
| 116 |
+
' {"id": TENCENT_HY3_FREE_MODEL_ID, "label": "Tencent HY3:free", "recommended": True},\n'
|
| 117 |
+
' {"id": GEMMA_4_31B_FREE_MODEL_ID, "label": "Gemma 4 31B:free", "recommended": True},\n'
|
| 118 |
+
' {"id": LLAMA_3_3_70B_FREE_MODEL_ID, "label": "Llama 3.3 70B:free", "recommended": True},\n'
|
| 119 |
+
' {"id": LAGUNA_M1_FREE_MODEL_ID, "label": "Laguna M.1:free", "recommended": True},\n'
|
| 120 |
+
' {"id": DEFAULT_GPT_MODEL_ID, "label": "Laguna S 2.1:free", "recommended": True},\n'
|
| 121 |
+
' {"id": NEX_N2_MINI_MODEL_ID, "label": "nex-agi/nex-n2-mini", "recommended": True},\n'
|
| 122 |
+
' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 123 |
+
' {"id": LLAMA_3_1_8B_MODEL_ID, "label": "Llama 3.1 8B"},\n'
|
| 124 |
+
' {"id": KIMI_K27_CODE_MODEL_ID, "label": "DeepSeek V4 Pro"},\n'
|
| 125 |
+
' {"id": MINIMAX_M3_MODEL_ID, "label": "DeepSeek V4 Flash"},\n'
|
| 126 |
+
' {"id": DEFAULT_MODEL_ID, "label": "DeepSeek V4 Flash"},\n'
|
| 127 |
+
' {"id": DEEPSEEK_V4_PRO_MODEL_ID, "label": "Nemotron 3 Super 120B"},\n'
|
| 128 |
+
' {"id": DEEPSEEK_V4_FLASH_LATEST_MODEL_ID, "label": "DeepSeek V4 Flash latest"},\n'
|
| 129 |
+
" ]\n"
|
| 130 |
+
" return models"
|
| 131 |
+
)
|
| 132 |
|
| 133 |
+
# Find the old function by pattern
|
| 134 |
func_match = re.search(
|
| 135 |
r"def _available_models\(\)\s*->\s*list\[dict\[str,\s*Any\]\]:.*?return models",
|
| 136 |
content, re.DOTALL
|
| 137 |
)
|
| 138 |
if func_match:
|
| 139 |
content = content[:func_match.start()] + new_func + content[func_match.end():]
|
| 140 |
+
print("OK: Replaced _available_models()")
|
| 141 |
else:
|
| 142 |
+
# Fallback: try exact match
|
| 143 |
old_func = (
|
| 144 |
"def _available_models() -> list[dict[str, Any]]:\n"
|
| 145 |
" models = [\n"
|
|
|
|
| 156 |
content = content.replace(old_func, new_func)
|
| 157 |
print("OK: String replace of _available_models()")
|
| 158 |
else:
|
| 159 |
+
print("FAIL: Cannot find _available_models()")
|
| 160 |
+
print("Content excerpt around 'def _available_models':")
|
| 161 |
idx = content.find("def _available_models")
|
| 162 |
if idx >= 0:
|
| 163 |
+
print(content[idx:idx+500])
|
| 164 |
+
import sys; sys.exit(1)
|
| 165 |
|
| 166 |
+
# Update title gen model
|
| 167 |
content = content.replace(
|
| 168 |
'"openai/gpt-oss-120b:cerebras",',
|
| 169 |
'"huggingface/deepseek-ai/DeepSeek-V4-Pro",'
|
| 170 |
)
|
| 171 |
|
| 172 |
+
# Validate syntax
|
| 173 |
try:
|
| 174 |
ast.parse(content)
|
| 175 |
print("OK: agent.py syntax OK")
|
|
|
|
| 179 |
|
| 180 |
with open(AGENT_FILE, "w") as f:
|
| 181 |
f.write(content)
|
| 182 |
+
print("OK: agent.py patched")
|
| 183 |
|
| 184 |
+
# === Step 3: Patch _resolve_llm_params ===
|
|
|
|
|
|
|
|
|
|
| 185 |
with open(LLM_PARAMS_FILE) as f:
|
| 186 |
llm_content = f.read()
|
| 187 |
|
|
|
|
| 190 |
if api_key_find in llm_content:
|
| 191 |
line_end = llm_content.find("\n", llm_content.find(api_key_find) + len(api_key_find)) + 1
|
| 192 |
routing_insert = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
' # Route openai/-prefixed models to OpenRouter\n'
|
|
|
|
|
|
|
|
|
|
| 194 |
' if normalized_model.startswith("openai/"):\n'
|
|
|
|
| 195 |
' return {\n'
|
| 196 |
+
' "model": normalized_model,\n'
|
| 197 |
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 198 |
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 199 |
' }\n\n'
|
| 200 |
)
|
| 201 |
llm_content = llm_content[:line_end] + routing_insert + llm_content[line_end:]
|
| 202 |
+
print("OK: Patched _resolve_llm_params")
|
| 203 |
else:
|
| 204 |
print("WARN: Could not find api_key line")
|
| 205 |
else:
|
|
|
|
| 215 |
with open(LLM_PARAMS_FILE, "w") as f:
|
| 216 |
f.write(llm_content)
|
| 217 |
|
| 218 |
+
print("OK: ALL BACKEND PATCHES APPLIED")
|
patch_multi_upload.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
"""Patch: allow multiple file uploads in one request.
|
| 2 |
-
|
| 3 |
-
Backend changes:
|
| 4 |
-
1. Replace the single-file helper with a multi-file helper.
|
| 5 |
-
2. Rewrite the upload route to accept up to 20 files and return a list of
|
| 6 |
-
DatasetUploadResponse, uploading each file to the session's Hub dataset
|
| 7 |
-
repo and injecting a context note for each.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import ast
|
| 11 |
-
import re
|
| 12 |
-
import os
|
| 13 |
-
|
| 14 |
-
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 15 |
-
|
| 16 |
-
with open(AGENT_FILE) as f:
|
| 17 |
-
c = f.read()
|
| 18 |
-
|
| 19 |
-
# ---- 1. Replace single-file helper with multi-file helper ----
|
| 20 |
-
old_helper = '''def _dataset_upload_file_from_form(form: FormData) -> UploadFile:
|
| 21 |
-
uploaded_files = [
|
| 22 |
-
(key, value)
|
| 23 |
-
for key, value in form.multi_items()
|
| 24 |
-
if isinstance(value, UploadFile)
|
| 25 |
-
]
|
| 26 |
-
if len(uploaded_files) != 1:
|
| 27 |
-
raise HTTPException(
|
| 28 |
-
status_code=400,
|
| 29 |
-
detail="Upload exactly one dataset file.",
|
| 30 |
-
)
|
| 31 |
-
field_name, upload = uploaded_files[0]
|
| 32 |
-
if field_name != "file":
|
| 33 |
-
raise HTTPException(
|
| 34 |
-
status_code=400,
|
| 35 |
-
detail="Missing 'file' upload field.",
|
| 36 |
-
)
|
| 37 |
-
return upload
|
| 38 |
-
'''
|
| 39 |
-
|
| 40 |
-
new_helper = '''def _dataset_upload_files_from_form(form: FormData) -> list[UploadFile]:
|
| 41 |
-
"""Return all uploaded files from the multipart form (field 'file' or 'files')."""
|
| 42 |
-
uploaded_files = [
|
| 43 |
-
value
|
| 44 |
-
for key, value in form.multi_items()
|
| 45 |
-
if isinstance(value, UploadFile)
|
| 46 |
-
]
|
| 47 |
-
if not uploaded_files:
|
| 48 |
-
raise HTTPException(
|
| 49 |
-
status_code=400,
|
| 50 |
-
detail="No file uploads found in the request.",
|
| 51 |
-
)
|
| 52 |
-
return uploaded_files
|
| 53 |
-
'''
|
| 54 |
-
|
| 55 |
-
if old_helper in c:
|
| 56 |
-
c = c.replace(old_helper, new_helper)
|
| 57 |
-
print("OK: replaced helper with multi-file helper")
|
| 58 |
-
else:
|
| 59 |
-
print("WARN: single-file helper not found (check indentation)")
|
| 60 |
-
|
| 61 |
-
# ---- 2. Rewrite the upload route to handle multiple files ----
|
| 62 |
-
# Replace the entire function based on its decorator + route path.
|
| 63 |
-
start_marker = '@router.post("/session/{session_id}/datasets"'
|
| 64 |
-
end_marker = '@router.patch("/session/{session_id}/yolo")'
|
| 65 |
-
start = c.find(start_marker)
|
| 66 |
-
end = c.find(end_marker)
|
| 67 |
-
if start == -1 or end == -1:
|
| 68 |
-
print(f"FAIL: route markers not found (start={start}, end={end})")
|
| 69 |
-
raise SystemExit(1)
|
| 70 |
-
|
| 71 |
-
new_route = '''@router.post("/session/{session_id}/datasets", response_model=list[DatasetUploadResponse])
|
| 72 |
-
async def upload_session_dataset(
|
| 73 |
-
session_id: str,
|
| 74 |
-
request: Request,
|
| 75 |
-
user: dict = Depends(get_current_user),
|
| 76 |
-
) -> list[DatasetUploadResponse]:
|
| 77 |
-
"""Upload one or more document/dataset files to a private Hub dataset for this session."""
|
| 78 |
-
files: list[UploadFile] = []
|
| 79 |
-
try:
|
| 80 |
-
_reject_oversize_dataset_upload(request)
|
| 81 |
-
agent_session = await _check_session_access(session_id, user, request)
|
| 82 |
-
if not agent_session or not agent_session.is_active:
|
| 83 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 84 |
-
if agent_session.is_processing:
|
| 85 |
-
raise HTTPException(
|
| 86 |
-
status_code=409,
|
| 87 |
-
detail="Cannot upload files while the agent is processing.",
|
| 88 |
-
)
|
| 89 |
-
if agent_session.session.pending_approval:
|
| 90 |
-
raise HTTPException(
|
| 91 |
-
status_code=409,
|
| 92 |
-
detail="Resolve pending approvals before uploading files.",
|
| 93 |
-
)
|
| 94 |
-
|
| 95 |
-
hf_token = (
|
| 96 |
-
resolve_hf_request_token(request, include_env_fallback=False)
|
| 97 |
-
or _user_hf_token(user)
|
| 98 |
-
or resolve_hf_request_token(request)
|
| 99 |
-
)
|
| 100 |
-
if not hf_token:
|
| 101 |
-
raise HTTPException(
|
| 102 |
-
status_code=401,
|
| 103 |
-
detail="A Hugging Face token is required to upload files.",
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
form = await request.form(
|
| 107 |
-
max_files=20,
|
| 108 |
-
max_fields=20,
|
| 109 |
-
max_part_size=MAX_DATASET_UPLOAD_BYTES,
|
| 110 |
-
)
|
| 111 |
-
files = _dataset_upload_files_from_form(form)
|
| 112 |
-
if not files:
|
| 113 |
-
raise HTTPException(status_code=400, detail="No files to upload.")
|
| 114 |
-
|
| 115 |
-
hf_username = user.get("username") or agent_session.hf_username
|
| 116 |
-
results: list[DatasetUploadResponse] = []
|
| 117 |
-
for file in files:
|
| 118 |
-
uploaded = await push_dataset_upload_to_hub(
|
| 119 |
-
upload=file,
|
| 120 |
-
session_id=session_id,
|
| 121 |
-
hf_username=hf_username,
|
| 122 |
-
hf_token=hf_token,
|
| 123 |
-
)
|
| 124 |
-
agent_session.session.context_manager.add_message(
|
| 125 |
-
Message(role="user", content=format_uploaded_document_context(
|
| 126 |
-
filename=uploaded.original_filename,
|
| 127 |
-
stored_filename=uploaded.filename,
|
| 128 |
-
repo_id=uploaded.repo_id,
|
| 129 |
-
path_in_repo=uploaded.path_in_repo,
|
| 130 |
-
hub_url=uploaded.hub_url,
|
| 131 |
-
size_bytes=uploaded.size_bytes,
|
| 132 |
-
file_format=uploaded.format,
|
| 133 |
-
extracted_text=uploaded.extracted_text,
|
| 134 |
-
warning=uploaded.extraction_warning,
|
| 135 |
-
))
|
| 136 |
-
)
|
| 137 |
-
results.append(DatasetUploadResponse(**uploaded.response_payload()))
|
| 138 |
-
logger.info(
|
| 139 |
-
"Uploaded file %s to %s for session %s",
|
| 140 |
-
uploaded.filename,
|
| 141 |
-
uploaded.repo_id,
|
| 142 |
-
session_id,
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
session_manager._touch(agent_session)
|
| 146 |
-
await session_manager.persist_session_snapshot(agent_session)
|
| 147 |
-
return results
|
| 148 |
-
except HTTPException:
|
| 149 |
-
raise
|
| 150 |
-
except HfHubHTTPError as e:
|
| 151 |
-
logger.warning(
|
| 152 |
-
"Hub rejected file upload for session %s: status=%s request_id=%s",
|
| 153 |
-
session_id,
|
| 154 |
-
getattr(e.response, "status_code", None),
|
| 155 |
-
getattr(e, "request_id", None),
|
| 156 |
-
)
|
| 157 |
-
raise _dataset_upload_hub_http_exception(e)
|
| 158 |
-
except Exception:
|
| 159 |
-
logger.exception("File upload failed for session %s", session_id)
|
| 160 |
-
raise HTTPException(
|
| 161 |
-
status_code=502,
|
| 162 |
-
detail="File upload failed. Please try again.",
|
| 163 |
-
)
|
| 164 |
-
finally:
|
| 165 |
-
for f in files:
|
| 166 |
-
await f.close()
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
'''
|
| 170 |
-
|
| 171 |
-
c = c[:start] + new_route + c[end:]
|
| 172 |
-
|
| 173 |
-
with open(AGENT_FILE, "w") as f:
|
| 174 |
-
f.write(c)
|
| 175 |
-
|
| 176 |
-
try:
|
| 177 |
-
ast.parse(c)
|
| 178 |
-
print("OK: agent.py written & syntax valid")
|
| 179 |
-
except SyntaxError as e:
|
| 180 |
-
print(f"FAIL: agent.py syntax error: {e}")
|
| 181 |
-
raise
|
| 182 |
-
|
| 183 |
-
print("DONE: multi-file upload backend patch complete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_new_models.py
DELETED
|
@@ -1,88 +0,0 @@
|
|
| 1 |
-
"""Patch backend: add 3 new OpenRouter models (openai/ prefix routes to OpenRouter).
|
| 2 |
-
|
| 3 |
-
New models:
|
| 4 |
-
- openai/upstage/solar-pro4
|
| 5 |
-
- openai/nvidia/nemotron-3.5-lightning:free
|
| 6 |
-
- openai/liquid/lfm-2.5-2.6b:free
|
| 7 |
-
|
| 8 |
-
The `openai/` prefix is already stripped in _resolve_llm_params and routed to
|
| 9 |
-
OpenRouter, so we only need to register the model IDs, imports, and the
|
| 10 |
-
available-models list.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import ast
|
| 14 |
-
import os
|
| 15 |
-
|
| 16 |
-
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 17 |
-
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 18 |
-
|
| 19 |
-
# ---------- 1. model_ids.py ----------
|
| 20 |
-
with open(IDS_FILE) as f:
|
| 21 |
-
c = f.read()
|
| 22 |
-
|
| 23 |
-
new_constants = '''UPSTAGE_SOLAR_PRO4_MODEL_ID = "openai/upstage/solar-pro4"
|
| 24 |
-
NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-lightning:free"
|
| 25 |
-
LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID = "openai/liquid/lfm-2.5-2.6b:free"
|
| 26 |
-
'''
|
| 27 |
-
|
| 28 |
-
if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in c:
|
| 29 |
-
# Insert before DEEPSEEK_V4_PRO_MODEL_ID definition
|
| 30 |
-
target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
|
| 31 |
-
if target in c:
|
| 32 |
-
c = c.replace(target, new_constants + target)
|
| 33 |
-
print("OK: added 3 new model constants to model_ids.py")
|
| 34 |
-
else:
|
| 35 |
-
# fallback: append at end of constants near TENCENT/HY3 block
|
| 36 |
-
anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
|
| 37 |
-
if anchor in c:
|
| 38 |
-
c = c.replace(anchor, anchor + "\n" + new_constants)
|
| 39 |
-
print("OK: added constants after LING block")
|
| 40 |
-
else:
|
| 41 |
-
print("FAIL: could not find insertion anchor in model_ids.py")
|
| 42 |
-
else:
|
| 43 |
-
print("OK: constants already present")
|
| 44 |
-
|
| 45 |
-
with open(IDS_FILE, "w") as f:
|
| 46 |
-
f.write(c)
|
| 47 |
-
ast.parse(c)
|
| 48 |
-
print("OK: model_ids.py syntax OK")
|
| 49 |
-
|
| 50 |
-
# ---------- 2. agent.py : imports + _available_models ----------
|
| 51 |
-
with open(AGENT_FILE) as f:
|
| 52 |
-
ac = f.read()
|
| 53 |
-
|
| 54 |
-
# Add imports
|
| 55 |
-
if "UPSTAGE_SOLAR_PRO4_MODEL_ID" not in ac:
|
| 56 |
-
old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 57 |
-
new_imp = (
|
| 58 |
-
" NEX_N2_MINI_MODEL_ID,\n"
|
| 59 |
-
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 60 |
-
" UPSTAGE_SOLAR_PRO4_MODEL_ID,\n"
|
| 61 |
-
" NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID,\n"
|
| 62 |
-
" LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID,\n"
|
| 63 |
-
)
|
| 64 |
-
if old_imp in ac:
|
| 65 |
-
ac = ac.replace(old_imp, new_imp)
|
| 66 |
-
print("OK: added new model imports")
|
| 67 |
-
else:
|
| 68 |
-
print("WARN: import anchor not matched")
|
| 69 |
-
|
| 70 |
-
# Add to _available_models list - insert after LING_3_0_FLASH line
|
| 71 |
-
old_line = ' {"id": LING_3_0_FLASH_FREE_MODEL_ID, "label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 72 |
-
new_line = old_line + (
|
| 73 |
-
' {"id": UPSTAGE_SOLAR_PRO4_MODEL_ID, "label": "UPSTAGE Solar Pro 4", "recommended": True},\n'
|
| 74 |
-
' {"id": NEMOTRON_3_5_LIGHTNING_FREE_MODEL_ID, "label": "NVIDIA Nemotron 3.5 Lightning:free", "recommended": True},\n'
|
| 75 |
-
' {"id": LIQUID_LFM_2_5_2_6B_FREE_MODEL_ID, "label": "Liquid LFM 2.5 2.6B:free", "recommended": True},\n'
|
| 76 |
-
)
|
| 77 |
-
if old_line in ac:
|
| 78 |
-
ac = ac.replace(old_line, new_line)
|
| 79 |
-
print("OK: added 3 new models to _available_models")
|
| 80 |
-
else:
|
| 81 |
-
print("WARN: _available_models LING line not matched (check exact text)")
|
| 82 |
-
|
| 83 |
-
with open(AGENT_FILE, "w") as f:
|
| 84 |
-
f.write(ac)
|
| 85 |
-
ast.parse(ac)
|
| 86 |
-
print("OK: agent.py syntax OK")
|
| 87 |
-
|
| 88 |
-
print("DONE: 3 new OpenRouter models added (backend)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_openrouter_fix.py
DELETED
|
@@ -1,92 +0,0 @@
|
|
| 1 |
-
"""Patch backend llm_params.py: force OpenRouter as the LiteLLM provider.
|
| 2 |
-
|
| 3 |
-
Root cause: the openai/-prefixed (and deepseek/) model routing returns only
|
| 4 |
-
``api_base`` + ``api_key``. LiteLLM resolves the provider from the model
|
| 5 |
-
string's org prefix, so for models whose org is NOT a recognized LiteLLM
|
| 6 |
-
provider (upstage/, nvidia/, liquid/, meta-llama/, google/, poolside/,
|
| 7 |
-
inclusionai/, ...) it raises "LLM Provider NOT provided" even though
|
| 8 |
-
``api_base`` points at OpenRouter. Only orgs that happen to match a real
|
| 9 |
-
LiteLLM provider (e.g. tencent/) worked by luck.
|
| 10 |
-
|
| 11 |
-
Fix: add ``custom_llm_provider: "openrouter"`` to both OpenRouter routing
|
| 12 |
-
branches so LiteLLM always uses the OpenRouter OpenAI-compatible client.
|
| 13 |
-
|
| 14 |
-
This runs AFTER patch_models.py (which may already have inserted the routing
|
| 15 |
-
blocks); it is idempotent.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import ast
|
| 19 |
-
import os
|
| 20 |
-
|
| 21 |
-
FILE = "/app/agent/core/llm_params.py"
|
| 22 |
-
|
| 23 |
-
with open(FILE) as f:
|
| 24 |
-
c = f.read()
|
| 25 |
-
|
| 26 |
-
before = c
|
| 27 |
-
|
| 28 |
-
# --- 1. openai/ branch (unique via or_model line) ---
|
| 29 |
-
openai_old = (
|
| 30 |
-
' or_model = normalized_model[len("openai/"):]\n'
|
| 31 |
-
' return {\n'
|
| 32 |
-
' "model": or_model,\n'
|
| 33 |
-
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 34 |
-
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 35 |
-
' }\n'
|
| 36 |
-
)
|
| 37 |
-
openai_new = (
|
| 38 |
-
' or_model = normalized_model[len("openai/"):]\n'
|
| 39 |
-
' return {\n'
|
| 40 |
-
' "model": or_model,\n'
|
| 41 |
-
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 42 |
-
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 43 |
-
' "custom_llm_provider": "openrouter",\n'
|
| 44 |
-
' }\n'
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
# --- 2. deepseek/ branch (unique via normalized_model on its own line) ---
|
| 48 |
-
deepseek_old = (
|
| 49 |
-
' if normalized_model.startswith("deepseek/"):\n'
|
| 50 |
-
' return {\n'
|
| 51 |
-
' "model": normalized_model,\n'
|
| 52 |
-
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 53 |
-
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 54 |
-
' }\n'
|
| 55 |
-
)
|
| 56 |
-
deepseek_new = (
|
| 57 |
-
' if normalized_model.startswith("deepseek/"):\n'
|
| 58 |
-
' return {\n'
|
| 59 |
-
' "model": normalized_model,\n'
|
| 60 |
-
' "api_base": "https://openrouter.ai/api/v1",\n'
|
| 61 |
-
' "api_key": os.environ.get("OPENROUTER_API_KEY") or api_key or "",\n'
|
| 62 |
-
' "custom_llm_provider": "openrouter",\n'
|
| 63 |
-
' }\n'
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
if 'custom_llm_provider' in c:
|
| 67 |
-
print("OK: llm_params.py already contains custom_llm_provider (idempotent)")
|
| 68 |
-
else:
|
| 69 |
-
n1 = c.count(openai_old)
|
| 70 |
-
n2 = c.count(deepseek_old)
|
| 71 |
-
if n1 == 1:
|
| 72 |
-
c = c.replace(openai_old, openai_new)
|
| 73 |
-
print("OK: openai/ branch -> custom_llm_provider='openrouter'")
|
| 74 |
-
else:
|
| 75 |
-
print(f"WARN: openai/ branch match count = {n1} (expected 1)")
|
| 76 |
-
if n2 == 1:
|
| 77 |
-
c = c.replace(deepseek_old, deepseek_new)
|
| 78 |
-
print("OK: deepseek/ branch -> custom_llm_provider='openrouter'")
|
| 79 |
-
else:
|
| 80 |
-
print(f"WARN: deepseek/ branch match count = {n2} (expected 1)")
|
| 81 |
-
|
| 82 |
-
if c != before:
|
| 83 |
-
with open(FILE, "w") as f:
|
| 84 |
-
f.write(c)
|
| 85 |
-
try:
|
| 86 |
-
ast.parse(c)
|
| 87 |
-
print("OK: llm_params.py written & syntax valid")
|
| 88 |
-
except SyntaxError as e:
|
| 89 |
-
print(f"FAIL: llm_params.py syntax error: {e}")
|
| 90 |
-
raise
|
| 91 |
-
else:
|
| 92 |
-
print("WARN: no changes applied")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_permanent_session_logging.py
DELETED
|
@@ -1,358 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Patch: Permanent session & user context logging.
|
| 4 |
-
|
| 5 |
-
Lưu vĩnh viễn ngữ cảnh từng phiên/user vào dataset HF ngay khi có message/event mới.
|
| 6 |
-
Tắt thông báo "where we here now?". Đảm bảo mọi model đều được lưu session.
|
| 7 |
-
|
| 8 |
-
Chạy trong Docker build — an toàn với mọi trường hợp lỗi (never fails build).
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
import ast
|
| 12 |
-
import os
|
| 13 |
-
import sys
|
| 14 |
-
|
| 15 |
-
PATCHED_MARKER = "# === PATCH: permanent session logging ==="
|
| 16 |
-
|
| 17 |
-
def _safe_replace(content, old, new, count=1):
|
| 18 |
-
"""Replace and verify count changed."""
|
| 19 |
-
if old in content:
|
| 20 |
-
content = content.replace(old, new, count)
|
| 21 |
-
return content, True
|
| 22 |
-
return content, False
|
| 23 |
-
|
| 24 |
-
def patch_file(path, patch_fn):
|
| 25 |
-
"""Safely read, patch, and write a file. Returns True if patched."""
|
| 26 |
-
if not os.path.exists(path):
|
| 27 |
-
print(f"SKIP: {path} not found")
|
| 28 |
-
return False
|
| 29 |
-
try:
|
| 30 |
-
with open(path, "r") as f:
|
| 31 |
-
content = f.read()
|
| 32 |
-
content, changed = patch_fn(content)
|
| 33 |
-
if not changed:
|
| 34 |
-
print(f"SKIP: {path} — no changes needed")
|
| 35 |
-
return False
|
| 36 |
-
# Validate syntax
|
| 37 |
-
try:
|
| 38 |
-
ast.parse(content)
|
| 39 |
-
except SyntaxError as e:
|
| 40 |
-
print(f"FAIL: {path} syntax: {e}")
|
| 41 |
-
return False
|
| 42 |
-
with open(path, "w") as f:
|
| 43 |
-
f.write(content)
|
| 44 |
-
print(f"OK: {path} patched")
|
| 45 |
-
return True
|
| 46 |
-
except Exception as e:
|
| 47 |
-
print(f"WARN: {path} patch error: {e}")
|
| 48 |
-
return False
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
# ─── Patch session_manager.py ────────────────────────────────────────────────
|
| 52 |
-
print("=== Patching session_manager.py ===")
|
| 53 |
-
|
| 54 |
-
SESSION_MANAGER = "/app/backend/session_manager.py"
|
| 55 |
-
|
| 56 |
-
def _patch_session_manager(content):
|
| 57 |
-
changed = False
|
| 58 |
-
|
| 59 |
-
# Add import
|
| 60 |
-
if "from backend.session_logger import" not in content:
|
| 61 |
-
content, ok = _safe_replace(
|
| 62 |
-
content,
|
| 63 |
-
"from agent.messaging.gateway import NotificationGateway",
|
| 64 |
-
"from agent.messaging.gateway import NotificationGateway\n"
|
| 65 |
-
"from backend.session_logger import log_message as _perm_log_message, log_trajectory as _perm_log_trajectory",
|
| 66 |
-
)
|
| 67 |
-
if ok:
|
| 68 |
-
print(" + Added session_logger import")
|
| 69 |
-
changed = True
|
| 70 |
-
|
| 71 |
-
# Patch create_session
|
| 72 |
-
if PATCHED_MARKER not in content:
|
| 73 |
-
# Add session creation logging after session_id = str(uuid.uuid4())
|
| 74 |
-
old = " session_id = str(uuid.uuid4())\n"
|
| 75 |
-
new = ''' session_id = str(uuid.uuid4())
|
| 76 |
-
|
| 77 |
-
# === PATCH: permanent session logging ===
|
| 78 |
-
# Log session creation to dataset IMMEDIATELY — every model gets session saved
|
| 79 |
-
try:
|
| 80 |
-
def _do_create_log():
|
| 81 |
-
_perm_log_message(
|
| 82 |
-
session_id=session_id,
|
| 83 |
-
user_id=user_id or "dev",
|
| 84 |
-
model_name=model or "",
|
| 85 |
-
role="system",
|
| 86 |
-
content=f"Session created for {user_id or 'dev'} with model {model or 'default'}",
|
| 87 |
-
event_type="session_created",
|
| 88 |
-
seq=0,
|
| 89 |
-
extra={"surface": "frontend"},
|
| 90 |
-
hf_user=hf_username,
|
| 91 |
-
)
|
| 92 |
-
asyncio.get_event_loop().run_in_executor(None, _do_create_log)
|
| 93 |
-
except Exception as _e:
|
| 94 |
-
logger.debug("Session creation logging failed: %s", _e)
|
| 95 |
-
'''
|
| 96 |
-
content, ok = _safe_replace(content, old, new)
|
| 97 |
-
if ok:
|
| 98 |
-
print(" + Added session creation logging")
|
| 99 |
-
changed = True
|
| 100 |
-
|
| 101 |
-
# Patch submit_user_input
|
| 102 |
-
old_submit = " agent_session.submission_queue.put_nowait(submission)\n self._touch(agent_session)"
|
| 103 |
-
new_submit = ''' agent_session.submission_queue.put_nowait(submission)
|
| 104 |
-
self._touch(agent_session)
|
| 105 |
-
|
| 106 |
-
# === PATCH: log user message immediately ===
|
| 107 |
-
try:
|
| 108 |
-
model_name = getattr(agent_session.session.config, 'model_name', '')
|
| 109 |
-
def _do_msg_log():
|
| 110 |
-
_perm_log_message(
|
| 111 |
-
session_id=session_id,
|
| 112 |
-
user_id=user_id,
|
| 113 |
-
model_name=model_name,
|
| 114 |
-
role="user",
|
| 115 |
-
content=text,
|
| 116 |
-
event_type="message",
|
| 117 |
-
seq=len(agent_session.session.context_manager.items),
|
| 118 |
-
extra={"source": "submit_user_input"},
|
| 119 |
-
hf_user=agent_session.hf_username or agent_session.user_id,
|
| 120 |
-
)
|
| 121 |
-
asyncio.get_event_loop().run_in_executor(None, _do_msg_log)
|
| 122 |
-
except Exception as _e:
|
| 123 |
-
logger.debug("Message logging failed: %s", _e)'''
|
| 124 |
-
content, ok = _safe_replace(content, old_submit, new_submit)
|
| 125 |
-
if ok:
|
| 126 |
-
print(" + Added immediate message logging")
|
| 127 |
-
changed = True
|
| 128 |
-
|
| 129 |
-
# Patch trajectory upload
|
| 130 |
-
old_refresh = " session.save_and_upload_detached(session.config.session_dataset_repo)"
|
| 131 |
-
new_refresh = ''' session.save_and_upload_detached(session.config.session_dataset_repo)
|
| 132 |
-
# === PATCH: upload full trajectory to permanent dataset ===
|
| 133 |
-
try:
|
| 134 |
-
trajectory = session.get_trajectory()
|
| 135 |
-
hf_user = agent_session.hf_username or agent_session.user_id
|
| 136 |
-
def _do_traj_upload():
|
| 137 |
-
_perm_log_trajectory(trajectory, session.user_id or "dev", hf_user, agent_session.hf_token, False)
|
| 138 |
-
asyncio.get_event_loop().run_in_executor(None, _do_traj_upload)
|
| 139 |
-
except Exception as _e:
|
| 140 |
-
logger.debug("Trajectory upload failed: %s", _e)'''
|
| 141 |
-
content, ok = _safe_replace(content, old_refresh, new_refresh)
|
| 142 |
-
if ok:
|
| 143 |
-
print(" + Added trajectory upload")
|
| 144 |
-
changed = True
|
| 145 |
-
|
| 146 |
-
# Remove 'where we here now?'
|
| 147 |
-
if "where we here now" in content.lower():
|
| 148 |
-
lines = content.split("\n")
|
| 149 |
-
content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
|
| 150 |
-
print(" + Removed 'where we here now?' notification")
|
| 151 |
-
changed = True
|
| 152 |
-
|
| 153 |
-
return content, changed
|
| 154 |
-
|
| 155 |
-
patch_file(SESSION_MANAGER, _patch_session_manager)
|
| 156 |
-
|
| 157 |
-
# ─── Patch routes/agent.py ───────────────────────────────────────────────────
|
| 158 |
-
print("\n=== Patching routes/agent.py ===")
|
| 159 |
-
|
| 160 |
-
ROUTES_AGENT = "/app/backend/routes/agent.py"
|
| 161 |
-
|
| 162 |
-
def _patch_routes(content):
|
| 163 |
-
changed = False
|
| 164 |
-
|
| 165 |
-
# Add import
|
| 166 |
-
if "from backend.session_logger import" not in content:
|
| 167 |
-
content, ok = _safe_replace(
|
| 168 |
-
content,
|
| 169 |
-
"from session_manager import (\n",
|
| 170 |
-
"from backend.session_logger import log_message as _log_session, PUBLIC_SESSIONS_DATASET\n"
|
| 171 |
-
"from session_manager import (\n",
|
| 172 |
-
)
|
| 173 |
-
if ok:
|
| 174 |
-
print(" + Added session_logger import")
|
| 175 |
-
changed = True
|
| 176 |
-
|
| 177 |
-
# Add endpoints
|
| 178 |
-
if "/session-log/" not in content:
|
| 179 |
-
log_endpoint = '''
|
| 180 |
-
|
| 181 |
-
@router.post("/session-log/{session_id}")
|
| 182 |
-
async def log_session_event(
|
| 183 |
-
session_id: str,
|
| 184 |
-
body: dict,
|
| 185 |
-
user: dict = Depends(get_current_user),
|
| 186 |
-
) -> dict:
|
| 187 |
-
"""Receive permanent session event logs from the frontend."""
|
| 188 |
-
event_type = body.get("event_type", "frontend_event")
|
| 189 |
-
data = body.get("data", {})
|
| 190 |
-
seq = body.get("seq", 0)
|
| 191 |
-
role = body.get("role", "frontend")
|
| 192 |
-
content_text = body.get("content", "")
|
| 193 |
-
extra = {k: v for k, v in body.items() if k not in ("event_type", "data", "timestamp", "seq", "role", "content")}
|
| 194 |
-
try:
|
| 195 |
-
def _do_log():
|
| 196 |
-
_log_session(
|
| 197 |
-
session_id=session_id,
|
| 198 |
-
user_id=user.get("user_id") or user.get("username") or "dev",
|
| 199 |
-
model_name="",
|
| 200 |
-
role=role,
|
| 201 |
-
content=content_text or str(data)[:500],
|
| 202 |
-
event_type=event_type,
|
| 203 |
-
seq=seq,
|
| 204 |
-
extra=extra,
|
| 205 |
-
hf_user=user.get("username") or user.get("user_id"),
|
| 206 |
-
)
|
| 207 |
-
asyncio.get_event_loop().run_in_executor(None, _do_log)
|
| 208 |
-
except Exception:
|
| 209 |
-
pass
|
| 210 |
-
return {"status": "logged", "session_id": session_id}
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
@router.get("/sessions-history")
|
| 214 |
-
async def get_sessions_history(user: dict = Depends(get_current_user)) -> list:
|
| 215 |
-
"""Get ALL sessions for user from permanent dataset (survives restart)."""
|
| 216 |
-
from backend.session_logger import get_user_sessions
|
| 217 |
-
user_id = user.get("user_id") or user.get("username") or "dev"
|
| 218 |
-
return get_user_sessions(user_id, limit=100)
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
@router.get("/session-history/{session_id}")
|
| 222 |
-
async def get_session_history(
|
| 223 |
-
session_id: str, user: dict = Depends(get_current_user)
|
| 224 |
-
) -> list:
|
| 225 |
-
"""Get full message history for a session from the permanent dataset."""
|
| 226 |
-
from backend.session_logger import load_session_history
|
| 227 |
-
return load_session_history(session_id)
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
@router.get("/dataset-info")
|
| 231 |
-
async def get_dataset_info(user: dict = Depends(get_current_user)) -> dict:
|
| 232 |
-
"""Info about the permanent sessions dataset."""
|
| 233 |
-
return {
|
| 234 |
-
"dataset_repo": PUBLIC_SESSIONS_DATASET,
|
| 235 |
-
"dataset_url": f"https://huggingface.co/datasets/{PUBLIC_SESSIONS_DATASET}",
|
| 236 |
-
"storage": "permanent (HF Dataset, never lost)",
|
| 237 |
-
"note": "Sessions saved to dataset immediately on creation and on each message.",
|
| 238 |
-
}
|
| 239 |
-
'''
|
| 240 |
-
content = content.rstrip() + "\n" + log_endpoint
|
| 241 |
-
print(" + Added permanent logging endpoints")
|
| 242 |
-
changed = True
|
| 243 |
-
|
| 244 |
-
# Remove 'where we here now?'
|
| 245 |
-
if "where we here now" in content.lower():
|
| 246 |
-
lines = content.split("\n")
|
| 247 |
-
content = "\n".join([l for l in lines if "where we here now" not in l.lower()])
|
| 248 |
-
print(" + Removed 'where we here now?' notification")
|
| 249 |
-
changed = True
|
| 250 |
-
|
| 251 |
-
return content, changed
|
| 252 |
-
|
| 253 |
-
patch_file(ROUTES_AGENT, _patch_routes)
|
| 254 |
-
|
| 255 |
-
# ─── Patch session.py ────────────────────────────────────────────────────────
|
| 256 |
-
print("\n=== Patching session.py ===")
|
| 257 |
-
|
| 258 |
-
SESSION_FILE = "/app/agent/core/session.py"
|
| 259 |
-
|
| 260 |
-
def _patch_session(content):
|
| 261 |
-
changed = False
|
| 262 |
-
|
| 263 |
-
# Add import
|
| 264 |
-
if "from backend.session_logger import" not in content:
|
| 265 |
-
content, ok = _safe_replace(
|
| 266 |
-
content,
|
| 267 |
-
"import uuid\n",
|
| 268 |
-
"import uuid\n"
|
| 269 |
-
"# === PATCH: permanent session logging ===\n"
|
| 270 |
-
"try:\n"
|
| 271 |
-
" from backend.session_logger import log_message as _perm_log_message\n"
|
| 272 |
-
"except Exception:\n"
|
| 273 |
-
" def _perm_log_message(*a, **kw): pass\n",
|
| 274 |
-
)
|
| 275 |
-
if ok:
|
| 276 |
-
print(" + Added session_logger import")
|
| 277 |
-
changed = True
|
| 278 |
-
|
| 279 |
-
# Patch send_event to log permanently
|
| 280 |
-
if "# === PATCH: permanent event logging ===" not in content:
|
| 281 |
-
old_put = " await self.event_queue.put(event)\n await self._enqueue_auto_notification_requests(event)"
|
| 282 |
-
new_put = ''' await self.event_queue.put(event)
|
| 283 |
-
# === PATCH: permanent event logging ===
|
| 284 |
-
try:
|
| 285 |
-
def _do_event_log():
|
| 286 |
-
_perm_log_message(
|
| 287 |
-
session_id=self.session_id,
|
| 288 |
-
user_id=self.user_id or "dev",
|
| 289 |
-
model_name=self.config.model_name,
|
| 290 |
-
role="event",
|
| 291 |
-
content=str(event.data) if event.data else "",
|
| 292 |
-
event_type=event.event_type,
|
| 293 |
-
seq=event.seq,
|
| 294 |
-
extra={"session_id": self.session_id},
|
| 295 |
-
hf_user=self.hf_username or self.user_id,
|
| 296 |
-
)
|
| 297 |
-
asyncio.get_event_loop().run_in_executor(None, _do_event_log)
|
| 298 |
-
except Exception as _e:
|
| 299 |
-
logger.debug("Permanent event logging failed: %s", _e)
|
| 300 |
-
await self._enqueue_auto_notification_requests(event)'''
|
| 301 |
-
content, ok = _safe_replace(content, old_put, new_put)
|
| 302 |
-
if ok:
|
| 303 |
-
print(" + Added permanent event logging to send_event")
|
| 304 |
-
changed = True
|
| 305 |
-
|
| 306 |
-
# Patch start_new_conversation to log
|
| 307 |
-
if "# === PATCH: log new conversation ===" not in content:
|
| 308 |
-
old_conv = " self.context_manager.items = [system_msg] if system_msg is not None else []"
|
| 309 |
-
new_conv = ''' self.context_manager.items = [system_msg] if system_msg is not None else []
|
| 310 |
-
# === PATCH: log new conversation start ===
|
| 311 |
-
try:
|
| 312 |
-
def _do_conv_log():
|
| 313 |
-
_perm_log_message(
|
| 314 |
-
session_id=self.session_id,
|
| 315 |
-
user_id=self.user_id or "dev",
|
| 316 |
-
model_name=self.config.model_name,
|
| 317 |
-
role="system",
|
| 318 |
-
content=f"New conversation started (session: {self.session_id})",
|
| 319 |
-
event_type="new_conversation",
|
| 320 |
-
seq=0,
|
| 321 |
-
extra={},
|
| 322 |
-
hf_user=self.hf_username or self.user_id,
|
| 323 |
-
)
|
| 324 |
-
asyncio.get_event_loop().run_in_executor(None, _do_conv_log)
|
| 325 |
-
except Exception as _e:
|
| 326 |
-
logger.debug("New conversation logging failed: %s", _e)'''
|
| 327 |
-
content, ok = _safe_replace(content, old_conv, new_conv)
|
| 328 |
-
if ok:
|
| 329 |
-
print(" + Added new conversation logging")
|
| 330 |
-
changed = True
|
| 331 |
-
|
| 332 |
-
return content, changed
|
| 333 |
-
|
| 334 |
-
patch_file(SESSION_FILE, _patch_session)
|
| 335 |
-
|
| 336 |
-
# ─── Patch frontend: disable 'where we here now?' ──────────────────────────────
|
| 337 |
-
print("\n=== Patching frontend for notification removal ===")
|
| 338 |
-
frontend_src = "/source/frontend/src"
|
| 339 |
-
if os.path.exists(frontend_src):
|
| 340 |
-
for root, dirs, files in os.walk(frontend_src):
|
| 341 |
-
for fname in files:
|
| 342 |
-
if fname.endswith((".ts", ".tsx")):
|
| 343 |
-
fpath = os.path.join(root, fname)
|
| 344 |
-
try:
|
| 345 |
-
with open(fpath, "r", encoding="utf-8") as f:
|
| 346 |
-
fcontent = f.read()
|
| 347 |
-
if "where we here now" in fcontent.lower():
|
| 348 |
-
lines = fcontent.split("\n")
|
| 349 |
-
filtered = [l for l in lines if "where we here now" not in l.lower()]
|
| 350 |
-
with open(fpath, "w", encoding="utf-8") as f:
|
| 351 |
-
f.write("\n".join(filtered))
|
| 352 |
-
print(f" + Removed 'where we here now?' from {fpath}")
|
| 353 |
-
except Exception:
|
| 354 |
-
pass
|
| 355 |
-
else:
|
| 356 |
-
print(" SKIP: frontend source not found")
|
| 357 |
-
|
| 358 |
-
print("\n=== All patches applied (build will not fail regardless) ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_restore_full_app.py
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
"""Restore the pre-retirement full app: App.tsx (frontend) + start.sh (backend).
|
| 2 |
-
|
| 3 |
-
Upstream smolagents/ml-intern retired the hosted app: frontend App.tsx now
|
| 4 |
-
renders RetirementPage ("ML Intern has moved to HuggingChat") and backend/start.sh
|
| 5 |
-
now runs retired_main:app. That turns this Space into a static redirect page and
|
| 6 |
-
the backend never starts, so our model patches have no visible effect.
|
| 7 |
-
|
| 8 |
-
This patch overwrites both files with the last pre-retirement versions that this
|
| 9 |
-
Space's Dockerfile was built on:
|
| 10 |
-
- frontend/src/App.tsx -> full AppLayout + useAuth app
|
| 11 |
-
- backend/start.sh -> uvicorn main:app (the real backend)
|
| 12 |
-
|
| 13 |
-
Runs from either build stage: it patches whichever target exists and prints
|
| 14 |
-
SKIP for the other (idempotent, re-runnable).
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
import os
|
| 18 |
-
|
| 19 |
-
FRONTEND_APP = r"""import { Box } from '@mui/material';
|
| 20 |
-
import AppLayout from '@/components/Layout/AppLayout';
|
| 21 |
-
import { useAuth } from '@/hooks/useAuth';
|
| 22 |
-
|
| 23 |
-
function App() {
|
| 24 |
-
// Non-blocking auth check -- fires in background, updates store when done.
|
| 25 |
-
// If auth fails later, apiFetch redirects to /auth/login.
|
| 26 |
-
useAuth();
|
| 27 |
-
|
| 28 |
-
return (
|
| 29 |
-
<Box sx={{ height: '100vh', display: 'flex' }}>
|
| 30 |
-
<AppLayout />
|
| 31 |
-
</Box>
|
| 32 |
-
);
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
export default App;
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
BACKEND_START = r"""#!/bin/bash
|
| 39 |
-
# Entrypoint for HF Spaces dev mode compatibility.
|
| 40 |
-
# Dev mode spawns CMD multiple times simultaneously on restart.
|
| 41 |
-
# Only the first instance can bind port 7860 - the rest must exit
|
| 42 |
-
# with code 0 so the dev mode daemon doesn't mark the app as crashed.
|
| 43 |
-
|
| 44 |
-
# Run uvicorn; if it fails due to port conflict, exit cleanly.
|
| 45 |
-
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 46 |
-
EXIT_CODE=$?
|
| 47 |
-
|
| 48 |
-
if [ $EXIT_CODE -ne 0 ]; then
|
| 49 |
-
# Check if this was a port-in-use failure (another instance already running)
|
| 50 |
-
echo "uvicorn exited with code $EXIT_CODE, exiting gracefully."
|
| 51 |
-
exit 0
|
| 52 |
-
fi
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def _write(path: str, content: str, executable: bool = False) -> bool:
|
| 57 |
-
if not os.path.exists(path):
|
| 58 |
-
return False
|
| 59 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 60 |
-
f.write(content)
|
| 61 |
-
if executable:
|
| 62 |
-
os.chmod(path, 0o755)
|
| 63 |
-
return True
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
changed = False
|
| 67 |
-
|
| 68 |
-
written = _write("/source/frontend/src/App.tsx", FRONTEND_APP)
|
| 69 |
-
if written:
|
| 70 |
-
print("OK: restored /source/frontend/src/App.tsx (full AppLayout app)")
|
| 71 |
-
changed = True
|
| 72 |
-
else:
|
| 73 |
-
print("SKIP: /source/frontend/src/App.tsx not present (backend stage?)")
|
| 74 |
-
|
| 75 |
-
written = _write("/app/backend/start.sh", BACKEND_START, executable=True)
|
| 76 |
-
if written:
|
| 77 |
-
print("OK: restored /app/backend/start.sh (uvicorn main:app)")
|
| 78 |
-
changed = True
|
| 79 |
-
else:
|
| 80 |
-
print("SKIP: /app/backend/start.sh not present (frontend stage?)")
|
| 81 |
-
|
| 82 |
-
if not changed:
|
| 83 |
-
raise SystemExit("FAIL: neither target file found - unexpected layout")
|
| 84 |
-
print("OK: full-app restore done")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_space_bunny.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
"""Patch frontend: add stealth/space-bunny-alpha beside Laguna S 2.1.
|
| 2 |
-
|
| 3 |
-
The model uses the same OpenRouter-style `openai/` model path convention as
|
| 4 |
-
Laguna S 2.1 (`openai/poolside/laguna-s-2.1:free`).
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
|
| 9 |
-
FILE = "/source/frontend/src/components/Chat/ChatInput.tsx"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def patch():
|
| 13 |
-
if not os.path.exists(FILE):
|
| 14 |
-
print(f"SKIP: {FILE} not found")
|
| 15 |
-
return
|
| 16 |
-
|
| 17 |
-
with open(FILE, "r", encoding="utf-8") as f:
|
| 18 |
-
content = f.read()
|
| 19 |
-
|
| 20 |
-
if "space-bunny-alpha" in content:
|
| 21 |
-
print("OK: stealth/space-bunny-alpha already present")
|
| 22 |
-
return
|
| 23 |
-
|
| 24 |
-
anchor = (
|
| 25 |
-
" {\n"
|
| 26 |
-
" id: 'laguna-s21',\n"
|
| 27 |
-
" name: 'Laguna S 2.1:free',\n"
|
| 28 |
-
" modelPath: 'openai/poolside/laguna-s-2.1:free',\n"
|
| 29 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/poolside',\n"
|
| 30 |
-
" recommended: true,\n"
|
| 31 |
-
" },\n"
|
| 32 |
-
)
|
| 33 |
-
new_entry = (
|
| 34 |
-
" {\n"
|
| 35 |
-
" id: 'space-bunny-alpha',\n"
|
| 36 |
-
" name: 'stealth/space-bunny-alpha',\n"
|
| 37 |
-
" modelPath: 'openai/stealth/space-bunny-alpha',\n"
|
| 38 |
-
" avatarUrl: 'https://huggingface.co/api/avatars/stealth',\n"
|
| 39 |
-
" recommended: true,\n"
|
| 40 |
-
" },\n"
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
-
if anchor in content:
|
| 44 |
-
content = content.replace(anchor, anchor + new_entry, 1)
|
| 45 |
-
print("OK: added stealth/space-bunny-alpha after Laguna S 2.1")
|
| 46 |
-
else:
|
| 47 |
-
print("FAIL: could not find Laguna S 2.1 model entry")
|
| 48 |
-
return
|
| 49 |
-
|
| 50 |
-
with open(FILE, "w", encoding="utf-8") as f:
|
| 51 |
-
f.write(content)
|
| 52 |
-
print("OK: ChatInput.tsx patched")
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
if __name__ == "__main__":
|
| 56 |
-
patch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_xing_nemotron_vl.py
DELETED
|
@@ -1,93 +0,0 @@
|
|
| 1 |
-
"""Patch backend: add 2 new OpenRouter models (openai/ prefix routes to OpenRouter).
|
| 2 |
-
|
| 3 |
-
New models (same `openai/` path convention as Laguna S 2.1:free):
|
| 4 |
-
- openai/xingchen-agi/xing4.0-29b-a4b:free
|
| 5 |
-
- openai/nvidia/nemotron-3.5-super-vl:free
|
| 6 |
-
|
| 7 |
-
The `openai/` prefix is already stripped in _resolve_llm_params and routed to
|
| 8 |
-
OpenRouter, so we only need to register the model IDs, imports, and the
|
| 9 |
-
available-models list.
|
| 10 |
-
|
| 11 |
-
Runs AFTER patch_models.py / patch_new_models.py / patch_apodex_ling_beatapi.py.
|
| 12 |
-
Idempotent.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
import ast
|
| 16 |
-
|
| 17 |
-
IDS_FILE = "/app/agent/core/model_ids.py"
|
| 18 |
-
AGENT_FILE = "/app/backend/routes/agent.py"
|
| 19 |
-
|
| 20 |
-
# ---------- 1. model_ids.py ----------
|
| 21 |
-
with open(IDS_FILE) as f:
|
| 22 |
-
c = f.read()
|
| 23 |
-
|
| 24 |
-
new_constants = (
|
| 25 |
-
'XINGCHEN_XING4_0_29B_FREE_MODEL_ID = "openai/xingchen-agi/xing4.0-29b-a4b:free"\n'
|
| 26 |
-
'NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID = "openai/nvidia/nemotron-3.5-super-vl:free"\n'
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in c:
|
| 30 |
-
# Insert before DEEPSEEK_V4_PRO_MODEL_ID definition (same anchor as
|
| 31 |
-
# patch_new_models.py uses).
|
| 32 |
-
target = 'DEEPSEEK_V4_PRO_MODEL_ID = "nvidia/nemotron-3-super-120b-a12b:free"'
|
| 33 |
-
if target in c:
|
| 34 |
-
c = c.replace(target, new_constants + target)
|
| 35 |
-
print("OK: added 2 new model constants to model_ids.py")
|
| 36 |
-
else:
|
| 37 |
-
# Fallback anchor: the LING_3_0_FLASH line injected by patch_models.py.
|
| 38 |
-
anchor = 'LING_3_0_FLASH_FREE_MODEL_ID = "openai/inclusionai/ling-3.0-flash:free"'
|
| 39 |
-
if anchor in c:
|
| 40 |
-
c = c.replace(anchor, anchor + "\n" + new_constants)
|
| 41 |
-
print("OK: added constants after LING block")
|
| 42 |
-
else:
|
| 43 |
-
print("FAIL: could not find insertion anchor in model_ids.py")
|
| 44 |
-
else:
|
| 45 |
-
print("OK: constants already present")
|
| 46 |
-
|
| 47 |
-
with open(IDS_FILE, "w") as f:
|
| 48 |
-
f.write(c)
|
| 49 |
-
ast.parse(c)
|
| 50 |
-
print("OK: model_ids.py syntax OK")
|
| 51 |
-
|
| 52 |
-
# ---------- 2. agent.py : imports + _available_models ----------
|
| 53 |
-
with open(AGENT_FILE) as f:
|
| 54 |
-
ac = f.read()
|
| 55 |
-
|
| 56 |
-
# Add imports after LING_3_0_FLASH_FREE_MODEL_ID,
|
| 57 |
-
if "XINGCHEN_XING4_0_29B_FREE_MODEL_ID" not in ac:
|
| 58 |
-
old_imp = " NEX_N2_MINI_MODEL_ID,\n LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 59 |
-
new_imp = (
|
| 60 |
-
" NEX_N2_MINI_MODEL_ID,\n"
|
| 61 |
-
" LING_3_0_FLASH_FREE_MODEL_ID,\n"
|
| 62 |
-
" XINGCHEN_XING4_0_29B_FREE_MODEL_ID,\n"
|
| 63 |
-
" NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID,\n"
|
| 64 |
-
)
|
| 65 |
-
if old_imp in ac:
|
| 66 |
-
ac = ac.replace(old_imp, new_imp)
|
| 67 |
-
print("OK: added new model imports")
|
| 68 |
-
else:
|
| 69 |
-
print("WARN: import anchor not matched")
|
| 70 |
-
|
| 71 |
-
# Add to _available_models list - insert after the LING_3_0_FLASH entry
|
| 72 |
-
old_line = (
|
| 73 |
-
' {"id": LING_3_0_FLASH_FREE_MODEL_ID, '
|
| 74 |
-
'"label": "inclusionai/ling-3.0-flash:free", "recommended": True},\n'
|
| 75 |
-
)
|
| 76 |
-
new_line = old_line + (
|
| 77 |
-
' {"id": XINGCHEN_XING4_0_29B_FREE_MODEL_ID, '
|
| 78 |
-
'"label": "xingchen-agi/xing4.0-29b-a4b:free", "recommended": True},\n'
|
| 79 |
-
' {"id": NEMOTRON_3_5_SUPER_VL_FREE_MODEL_ID, '
|
| 80 |
-
'"label": "nvidia/nemotron-3.5-super-vl:free", "recommended": True},\n'
|
| 81 |
-
)
|
| 82 |
-
if old_line in ac:
|
| 83 |
-
ac = ac.replace(old_line, new_line)
|
| 84 |
-
print("OK: added 2 new models to _available_models")
|
| 85 |
-
else:
|
| 86 |
-
print("WARN: _available_models LING line not matched (check exact text)")
|
| 87 |
-
|
| 88 |
-
with open(AGENT_FILE, "w") as f:
|
| 89 |
-
f.write(ac)
|
| 90 |
-
ast.parse(ac)
|
| 91 |
-
print("OK: agent.py syntax OK")
|
| 92 |
-
|
| 93 |
-
print("DONE: 2 new OpenRouter models added (backend)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/build_kpis.py
DELETED
|
@@ -1,917 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""Hourly KPI rollup for the session-trajectory dataset.
|
| 3 |
-
|
| 4 |
-
================================================================================
|
| 5 |
-
Data flow
|
| 6 |
-
================================================================================
|
| 7 |
-
|
| 8 |
-
┌────────────────────┐ heartbeat ┌────────────────────────────────┐
|
| 9 |
-
│ agent (CLI/web) │ ───────────────▶ │ hf-agent-sessions (dataset) │
|
| 10 |
-
│ Session.send_event│ │ sessions/YYYY-MM-DD/<id>.jsonl│
|
| 11 |
-
└────────────────────┘ └───────────────┬────────────────┘
|
| 12 |
-
│ cron @:05 each hour
|
| 13 |
-
▼
|
| 14 |
-
┌──────────────────────────────────┐
|
| 15 |
-
│ scripts/build_kpis.py │
|
| 16 |
-
│ (GitHub Actions) │
|
| 17 |
-
└───────────────┬──────────────────┘
|
| 18 |
-
│ upload CSV
|
| 19 |
-
▼
|
| 20 |
-
┌──────────────────────────────────┐
|
| 21 |
-
│ hf-agent-kpis (dataset) │
|
| 22 |
-
│ hourly/YYYY-MM-DD/HH.csv │
|
| 23 |
-
└──────────────────────────────────┘
|
| 24 |
-
|
| 25 |
-
Each hourly run reads today's + yesterday's session folders (to cover sessions
|
| 26 |
-
that crossed midnight), filters events into the target hour window
|
| 27 |
-
``[hour, hour+1h)``, computes aggregates, and writes one CSV at
|
| 28 |
-
``hourly/<date>/<HH>.csv`` in the target dataset. Uploads are idempotent —
|
| 29 |
-
re-running the same hour overwrites.
|
| 30 |
-
|
| 31 |
-
================================================================================
|
| 32 |
-
Metrics (one row per hour)
|
| 33 |
-
================================================================================
|
| 34 |
-
|
| 35 |
-
sessions — distinct session_ids with ≥1 event in window
|
| 36 |
-
users — distinct user ids (when present on session rows)
|
| 37 |
-
turns — sum of user-message counts across active sessions
|
| 38 |
-
llm_calls — count of llm_call events
|
| 39 |
-
tokens_prompt / _completion / _cache_read / _cache_creation
|
| 40 |
-
cost_usd — sum of llm_call.cost_usd
|
| 41 |
-
cost_per_session_mean / _p50 / _p95 — per-session cost distribution
|
| 42 |
-
cache_hit_ratio — cache_read / (cache_read + prompt)
|
| 43 |
-
tool_calls_total / _succeeded / _failed — per-tool_output reliability counts
|
| 44 |
-
tool_success_rate — succeeded / total (kept for back-compat)
|
| 45 |
-
successful_sessions / errored_sessions / regenerated_sessions — outcome counts
|
| 46 |
-
failure_rate / regenerate_rate — kept for back-compat
|
| 47 |
-
time_to_first_action_s_p50 / _p95 — from session_start to first tool_call
|
| 48 |
-
thumbs_up / thumbs_down
|
| 49 |
-
hf_jobs_submitted / _succeeded / _blocked
|
| 50 |
-
sandboxes_created / _cpu / _gpu — sandbox_create events bucketed by hardware
|
| 51 |
-
pro_cta_clicks
|
| 52 |
-
gpu_hours_by_flavor_json — JSON-serialised {flavor: gpu-hours}
|
| 53 |
-
research_calls — total `research` tool_call events
|
| 54 |
-
sessions_with_research — sessions that called `research` ≥1
|
| 55 |
-
research_calls_per_session_p50 / _p95 — among sessions that did any (zero-only sessions excluded)
|
| 56 |
-
distinct_tools_per_session_p50 / _p95 — among sessions with ≥1 named tool_call
|
| 57 |
-
tool_calls_per_session_p50 / _p95 — among sessions with ≥1 named tool_call
|
| 58 |
-
tool_calls_per_turn_p50 / _p95 — calls / turns, among sessions with turns>0
|
| 59 |
-
tool_calls_by_name_json — JSON {tool: total_calls} (all tools seen)
|
| 60 |
-
sessions_using_tool_json — JSON {tool: distinct_sessions_using}
|
| 61 |
-
sessions_by_model_json — JSON {model_name: count} (router/local split)
|
| 62 |
-
|
| 63 |
-
================================================================================
|
| 64 |
-
Usage
|
| 65 |
-
================================================================================
|
| 66 |
-
|
| 67 |
-
# Run for the most recently completed hour (default — the cron path):
|
| 68 |
-
python scripts/build_kpis.py
|
| 69 |
-
|
| 70 |
-
# Backfill last 24 hours:
|
| 71 |
-
python scripts/build_kpis.py --hours 24
|
| 72 |
-
|
| 73 |
-
# Explicit hour (UTC):
|
| 74 |
-
python scripts/build_kpis.py --datetime 2026-04-24T14
|
| 75 |
-
|
| 76 |
-
Env:
|
| 77 |
-
HF_TOKEN (or HF_KPI_WRITE_TOKEN) — write access to the target dataset.
|
| 78 |
-
|
| 79 |
-
================================================================================
|
| 80 |
-
Deploy
|
| 81 |
-
================================================================================
|
| 82 |
-
|
| 83 |
-
See ``.github/workflows/build-kpis.yml`` — runs every hour at :05. To provision:
|
| 84 |
-
|
| 85 |
-
1. Create the target dataset (once):
|
| 86 |
-
huggingface-cli repo create hf-agent-kpis --type dataset
|
| 87 |
-
2. Put ``HF_KPI_WRITE_TOKEN`` (or ``HF_TOKEN``) into repo Actions secrets.
|
| 88 |
-
3. Merge this file; the first scheduled run fires within the hour.
|
| 89 |
-
"""
|
| 90 |
-
|
| 91 |
-
from __future__ import annotations
|
| 92 |
-
|
| 93 |
-
import argparse
|
| 94 |
-
import io
|
| 95 |
-
import json
|
| 96 |
-
import logging
|
| 97 |
-
import os
|
| 98 |
-
import sys
|
| 99 |
-
import tempfile
|
| 100 |
-
from collections import defaultdict
|
| 101 |
-
from datetime import date, datetime, timedelta, timezone
|
| 102 |
-
from typing import Any, Iterable
|
| 103 |
-
|
| 104 |
-
logger = logging.getLogger("build_kpis")
|
| 105 |
-
|
| 106 |
-
# Rough gpu-hour pricing for hf_jobs flavor strings. Keep conservative; used
|
| 107 |
-
# only to compute gpu-hours (not dollars) — wall_time_s * flavor_gpu_count.
|
| 108 |
-
_FLAVOR_GPU_COUNT = {
|
| 109 |
-
"cpu-basic": 0,
|
| 110 |
-
"cpu-upgrade": 0,
|
| 111 |
-
"t4-small": 1,
|
| 112 |
-
"t4-medium": 1,
|
| 113 |
-
"l4x1": 1,
|
| 114 |
-
"l4x4": 4,
|
| 115 |
-
"l40sx1": 1,
|
| 116 |
-
"l40sx4": 4,
|
| 117 |
-
"l40sx8": 8,
|
| 118 |
-
"a10g-small": 1,
|
| 119 |
-
"a10g-large": 1,
|
| 120 |
-
"a10g-largex2": 2,
|
| 121 |
-
"a10g-largex4": 4,
|
| 122 |
-
"a100-large": 1,
|
| 123 |
-
"a100x2": 2,
|
| 124 |
-
"a100x4": 4,
|
| 125 |
-
"a100x8": 8,
|
| 126 |
-
"h100": 1,
|
| 127 |
-
"h100x8": 8,
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
def _percentile(values: list[float], p: float) -> float:
|
| 132 |
-
if not values:
|
| 133 |
-
return 0.0
|
| 134 |
-
values = sorted(values)
|
| 135 |
-
k = (len(values) - 1) * p
|
| 136 |
-
f = int(k)
|
| 137 |
-
c = min(f + 1, len(values) - 1)
|
| 138 |
-
if f == c:
|
| 139 |
-
return float(values[f])
|
| 140 |
-
return float(values[f] + (values[c] - values[f]) * (k - f))
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def _parse_ts(s: Any) -> datetime | None:
|
| 144 |
-
if not s or not isinstance(s, str):
|
| 145 |
-
return None
|
| 146 |
-
try:
|
| 147 |
-
dt = datetime.fromisoformat(s)
|
| 148 |
-
except Exception:
|
| 149 |
-
return None
|
| 150 |
-
# Normalise to aware UTC so comparisons work against window bounds.
|
| 151 |
-
if dt.tzinfo is None:
|
| 152 |
-
dt = dt.replace(tzinfo=timezone.utc)
|
| 153 |
-
return dt
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def _iter_session_files(api, repo_id: str, day: date, token: str) -> Iterable[str]:
|
| 157 |
-
"""Yield repo-relative paths for all sessions under ``sessions/YYYY-MM-DD/``."""
|
| 158 |
-
prefix = f"sessions/{day.isoformat()}/"
|
| 159 |
-
try:
|
| 160 |
-
files = api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token)
|
| 161 |
-
except Exception as e:
|
| 162 |
-
logger.warning("list_repo_files(%s) failed: %s", repo_id, e)
|
| 163 |
-
return []
|
| 164 |
-
return [f for f in files if f.startswith(prefix) and f.endswith(".jsonl")]
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def _is_row_format(row: dict) -> bool:
|
| 168 |
-
"""True if this row is the single-row 'row' format (has session_id)."""
|
| 169 |
-
return isinstance(row, dict) and "session_id" in row
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def _is_cc_event(row: dict) -> bool:
|
| 173 |
-
"""True if this row is a Claude-Code-format event line."""
|
| 174 |
-
return (
|
| 175 |
-
isinstance(row, dict)
|
| 176 |
-
and "type" in row
|
| 177 |
-
and "message" in row
|
| 178 |
-
and "uuid" in row
|
| 179 |
-
)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def _cc_event_to_internal(ev: dict) -> dict:
|
| 183 |
-
"""Convert one Claude-Code-format event into the internal session shape.
|
| 184 |
-
|
| 185 |
-
The session_uploader writes one event per line (see
|
| 186 |
-
agent/core/session_uploader.py::to_claude_code_jsonl). build_kpis needs
|
| 187 |
-
``messages`` and ``events`` — reconstruct them from the CC stream.
|
| 188 |
-
"""
|
| 189 |
-
etype = ev.get("type")
|
| 190 |
-
msg = ev.get("message") or {}
|
| 191 |
-
role = msg.get("role")
|
| 192 |
-
content = msg.get("content")
|
| 193 |
-
ts = ev.get("timestamp")
|
| 194 |
-
|
| 195 |
-
if etype == "user" and role == "user":
|
| 196 |
-
blocks = content if isinstance(content, list) else None
|
| 197 |
-
tool_results = []
|
| 198 |
-
text_parts = []
|
| 199 |
-
if blocks:
|
| 200 |
-
for b in blocks:
|
| 201 |
-
if not isinstance(b, dict):
|
| 202 |
-
text_parts.append(str(b))
|
| 203 |
-
continue
|
| 204 |
-
if b.get("type") == "tool_result":
|
| 205 |
-
tool_results.append(
|
| 206 |
-
{
|
| 207 |
-
"tool_call_id": b.get("tool_use_id") or "",
|
| 208 |
-
"content": b.get("content"),
|
| 209 |
-
"timestamp": ts,
|
| 210 |
-
}
|
| 211 |
-
)
|
| 212 |
-
elif "text" in b:
|
| 213 |
-
text_parts.append(b.get("text") or "")
|
| 214 |
-
else:
|
| 215 |
-
text_parts.append(json.dumps(b, default=str))
|
| 216 |
-
if tool_results:
|
| 217 |
-
return {"messages": [], "events": tool_results}
|
| 218 |
-
return {"messages": [{"role": "user", "content": "\n".join(text_parts), "timestamp": ts}], "events": []}
|
| 219 |
-
|
| 220 |
-
if etype == "assistant" and role == "assistant":
|
| 221 |
-
text_parts = []
|
| 222 |
-
tool_calls = []
|
| 223 |
-
blocks = content if isinstance(content, list) else None
|
| 224 |
-
if blocks:
|
| 225 |
-
for b in blocks:
|
| 226 |
-
if not isinstance(b, dict):
|
| 227 |
-
text_parts.append(str(b))
|
| 228 |
-
continue
|
| 229 |
-
if b.get("type") == "tool_use":
|
| 230 |
-
tool_calls.append(
|
| 231 |
-
{
|
| 232 |
-
"id": b.get("id") or "",
|
| 233 |
-
"type": "function",
|
| 234 |
-
"function": {
|
| 235 |
-
"name": b.get("name") or "",
|
| 236 |
-
"arguments": json.dumps(b.get("input") or {}, default=str),
|
| 237 |
-
},
|
| 238 |
-
}
|
| 239 |
-
)
|
| 240 |
-
elif "text" in b:
|
| 241 |
-
text_parts.append(b.get("text") or "")
|
| 242 |
-
return {
|
| 243 |
-
"messages": [
|
| 244 |
-
{
|
| 245 |
-
"role": "assistant",
|
| 246 |
-
"content": "\n".join(text_parts),
|
| 247 |
-
"tool_calls": tool_calls,
|
| 248 |
-
"timestamp": ts,
|
| 249 |
-
}
|
| 250 |
-
],
|
| 251 |
-
"events": [],
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
return {"messages": [], "events": []}
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def _download_session(repo_id: str, path: str, token: str) -> dict | None:
|
| 258 |
-
"""Fetch one session file and decode it into the internal session shape.
|
| 259 |
-
|
| 260 |
-
Supports two on-disk formats:
|
| 261 |
-
|
| 262 |
-
* ``row`` — single-line JSONL row used by the org telemetry/KPI pipeline
|
| 263 |
-
(``session_id`` + ``messages``/``events`` as JSON strings).
|
| 264 |
-
* ``claude_code`` — one event per line in the Claude Code JSONL schema,
|
| 265 |
-
auto-detected by the HF Agent Trace Viewer
|
| 266 |
-
(https://huggingface.co/changelog/agent-trace-viewer). Each line is
|
| 267 |
-
converted into the internal ``messages``/``events`` shape.
|
| 268 |
-
|
| 269 |
-
``hf_hub_download`` caches; second run within the same process / runner
|
| 270 |
-
directory is near-free.
|
| 271 |
-
"""
|
| 272 |
-
from huggingface_hub import hf_hub_download
|
| 273 |
-
|
| 274 |
-
try:
|
| 275 |
-
local = hf_hub_download(
|
| 276 |
-
repo_id=repo_id,
|
| 277 |
-
filename=path,
|
| 278 |
-
repo_type="dataset",
|
| 279 |
-
token=token,
|
| 280 |
-
)
|
| 281 |
-
except Exception as e:
|
| 282 |
-
logger.warning("hf_hub_download(%s) failed: %s", path, e)
|
| 283 |
-
return None
|
| 284 |
-
try:
|
| 285 |
-
with open(local, "r") as f:
|
| 286 |
-
raw = f.read()
|
| 287 |
-
|
| 288 |
-
if not raw.strip():
|
| 289 |
-
return None
|
| 290 |
-
|
| 291 |
-
# Peek at the first non-empty line to decide the format.
|
| 292 |
-
first_line = next(
|
| 293 |
-
(ln.strip() for ln in raw.splitlines() if ln.strip()), ""
|
| 294 |
-
)
|
| 295 |
-
if not first_line:
|
| 296 |
-
return None
|
| 297 |
-
try:
|
| 298 |
-
first = json.loads(first_line)
|
| 299 |
-
except Exception as e:
|
| 300 |
-
logger.warning("parse(%s) failed: %s", path, e)
|
| 301 |
-
return None
|
| 302 |
-
|
| 303 |
-
if _is_row_format(first):
|
| 304 |
-
row = first
|
| 305 |
-
for key in ("messages", "events", "tools"):
|
| 306 |
-
v = row.get(key)
|
| 307 |
-
if isinstance(v, str):
|
| 308 |
-
try:
|
| 309 |
-
row[key] = json.loads(v)
|
| 310 |
-
except Exception:
|
| 311 |
-
row[key] = []
|
| 312 |
-
return row
|
| 313 |
-
|
| 314 |
-
if not _is_cc_event(first):
|
| 315 |
-
logger.warning("parse(%s): unrecognised format, skipping", path)
|
| 316 |
-
return None
|
| 317 |
-
|
| 318 |
-
# Claude-Code format: one event per line. Reconstruct the internal
|
| 319 |
-
# session shape by walking every line.
|
| 320 |
-
session_id = first.get("sessionId") or ""
|
| 321 |
-
session_start = first.get("timestamp") or ""
|
| 322 |
-
messages: list[dict] = []
|
| 323 |
-
events: list[dict] = []
|
| 324 |
-
for line in raw.splitlines():
|
| 325 |
-
line = line.strip()
|
| 326 |
-
if not line:
|
| 327 |
-
continue
|
| 328 |
-
try:
|
| 329 |
-
ev = json.loads(line)
|
| 330 |
-
except Exception:
|
| 331 |
-
continue
|
| 332 |
-
if not _is_cc_event(ev):
|
| 333 |
-
continue
|
| 334 |
-
converted = _cc_event_to_internal(ev)
|
| 335 |
-
messages.extend(converted.get("messages") or [])
|
| 336 |
-
events.extend(converted.get("events") or [])
|
| 337 |
-
|
| 338 |
-
return {
|
| 339 |
-
"session_id": session_id,
|
| 340 |
-
"session_start_time": session_start,
|
| 341 |
-
"session_end_time": session_start,
|
| 342 |
-
"model_name": "",
|
| 343 |
-
"total_cost_usd": None,
|
| 344 |
-
"messages": messages,
|
| 345 |
-
"events": events,
|
| 346 |
-
"tools": [],
|
| 347 |
-
}
|
| 348 |
-
except Exception as e:
|
| 349 |
-
logger.warning("parse(%s) failed: %s", path, e)
|
| 350 |
-
return None
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def _filter_session_to_window(
|
| 354 |
-
session: dict,
|
| 355 |
-
start: datetime,
|
| 356 |
-
end: datetime,
|
| 357 |
-
) -> dict | None:
|
| 358 |
-
"""Return a copy of ``session`` whose events are only those in ``[start, end)``.
|
| 359 |
-
|
| 360 |
-
``None`` if no event falls in the window — the caller drops the session
|
| 361 |
-
from this hour's aggregate.
|
| 362 |
-
"""
|
| 363 |
-
events = session.get("events") or []
|
| 364 |
-
in_window = []
|
| 365 |
-
for ev in events:
|
| 366 |
-
ts = _parse_ts(ev.get("timestamp"))
|
| 367 |
-
if ts is None:
|
| 368 |
-
continue
|
| 369 |
-
if start <= ts < end:
|
| 370 |
-
in_window.append(ev)
|
| 371 |
-
if not in_window:
|
| 372 |
-
return None
|
| 373 |
-
return {**session, "events": in_window}
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def _session_metrics(session: dict) -> dict:
|
| 377 |
-
"""Reduce a single session trajectory to its KPI contributions.
|
| 378 |
-
|
| 379 |
-
Assumes ``events`` are already filtered to the target window by the caller.
|
| 380 |
-
"""
|
| 381 |
-
# Pre-seed every numeric key so downstream aggregation can sum without
|
| 382 |
-
# having to special-case empty sessions.
|
| 383 |
-
out: dict = {
|
| 384 |
-
"sessions": 0,
|
| 385 |
-
"turns": 0,
|
| 386 |
-
"llm_calls": 0,
|
| 387 |
-
"tokens_prompt": 0,
|
| 388 |
-
"tokens_completion": 0,
|
| 389 |
-
"tokens_cache_read": 0,
|
| 390 |
-
"tokens_cache_creation": 0,
|
| 391 |
-
"cost_usd": 0.0,
|
| 392 |
-
"tool_calls_total": 0,
|
| 393 |
-
"tool_calls_success": 0,
|
| 394 |
-
"failures": 0,
|
| 395 |
-
"regenerate_sessions": 0,
|
| 396 |
-
"thumbs_up": 0,
|
| 397 |
-
"thumbs_down": 0,
|
| 398 |
-
"hf_jobs_submitted": 0,
|
| 399 |
-
"hf_jobs_succeeded": 0,
|
| 400 |
-
"hf_jobs_blocked": 0,
|
| 401 |
-
"pro_cta_clicks": 0,
|
| 402 |
-
"pro_conversions": 0,
|
| 403 |
-
"credits_topped_up": 0,
|
| 404 |
-
"sandboxes_created": 0,
|
| 405 |
-
"sandboxes_cpu": 0,
|
| 406 |
-
"sandboxes_gpu": 0,
|
| 407 |
-
"first_tool_s": -1,
|
| 408 |
-
}
|
| 409 |
-
events = session.get("events") or []
|
| 410 |
-
messages = session.get("messages") or []
|
| 411 |
-
|
| 412 |
-
turn_count = sum(1 for m in messages if m.get("role") == "user")
|
| 413 |
-
out["turns"] = turn_count
|
| 414 |
-
out["sessions"] = 1
|
| 415 |
-
|
| 416 |
-
tool_success = 0
|
| 417 |
-
tool_total = 0
|
| 418 |
-
had_error = False
|
| 419 |
-
had_undo = False
|
| 420 |
-
first_tool_ts = None
|
| 421 |
-
session_start = session.get("session_start_time")
|
| 422 |
-
gpu_hours_by_flavor: dict[str, float] = defaultdict(float)
|
| 423 |
-
jobs_submitted = 0
|
| 424 |
-
jobs_succeeded = 0
|
| 425 |
-
thumbs_up = 0
|
| 426 |
-
thumbs_down = 0
|
| 427 |
-
sandboxes_created = 0
|
| 428 |
-
sandboxes_cpu = 0
|
| 429 |
-
sandboxes_gpu = 0
|
| 430 |
-
jobs_blocked = 0
|
| 431 |
-
pro_cta_clicks = 0
|
| 432 |
-
pro_conversions = 0
|
| 433 |
-
credits_topped_up = 0
|
| 434 |
-
pro_cta_by_source: dict[str, int] = defaultdict(int)
|
| 435 |
-
# Per-tool counters from tool_call events. Counted off tool_call (which
|
| 436 |
-
# carries data["tool"]) rather than tool_output (which only carries
|
| 437 |
-
# success/output) so we can attribute calls to specific tools.
|
| 438 |
-
tool_calls_by_name: dict[str, int] = defaultdict(int)
|
| 439 |
-
total_named_tool_calls = 0
|
| 440 |
-
|
| 441 |
-
start_dt = _parse_ts(session_start)
|
| 442 |
-
|
| 443 |
-
for ev in events:
|
| 444 |
-
et = ev.get("event_type")
|
| 445 |
-
data = ev.get("data") or {}
|
| 446 |
-
ts = _parse_ts(ev.get("timestamp"))
|
| 447 |
-
|
| 448 |
-
if et == "llm_call":
|
| 449 |
-
out["llm_calls"] += 1
|
| 450 |
-
out["tokens_prompt"] += int(data.get("prompt_tokens") or 0)
|
| 451 |
-
out["tokens_completion"] += int(data.get("completion_tokens") or 0)
|
| 452 |
-
out["tokens_cache_read"] += int(data.get("cache_read_tokens") or 0)
|
| 453 |
-
out["tokens_cache_creation"] += int(data.get("cache_creation_tokens") or 0)
|
| 454 |
-
out["cost_usd"] += float(data.get("cost_usd") or 0.0)
|
| 455 |
-
|
| 456 |
-
elif et == "tool_output":
|
| 457 |
-
tool_total += 1
|
| 458 |
-
if data.get("success"):
|
| 459 |
-
tool_success += 1
|
| 460 |
-
if first_tool_ts is None and ts is not None and start_dt is not None:
|
| 461 |
-
first_tool_ts = (ts - start_dt).total_seconds()
|
| 462 |
-
|
| 463 |
-
elif et == "tool_call":
|
| 464 |
-
name = data.get("tool")
|
| 465 |
-
if name:
|
| 466 |
-
tool_calls_by_name[name] += 1
|
| 467 |
-
total_named_tool_calls += 1
|
| 468 |
-
if first_tool_ts is None and ts is not None and start_dt is not None:
|
| 469 |
-
first_tool_ts = (ts - start_dt).total_seconds()
|
| 470 |
-
|
| 471 |
-
elif et == "error":
|
| 472 |
-
had_error = True
|
| 473 |
-
|
| 474 |
-
elif et == "undo_complete":
|
| 475 |
-
had_undo = True
|
| 476 |
-
|
| 477 |
-
elif et == "feedback":
|
| 478 |
-
rating = data.get("rating")
|
| 479 |
-
if rating == "up":
|
| 480 |
-
thumbs_up += 1
|
| 481 |
-
elif rating == "down":
|
| 482 |
-
thumbs_down += 1
|
| 483 |
-
|
| 484 |
-
elif et == "hf_job_submit":
|
| 485 |
-
jobs_submitted += 1
|
| 486 |
-
|
| 487 |
-
elif et == "hf_job_complete":
|
| 488 |
-
flavor = data.get("flavor") or "unknown"
|
| 489 |
-
status = (data.get("final_status") or "").lower()
|
| 490 |
-
wall = float(data.get("wall_time_s") or 0.0)
|
| 491 |
-
gpus = _FLAVOR_GPU_COUNT.get(flavor, 0)
|
| 492 |
-
gpu_hours_by_flavor[flavor] += wall * gpus / 3600.0
|
| 493 |
-
if status in ("completed", "succeeded", "success"):
|
| 494 |
-
jobs_succeeded += 1
|
| 495 |
-
|
| 496 |
-
elif et == "jobs_access_blocked":
|
| 497 |
-
jobs_blocked += 1
|
| 498 |
-
|
| 499 |
-
elif et == "pro_cta_click":
|
| 500 |
-
pro_cta_clicks += 1
|
| 501 |
-
source = str(data.get("source") or "unknown")
|
| 502 |
-
pro_cta_by_source[source] += 1
|
| 503 |
-
|
| 504 |
-
elif et == "pro_conversion":
|
| 505 |
-
pro_conversions += 1
|
| 506 |
-
|
| 507 |
-
elif et == "credits_topped_up":
|
| 508 |
-
credits_topped_up += 1
|
| 509 |
-
|
| 510 |
-
elif et == "sandbox_create":
|
| 511 |
-
sandboxes_created += 1
|
| 512 |
-
hardware = (data.get("hardware") or "").lower()
|
| 513 |
-
# CPU flavors are explicitly named "cpu-*". Everything else
|
| 514 |
-
# (including unknown/missing hardware strings) lands in the GPU
|
| 515 |
-
# bucket, since the auto-create default is "cpu-basic" which is
|
| 516 |
-
# matched here — anything that isn't is almost always an explicit
|
| 517 |
-
# GPU choice.
|
| 518 |
-
if hardware.startswith("cpu-"):
|
| 519 |
-
sandboxes_cpu += 1
|
| 520 |
-
else:
|
| 521 |
-
sandboxes_gpu += 1
|
| 522 |
-
|
| 523 |
-
out["tool_calls_total"] = tool_total
|
| 524 |
-
out["tool_calls_success"] = tool_success
|
| 525 |
-
out["failures"] = 1 if had_error else 0
|
| 526 |
-
out["regenerate_sessions"] = 1 if had_undo else 0
|
| 527 |
-
out["thumbs_up"] = thumbs_up
|
| 528 |
-
out["thumbs_down"] = thumbs_down
|
| 529 |
-
out["hf_jobs_submitted"] = jobs_submitted
|
| 530 |
-
out["hf_jobs_succeeded"] = jobs_succeeded
|
| 531 |
-
out["sandboxes_created"] = sandboxes_created
|
| 532 |
-
out["sandboxes_cpu"] = sandboxes_cpu
|
| 533 |
-
out["sandboxes_gpu"] = sandboxes_gpu
|
| 534 |
-
out["hf_jobs_blocked"] = jobs_blocked
|
| 535 |
-
out["pro_cta_clicks"] = pro_cta_clicks
|
| 536 |
-
out["pro_conversions"] = pro_conversions
|
| 537 |
-
out["credits_topped_up"] = credits_topped_up
|
| 538 |
-
out["first_tool_s"] = first_tool_ts if first_tool_ts is not None else -1
|
| 539 |
-
out["_gpu_hours_by_flavor"] = dict(gpu_hours_by_flavor)
|
| 540 |
-
out["_pro_cta_by_source"] = dict(pro_cta_by_source)
|
| 541 |
-
out["_user"] = session.get("user_id") or session.get("session_id")
|
| 542 |
-
# Intra-session tool fields. Underscore-prefixed = consumed by _aggregate
|
| 543 |
-
# only, never written to CSV directly.
|
| 544 |
-
out["_tool_calls_by_name"] = dict(tool_calls_by_name)
|
| 545 |
-
out["_research_calls"] = tool_calls_by_name.get("research", 0)
|
| 546 |
-
out["_distinct_tools_used"] = len(tool_calls_by_name)
|
| 547 |
-
out["_total_named_tool_calls"] = total_named_tool_calls
|
| 548 |
-
out["_model_name"] = session.get("model_name") or "unknown"
|
| 549 |
-
return dict(out)
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
def _aggregate(per_session: list[dict]) -> dict:
|
| 553 |
-
"""Collapse a bucket's worth of session rollups into the final KPI row."""
|
| 554 |
-
ttfa_values = [
|
| 555 |
-
s["first_tool_s"] for s in per_session if s.get("first_tool_s", -1) >= 0
|
| 556 |
-
]
|
| 557 |
-
gpu_hours: dict[str, float] = defaultdict(float)
|
| 558 |
-
for s in per_session:
|
| 559 |
-
for f, h in (s.get("_gpu_hours_by_flavor") or {}).items():
|
| 560 |
-
gpu_hours[f] += h
|
| 561 |
-
|
| 562 |
-
# Per-tool aggregates. ``sessions_using_tool`` counts each session at most
|
| 563 |
-
# once per tool, so the dashboard can show "how many sessions reached for
|
| 564 |
-
# research" alongside "how many research calls overall".
|
| 565 |
-
tool_calls_by_name: dict[str, int] = defaultdict(int)
|
| 566 |
-
sessions_using_tool: dict[str, int] = defaultdict(int)
|
| 567 |
-
sessions_by_model: dict[str, int] = defaultdict(int)
|
| 568 |
-
for s in per_session:
|
| 569 |
-
for name, count in (s.get("_tool_calls_by_name") or {}).items():
|
| 570 |
-
tool_calls_by_name[name] += int(count)
|
| 571 |
-
sessions_using_tool[name] += 1
|
| 572 |
-
sessions_by_model[s.get("_model_name") or "unknown"] += 1
|
| 573 |
-
|
| 574 |
-
# Percentile inputs. All "per session" percentiles exclude sessions that
|
| 575 |
-
# never reached for the relevant signal — otherwise quiet hours
|
| 576 |
-
# (status-check sessions, abandoned new conversations) drag every median
|
| 577 |
-
# to 0 and the chart tells you nothing.
|
| 578 |
-
research_calls_nz = [
|
| 579 |
-
s.get("_research_calls", 0)
|
| 580 |
-
for s in per_session
|
| 581 |
-
if s.get("_research_calls", 0) > 0
|
| 582 |
-
]
|
| 583 |
-
distinct_tools_values = [
|
| 584 |
-
s.get("_distinct_tools_used", 0)
|
| 585 |
-
for s in per_session
|
| 586 |
-
if s.get("_distinct_tools_used", 0) > 0
|
| 587 |
-
]
|
| 588 |
-
total_calls_values = [
|
| 589 |
-
s.get("_total_named_tool_calls", 0)
|
| 590 |
-
for s in per_session
|
| 591 |
-
if s.get("_total_named_tool_calls", 0) > 0
|
| 592 |
-
]
|
| 593 |
-
# Per-turn intensity: turns>0 is the natural filter here (a session with
|
| 594 |
-
# 5 turns and 0 tools is a meaningful 0). Don't strip those.
|
| 595 |
-
calls_per_turn_values = [
|
| 596 |
-
s.get("_total_named_tool_calls", 0) / s["turns"]
|
| 597 |
-
for s in per_session
|
| 598 |
-
if s.get("turns", 0) > 0
|
| 599 |
-
]
|
| 600 |
-
|
| 601 |
-
total_sessions = sum(s["sessions"] for s in per_session)
|
| 602 |
-
total_turns = sum(s["turns"] for s in per_session)
|
| 603 |
-
tokens_prompt = sum(s["tokens_prompt"] for s in per_session)
|
| 604 |
-
tokens_cache_read = sum(s["tokens_cache_read"] for s in per_session)
|
| 605 |
-
tool_total = sum(s["tool_calls_total"] for s in per_session)
|
| 606 |
-
tool_success = sum(s["tool_calls_success"] for s in per_session)
|
| 607 |
-
failures = int(sum(s["failures"] for s in per_session))
|
| 608 |
-
regenerates = int(sum(s["regenerate_sessions"] for s in per_session))
|
| 609 |
-
research_calls_total = int(sum(s.get("_research_calls", 0) for s in per_session))
|
| 610 |
-
sessions_with_research = sum(
|
| 611 |
-
1 for s in per_session if s.get("_research_calls", 0) > 0
|
| 612 |
-
)
|
| 613 |
-
|
| 614 |
-
# Per-session cost percentiles — chart "median session cost" alongside the
|
| 615 |
-
# mean so a few $700 outliers don't make you think every session is pricey.
|
| 616 |
-
session_costs = [float(s.get("cost_usd") or 0.0) for s in per_session]
|
| 617 |
-
cost_p50 = _percentile(session_costs, 0.5)
|
| 618 |
-
cost_p95 = _percentile(session_costs, 0.95)
|
| 619 |
-
|
| 620 |
-
unique_users = {s.get("_user") for s in per_session if s.get("_user")}
|
| 621 |
-
|
| 622 |
-
return {
|
| 623 |
-
"sessions": total_sessions,
|
| 624 |
-
"users": len(unique_users),
|
| 625 |
-
"turns": total_turns,
|
| 626 |
-
"llm_calls": int(sum(s["llm_calls"] for s in per_session)),
|
| 627 |
-
"tokens_prompt": int(tokens_prompt),
|
| 628 |
-
"tokens_completion": int(sum(s["tokens_completion"] for s in per_session)),
|
| 629 |
-
"tokens_cache_read": int(tokens_cache_read),
|
| 630 |
-
"tokens_cache_creation": int(
|
| 631 |
-
sum(s["tokens_cache_creation"] for s in per_session)
|
| 632 |
-
),
|
| 633 |
-
"cost_usd": round(sum(s["cost_usd"] for s in per_session), 4),
|
| 634 |
-
# Per-session cost summaries.
|
| 635 |
-
"cost_per_session_mean": round(
|
| 636 |
-
sum(s["cost_usd"] for s in per_session) / total_sessions, 6
|
| 637 |
-
)
|
| 638 |
-
if total_sessions > 0
|
| 639 |
-
else 0.0,
|
| 640 |
-
"cost_per_session_p50": round(cost_p50, 6),
|
| 641 |
-
"cost_per_session_p95": round(cost_p95, 6),
|
| 642 |
-
"cache_hit_ratio": round(
|
| 643 |
-
tokens_cache_read / (tokens_cache_read + tokens_prompt), 4
|
| 644 |
-
)
|
| 645 |
-
if (tokens_cache_read + tokens_prompt) > 0
|
| 646 |
-
else 0.0,
|
| 647 |
-
# Raw reliability COUNTS (these are what the dashboard shows directly).
|
| 648 |
-
"tool_calls_total": int(tool_total),
|
| 649 |
-
"tool_calls_succeeded": int(tool_success),
|
| 650 |
-
"tool_calls_failed": int(tool_total - tool_success),
|
| 651 |
-
"errored_sessions": failures,
|
| 652 |
-
# Successful = "did not raise an error event". Mutually exclusive
|
| 653 |
-
# with errored_sessions; sums with errored_sessions to total sessions.
|
| 654 |
-
"successful_sessions": int(total_sessions - failures),
|
| 655 |
-
# Regenerated is an orthogonal dimension (the user retried) — a
|
| 656 |
-
# session can be both successful and regenerated, or both errored
|
| 657 |
-
# and regenerated.
|
| 658 |
-
"regenerated_sessions": regenerates,
|
| 659 |
-
# Rates kept for backwards compatibility with anything reading the
|
| 660 |
-
# KPI dataset directly.
|
| 661 |
-
"tool_success_rate": round(tool_success / tool_total, 4)
|
| 662 |
-
if tool_total > 0
|
| 663 |
-
else 0.0,
|
| 664 |
-
"failure_rate": round(failures / total_sessions, 4)
|
| 665 |
-
if total_sessions > 0
|
| 666 |
-
else 0.0,
|
| 667 |
-
"regenerate_rate": round(regenerates / total_sessions, 4)
|
| 668 |
-
if total_sessions > 0
|
| 669 |
-
else 0.0,
|
| 670 |
-
"time_to_first_action_s_p50": round(_percentile(ttfa_values, 0.5), 2),
|
| 671 |
-
"time_to_first_action_s_p95": round(_percentile(ttfa_values, 0.95), 2),
|
| 672 |
-
"thumbs_up": int(sum(s["thumbs_up"] for s in per_session)),
|
| 673 |
-
"thumbs_down": int(sum(s["thumbs_down"] for s in per_session)),
|
| 674 |
-
"hf_jobs_submitted": int(sum(s["hf_jobs_submitted"] for s in per_session)),
|
| 675 |
-
"hf_jobs_succeeded": int(sum(s["hf_jobs_succeeded"] for s in per_session)),
|
| 676 |
-
"sandboxes_created": int(
|
| 677 |
-
sum(s.get("sandboxes_created", 0) for s in per_session)
|
| 678 |
-
),
|
| 679 |
-
"sandboxes_cpu": int(sum(s.get("sandboxes_cpu", 0) for s in per_session)),
|
| 680 |
-
"sandboxes_gpu": int(sum(s.get("sandboxes_gpu", 0) for s in per_session)),
|
| 681 |
-
"hf_jobs_blocked": int(sum(s.get("hf_jobs_blocked", 0) for s in per_session)),
|
| 682 |
-
"pro_cta_clicks": int(sum(s.get("pro_cta_clicks", 0) for s in per_session)),
|
| 683 |
-
"pro_conversions": int(sum(s.get("pro_conversions", 0) for s in per_session)),
|
| 684 |
-
"credits_topped_up": int(
|
| 685 |
-
sum(s.get("credits_topped_up", 0) for s in per_session)
|
| 686 |
-
),
|
| 687 |
-
"gpu_hours_by_flavor_json": json.dumps(dict(gpu_hours), sort_keys=True),
|
| 688 |
-
# Research KPIs — answer "is the agent reaching for research?".
|
| 689 |
-
"research_calls": research_calls_total,
|
| 690 |
-
"sessions_with_research": int(sessions_with_research),
|
| 691 |
-
"research_calls_per_session_p50": round(_percentile(research_calls_nz, 0.5), 2),
|
| 692 |
-
"research_calls_per_session_p95": round(
|
| 693 |
-
_percentile(research_calls_nz, 0.95), 2
|
| 694 |
-
),
|
| 695 |
-
# Intra-session breadth + intensity. p50 + p95 over per-session values.
|
| 696 |
-
"distinct_tools_per_session_p50": round(
|
| 697 |
-
_percentile(distinct_tools_values, 0.5), 2
|
| 698 |
-
),
|
| 699 |
-
"distinct_tools_per_session_p95": round(
|
| 700 |
-
_percentile(distinct_tools_values, 0.95), 2
|
| 701 |
-
),
|
| 702 |
-
"tool_calls_per_session_p50": round(_percentile(total_calls_values, 0.5), 2),
|
| 703 |
-
"tool_calls_per_session_p95": round(_percentile(total_calls_values, 0.95), 2),
|
| 704 |
-
"tool_calls_per_turn_p50": round(_percentile(calls_per_turn_values, 0.5), 2),
|
| 705 |
-
"tool_calls_per_turn_p95": round(_percentile(calls_per_turn_values, 0.95), 2),
|
| 706 |
-
# JSON columns let the dashboard add/remove tools without schema churn.
|
| 707 |
-
"tool_calls_by_name_json": json.dumps(dict(tool_calls_by_name), sort_keys=True),
|
| 708 |
-
"sessions_using_tool_json": json.dumps(
|
| 709 |
-
dict(sessions_using_tool), sort_keys=True
|
| 710 |
-
),
|
| 711 |
-
# Surface split by selected model for dashboard drilldowns.
|
| 712 |
-
"sessions_by_model_json": json.dumps(dict(sessions_by_model), sort_keys=True),
|
| 713 |
-
}
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
# Back-compat alias: older tests call _aggregate_day.
|
| 717 |
-
_aggregate_day = _aggregate
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
def _csv_cell(v: Any) -> str:
|
| 721 |
-
s = str(v)
|
| 722 |
-
if "," in s or '"' in s or "\n" in s:
|
| 723 |
-
return '"' + s.replace('"', '""') + '"'
|
| 724 |
-
return s
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
def _write_csv(
|
| 728 |
-
api,
|
| 729 |
-
row: dict,
|
| 730 |
-
bucket_key: str,
|
| 731 |
-
path_in_repo: str,
|
| 732 |
-
target_repo: str,
|
| 733 |
-
token: str,
|
| 734 |
-
) -> None:
|
| 735 |
-
"""Render ``row`` to CSV with a leading ``bucket`` column and upload.
|
| 736 |
-
|
| 737 |
-
``bucket_key`` is the hour string (ISO ``YYYY-MM-DDTHH``) or date string;
|
| 738 |
-
written as the ``bucket`` column so downstream consumers can union all
|
| 739 |
-
CSVs without date-parsing paths. ``api`` is the caller's ``HfApi``
|
| 740 |
-
instance — reused so we don't spin up a fresh one per CSV.
|
| 741 |
-
"""
|
| 742 |
-
columns = list(row.keys())
|
| 743 |
-
buf = io.StringIO()
|
| 744 |
-
buf.write(",".join(["bucket", *columns]) + "\n")
|
| 745 |
-
buf.write(",".join([bucket_key, *[_csv_cell(row[c]) for c in columns]]) + "\n")
|
| 746 |
-
|
| 747 |
-
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as tmp:
|
| 748 |
-
tmp.write(buf.getvalue())
|
| 749 |
-
tmp_path = tmp.name
|
| 750 |
-
|
| 751 |
-
try:
|
| 752 |
-
api.create_repo(
|
| 753 |
-
repo_id=target_repo,
|
| 754 |
-
repo_type="dataset",
|
| 755 |
-
exist_ok=True,
|
| 756 |
-
token=token,
|
| 757 |
-
)
|
| 758 |
-
api.upload_file(
|
| 759 |
-
path_or_fileobj=tmp_path,
|
| 760 |
-
path_in_repo=path_in_repo,
|
| 761 |
-
repo_id=target_repo,
|
| 762 |
-
repo_type="dataset",
|
| 763 |
-
token=token,
|
| 764 |
-
commit_message=f"KPIs for {bucket_key}",
|
| 765 |
-
)
|
| 766 |
-
finally:
|
| 767 |
-
try:
|
| 768 |
-
os.unlink(tmp_path)
|
| 769 |
-
except Exception:
|
| 770 |
-
pass
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
def run_for_hour(
|
| 774 |
-
api,
|
| 775 |
-
source_repo: str,
|
| 776 |
-
target_repo: str,
|
| 777 |
-
hour_dt: datetime,
|
| 778 |
-
token: str,
|
| 779 |
-
) -> dict:
|
| 780 |
-
"""Roll up one UTC hour [hour_dt, hour_dt+1h).
|
| 781 |
-
|
| 782 |
-
Reads today's + yesterday's session folders so sessions that crossed
|
| 783 |
-
midnight land in the right hourly bucket.
|
| 784 |
-
"""
|
| 785 |
-
if hour_dt.tzinfo is None:
|
| 786 |
-
hour_dt = hour_dt.replace(tzinfo=timezone.utc)
|
| 787 |
-
window_start = hour_dt.replace(minute=0, second=0, microsecond=0)
|
| 788 |
-
window_end = window_start + timedelta(hours=1)
|
| 789 |
-
|
| 790 |
-
# Sessions partition by session_start_time date. A session that started
|
| 791 |
-
# at 23:50 yesterday can still emit events in today's first hours, so we
|
| 792 |
-
# look at both folders.
|
| 793 |
-
candidate_dates = {window_start.date(), (window_start - timedelta(days=1)).date()}
|
| 794 |
-
|
| 795 |
-
per_session: list[dict] = []
|
| 796 |
-
for d in sorted(candidate_dates):
|
| 797 |
-
for path in _iter_session_files(api, source_repo, d, token):
|
| 798 |
-
sess = _download_session(source_repo, path, token)
|
| 799 |
-
if not sess:
|
| 800 |
-
continue
|
| 801 |
-
windowed = _filter_session_to_window(sess, window_start, window_end)
|
| 802 |
-
if windowed is None:
|
| 803 |
-
continue
|
| 804 |
-
per_session.append(_session_metrics(windowed))
|
| 805 |
-
|
| 806 |
-
if not per_session:
|
| 807 |
-
logger.info("No sessions in window %s — skipping", window_start.isoformat())
|
| 808 |
-
return {}
|
| 809 |
-
|
| 810 |
-
row = _aggregate(per_session)
|
| 811 |
-
bucket_key = window_start.strftime("%Y-%m-%dT%H")
|
| 812 |
-
path_in_repo = (
|
| 813 |
-
f"hourly/{window_start.strftime('%Y-%m-%d')}/{window_start.strftime('%H')}.csv"
|
| 814 |
-
)
|
| 815 |
-
_write_csv(api, row, bucket_key, path_in_repo, target_repo, token)
|
| 816 |
-
logger.info(
|
| 817 |
-
"Wrote KPIs for %s (%d sessions): %s",
|
| 818 |
-
bucket_key,
|
| 819 |
-
per_session and len(per_session),
|
| 820 |
-
row,
|
| 821 |
-
)
|
| 822 |
-
return row
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
# Back-compat for daily backfills — unchanged behaviour.
|
| 826 |
-
def run_for_day(api, source_repo: str, target_repo: str, day: date, token: str) -> dict:
|
| 827 |
-
paths = _iter_session_files(api, source_repo, day, token)
|
| 828 |
-
per_session: list[dict] = []
|
| 829 |
-
for path in paths:
|
| 830 |
-
sess = _download_session(source_repo, path, token)
|
| 831 |
-
if not sess:
|
| 832 |
-
continue
|
| 833 |
-
per_session.append(_session_metrics(sess))
|
| 834 |
-
if not per_session:
|
| 835 |
-
logger.info("No sessions found for %s — skipping", day)
|
| 836 |
-
return {}
|
| 837 |
-
row = _aggregate(per_session)
|
| 838 |
-
path_in_repo = f"daily/{day.isoformat()}.csv"
|
| 839 |
-
_write_csv(api, row, day.isoformat(), path_in_repo, target_repo, token)
|
| 840 |
-
return row
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
def _parse_hour_arg(s: str) -> datetime:
|
| 844 |
-
"""Accept ``YYYY-MM-DDTHH`` or full ISO — always pinned to the start of the hour, UTC."""
|
| 845 |
-
dt = datetime.fromisoformat(s)
|
| 846 |
-
if dt.tzinfo is None:
|
| 847 |
-
dt = dt.replace(tzinfo=timezone.utc)
|
| 848 |
-
return dt.replace(minute=0, second=0, microsecond=0)
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
def main(argv: list[str] | None = None) -> int:
|
| 852 |
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
| 853 |
-
ap = argparse.ArgumentParser()
|
| 854 |
-
ap.add_argument("--source", default="smolagents/ml-intern-sessions")
|
| 855 |
-
ap.add_argument("--target", default="smolagents/ml-intern-kpis")
|
| 856 |
-
ap.add_argument(
|
| 857 |
-
"--hours",
|
| 858 |
-
type=int,
|
| 859 |
-
default=1,
|
| 860 |
-
help="Number of trailing hours to roll up (default: 1 = last completed hour).",
|
| 861 |
-
)
|
| 862 |
-
ap.add_argument(
|
| 863 |
-
"--datetime",
|
| 864 |
-
type=str,
|
| 865 |
-
default=None,
|
| 866 |
-
help="Single hour, ISO ``YYYY-MM-DDTHH`` (UTC); overrides --hours.",
|
| 867 |
-
)
|
| 868 |
-
ap.add_argument(
|
| 869 |
-
"--daily-backfill",
|
| 870 |
-
type=str,
|
| 871 |
-
default=None,
|
| 872 |
-
help="Escape hatch: aggregate a whole day at once (YYYY-MM-DD). "
|
| 873 |
-
"Writes to daily/<date>.csv. Use for historical backfill only.",
|
| 874 |
-
)
|
| 875 |
-
args = ap.parse_args(argv)
|
| 876 |
-
|
| 877 |
-
token = (
|
| 878 |
-
os.environ.get("HF_KPI_WRITE_TOKEN")
|
| 879 |
-
or os.environ.get("HF_SESSION_UPLOAD_TOKEN")
|
| 880 |
-
or os.environ.get("HF_TOKEN")
|
| 881 |
-
or os.environ.get("HF_ADMIN_TOKEN")
|
| 882 |
-
)
|
| 883 |
-
if not token:
|
| 884 |
-
logger.error(
|
| 885 |
-
"No HF token found. Set one of: HF_KPI_WRITE_TOKEN, "
|
| 886 |
-
"HF_SESSION_UPLOAD_TOKEN, HF_TOKEN, HF_ADMIN_TOKEN."
|
| 887 |
-
)
|
| 888 |
-
return 1
|
| 889 |
-
|
| 890 |
-
from huggingface_hub import HfApi
|
| 891 |
-
|
| 892 |
-
api = HfApi()
|
| 893 |
-
|
| 894 |
-
if args.daily_backfill:
|
| 895 |
-
run_for_day(
|
| 896 |
-
api,
|
| 897 |
-
args.source,
|
| 898 |
-
args.target,
|
| 899 |
-
date.fromisoformat(args.daily_backfill),
|
| 900 |
-
token,
|
| 901 |
-
)
|
| 902 |
-
return 0
|
| 903 |
-
|
| 904 |
-
if args.datetime:
|
| 905 |
-
target_hours = [_parse_hour_arg(args.datetime)]
|
| 906 |
-
else:
|
| 907 |
-
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
| 908 |
-
# Roll up *completed* hours: start from the hour before ``now``.
|
| 909 |
-
target_hours = [now - timedelta(hours=i) for i in range(1, args.hours + 1)]
|
| 910 |
-
|
| 911 |
-
for hour in target_hours:
|
| 912 |
-
run_for_hour(api, args.source, args.target, hour, token)
|
| 913 |
-
return 0
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
if __name__ == "__main__":
|
| 917 |
-
sys.exit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|