diff --git a/.env.example b/.env.example index 24271269..fcb50611 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,6 @@ FRONTEND_HOST=http://localhost:5173 ENVIRONMENT=local - PROJECT_NAME="AI Platform" STACK_NAME=ai-platform diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 0d1d2e94..4c096882 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -2,9 +2,9 @@ name: AI Platform CI on: push: - branches: [staging] + branches: [main] pull_request: - branches: [staging] + branches: [main] jobs: checks: diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 9799c3f9..e18ac930 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils,project,organization, project_user, api_keys + +from app.api.routes import items, login, private, users, utils, project, organization, project_user, api_keys, threads from app.core.config import settings api_router = APIRouter() @@ -7,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(threads.router) api_router.include_router(organization.router) api_router.include_router(project.router) api_router.include_router(project_user.router) diff --git a/backend/app/api/routes/threads.py b/backend/app/api/routes/threads.py new file mode 100644 index 00000000..9da6b337 --- /dev/null +++ b/backend/app/api/routes/threads.py @@ -0,0 +1,140 @@ +import re +import requests + +import openai +from openai import OpenAI +from fastapi import APIRouter, BackgroundTasks + +from app.utils import APIResponse +from app.core import settings, logging + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["threads"]) + + +def send_callback(callback_url: str, data: dict): + """Send results to the callback URL (synchronously).""" + try: + session = requests.Session() + # uncomment this to run locally without SSL + # session.verify = False + response = session.post(callback_url, json=data) + response.raise_for_status() + return True + except requests.RequestException as e: + logger.error(f"Callback failed: {str(e)}") + return False + + +def process_run(request: dict, client: OpenAI): + """ + Background task to run create_and_poll, then send the callback with the result. + This function is run in the background after we have already returned an initial response. + """ + try: + # Start the run + run = client.beta.threads.runs.create_and_poll( + thread_id=request["thread_id"], + assistant_id=request["assistant_id"], + ) + + if run.status == "completed": + messages = client.beta.threads.messages.list( + thread_id=request["thread_id"]) + latest_message = messages.data[0] + message_content = latest_message.content[0].text.value + + remove_citation = request.get("remove_citation", False) + + if remove_citation: + message = re.sub(r"【\d+(?::\d+)?†[^】]*】", "", message_content) + else: + message = message_content + + # Update the data dictionary with additional fields from the request, excluding specific keys + additional_data = {k: v for k, v in request.items( + ) if k not in {"question", "assistant_id", "callback_url", "thread_id"}} + callback_response = APIResponse.success_response(data={ + "status": "success", + "message": message, + "thread_id": request["thread_id"], + "endpoint": getattr(request, "endpoint", "some-default-endpoint"), + **additional_data + }) + else: + callback_response = APIResponse.failure_response( + error=f"Run failed with status: {run.status}") + + # Send callback with results + send_callback(request["callback_url"], callback_response.model_dump()) + + except openai.OpenAIError as e: + # Handle any other OpenAI API errors + if isinstance(e.body, dict) and "message" in e.body: + error_message = e.body["message"] + else: + error_message = str(e) + + callback_response = APIResponse.failure_response(error=error_message) + + send_callback(request["callback_url"], callback_response.model_dump()) + + +@router.post("/threads") +async def threads(request: dict, background_tasks: BackgroundTasks): + """ + Accepts a question, assistant_id, callback_url, and optional thread_id from the request body. + Returns an immediate "processing" response, then continues to run create_and_poll in background. + Once completed, calls send_callback with the final result. + """ + client = OpenAI(api_key=settings.OPENAI_API_KEY) + + # Use get method to safely access thread_id + thread_id = request.get("thread_id") + + # 1. Validate or check if there's an existing thread with an in-progress run + if thread_id: + try: + runs = client.beta.threads.runs.list(thread_id=thread_id) + # Get the most recent run (first in the list) if any + if runs.data and len(runs.data) > 0: + latest_run = runs.data[0] + if latest_run.status in ["queued", "in_progress", "requires_action"]: + return APIResponse.failure_response(error=f"There is an active run on this thread (status: {latest_run.status}). Please wait for it to complete.") + except openai.OpenAIError: + # Handle invalid thread ID + return APIResponse.failure_response(error=f"Invalid thread ID provided {thread_id}") + + # Use existing thread + client.beta.threads.messages.create( + thread_id=thread_id, role="user", content=request["question"] + ) + else: + try: + # Create new thread + thread = client.beta.threads.create() + client.beta.threads.messages.create( + thread_id=thread.id, role="user", content=request["question"] + ) + request["thread_id"] = thread.id + except openai.OpenAIError as e: + # Handle any other OpenAI API errors + if isinstance(e.body, dict) and "message" in e.body: + error_message = e.body["message"] + else: + error_message = str(e) + return APIResponse.failure_response(error=error_message) + + # 2. Send immediate response to complete the API call + initial_response = APIResponse.success_response(data={ + "status": "processing", + "message": "Run started", + "thread_id": request.get("thread_id"), + "success": True, + }) + + # 3. Schedule the background task to run create_and_poll and send callback + background_tasks.add_task(process_run, request, client) + + # 4. Return immediately so the client knows we've accepted the request + return initial_response diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py index e69de29b..8a64c300 100644 --- a/backend/app/core/__init__.py +++ b/backend/app/core/__init__.py @@ -0,0 +1,4 @@ +from .config import settings +from .logger import logging + +__all__ = ['settings', 'logging'] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 03589d12..5e20683b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,6 @@ import secrets import warnings +import os from typing import Annotated, Any, Literal from pydantic import ( @@ -31,6 +32,7 @@ class Settings(BaseSettings): env_ignore_empty=True, extra="ignore", ) + OPENAI_API_KEY: str API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 1 days = 1 days @@ -95,6 +97,9 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + LOG_DIR: str = os.path.join(os.path.dirname( + os.path.dirname(__file__)), "logs") + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 00000000..70605b5a --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,22 @@ +import logging +import os +from logging.handlers import RotatingFileHandler +from app.core.config import settings + +LOG_DIR = settings.LOG_DIR +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log") + +LOGGING_LEVEL = logging.INFO +LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +logging.basicConfig(level=LOGGING_LEVEL, format=LOGGING_FORMAT) + +file_handler = RotatingFileHandler( + LOG_FILE_PATH, maxBytes=10485760, backupCount=5) +file_handler.setLevel(LOGGING_LEVEL) +file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) + +logging.getLogger("").addHandler(file_handler) diff --git a/backend/app/tests/api/routes/test_threads.py b/backend/app/tests/api/routes/test_threads.py new file mode 100644 index 00000000..78e406ab --- /dev/null +++ b/backend/app/tests/api/routes/test_threads.py @@ -0,0 +1,111 @@ +import pytest +import openai + +from unittest.mock import MagicMock, patch +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.routes.threads import router, process_run +from app.utils import APIResponse + +# Wrap the router in a FastAPI app instance. +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +@patch("app.api.routes.threads.OpenAI") +def test_threads_endpoint(mock_openai): + """ + Test the /threads endpoint when creating a new thread. + The patched OpenAI client simulates: + - A successful assistant ID validation. + - New thread creation with a dummy thread id. + - No existing runs. + The expected response should have status "processing" and include a thread_id. + """ + # Create a dummy client to simulate OpenAI API behavior. + dummy_client = MagicMock() + # Simulate a valid assistant ID by ensuring retrieve doesn't raise an error. + dummy_client.beta.assistants.retrieve.return_value = None + # Simulate thread creation. + dummy_thread = MagicMock() + dummy_thread.id = "dummy_thread_id" + dummy_client.beta.threads.create.return_value = dummy_thread + # Simulate message creation. + dummy_client.beta.threads.messages.create.return_value = None + # Simulate that no active run exists. + dummy_client.beta.threads.runs.list.return_value = MagicMock(data=[]) + + mock_openai.return_value = dummy_client + + request_data = { + "question": "What is Glific?", + "assistant_id": "assistant_123", + "callback_url": "http://example.com/callback", + } + response = client.post("/threads", json=request_data) + assert response.status_code == 200 + response_json = response.json() + assert response_json["success"] is True + assert response_json["data"]["status"] == "processing" + assert response_json["data"]["message"] == "Run started" + assert response_json["data"]["thread_id"] == "dummy_thread_id" + + +@patch("app.api.routes.threads.OpenAI") +@pytest.mark.parametrize( + "remove_citation, expected_message", + [ + ( + True, + "Glific is an open-source, two-way messaging platform designed for nonprofits to scale their outreach via WhatsApp", + ), + ( + False, + "Glific is an open-source, two-way messaging platform designed for nonprofits to scale their outreach via WhatsApp【1:2†citation】", + ), + ], +) +def test_process_run_variants(mock_openai, remove_citation, expected_message): + """ + Test process_run for both remove_citation variants: + - Mocks the OpenAI client to simulate a completed run. + - Verifies that send_callback is called with the expected message based on the remove_citation flag. + """ + # Setup the mock client. + mock_client = MagicMock() + mock_openai.return_value = mock_client + + # Create the request with the variable remove_citation flag. + request = { + "question": "What is Glific?", + "assistant_id": "assistant_123", + "callback_url": "http://example.com/callback", + "thread_id": "thread_123", + "remove_citation": remove_citation, + } + + # Simulate a completed run. + mock_run = MagicMock() + mock_run.status = "completed" + mock_client.beta.threads.runs.create_and_poll.return_value = mock_run + + # Set up the dummy message based on the remove_citation flag. + base_message = "Glific is an open-source, two-way messaging platform designed for nonprofits to scale their outreach via WhatsApp" + citation_message = base_message if remove_citation else f"{base_message}【1:2†citation】" + dummy_message = MagicMock() + dummy_message.content = [MagicMock(text=MagicMock(value=citation_message))] + mock_client.beta.threads.messages.list.return_value.data = [dummy_message] + + # Patch send_callback and invoke process_run. + with patch("app.api.routes.threads.send_callback") as mock_send_callback: + process_run(request, mock_client) + mock_send_callback.assert_called_once() + callback_url, payload = mock_send_callback.call_args[0] + print(payload) + assert callback_url == request["callback_url"] + assert payload["data"]["message"] == expected_message + assert payload["data"]["status"] == "success" + assert payload["data"]["thread_id"] == "thread_123" + assert payload["success"] is True \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1c77b83d..bfe69964 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "pydantic-settings<3.0.0,>=2.2.1", "sentry-sdk[fastapi]<2.0.0,>=1.40.6", "pyjwt<3.0.0,>=2.8.0", + "openai>=1.67.0", + "pytest>=7.4.4", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index cfc200d3..48108f89 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version < '3.13'", @@ -55,11 +56,13 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "openai" }, { name = "passlib", extra = ["bcrypt"] }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pytest" }, { name = "python-multipart" }, { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, @@ -85,11 +88,13 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, + { name = "openai", specifier = ">=1.67.0" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4,<2.0.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" }, { name = "pydantic", specifier = ">2.0" }, { name = "pydantic-settings", specifier = ">=2.2.1,<3.0.0" }, { name = "pyjwt", specifier = ">=2.8.0,<3.0.0" }, + { name = "pytest", specifier = ">=7.4.4" }, { name = "python-multipart", specifier = ">=0.0.7,<1.0.0" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=1.40.6,<2.0.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, @@ -220,7 +225,7 @@ name = "click" version = "8.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } wheels = [ @@ -325,6 +330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -581,6 +595,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, ] +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/82/39f7c9e67b3b0121f02a0b90d433626caa95a565c3d2449fea6bcfa3f5f5/jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", size = 314540 }, + { url = "https://files.pythonhosted.org/packages/01/07/7bf6022c5a152fca767cf5c086bb41f7c28f70cf33ad259d023b53c0b858/jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", size = 321065 }, + { url = "https://files.pythonhosted.org/packages/6c/b2/de3f3446ecba7c48f317568e111cc112613da36c7b29a6de45a1df365556/jiter-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", size = 341664 }, + { url = "https://files.pythonhosted.org/packages/13/cf/6485a4012af5d407689c91296105fcdb080a3538e0658d2abf679619c72f/jiter-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538", size = 364635 }, + { url = "https://files.pythonhosted.org/packages/0d/f7/4a491c568f005553240b486f8e05c82547340572d5018ef79414b4449327/jiter-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", size = 406288 }, + { url = "https://files.pythonhosted.org/packages/d3/ca/f4263ecbce7f5e6bded8f52a9f1a66540b270c300b5c9f5353d163f9ac61/jiter-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", size = 397499 }, + { url = "https://files.pythonhosted.org/packages/ac/a2/522039e522a10bac2f2194f50e183a49a360d5f63ebf46f6d890ef8aa3f9/jiter-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", size = 352926 }, + { url = "https://files.pythonhosted.org/packages/b1/67/306a5c5abc82f2e32bd47333a1c9799499c1c3a415f8dde19dbf876f00cb/jiter-0.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", size = 384506 }, + { url = "https://files.pythonhosted.org/packages/0f/89/c12fe7b65a4fb74f6c0d7b5119576f1f16c79fc2953641f31b288fad8a04/jiter-0.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", size = 520621 }, + { url = "https://files.pythonhosted.org/packages/c4/2b/d57900c5c06e6273fbaa76a19efa74dbc6e70c7427ab421bf0095dfe5d4a/jiter-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", size = 512613 }, + { url = "https://files.pythonhosted.org/packages/89/05/d8b90bfb21e58097d5a4e0224f2940568366f68488a079ae77d4b2653500/jiter-0.9.0-cp310-cp310-win32.whl", hash = "sha256:cb12e6d65ebbefe5518de819f3eda53b73187b7089040b2d17f5b39001ff31c4", size = 206613 }, + { url = "https://files.pythonhosted.org/packages/2c/1d/5767f23f88e4f885090d74bbd2755518050a63040c0f59aa059947035711/jiter-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:c43ca669493626d8672be3b645dbb406ef25af3f4b6384cfd306da7eb2e70322", size = 208371 }, + { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 }, + { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 }, + { url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097 }, + { url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603 }, + { url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625 }, + { url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832 }, + { url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590 }, + { url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690 }, + { url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649 }, + { url = "https://files.pythonhosted.org/packages/a6/e5/4e385945179bcf128fa10ad8dca9053d717cbe09e258110e39045c881fe5/jiter-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", size = 206920 }, + { url = "https://files.pythonhosted.org/packages/4c/47/5e0b94c603d8e54dd1faab439b40b832c277d3b90743e7835879ab663757/jiter-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", size = 210119 }, + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, +] + [[package]] name = "lxml" version = "5.3.0" @@ -790,6 +863,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "openai" +version = "1.67.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/63/6fd027fa4cb7c3b6bee4c3150f44803b3a7e4335f0b6e49e83a0c51c321b/openai-1.67.0.tar.gz", hash = "sha256:3b386a866396daa4bf80e05a891c50a7746ecd7863b8a27423b62136e3b8f6bc", size = 403596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/de/b42ddabe211411645105ae99ad93f4f3984f53be7ced2ad441378c27f62e/openai-1.67.0-py3-none-any.whl", hash = "sha256:dbbb144f38739fc0e1d951bc67864647fca0b9ffa05aef6b70eeea9f71d79663", size = 580168 }, +] + [[package]] name = "packaging" version = "24.1" @@ -1313,6 +1405,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + [[package]] name = "typer" version = "0.12.5"