diff --git a/application/app.py b/application/app.py index ad00d0b6..bf27bf52 100644 --- a/application/app.py +++ b/application/app.py @@ -183,6 +183,10 @@ def _reset_log_context(_exc): # leak into the stream's view of the context. token = getattr(request, _LOG_CTX_TOKEN_ATTR, None) if token is not None: + # Flask >= 3.1.2 tears a stream_with_context request down twice: once + # when the view returns, once when the generator is finalized. Clear + # the token first — resetting one twice raises RuntimeError. + setattr(request, _LOG_CTX_TOKEN_ATTR, None) log_context.reset(token) diff --git a/application/llm/openai.py b/application/llm/openai.py index 3bd65446..8c2043e3 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -11,6 +11,9 @@ from application.core.settings import settings from application.llm.base import BaseLLM from application.storage.storage_creator import StorageCreator +# Placeholder sent to OpenAI-compatible backends that require no credentials. +NO_API_KEY = "sk-no-key" + def _truncate_base64_for_logging(messages): """ @@ -124,7 +127,12 @@ class OpenAILLM(BaseLLM): ): super().__init__(*args, **kwargs) - self.api_key = api_key or settings.OPENAI_API_KEY or settings.API_KEY + # openai>=2.53 rejects a falsy api_key at construction. Keyless + # OpenAI-compatible backends (Ollama, llama.cpp, vLLM) legitimately have + # none, and pydantic-settings yields "" for a bare `API_KEY=` in .env. + self.api_key = ( + api_key or settings.OPENAI_API_KEY or settings.API_KEY or NO_API_KEY + ) self.user_api_key = user_api_key # Priority: 1) Parameter base_url, 2) Settings OPENAI_BASE_URL, 3) Default diff --git a/application/requirements.txt b/application/requirements.txt index 6773338e..bb052ff8 100644 --- a/application/requirements.txt +++ b/application/requirements.txt @@ -1,16 +1,16 @@ a2wsgi==1.10.10 alembic>=1.13,<2 -anthropic==0.88.0 +anthropic==0.121.0 asgiref>=3.11.1 -boto3==1.42.83 -beautifulsoup4==4.14.3 +boto3==1.43.67 +beautifulsoup4==4.15.0 cel-python==0.5.0 celery==5.6.3 -celery-redbeat==2.3.3 -croniter==6.2.2 -cryptography==46.0.7 +celery-redbeat==2.4.2 +croniter==6.2.4 +cryptography==50.0.0 dataclasses-json==0.6.7 -daytona==0.190.1 +daytona==0.203.0 defusedxml==0.7.1 docling==2.84.0 rapidocr>=1.4.0 @@ -18,19 +18,19 @@ onnxruntime>=1.19.0 docx2txt==0.9 ddgs>=8.0.0 fast-ebook -elevenlabs==2.43.0 -Flask==3.1.1 -faiss-cpu==1.13.2 -fastmcp==3.2.4 +elevenlabs==2.62.0 +Flask==3.1.3 +faiss-cpu==1.15.0 +fastmcp==3.4.6 flask-restx==1.3.2 -google-genai==1.73.1 -google-api-python-client==2.193.0 -google-auth-httplib2==0.3.1 -google-auth-oauthlib==1.3.1 +google-genai==2.17.0 +google-api-python-client==2.198.0 +google-auth-httplib2==0.4.1 +google-auth-oauthlib==1.4.0 gTTS==2.5.4 -gunicorn==25.3.0 +gunicorn==26.0.0 jinja2==3.1.6 -jiter==0.13.0 +jiter==0.16.0 jmespath==1.1.0 joblib==1.5.3 jsonpatch==1.33 @@ -38,21 +38,22 @@ jsonpointer==3.1.1 kombu==5.6.2 langchain==1.2.3 langchain-community==0.4.1 -langchain-core==1.2.29 +langchain-core==1.2.31 langchain-openai==1.1.12 -langchain-text-splitters==1.1.1 +langchain-text-splitters==1.1.2 langsmith==0.7.31 lazy-object-proxy==1.12.0 -lxml==6.0.2 +lxml==6.1.1 markupsafe==3.0.3 -marshmallow>=3.18.0,<5.0.0 +# dataclasses-json (via langchain-community) hard-caps marshmallow <4. +marshmallow>=3.18.0,<4.0.0 mpmath==1.3.0 multidict==6.7.1 -msal==1.35.1 +msal==1.37.0 mypy-extensions==1.1.0 networkx==3.6.1 -numpy==2.4.4 -openai==2.32.0 +numpy==2.5.1 +openai==2.53.0 openapi3-parser==1.1.22 opentelemetry-distro>=0.50b0,<1 opentelemetry-exporter-otlp>=1.29.0,<2 @@ -64,23 +65,23 @@ opentelemetry-instrumentation-redis>=0.50b0,<1 opentelemetry-instrumentation-requests>=0.50b0,<1 opentelemetry-instrumentation-sqlalchemy>=0.50b0,<1 opentelemetry-instrumentation-starlette>=0.50b0,<1 -orjson==3.11.7 -packaging==26.0 -pandas==3.0.2 +orjson==3.11.9 +packaging==26.3 +pandas==3.0.5 openpyxl==3.1.5 -pathable==0.5.0 +pathable==0.6.0 pdf2image>=1.17.0 -pgvector>=0.2,<1 -pillow +pgvector>=0.5,<1 +pillow==12.3.0 portalocker>=2.7.0,<4.0.0 -prompt-toolkit==3.0.52 -protobuf==6.33.6 +prompt-toolkit==3.0.53 +protobuf==7.35.1 psycopg[binary,pool]>=3.1,<4 py==1.11.0 pydantic pydantic-core pydantic-settings -pypdf==6.9.2 +pypdf==6.15.0 python-dateutil==2.9.0.post0 python-dotenv python-jose==3.5.0 @@ -88,28 +89,31 @@ python-pptx==1.0.2 PyYAML redis==7.4.0 referencing>=0.28.0,<0.38.0 -regex==2026.4.4 -requests==2.33.1 +regex==2026.7.19 +requests==2.34.2 retry==0.9.2 -sentence-transformers==5.3.0 +sentence-transformers==5.7.0 sqlalchemy>=2.0,<3 starlette>=1.0,<2 -tiktoken==0.12.0 +tiktoken==0.13.0 tokenizers==0.22.2 torch==2.11.0 tqdm==4.67.3 -transformers==5.4.0 -typing-extensions==4.15.0 +# Capped <5.9.0 to match docling-core's own darwin pin: 5.9+ breaks docling's +# PDF layout model on Apple Silicon ("Cannot convert a MPS Tensor to float64"). +transformers==5.8.1 +typing-extensions==4.16.0 typing-inspect==0.9.0 -tzdata==2026.1 -urllib3==2.6.3 +tzdata==2026.3 +urllib3==2.7.0 uvicorn[standard]>=0.30,<1 uvicorn-worker>=0.4,<1 vine==5.1.0 -wcwidth==0.6.0 +wcwidth==0.8.2 werkzeug>=3.1.0 -yarl==1.23.0 -markdownify==1.2.2 -tldextract==5.3.1 +yarl==1.24.5 +markdownify==1.2.3 +tldextract==5.3.2 +# google-genai caps websockets <17.0; 17.x will not resolve. websockets==16.0 websocket-client==1.9.0 diff --git a/application/sandbox/daytona.py b/application/sandbox/daytona.py index 3ce1605d..37fd7682 100644 --- a/application/sandbox/daytona.py +++ b/application/sandbox/daytona.py @@ -326,7 +326,7 @@ class DaytonaSandbox(CodeSandbox): The tools pass a token DIRECTORY (e.g. ``artifacts/{token}``), so the delete is recursive — ``fs.delete_file`` with ``recursive=True`` removes a folder and its - contents (daytona==0.190.1). + contents (daytona==0.203.0). """ try: handle = self._get_handle(session_id) diff --git a/application/stt/openai_stt.py b/application/stt/openai_stt.py index 96a46a58..44eb3c65 100644 --- a/application/stt/openai_stt.py +++ b/application/stt/openai_stt.py @@ -6,6 +6,9 @@ from openai import OpenAI from application.core.settings import settings from application.stt.base import BaseSTT +# Placeholder sent to OpenAI-compatible backends that require no credentials. +NO_API_KEY = "sk-no-key" + class OpenAISTT(BaseSTT): def __init__( @@ -14,7 +17,10 @@ class OpenAISTT(BaseSTT): base_url: Optional[str] = None, model: Optional[str] = None, ): - self.api_key = api_key or settings.OPENAI_API_KEY or settings.API_KEY + # See application/llm/openai.py: openai>=2.53 rejects a falsy api_key. + self.api_key = ( + api_key or settings.OPENAI_API_KEY or settings.API_KEY or NO_API_KEY + ) self.base_url = base_url or settings.OPENAI_BASE_URL or "https://api.openai.com/v1" self.model = model or settings.OPENAI_STT_MODEL self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) diff --git a/application/vectorstore/embeddings_local.py b/application/vectorstore/embeddings_local.py index fb6e5f4f..a811a3bb 100644 --- a/application/vectorstore/embeddings_local.py +++ b/application/vectorstore/embeddings_local.py @@ -24,7 +24,13 @@ class EmbeddingsWrapper: raise ValueError( f"SentenceTransformer model failed to load properly for: {model_name}" ) - self.dimension = self.model.get_sentence_embedding_dimension() + # Renamed in sentence-transformers 5.4; keep the old name as a fallback. + get_dimension = getattr( + self.model, + "get_embedding_dimension", + getattr(self.model, "get_sentence_embedding_dimension", None), + ) + self.dimension = get_dimension() logging.info(f"Successfully loaded model with dimension: {self.dimension}") except Exception as e: logging.error( diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py index a58031ef..65707845 100644 --- a/tests/llm/test_openai.py +++ b/tests/llm/test_openai.py @@ -2049,3 +2049,34 @@ class TestInlineFilePartResolution: # Upload happened because the read errored → treated as miss. assert len(llm.client.files.calls) == 1 assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}} + + +@pytest.mark.unit +class TestKeylessConstruction: + """openai>=2.53 raises on a falsy api_key at construction. + + Keyless OpenAI-compatible backends (Ollama, llama.cpp, vLLM) have no + credential, and pydantic-settings yields "" — not None — for a bare + `API_KEY=` line in .env, so the empty string must not reach the SDK. + """ + + @pytest.mark.parametrize("blank", ["", None]) + def test_llm_accepts_blank_key(self, blank): + with patch("application.llm.openai.settings") as mock_settings: + mock_settings.OPENAI_API_KEY = blank + mock_settings.API_KEY = blank + mock_settings.OPENAI_BASE_URL = "http://localhost:11434/v1" + llm = OpenAILLM(api_key=blank) + assert llm.api_key == "sk-no-key" + + @pytest.mark.parametrize("blank", ["", None]) + def test_stt_accepts_blank_key(self, blank): + from application.stt.openai_stt import OpenAISTT + + with patch("application.stt.openai_stt.settings") as mock_settings: + mock_settings.OPENAI_API_KEY = blank + mock_settings.API_KEY = blank + mock_settings.OPENAI_BASE_URL = "http://localhost:11434/v1" + mock_settings.OPENAI_STT_MODEL = "whisper-1" + stt = OpenAISTT(api_key=blank) + assert stt.api_key == "sk-no-key" diff --git a/tests/test_app.py b/tests/test_app.py index a423ca42..ac2bc5ba 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -19,3 +19,26 @@ def test_app_config(): assert app.config["UPLOAD_FOLDER"] == "inputs" assert app.config["CELERY_BROKER_URL"] == settings.CELERY_BROKER_URL assert app.config["CELERY_RESULT_BACKEND"] == settings.CELERY_RESULT_BACKEND + + +@pytest.mark.unit +class TestLogContextTeardown: + """Flask >= 3.1.2 tears a stream_with_context request down twice: once when + the view returns, once when the generator is finalized. A ContextVar token + may only be reset once, so the teardown hook has to be idempotent.""" + + def test_reset_is_idempotent(self): + from flask import request + + from application.app import _LOG_CTX_TOKEN_ATTR, _reset_log_context, app + from application.core import log_context + + with app.test_request_context("/"): + token = log_context.bind(activity_id="abc", endpoint="stream") + setattr(request, _LOG_CTX_TOKEN_ATTR, token) + + _reset_log_context(None) + _reset_log_context(None) # second teardown must not raise + + assert getattr(request, _LOG_CTX_TOKEN_ATTR) is None + assert "activity_id" not in log_context.snapshot() diff --git a/tests/vectorstore/test_embeddings_local.py b/tests/vectorstore/test_embeddings_local.py index 7b85bd35..b96fbed3 100644 --- a/tests/vectorstore/test_embeddings_local.py +++ b/tests/vectorstore/test_embeddings_local.py @@ -9,7 +9,7 @@ class TestEmbeddingsWrapper: def test_init_success(self, mock_st_cls): mock_model = MagicMock() mock_model._first_module.return_value = MagicMock() - mock_model.get_sentence_embedding_dimension.return_value = 768 + mock_model.get_embedding_dimension.return_value = 768 mock_st_cls.return_value = mock_model from application.vectorstore.embeddings_local import EmbeddingsWrapper @@ -19,6 +19,22 @@ class TestEmbeddingsWrapper: mock_st_cls.assert_called_once() assert wrapper.dimension == 768 + @patch("application.vectorstore.embeddings_local.SentenceTransformer") + def test_init_falls_back_to_legacy_dimension_api(self, mock_st_cls): + """sentence-transformers < 5.4 only exposes get_sentence_embedding_dimension.""" + mock_model = MagicMock() + mock_model._first_module.return_value = MagicMock() + del mock_model.get_embedding_dimension + mock_model.get_sentence_embedding_dimension.return_value = 768 + mock_st_cls.return_value = mock_model + + from application.vectorstore.embeddings_local import EmbeddingsWrapper + + wrapper = EmbeddingsWrapper("test-model") + + assert wrapper.dimension == 768 + mock_model.get_sentence_embedding_dimension.assert_called_once() + @patch("application.vectorstore.embeddings_local.SentenceTransformer") def test_init_failure(self, mock_st_cls): mock_st_cls.side_effect = Exception("model not found")