From 54d5be290d207aedd924690de7a6adf5ea36fb21 Mon Sep 17 00:00:00 2001 From: haoshan98 Date: Mon, 3 Feb 2025 15:49:54 +0000 Subject: [PATCH 1/2] Copy repo --- .env.example | 30 - .gitattributes | 2 +- .github/workflows/ci-win.yml | 18 + .github/workflows/ci.yml | 57 +- .github/workflows/github_bot.yml | 44 ++ .prettierignore | 44 +- CHANGELOG.md | 81 ++- clients/python/README.md | 4 +- clients/python/pyproject.toml | 2 + clients/python/src/jamaibase/client.py | 36 +- clients/python/src/jamaibase/protocol.py | 189 +++++- clients/python/src/jamaibase/utils/io.py | 26 +- clients/python/tests/cloud/test_admin.py | 23 +- .../files/mp3/turning-a4-size-magazine.mp3 | Bin 0 -> 19644 bytes .../files/wav/turning-a4-size-magazine.wav | Bin 0 -> 210644 bytes .../tests/oss/gen_table/test_export_ops.py | 2 +- .../tests/oss/gen_table/test_row_ops.py | 419 ++++++++++++- .../tests/oss/gen_table/test_table_ops.py | 48 +- clients/python/tests/oss/test_chat.py | 155 ++++- clients/python/tests/oss/test_file.py | 75 ++- clients/python/tests/oss/test_gen_executor.py | 178 ++++++ clients/typescript/__tests__/gentable.test.ts | 6 +- .../typescript/src/resources/files/index.ts | 2 +- .../src/resources/gen_tables/tables.ts | 4 +- clients/typescript/src/resources/llm/model.ts | 6 +- docker/Dockerfile.owl | 1 + docker/compose.cpu.yml | 15 + docker/compose.mac.yml | 182 ------ scripts/migration_v040.py | 234 ++++++++ services/api/pyproject.toml | 7 +- services/api/src/owl/configs/manager.py | 30 +- services/api/src/owl/configs/models_ci.json | 31 +- services/api/src/owl/db/gen_executor.py | 410 ++++++++++--- services/api/src/owl/db/gen_table.py | 16 +- services/api/src/owl/entrypoints/api.py | 13 +- services/api/src/owl/llm.py | 121 +++- services/api/src/owl/loaders.py | 7 +- services/api/src/owl/models.py | 2 +- services/api/src/owl/protocol.py | 236 ++++++-- services/api/src/owl/routers/file.py | 16 +- services/api/src/owl/routers/gen_table.py | 53 +- services/api/src/owl/routers/llm.py | 9 +- services/api/src/owl/utils/auth.py | 31 +- services/api/src/owl/utils/code.py | 58 ++ services/api/src/owl/utils/io.py | 51 +- services/api/src/owl/utils/jwt.py | 7 +- services/app/package-lock.json | 7 + services/app/package.json | 1 + services/app/src/hooks.server.ts | 31 +- .../lib/components/preset/ModelSelect.svelte | 8 +- .../lib/components/preset/PlanSelect.svelte | 2 +- .../tables/(sub)/ColumnDropdown.svelte | 68 +-- .../tables/(sub)/ColumnHeader.svelte | 425 +++++++++++++ .../tables/(sub)/ColumnSettings.svelte | 429 +++++++++----- .../components/tables/(sub)/ConvList.svelte | 63 ++ .../tables/(sub)/Conversations.svelte | 383 ++++++++++++ .../tables/(sub)/FileColumnView.svelte | 20 +- .../components/tables/(sub)/FileSelect.svelte | 42 +- .../tables/(sub)/FileThumbsFetch.svelte | 9 +- .../lib/components/tables/(sub)/NewRow.svelte | 73 ++- .../tables/(sub)/TablePagination.svelte | 58 +- .../src/lib/components/tables/(sub)/index.ts | 2 + .../lib/components/tables/ActionTable.svelte | 559 ++++-------------- .../lib/components/tables/ChatTable.svelte | 532 ++++------------- .../components/tables/KnowledgeTable.svelte | 547 ++++------------- .../src/lib/components/tables/tablesStore.ts | 102 +++- .../app/src/lib/components/ui/button/index.ts | 2 +- .../ui/pagination/pagination-ellipsis.svelte | 2 +- .../ui/pagination/pagination-link.svelte | 21 +- .../pagination/pagination-next-button.svelte | 21 +- .../pagination/pagination-prev-button.svelte | 21 +- .../components/ui/skeleton/skeleton.svelte | 2 +- services/app/src/lib/constants.ts | 46 +- services/app/src/lib/db.ts | 17 + services/app/src/lib/types.ts | 19 +- services/app/src/routes/(main)/+layout.svelte | 53 +- .../src/routes/(main)/BreadcrumbsBar.svelte | 4 +- .../app/src/routes/(main)/SideDock.svelte | 4 +- .../app/src/routes/(main)/UploadTab.svelte | 4 +- .../src/routes/(main)/project/+page.svelte | 13 +- .../(main)/project/ExportProjectButton.svelte | 54 ++ .../(components)/ActionsDropdown.svelte | 58 +- .../(components)/ExportTableButton.svelte | 61 ++ .../(components)/GenerateButton.svelte | 49 +- .../[project_id]/(components)/index.ts | 3 +- .../(dialogs)/AddColumnDialog.svelte | 417 +++++++------ .../(dialogs)/DeleteDialogs.svelte | 15 +- .../(dialogs)/ImportTableDialog.svelte | 2 +- .../project/[project_id]/+layout.svelte | 71 +-- .../(dialogs)/AddTableDialog.svelte | 78 ++- .../[project_id]/action-table/+page.svelte | 19 +- .../[table_id]/+page@project.svelte | 86 ++- .../(dialogs)/AddConversationDialog.svelte | 25 +- .../[project_id]/chat-table/+page.svelte | 62 +- .../chat-table/[table_id]/+page.ts | 1 + .../[table_id]/+page@project.svelte | 135 +++-- .../chat-table/[table_id]/ChatMode.svelte | 32 +- .../(dialogs)/AddTableDialog.svelte | 20 +- .../[project_id]/knowledge-table/+page.svelte | 21 +- .../[table_id]/+page@project.svelte | 86 ++- .../src/routes/(main)/settings/+layout.svelte | 2 +- services/app/src/routes/+layout.server.ts | 274 +++++---- services/app/tests/pages/table.page.ts | 2 +- services/app/tests/pages/tableList.page.ts | 1 + services/app/tests/tableList.spec.ts | 21 +- 105 files changed, 5183 insertions(+), 2983 deletions(-) delete mode 100644 .env.example create mode 100644 .github/workflows/github_bot.yml create mode 100644 clients/python/tests/files/mp3/turning-a4-size-magazine.mp3 create mode 100644 clients/python/tests/files/wav/turning-a4-size-magazine.wav delete mode 100644 docker/compose.mac.yml create mode 100644 scripts/migration_v040.py create mode 100644 services/api/src/owl/utils/code.py create mode 100644 services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/ConvList.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/Conversations.svelte create mode 100644 services/app/src/lib/db.ts create mode 100644 services/app/src/routes/(main)/project/ExportProjectButton.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte diff --git a/.env.example b/.env.example deleted file mode 100644 index b927624..0000000 --- a/.env.example +++ /dev/null @@ -1,30 +0,0 @@ -# External API keys -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -COHERE_API_KEY= -TOGETHER_API_KEY= -HYPERBOLIC_API_KEY= -CEREBRAS_API_KEY= -SAMBANOVA_API_KEY= - -# Service URLs -DOCIO_URL=http://docio:6979/api/docio -UNSTRUCTUREDIO_URL=http://unstructuredio:6989 -JAMAI_API_BASE=http://owl:6969/api - -# Frontend config -JAMAI_URL=http://owl:6969 -PUBLIC_JAMAI_URL= -PUBLIC_IS_SPA=false -CHECK_ORIGIN=false - -# Configuration -OWL_PORT=6969 -OWL_WORKERS=3 -DOCIO_WORKERS=1 -DOCIO_DEVICE=cpu -EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 -RERANKER_MODEL=mixedbread-ai/mxbai-rerank-xsmall-v1 -OWL_CONCURRENT_ROWS_BATCH_SIZE=5 -OWL_CONCURRENT_COLS_BATCH_SIZE=5 -OWL_MAX_WRITE_BATCH_SIZE=1000 diff --git a/.gitattributes b/.gitattributes index 08e60cd..31b0a04 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,7 +19,7 @@ # https://git-scm.com/docs/gitattributes#_text *.css text *.html text -*.js text +*.js* text *.md text *.py text *.sh text diff --git a/.github/workflows/ci-win.yml b/.github/workflows/ci-win.yml index 4671b82..99c3276 100644 --- a/.github/workflows/ci-win.yml +++ b/.github/workflows/ci-win.yml @@ -17,9 +17,23 @@ concurrency: cancel-in-progress: true jobs: + check_changes: + name: Check for changes + runs-on: ubuntu-latest + outputs: + has-changes: ${{ steps.check.outputs.has-changes }} + steps: + - name: Check + id: check + uses: jiahuei/check-changes-action@v0 + with: + watch-dirs: "clients/python/ services/api/ services/docio/ docker/ .github/" + pyinstaller_electron_app: name: PyInstaller JamAIBase Electron App Compilation runs-on: windows-11-desktop + needs: check_changes + if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' timeout-minutes: 60 steps: @@ -98,6 +112,8 @@ jobs: pyinstaller_api: name: PyInstaller API Service Compilation runs-on: windows-11-desktop + needs: check_changes + if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' timeout-minutes: 60 steps: @@ -179,6 +195,8 @@ jobs: pyinstaller_docio: name: PyInstaller DocIO Service Compilation runs-on: windows-11-desktop + needs: check_changes + if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' timeout-minutes: 60 steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aea4711..b69078a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI (OSS) on: pull_request: @@ -18,10 +18,38 @@ concurrency: cancel-in-progress: true jobs: + check_changes: + name: Check for changes + runs-on: ubuntu-latest + outputs: + has-changes: ${{ steps.check.outputs.has-changes }} + steps: + - name: Check + id: check + uses: jiahuei/check-changes-action@v0 + with: + watch-dirs: "clients/python/ services/api/ docker/ .github/" + + sdk_tests_noop: + # This job is needed so that status checks can still pass + # This is because strategy matrix is evaluated after if condition + name: SDK unit tests + runs-on: ubuntu-latest + needs: check_changes + if: ${{ !(needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push') }} + strategy: + matrix: + python-version: ["3.10"] + timeout-minutes: 2 + steps: + - name: No-op + run: echo Tests skipped !!! + sdk_tests: name: SDK unit tests runs-on: ubuntu-latest-l - # runs-on: namespace-profile-ubuntu-latest-8cpu-16gb-96gb + needs: check_changes + if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' strategy: matrix: python-version: ["3.10"] @@ -42,6 +70,12 @@ jobs: run: | git --version + - name: Check Docker Version + run: docker version + + - name: Check Docker Compose Version + run: docker compose version + - name: Remove cloud-only modules and install Python client run: | set -e @@ -49,11 +83,10 @@ jobs: cd clients/python python -m pip install .[test] - - name: Check Docker Version - run: docker version - - - name: Check Docker Compose Version - run: docker compose version + - name: Install ffmpeg + run: | + set -e + sudo apt-get update -qq && sudo apt-get install ffmpeg libavcodec-extra -y - name: Authenticating to the Container registry run: echo $JH_PAT | docker login ghcr.io -u tanjiahuei@gmail.com --password-stdin @@ -87,13 +120,14 @@ jobs: TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} HYPERBOLIC_API_KEY: ${{ secrets.HYPERBOLIC_API_KEY }} + CUSTOM_API_KEY: ${{ secrets.CUSTOM_API_KEY }} - name: Launch services (OSS) id: launch_oss timeout-minutes: 20 run: | set -e - docker compose -p jamai -f docker/compose.cpu.yml --profile minio up --quiet-pull -d --wait + docker compose -p jamai -f docker/compose.cpu.yml --profile minio --profile kopi up --quiet-pull -d --wait env: COMPOSE_DOCKER_CLI_BUILD: 1 DOCKER_BUILDKIT: 1 @@ -116,7 +150,7 @@ jobs: --junitxml=junit/test-results-${{ matrix.python-version }}.xml \ --cov-report=xml \ --no-flaky-report \ - clients/python/tests/oss + clients/python/tests/oss/ - name: Inspect owl logs if Python SDK tests failed if: failure() && steps.python_sdk_test_oss.outcome == 'failure' @@ -170,11 +204,6 @@ jobs: --no-flaky-report \ clients/python/tests/oss/test_file.py - - name: Inspect owl logs if Python SDK tests failed - if: failure() && steps.python_sdk_test_oss_file.outcome == 'failure' - timeout-minutes: 1 - run: docker exec jamai-owl-1 cat /app/api/logs/owl.log - lance_tests: name: Lance tests runs-on: ubuntu-latest diff --git a/.github/workflows/github_bot.yml b/.github/workflows/github_bot.yml new file mode 100644 index 0000000..f985fc0 --- /dev/null +++ b/.github/workflows/github_bot.yml @@ -0,0 +1,44 @@ +name: JambuBot + +on: + issues: + types: [opened, edited] + pull_request: + types: [opened, synchronize] + +# Cancel in-progress CI jobs if there is a new push +# https://stackoverflow.com/a/72408109 +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + github-bot: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: true # Ensure submodules are checked out + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: "1.18" + + - name: Launching JambuBot + run: | + cd examples/github-bot/github-bot/src + go build -o github_bot cmd/main.go + ./github_bot + env: + TRIAGE_BOT_APP_ID: ${{ secrets.TRIAGE_BOT_APP_ID }} + TRIAGE_BOT_INSTALLATION_ID: ${{ secrets.TRIAGE_BOT_INSTALLATION_ID }} + TRIAGE_BOT_PRIVATE_KEY: ${{ secrets.TRIAGE_BOT_PRIVATE_KEY }} + TRIAGE_BOT_JAMAI_KEY: ${{ secrets.TRIAGE_BOT_JAMAI_KEY }} + TRIAGE_BOT_JAMAI_PROJECT_ID: ${{ secrets.TRIAGE_BOT_JAMAI_PROJECT_ID }} + TRIAGE_BOT_NAME: ${{github.actor}} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} diff --git a/.prettierignore b/.prettierignore index 42af708..bf91f7b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,24 +1,28 @@ +# OS +thumbs.db +.DS_Store + +# Internal references, dependencies, temporary folders & files +.env +archive/ +**/__ref__/ +*.log +*.lock +*.db +*.parquet + # Python __pycache__/ -*.py[cod] +*.py* *$py.class *.egg-info .pytest_cache .ipynb_checkpoints venv/ - -# Frontend -services/app -clients/typescript - -# Test files clients/python/tests/**/* -# Internal references, dependencies, temporary folders & files -archive/ -/dependencies/ -logs/ -*.log +# pip +**/build/ # pytest-cov **/.coverage* @@ -26,14 +30,12 @@ logs/ /htmlcov /coverage.xml -# pip -**/build/ +# jest-cov +**/coverage/* -# Docs -/docs/source/generated/ -/docs/source/api/generated/ -/docs/source/specs/ +# JavaScript +**/node_modules/ -# OS -thumbs.db -.DS_Store +# Frontend +services/app +clients/typescript diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccb399..c5f2d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,32 +16,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] -Backend - owl (API server) +### ADDED + +Python SDK - jamaibase -- Fix bge-small embedding size (1024 -> 384) -- Correctly filter models at auth level -- Fix ollama model deployment config +- Add `CodeGenConfig` for python code execution #446 -Frontend +TS SDK - jamaibase + +UI -- Added support for multiple multiturn columns in Chat table chat view. -- Added multiturn chat toggle to column settings. +- Support chat mode multiturn option in add column and column resize #451 +- Support `audio` data type #457 -Docker +Backend - owl (API server) -- Added Mac Apple Silicon `compose.mac.yml` -- Update `ollama.yml` to use Qwen2.5 3B -- Fix ollama default config +- GenTable + - Support `audio` input column and data type #443 + - Support python code execution column #446 + - **Breaking**: Add `Page` column to knowledge table #464 +- LLM + - Support function calling # 435 + - Support DeepSeek models #466 +- Billing + - Include background tasks for processing billing events #462 +- Auth + - Support specific user role in organization invite #446 + - Include background tasks for setting project updated at datetime #462 +- Handle and allow setting of file upload size limits for `embed file`, `image` and `audio` file types #443 -## [v0.3.1] (2024-11-26) +CI/CD -This is a bug fix release for frontend code. SDKs are not affected. +- Added a new CI workflow for cloud environments in`.github/workflows/ci.cloud.yml` #440 +- Add dummy test job to pass status checks if skipped #468 +- Added a `check_changes` job to the CI workflows to conditionally run SDK tests based on changes. #462 ### CHANGES / FIXED -Frontend +Python SDK - jamaibase + +TS SDK - jamaibase -- Enable Projects for OSS +- Update the `uploadFile` method in `index.ts` to remove the trailing slash from the API endpoint #462 + +UI + +- Remove unnecessary load function rerunning on client navigation #454 +- Add more export options with confirmation #459 +- Obfuscated external keys and credit values for non-admin users in the `+layout.server.ts` to enhance security and privacy #459 +- Update `FileSelect.svelte` and `NewRow.svelte` to remove trailing slashes from the file upload API endpoint #462 +- Bug fixes: + - Fix chat table scrollbar not showing issue #459 + - Fix keyboard navigation #459 + - Fix inappropriate model not showing issue in knowledge table column settings #459 + +Backend - owl (API server) + +- GenTable + - **Breaking**: Change `file` data type to `image` data type #460 +- LLM + - Handle usage tracking and improve error handling #462 + - Bug fixes + - Fix model config embedding size #441 + - Fix bug with default model choosing invalid models #442 + - Fix regen sequences issue after columns reordering #455 + +CI/CD + +- Dockerfile: Added `ffmpeg` installation for audio processing. #443 +- Dependency Updates: + - Set `litellm` to version `1.50.0` #443 + - Add `pydub` as a dependency for audio processing #443 + +### REMOVED ## [v0.3] (2024-11-20) @@ -427,7 +474,7 @@ Backend - owl (API server) - Windows: StreamResponse from FastAPI accumulates all SSE before yielding everything all at once to the client Fixes #145 - Enabled scanned pdf upload. Fixes #131 - Dependencies - - Support forked version of `unstructured-client==0.24.1`, changed nest-asyncio to ThreadPool, fixed the conflict with uvloop + - Support forked version of `unstructured-client==0.24.1`, changed `nest-asyncio` to `ThreadPool`, fixed the conflict with `uvloop` - Added `tenacity`, `pandas` - Bumped dependency versions @@ -441,7 +488,7 @@ Backend - Admin (cloud) - Improve insufficient credit error message: include quota/usage type in the message - Storage usage update is now a background process; fixes #87 -- Allow dot in the middle for project name and organization name. +- Allow dot in the middle for project name and organization name. - Update `models.json` in `set_model_config()` - Billing: Don't include Lance version directories in storage usage computation - Bug fixes diff --git a/clients/python/README.md b/clients/python/README.md index 413e7bf..8e1d122 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -160,7 +160,7 @@ table = jamai.table.create_action_table( p.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="image", dtype="file"), # Image input + p.ColumnSchemaCreate(id="image", dtype="image"), # Image input p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( @@ -557,7 +557,7 @@ def create_tables(jamai: JamAI): p.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="image", dtype="file"), # Image input + p.ColumnSchemaCreate(id="image", dtype="image"), # Image input p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 93aebe1..579af76 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -101,6 +101,7 @@ dependencies = [ "Pillow>=10.0.1", "pydantic-settings>=2.0.3", "pydantic>=2.4.2", + "pydub~=0.25.1", "srsly>=2.4.8", "toml>=0.10.2", "typing_extensions>=4.10.0", @@ -112,6 +113,7 @@ lint = ["ruff~=0.5.7"] test = [ "flaky~=3.8.1", "mypy~=1.11.1", + "pydub~=0.25.1", "pytest-asyncio>=0.23.8", "pytest-cov~=5.0.0", "pytest-timeout>=2.3.1", diff --git a/clients/python/src/jamaibase/client.py b/clients/python/src/jamaibase/client.py index 68f70d5..d5df736 100644 --- a/clients/python/src/jamaibase/client.py +++ b/clients/python/src/jamaibase/client.py @@ -597,6 +597,7 @@ def generate_invite_token( self, organization_id: str, user_email: str = "", + user_role: str = "", valid_days: int = 7, ) -> str: """ @@ -606,6 +607,8 @@ def generate_invite_token( organization_id (str): Organization ID. user_email (str, optional): User email. Leave blank to disable email check and generate a public invite. Defaults to "". + user_role (str, optional): User role. + Leave blank to default to guest. Defaults to "". valid_days (int, optional): How many days should this link be valid for. Defaults to 7. Returns: @@ -615,7 +618,10 @@ def generate_invite_token( self.api_base, "/admin/backend/v1/invite_tokens", params=dict( - organization_id=organization_id, user_email=user_email, valid_days=valid_days + organization_id=organization_id, + user_email=user_email, + user_role=user_role, + valid_days=valid_days, ), response_model=None, ) @@ -1222,7 +1228,7 @@ def upload_file(self, file_path: str) -> FileUploadResponse: with open(file_path, "rb") as f: return self._post( self.api_base, - "/v1/files/upload/", + "/v1/files/upload", body=None, response_model=FileUploadResponse, files={ @@ -2209,7 +2215,9 @@ def health(self) -> dict[str, Any]: def model_info( self, name: str = "", - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + capabilities: list[ + Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] + ] | None = None, ) -> ModelInfoResponse: """ @@ -2217,7 +2225,7 @@ def model_info( Args: name (str, optional): The model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): List of model capabilities to filter by. Defaults to None. Returns: @@ -2234,7 +2242,9 @@ def model_info( def model_names( self, prefer: str = "", - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + capabilities: list[ + Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] + ] | None = None, ) -> list[str]: """ @@ -2242,7 +2252,7 @@ def model_names( Args: prefer (str, optional): Preferred model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): List of model capabilities to filter by. Defaults to None. Returns: @@ -4002,7 +4012,7 @@ async def upload_file(self, file_path: str) -> FileUploadResponse: with open(file_path, "rb") as f: return await self._post( self.api_base, - "/v1/files/upload/", + "/v1/files/upload", body=None, response_model=FileUploadResponse, files={ @@ -4989,7 +4999,9 @@ async def health(self) -> dict[str, Any]: async def model_info( self, name: str = "", - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + capabilities: list[ + Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] + ] | None = None, ) -> ModelInfoResponse: """ @@ -4997,7 +5009,7 @@ async def model_info( Args: name (str, optional): The model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): List of model capabilities to filter by. Defaults to None. Returns: @@ -5014,7 +5026,9 @@ async def model_info( async def model_names( self, prefer: str = "", - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + capabilities: list[ + Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] + ] | None = None, ) -> list[str]: """ @@ -5022,7 +5036,7 @@ async def model_names( Args: prefer (str, optional): Preferred model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): List of model capabilities to filter by. Defaults to None. Returns: diff --git a/clients/python/src/jamaibase/protocol.py b/clients/python/src/jamaibase/protocol.py index 31b8e70..1e76862 100644 --- a/clients/python/src/jamaibase/protocol.py +++ b/clients/python/src/jamaibase/protocol.py @@ -52,22 +52,27 @@ def sanitise_document_id_list(v: list[str]) -> list[str]: DocumentID = Annotated[str, AfterValidator(sanitise_document_id)] DocumentIDList = Annotated[list[str], AfterValidator(sanitise_document_id_list)] -EXAMPLE_CHAT_MODEL = "openai/gpt-4o-mini" - +EXAMPLE_CHAT_MODEL_IDS = ["openai/gpt-4o-mini"] # for openai embedding models doc: https://platform.openai.com/docs/guides/embeddings # for cohere embedding models doc: https://docs.cohere.com/reference/embed # for jina embedding models doc: https://jina.ai/embeddings/ # for voyage embedding models doc: https://docs.voyageai.com/docs/embeddings # for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_EMBEDDING_MODEL = "openai/text-embedding-3-small-512" - +EXAMPLE_EMBEDDING_MODEL_IDS = [ + "openai/text-embedding-3-small-512", + "ellm/sentence-transformers/all-MiniLM-L6-v2", +] # for cohere reranking models doc: https://docs.cohere.com/reference/rerank-1 # for jina reranking models doc: https://jina.ai/reranker # for colbert reranking models doc: https://docs.voyageai.com/docs/reranker # for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_RERANKING_MODEL = "cohere/rerank-multilingual-v3.0" +EXAMPLE_RERANKING_MODEL_IDS = [ + "cohere/rerank-multilingual-v3.0", + "ellm/cross-encoder/ms-marco-TinyBERT-L-2", +] IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] +AUDIO_FILE_EXTENSIONS = [".mp3", ".wav"] DOCUMENT_FILE_EXTENSIONS = [ ".pdf", ".txt", @@ -182,7 +187,11 @@ class _ModelPrice(BaseModel): 'Unique identifier in the form of "{provider}/{model_id}". ' "Users will specify this to select a model." ), - examples=[EXAMPLE_CHAT_MODEL, EXAMPLE_EMBEDDING_MODEL, EXAMPLE_RERANKING_MODEL], + examples=[ + EXAMPLE_CHAT_MODEL_IDS[0], + EXAMPLE_EMBEDDING_MODEL_IDS[0], + EXAMPLE_RERANKING_MODEL_IDS[0], + ], ) name: str = Field( description="Name of the model.", @@ -192,10 +201,10 @@ class _ModelPrice(BaseModel): class LLMModelPrice(_ModelPrice): input_cost_per_mtoken: float = Field( - description="Cost in USD per million (mega) input / prompt token.", + description="Cost in USD per million input / prompt token.", ) output_cost_per_mtoken: float = Field( - description="Cost in USD per million (mega) output / completion token.", + description="Cost in USD per million output / completion token.", ) @@ -206,7 +215,7 @@ class EmbeddingModelPrice(_ModelPrice): class RerankingModelPrice(_ModelPrice): - cost_per_ksearch: float = Field(description="Cost in USD for a thousand searches.") + cost_per_ksearch: float = Field(description="Cost in USD for a thousand (kilo) searches.") class ModelPrice(BaseModel): @@ -674,7 +683,7 @@ class RAGParams(BaseModel): reranking_model: str | None = Field( default=None, description="Reranking model to use for hybrid search.", - examples=[EXAMPLE_RERANKING_MODEL, None], + examples=[EXAMPLE_RERANKING_MODEL_IDS[0], None], ) search_query: str = Field( default="", @@ -748,7 +757,7 @@ class ModelInfo(BaseModel): 'Unique identifier in the form of "{provider}/{model_id}". ' "Users will specify this to select a model." ), - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ) object: str = Field( default="model", @@ -771,7 +780,9 @@ class ModelInfo(BaseModel): description="The organization that owns the model.", examples=["openai"], ) - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] = Field( + capabilities: list[ + Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] + ] = Field( description="List of capabilities of model.", examples=[["chat"]], ) @@ -785,7 +796,7 @@ class ModelDeploymentConfig(BaseModel): 'For example, you can map "openai/gpt-4o" calls to "openai/gpt-4o-2024-08-06". ' 'For vLLM with OpenAI compatible server, use "openai/".' ), - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ) api_base: str = Field( default="", @@ -836,7 +847,7 @@ class EmbeddingModelConfig(ModelConfig): 'For self-hosted models with Infinity, use "ellm/{org}/{model}". ' "Users will specify this to select a model." ), - examples=["ellm/sentence-transformers/all-MiniLM-L6-v2", EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) embedding_size: int = Field( description="Embedding size of the model", @@ -870,7 +881,7 @@ class RerankingModelConfig(ModelConfig): 'For self-hosted models with Infinity, use "ellm/{org}/{model}". ' "Users will specify this to select a model." ), - examples=["ellm/cross-encoder/ms-marco-TinyBERT-L-2", EXAMPLE_RERANKING_MODEL], + examples=EXAMPLE_RERANKING_MODEL_IDS, ) capabilities: list[Literal["rerank"]] = Field( default=["rerank"], @@ -959,6 +970,17 @@ def sanitise_name(v: str) -> str: MessageName = Annotated[str, AfterValidator(sanitise_name)] +class MessageToolCallFunction(BaseModel): + arguments: str + name: str | None + + +class MessageToolCall(BaseModel): + id: str | None + function: MessageToolCallFunction + type: str + + class ChatEntry(BaseModel): """Represents a message in the chat context.""" @@ -1000,6 +1022,11 @@ def coerce_input(cls, value: Any) -> str | list[dict[str, str | dict[str, str]]] return str(value) +class ChatCompletionChoiceOutput(ChatEntry): + tool_calls: list[MessageToolCall] | None = None + """List of tool calls if the message includes tool call responses.""" + + class ChatThread(BaseModel): object: str = Field( default="chat.thread", @@ -1029,7 +1056,9 @@ class CompletionUsage(BaseModel): class ChatCompletionChoice(BaseModel): - message: ChatEntry = Field(description="A chat completion message generated by the model.") + message: ChatEntry | ChatCompletionChoiceOutput = Field( + description="A chat completion message generated by the model." + ) index: int = Field(description="The index of the choice in the list of choices.") finish_reason: str | None = Field( default=None, @@ -1049,7 +1078,7 @@ def text(self) -> str: class ChatCompletionChoiceDelta(ChatCompletionChoice): @computed_field @property - def delta(self) -> ChatEntry: + def delta(self) -> ChatEntry | ChatCompletionChoiceOutput: return self.message @@ -1157,7 +1186,7 @@ class ChatCompletionChunk(BaseModel): ) @property - def message(self) -> ChatEntry | None: + def message(self) -> ChatEntry | ChatCompletionChoiceOutput | None: return self.choices[0].message if len(self.choices) > 0 else None @property @@ -1188,6 +1217,49 @@ class GenTableStreamChatCompletionChunk(ChatCompletionChunk): row_id: str +class FunctionParameter(BaseModel): + type: str = Field( + default="", description="The type of the parameter, e.g., 'string', 'number'." + ) + description: str = Field(default="", description="A description of the parameter.") + enum: list[str] = Field( + default=[], description="An optional list of allowed values for the parameter." + ) + + +class FunctionParameters(BaseModel): + type: str = Field( + default="object", description="The type of the parameters object, usually 'object'." + ) + properties: dict[str, FunctionParameter] = Field( + description="The properties of the parameters object." + ) + required: list[str] = Field(description="A list of required parameter names.") + additionalProperties: bool = Field( + default=False, description="Whether additional properties are allowed." + ) + + +class Function(BaseModel): + name: str = Field(default="", description="The name of the function.") + description: str = Field(default="", description="A description of what the function does.") + parameters: FunctionParameters = Field(description="The parameters for the function.") + + +class Tool(BaseModel): + type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") + function: Function = Field(description="The function details of the tool.") + + +class ToolChoiceFunction(BaseModel): + name: str = Field(default="", description="The name of the function.") + + +class ToolChoice(BaseModel): + type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") + function: ToolChoiceFunction = Field(description="Select a tool for the chat model to use.") + + class ChatRequest(BaseModel): id: str = Field( default="", @@ -1295,6 +1367,48 @@ def convert_stop(cls, v: list[str] | None) -> list[str] | None: return v +class ChatRequestWithTools(ChatRequest): + tools: list[Tool] = Field( + description="A list of tools available for the chat model to use.", + min_length=1, + examples=[ + # --- [Tool Function] --- + # def get_delivery_date(order_id: str) -> datetime: + # # Connect to the database + # conn = sqlite3.connect('ecommerce.db') + # cursor = conn.cursor() + # # ... + [ + Tool( + type="function", + function=Function( + name="get_delivery_date", + description="Get the delivery date for a customer's order.", + parameters=FunctionParameters( + type="object", + properties={ + "order_id": FunctionParameter( + type="string", description="The customer's order ID." + ) + }, + required=["order_id"], + additionalProperties=False, + ), + ), + ) + ], + ], + ) + tool_choice: str | ToolChoice = Field( + default="auto", + description="Set `auto` to let chat model pick a tool or select a tool for the chat model to use.", + examples=[ + "auto", + ToolChoice(type="function", function=ToolChoiceFunction(name="get_delivery_date")), + ], + ) + + class EmbeddingRequest(BaseModel): input: str | list[str] = Field( description=( @@ -1309,7 +1423,7 @@ class EmbeddingRequest(BaseModel): "The ID of the model to use. " "You can use the List models API to see all of your available models." ), - examples=[EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) type: Literal["query", "document"] = Field( default="document", @@ -1424,7 +1538,8 @@ class DtypeCreateEnum(str, Enum, metaclass=MetaEnum): float_ = "float" bool_ = "bool" str_ = "str" - file_ = "file" + image_ = "image" + audio_ = "audio" def __getattribute__(cls, *args, **kwargs): warn(ENUM_DEPRECATE_MSSG, FutureWarning, stacklevel=1) @@ -1598,7 +1713,7 @@ class EmbedGenConfig(BaseModel): ) embedding_model: str = Field( description="The embedding model to use.", - examples=[EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) source_column: str = Field( description="The source column for embedding.", @@ -1606,6 +1721,18 @@ class EmbedGenConfig(BaseModel): ) +class CodeGenConfig(BaseModel): + object: Literal["gen_config.code"] = Field( + default="gen_config.code", + description='The object type, which is always "gen_config.code".', + examples=["gen_config.code"], + ) + source_column: str = Field( + description="The source column for python code to execute.", + examples=["code_column"], + ) + + def _gen_config_discriminator(x: Any) -> str | None: object_attr = getattr(x, "object", None) if object_attr: @@ -1622,9 +1749,10 @@ def _gen_config_discriminator(x: Any) -> str | None: return None -GenConfig = LLMGenConfig | EmbedGenConfig +GenConfig = LLMGenConfig | EmbedGenConfig | CodeGenConfig DiscriminatedGenConfig = Annotated[ Union[ + Annotated[CodeGenConfig, Tag("gen_config.code")], Annotated[LLMGenConfig, Tag("gen_config.llm")], Annotated[LLMGenConfig, Tag("gen_config.chat")], Annotated[EmbedGenConfig, Tag("gen_config.embed")], @@ -1664,9 +1792,12 @@ class ColumnSchema(BaseModel): class ColumnSchemaCreate(ColumnSchema): id: str = Field(description="Column name.") - dtype: Literal["int", "float", "bool", "str", "file"] = Field( + dtype: Literal["int", "float", "bool", "str", "file", "image", "audio"] = Field( default="str", - description='Column data type, one of ["int", "float", "bool", "str", "file"]', + description=( + 'Column data type, one of ["int", "float", "bool", "str", "file", "image", "audio"]' + ". Data type 'file' is deprecated, use 'image' instead." + ), ) @model_validator(mode="before") @@ -1899,11 +2030,12 @@ def check_data(self) -> Self: value.startswith("s3://") or value.startswith("file://") ): extension = splitext(value)[1].lower() - if extension not in IMAGE_FILE_EXTENSIONS: + if extension not in IMAGE_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS: raise ValueError( "Unsupported file type. Make sure the file belongs to " "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS}" + f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" + f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" ) return self @@ -1945,11 +2077,12 @@ def check_data(self) -> Self: value.startswith("s3://") or value.startswith("file://") ): extension = splitext(value)[1].lower() - if extension not in IMAGE_FILE_EXTENSIONS: + if extension not in IMAGE_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS: raise ValueError( "Unsupported file type. Make sure the file belongs to " "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS}" + f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" + f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" ) return self diff --git a/clients/python/src/jamaibase/utils/io.py b/clients/python/src/jamaibase/utils/io.py index 83e086f..00fc6bb 100644 --- a/clients/python/src/jamaibase/utils/io.py +++ b/clients/python/src/jamaibase/utils/io.py @@ -12,6 +12,7 @@ import srsly import toml from PIL import ExifTags, Image +from pydub import AudioSegment from jamaibase.utils.types import JSONInput, JSONOutput @@ -176,7 +177,7 @@ def read_image(img_path: str) -> tuple[np.ndarray, bool]: return np.asarray(image), is_rotated -def generate_thumbnail( +def generate_image_thumbnail( file_content: bytes, size: tuple[float, float] = (450.0, 450.0), ) -> bytes: @@ -201,3 +202,26 @@ def generate_thumbnail( except Exception as e: logger.exception(f"Failed to generate thumbnail due to {e.__class__.__name__}: {e}") return b"" + + +def generate_audio_thumbnail(file_content: bytes, duration_ms: int = 30000) -> bytes: + """ + Generates a thumbnail audio by extracting a segment from the original audio. + + Args: + file_content (bytes): The audio file content. + duration_ms (int): Duration of the thumbnail in milliseconds. + + Returns: + bytes: The thumbnail audio segment as bytes. + """ + # Use BytesIO to simulate a file object from the byte content + audio = AudioSegment.from_file(BytesIO(file_content)) + + # Extract the first `duration_ms` milliseconds + thumbnail = audio[:duration_ms] + + # Export the thumbnail to a bytes object + with BytesIO() as output: + thumbnail.export(output, format="mp3") + return output.getvalue() diff --git a/clients/python/tests/cloud/test_admin.py b/clients/python/tests/cloud/test_admin.py index bfc6ee1..a760683 100644 --- a/clients/python/tests/cloud/test_admin.py +++ b/clients/python/tests/cloud/test_admin.py @@ -50,7 +50,7 @@ UserUpdate, ) from jamaibase.utils import datetime_now_iso -from owl.configs.manager import PlanName, ProductType +from owl.configs.manager import ENV_CONFIG, PlanName, ProductType from owl.utils import uuid7_str CLIENT_CLS = [JamAI] @@ -413,6 +413,21 @@ def test_pat(client_cls: Type[JamAI]): with _create_gen_table(jamai, "action", "xx"): table = jamai.table.get_table("action", "xx") assert isinstance(table, TableMetaResponse) + ### --- Test service key auth --- ### + table = JamAI( + project_id=p0.id, + token=ENV_CONFIG.service_key_plain, + headers={"X-USER-ID": u0.id}, + ).table.get_table("action", "xx") + assert isinstance(table, TableMetaResponse) + # Try using invalid user ID + with pytest.raises(RuntimeError): + JamAI( + project_id=p0.id, + token=ENV_CONFIG.service_key_plain, + headers={"X-USER-ID": u1.id}, + ).table.get_table("action", "xx") + ### --- Test PAT --- ### # Try using invalid PAT with pytest.raises(RuntimeError): JamAI(project_id=p0.id, token=pat1.id).table.get_table("action", "xx") @@ -781,7 +796,7 @@ def test_join_and_leave_organization(client_cls: Type[JamAI]): # --- Join with public invite link --- # with _create_org(owl, u0.id, tier="pro") as pro_org: assert u1.id not in set(m.user_id for m in pro_org.members) - invite = owl.admin.backend.generate_invite_token(pro_org.id) + invite = owl.admin.backend.generate_invite_token(pro_org.id, user_role="member") member = owl.admin.backend.join_organization( OrgMemberCreate( user_id=u1.id, @@ -798,7 +813,9 @@ def test_join_and_leave_organization(client_cls: Type[JamAI]): with _create_org(owl, u0.id, tier="pro") as pro_org: assert u1.id not in set(m.user_id for m in pro_org.members) # Invite token email validation should be case and space insensitive - invite = owl.admin.backend.generate_invite_token(pro_org.id, f" {u1.email.upper()} ") + invite = owl.admin.backend.generate_invite_token( + pro_org.id, f" {u1.email.upper()} ", user_role="admin" + ) member = owl.admin.backend.join_organization( OrgMemberCreate( user_id=u1.id, diff --git a/clients/python/tests/files/mp3/turning-a4-size-magazine.mp3 b/clients/python/tests/files/mp3/turning-a4-size-magazine.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bbf15ef53e9628907b770f08b6d76de9848bbac5 GIT binary patch literal 19644 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

<6kk(wdrg|uGNpn~g;=ZK4)!Gxj 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 z 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­rLUBZuTZ!`{mKn0Kdkw% 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 G#s3HV3Jkaa literal 0 HcmV?d00001 diff --git a/clients/python/tests/oss/gen_table/test_export_ops.py b/clients/python/tests/oss/gen_table/test_export_ops.py index e0c20b7..66e2d15 100644 --- a/clients/python/tests/oss/gen_table/test_export_ops.py +++ b/clients/python/tests/oss/gen_table/test_export_ops.py @@ -81,7 +81,7 @@ def _create_table( p.ColumnSchemaCreate(id="words", dtype="int"), p.ColumnSchemaCreate(id="stars", dtype="float"), p.ColumnSchemaCreate(id="inputs", dtype="str"), - p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="photo", dtype="image"), p.ColumnSchemaCreate( id="summary", dtype="str", diff --git a/clients/python/tests/oss/gen_table/test_row_ops.py b/clients/python/tests/oss/gen_table/test_row_ops.py index c8f05b8..2a3dd62 100644 --- a/clients/python/tests/oss/gen_table/test_row_ops.py +++ b/clients/python/tests/oss/gen_table/test_row_ops.py @@ -79,6 +79,11 @@ def _get_chat_model(jamai: JamAI) -> str: return models[0] +def _get_audio_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="ellm/Qwen/Qwen-2-Audio-7B", capabilities=["audio"]) + return models[0] + + def _get_chat_only_model(jamai: JamAI) -> str: chat_models = jamai.model_names( prefer="ellm/meta-llama/Llama-3.1-8B-Instruct", capabilities=["chat"] @@ -118,7 +123,8 @@ def _create_table( p.ColumnSchemaCreate(id="words", dtype="int"), p.ColumnSchemaCreate(id="stars", dtype="float"), p.ColumnSchemaCreate(id="inputs", dtype="str"), - p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="photo", dtype="image"), + p.ColumnSchemaCreate(id="audio", dtype="audio"), p.ColumnSchemaCreate( id="summary", dtype="str", @@ -142,7 +148,18 @@ def _create_table( prompt="${photo} \n\nWhat's in the image?", temperature=0.001, top_p=0.001, - max_tokens=300, + max_tokens=20, + ), + ), + p.ColumnSchemaCreate( + id="narration", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt="${audio} \n\nWhat happened?", + temperature=0.001, + top_p=0.001, + max_tokens=10, ), ), ] @@ -196,13 +213,19 @@ def _add_row( chat_data: dict | None = None, ): if data is None: - upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + image_upload_response = jamai.file.upload_file( + "clients/python/tests/files/jpeg/rabbit.jpeg" + ) + audio_upload_response = jamai.file.upload_file( + "clients/python/tests/files/mp3/turning-a4-size-magazine.mp3" + ) data = dict( good=True, words=5, stars=7.9, inputs=TEXT, - photo=upload_response.uri, + photo=image_upload_response.uri, + audio=audio_upload_response.uri, ) if knowledge_data is None: @@ -490,7 +513,7 @@ def test_rag( @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("table_type", TABLE_TYPES) @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) -def test_rag_with_file_input( +def test_rag_with_image_input( client_cls: Type[JamAI], table_type: p.TableType, stream: bool, @@ -524,7 +547,7 @@ def test_rag_with_file_input( # Create the other table cols = [ - p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="photo", dtype="image"), p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate(id="words", dtype="int"), p.ColumnSchemaCreate( @@ -670,10 +693,14 @@ def test_add_row( assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "AI") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") + for r in responses + ) assert len("".join(r.text for r in responses)) > 0 assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) assert all(isinstance(r.usage, p.CompletionUsage) for r in responses) @@ -682,7 +709,7 @@ def test_add_row( else: assert isinstance(response, p.GenTableChatCompletionChunks) assert response.object == "gen_table.completion.chunks" - for output_column_name in ("summary", "captioning"): + for output_column_name in ("summary", "captioning", "narration"): assert len(response.columns[output_column_name].text) > 0 assert isinstance(response.columns[output_column_name].usage, p.CompletionUsage) assert isinstance(response.columns[output_column_name].prompt_tokens, int) @@ -695,9 +722,237 @@ def test_add_row( assert row["words"]["value"] == 5, row["words"] assert row["stars"]["value"] == 7.9, row["stars"] assert row["photo"]["value"].endswith("/rabbit.jpeg"), row["photo"]["value"] + assert row["audio"]["value"].endswith("/turning-a4-size-magazine.mp3"), row["audio"][ + "value" + ] for animal in ["deer", "rabbit"]: if animal in row["photo"]["value"].split("_")[0]: assert animal in row["captioning"]["value"] + assert "paper" in row["narration"]["value"] or "turn" in row["narration"]["value"] + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +def test_regen_with_reordered_columns( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="number", dtype="int"), + p.ColumnSchemaCreate( + id="col1-english", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in English, " + "only output the answer in uppercase without explanation." + ), + ), + ), + p.ColumnSchemaCreate( + id="col2-malay", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Malay, " + "only output the answer in uppercase without explanation." + ), + ), + ), + p.ColumnSchemaCreate( + id="col3-mandarin", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Mandarin (Chinese Character), " + "only output the answer in uppercase without explanation." + ), + ), + ), + p.ColumnSchemaCreate( + id="col4-roman", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Roman Numerals, " + "only output the answer in uppercase without explanation." + ), + ), + ), + ] + + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + row = _add_row( + jamai, + table_type, + False, + data=dict(number=1), + ) + assert isinstance(row, p.GenTableChatCompletionChunks) + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + _id = row["ID"] + assert row["number"]["value"] == 1, row["number"] + assert row["col1-english"]["value"] == "ONE", row["col1-english"] + assert row["col2-malay"]["value"] == "SATU", row["col2-malay"] + assert row["col3-mandarin"]["value"] == "一", row["col3-mandarin"] + assert row["col4-roman"]["value"] == "I", row["col4-roman"] + + # Update Input + Regen + jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=_id, + data=dict(number=2), + ), + ) + + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=table.id, + row_ids=[_id], + regen_strategy=p.RegenStrategy.RUN_ALL, + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["number"]["value"] == 2, row["number"] + assert row["col1-english"]["value"] == "TWO", row["col1-english"] + assert row["col2-malay"]["value"] == "DUA", row["col2-malay"] + assert row["col3-mandarin"]["value"] == "二", row["col3-mandarin"] + assert row["col4-roman"]["value"] == "II", row["col4-roman"] + + # Reorder + Update Input + Regen + # [1, 2, 3, 4] -> [3, 1, 4, 2] + new_cols = [ + "number", + "col3-mandarin", + "col1-english", + "col4-roman", + "col2-malay", + ] + if table_type == p.TableType.knowledge: + new_cols += ["Title", "Text", "Title Embed", "Text Embed", "File ID", "Page"] + elif table_type == p.TableType.chat: + new_cols += ["User", "AI"] + jamai.table.reorder_columns( + table_type=table_type, + request=p.ColumnReorderRequest( + table_id=TABLE_ID_A, + column_names=new_cols, + ), + ) + # RUN_SELECTED + jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=_id, + data=dict(number=5), + ), + ) + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=p.RegenStrategy.RUN_SELECTED, + output_column_id="col1-english", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["number"]["value"] == 5, row["number"] + assert row["col3-mandarin"]["value"] == "二", row["col3-mandarin"] + assert row["col1-english"]["value"] == "FIVE", row["col1-english"] + assert row["col4-roman"]["value"] == "II", row["col4-roman"] + assert row["col2-malay"]["value"] == "DUA", row["col2-malay"] + + # RUN_BEFORE + jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=_id, + data=dict(number=6), + ), + ) + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=p.RegenStrategy.RUN_BEFORE, + output_column_id="col4-roman", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["number"]["value"] == 6, row["number"] + assert row["col3-mandarin"]["value"] == "六", row["col3-mandarin"] + assert row["col1-english"]["value"] == "SIX", row["col1-english"] + assert row["col4-roman"]["value"] == "VI", row["col4-roman"] + assert row["col2-malay"]["value"] == "DUA", row["col2-malay"] + + # RUN_AFTER + jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=_id, + data=dict(number=7), + ), + ) + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=p.RegenStrategy.RUN_AFTER, + output_column_id="col4-roman", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["number"]["value"] == 7, row["number"] + assert row["col3-mandarin"]["value"] == "六", row["col3-mandarin"] + assert row["col1-english"]["value"] == "SIX", row["col1-english"] + assert row["col4-roman"]["value"] == "VII", row["col4-roman"] + assert row["col2-malay"]["value"] == "TUJUH", row["col2-malay"] @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -708,11 +963,85 @@ def test_add_row_sequential_image_model_completion( client_cls: Type[JamAI], table_type: p.TableType, stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="photo", dtype="image"), + p.ColumnSchemaCreate(id="photo2", dtype="image"), + p.ColumnSchemaCreate( + id="caption", + dtype="str", + gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), + ), + p.ColumnSchemaCreate( + id="question", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt="Caption: ${caption}\n\nImage: ${photo2}\n\nDoes the caption match? Reply True or False.", + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + response = _add_row( + jamai, + table_type, + stream, + TABLE_ID_A, + data=dict(photo=upload_response.uri, photo2=upload_response.uri), + ) + if stream: + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(r.object == "gen_table.completion.chunk" for r in responses) + if table_type == p.TableType.chat: + assert all( + r.output_column_name in ("caption", "question", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("caption", "question") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r.usage, p.CompletionUsage) for r in responses) + assert all(isinstance(r.prompt_tokens, int) for r in responses) + assert all(isinstance(r.completion_tokens, int) for r in responses) + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + for output_column_name in ("caption", "question"): + assert len(response.columns[output_column_name].text) > 0 + assert isinstance(response.columns[output_column_name].usage, p.CompletionUsage) + assert isinstance(response.columns[output_column_name].prompt_tokens, int) + assert isinstance(response.columns[output_column_name].completion_tokens, int) + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["photo"]["value"] == upload_response.uri, row["photo"]["value"] + assert row["photo2"]["value"] == upload_response.uri, row["photo"]["value"] + for animal in ["deer", "rabbit"]: + if animal in row["photo"]["value"].split("_")[0]: + assert animal in row["caption"]["value"] + if animal in row["photo2"]["value"].split("_")[0]: + assert "true" in row["question"]["value"].lower() + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False]) +def test_add_row_map_dtype_file_to_image( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, ): jamai = client_cls() cols = [ p.ColumnSchemaCreate(id="photo", dtype="file"), - p.ColumnSchemaCreate(id="photo2", dtype="file"), + p.ColumnSchemaCreate(id="photo2", dtype="image"), p.ColumnSchemaCreate( id="caption", dtype="str", @@ -772,6 +1101,10 @@ def test_add_row_sequential_image_model_completion( assert animal in row["caption"]["value"] if animal in row["photo2"]["value"].split("_")[0]: assert "true" in row["question"]["value"].lower() + meta = jamai.table.get_table(table_type, TABLE_ID_A) + for col in meta.cols: + if col.id == "photo": + assert col.dtype == "image" # @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -785,7 +1118,7 @@ def test_add_row_sequential_image_model_completion( # ): # jamai = client_cls() # cols = [ -# p.ColumnSchemaCreate(id="photo", dtype="file"), +# p.ColumnSchemaCreate(id="photo", dtype="image"), # p.ColumnSchemaCreate(id="question", dtype="str"), # p.ColumnSchemaCreate( # id="captioning", @@ -802,7 +1135,7 @@ def test_add_row_sequential_image_model_completion( # ), # p.ColumnSchemaCreate( # id="compare", -# dtype="file", +# dtype="image", # gen_config=p.LLMGenConfig( # model="", # prompt="Compare ${captioning} and ${answer}.", @@ -820,7 +1153,7 @@ def test_add_row_output_column_referred_image_input_with_chat_model( ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="photo", dtype="image"), p.ColumnSchemaCreate( id="captioning", dtype="str", @@ -959,10 +1292,14 @@ def test_add_row_image_file_type_with_generation( assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "AI") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") + for r in responses + ) assert len("".join(r.text for r in responses)) > 0 else: assert isinstance(response, p.GenTableChatCompletionChunks) @@ -1058,9 +1395,14 @@ def test_add_row_validate_one_image_per_completion( assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: - assert all(r.output_column_name in ("summary", "captioning", "AI") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses + ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") for r in responses + ) assert len("".join(r.text for r in responses)) > 0 rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) @@ -1090,10 +1432,14 @@ def test_add_row_wrong_dtype( assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "AI") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") + for r in responses + ) assert len("".join(r.text for r in responses)) > 0 else: assert isinstance(response, p.GenTableChatCompletionChunks) @@ -1144,10 +1490,14 @@ def test_add_row_missing_columns( assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "AI") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") + for r in responses + ) assert len("".join(r.text for r in responses)) > 0 else: assert isinstance(response, p.GenTableChatCompletionChunks) @@ -1296,7 +1646,12 @@ def test_regen_rows( assert isinstance(table, p.TableMetaResponse) assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + image_upload_response = jamai.file.upload_file( + "clients/python/tests/files/jpeg/rabbit.jpeg" + ) + audio_upload_response = jamai.file.upload_file( + "clients/python/tests/files/mp3/turning-a4-size-magazine.mp3" + ) response = _add_row( jamai, table_type, @@ -1306,7 +1661,8 @@ def test_regen_rows( words=10, stars=9.9, inputs=TEXT, - photo=upload_response.uri, + photo=image_upload_response.uri, + audio=audio_upload_response.uri, ), ) assert isinstance(response, p.GenTableChatCompletionChunks) @@ -1337,10 +1693,14 @@ def test_regen_rows( assert all(r.object == "gen_table.completion.chunk" for r in responses) if table_type == p.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "AI") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "AI") + for r in responses ) else: - assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert all( + r.output_column_name in ("summary", "captioning", "narration") + for r in responses + ) assert len("".join(r.text for r in responses)) > 0 else: assert isinstance(response, p.GenTableRowsChatCompletionChunks) @@ -1353,7 +1713,8 @@ def test_regen_rows( assert row["good"]["value"] is True assert row["words"]["value"] == 10 assert row["stars"]["value"] == 9.9 - assert row["photo"]["value"] == upload_response.uri + assert row["photo"]["value"] == image_upload_response.uri + assert row["audio"]["value"] == audio_upload_response.uri assert row["Updated at"] > original_ts assert "dune" in row["summary"]["value"].lower() @@ -1508,13 +1869,15 @@ def test_get_and_list_rows( "stars", "inputs", "photo", + "audio", "summary", "captioning", + "narration", } if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} else: @@ -2238,6 +2601,7 @@ def test_upload_file( assert all(len(r["Title"]["value"]) > 0 for r in rows.items) assert all(isinstance(r["Text"]["value"], str) for r in rows.items) assert all(len(r["Text"]["value"]) > 0 for r in rows.items) + assert all(r["Page"]["value"] > 0 for r in rows.items) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -2356,6 +2720,7 @@ def test_upload_long_file( assert all(len(r["Title"]["value"]) > 0 for r in rows.items) assert all(isinstance(r["Text"]["value"], str) for r in rows.items) assert all(len(r["Text"]["value"]) > 0 for r in rows.items) + assert all(r["Page"]["value"] > 0 for r in rows.items) if __name__ == "__main__": diff --git a/clients/python/tests/oss/gen_table/test_table_ops.py b/clients/python/tests/oss/gen_table/test_table_ops.py index 6e5fea8..a53d587 100644 --- a/clients/python/tests/oss/gen_table/test_table_ops.py +++ b/clients/python/tests/oss/gen_table/test_table_ops.py @@ -20,7 +20,7 @@ "bool": True, "str": '"Arrival" is a 2016 science fiction film. "Arrival" è un film di fantascienza del 2016. 「Arrival」は2016年のSF映画です。', } -KT_FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID"] +KT_FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] CT_FIXED_COLUMN_IDS = ["User"] TABLE_ID_A = "table_a" @@ -103,7 +103,7 @@ def _create_table( p.ColumnSchemaCreate(id="words", dtype="int"), p.ColumnSchemaCreate(id="stars", dtype="float"), p.ColumnSchemaCreate(id="inputs", dtype="str"), - p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="photo", dtype="image"), p.ColumnSchemaCreate( id="summary", dtype="str", @@ -232,7 +232,7 @@ def _create_table_v2( id=table_id, cols=cols, embedding_model=embedding_model ) ) - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: table = jamai.table.create_chat_table( p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) @@ -782,7 +782,7 @@ def test_default_image_model( jamai = client_cls() available_image_models = _get_image_models(jamai) cols = [ - p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate(id="input0", dtype="image"), p.ColumnSchemaCreate( id="output0", dtype="str", @@ -833,7 +833,7 @@ def test_default_image_model( dtype="str", gen_config=p.LLMGenConfig(prompt="${input0}"), ), - p.ColumnSchemaCreate(id="file_input1", dtype="file"), + p.ColumnSchemaCreate(id="file_input1", dtype="image"), p.ColumnSchemaCreate( id="output3", dtype="str", @@ -890,7 +890,7 @@ def test_invalid_image_model( jamai = client_cls() available_image_models = _get_image_models(jamai) cols = [ - p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate(id="input0", dtype="image"), p.ColumnSchemaCreate( id="output0", dtype="str", @@ -902,7 +902,7 @@ def test_invalid_image_model( pass cols = [ - p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate(id="input0", dtype="image"), p.ColumnSchemaCreate( id="output0", dtype="str", @@ -1066,7 +1066,7 @@ def test_default_prompts( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - input_cols |= {"Title", "Text", "File ID"} + input_cols |= {"Title", "Text", "File ID", "Page"} else: input_cols |= {"User"} cols = {c.id: c for c in table.cols} @@ -1116,7 +1116,7 @@ def test_default_prompts( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - input_cols |= {"Title", "Text", "File ID"} + input_cols |= {"Title", "Text", "File ID", "Page"} else: input_cols |= {"User"} cols = {c.id: c for c in table.cols} @@ -1132,7 +1132,7 @@ def test_default_prompts( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - input_cols |= {"Title", "Text", "File ID"} + input_cols |= {"Title", "Text", "File ID", "Page"} else: input_cols |= {"User"} for col_id in ["output3"]: @@ -1201,7 +1201,7 @@ def test_add_drop_columns( table = jamai.table.add_knowledge_columns( p.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) @@ -1254,7 +1254,7 @@ def test_add_drop_columns( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} else: @@ -1297,7 +1297,7 @@ def test_add_drop_file_column( # --- COLUMN ADD --- # cols = [ - p.ColumnSchemaCreate(id="add_in_file", dtype="file"), + p.ColumnSchemaCreate(id="add_in_file", dtype="image"), p.ColumnSchemaCreate( id="add_out_str", dtype="str", @@ -1318,7 +1318,7 @@ def test_add_drop_file_column( table = jamai.table.add_knowledge_columns( p.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) @@ -1360,7 +1360,7 @@ def test_add_drop_file_column( cols = [ p.ColumnSchemaCreate( id="add_out_file", - dtype="file", + dtype="image", gen_config=p.LLMGenConfig( model="", system_prompt="", @@ -1393,7 +1393,7 @@ def test_add_drop_file_column( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} else: @@ -1475,7 +1475,7 @@ def test_rename_columns( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} else: @@ -1500,7 +1500,7 @@ def test_rename_columns( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} elif table_type == p.TableType.chat: expected_cols |= {"User", "AI"} else: @@ -1599,10 +1599,10 @@ def test_reorder_columns( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] expected_order = ( expected_order[:2] - + ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order[2:] ) elif table_type == p.TableType.chat: @@ -1631,7 +1631,7 @@ def test_reorder_columns( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] elif table_type == p.TableType.chat: expected_order += ["User", "AI"] else: @@ -1690,10 +1690,10 @@ def test_reorder_columns_invalid( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] expected_order = ( expected_order[:2] - + ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order[2:] ) elif table_type == p.TableType.chat: @@ -1717,7 +1717,7 @@ def test_reorder_columns_invalid( if table_type == p.TableType.action: pass elif table_type == p.TableType.knowledge: - column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] elif table_type == p.TableType.chat: column_names += ["User", "AI"] else: diff --git a/clients/python/tests/oss/test_chat.py b/clients/python/tests/oss/test_chat.py index edd867e..7a0d18a 100644 --- a/clients/python/tests/oss/test_chat.py +++ b/clients/python/tests/oss/test_chat.py @@ -139,10 +139,19 @@ def _get_chat_request(model: str, **kwargs): return request -def _get_models(return_all: bool = False) -> list[str]: - models = JamAI().model_names(capabilities=["chat"]) +def _get_models( + capabilities: list[str] = None, return_all: bool = False, exclude_audio: bool = True +) -> list[str]: + if capabilities is None: + capabilities = ["chat"] + models = JamAI().model_names(capabilities=capabilities) + audio_models = JamAI().model_names(capabilities=["audio"]) if return_all: + if exclude_audio: + return list(set(models) - set(audio_models)) return models + if exclude_audio: + return list(set(models) - set(audio_models)) providers = sorted(set(m.split("/")[0] for m in models)) selected = [] for provider in providers: @@ -190,6 +199,148 @@ async def test_chat_completion( assert response.usage.total_tokens == response.prompt_tokens + response.completion_tokens +TOOLS = { + "get_weather": p.Tool( + type="function", + function=p.Function( + name="get_weather", + description="Get the current weather for a location", + parameters=p.FunctionParameters( + type="object", + properties={ + "location": p.FunctionParameter( + type="string", description="The city and state, e.g. San Francisco, CA" + ) + }, + required=["location"], + additionalProperties=False, + ), + ), + ), + "calculator": p.Tool( + type="function", + function=p.Function( + name="calculator", + description="Perform a basic arithmetic operation", + parameters=p.FunctionParameters( + type="object", + properties={ + "operation": p.FunctionParameter( + type="string", + description="The arithmetic operation to perform", + enum=["add", "subtract", "multiply", "divide"], + ), + "first_number": p.FunctionParameter( + type="number", + description="The first number", + ), + "second_number": p.FunctionParameter( + type="number", + description="The second number", + ), + }, + required=["operation", "first_number", "second_number"], + additionalProperties=False, + ), + ), + ), +} + +TOOL_PROMPTS = [ + { + "tool_choice": "get_weather", + "prompt": "What's the weather like in Paris?", + "response": ['{"location":'], + }, + { + "tool_choice": "calculator", + "prompt": "Divide 5 by 2.", + "response": ['"operation":"divide"', "first_number"], + }, +] + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("model", _get_models(capabilities=["tool"], return_all=True)) +@pytest.mark.parametrize("tool_prompt", TOOL_PROMPTS) +@pytest.mark.parametrize("set_multi_tools", [False, True]) +async def test_chat_completion_with_tools( + client_cls: Type[JamAI | JamAIAsync], model: str, tool_prompt: dict, set_multi_tools: bool +): + jamai = client_cls() + + tool_choice = p.ToolChoice( + type="function", + function=p.ToolChoiceFunction( + name=tool_prompt["tool_choice"], + ), + ) + + # Create a chat request with a tool + request = p.ChatRequestWithTools( + id="test", + model=model, + messages=[ + p.ChatEntry.system("You are a concise assistant."), + p.ChatEntry.user(tool_prompt["prompt"]), + ], + tools=[v for _, v in TOOLS.items()] + if set_multi_tools + else [TOOLS[tool_prompt["tool_choice"]]], + tool_choice="auto" if model.startswith("openai/") else tool_choice, + temperature=0.001, + top_p=0.001, + max_tokens=50, + stream=False, + ) + + # Non-streaming + response = await run(jamai.generate_chat_completions, request) + assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response.text, str) + assert len(response.text) == 0 + tool_calls = response.message.tool_calls + assert isinstance(tool_calls, list) + assert len(tool_calls) == 1 + assert tool_calls[0].function.name == tool_prompt["tool_choice"] + for argument in tool_prompt["response"]: + assert argument in tool_calls[0].function.arguments.replace(" ", "") + assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.prompt_tokens, int) + assert isinstance(response.completion_tokens, int) + assert response.references is None + + # Streaming + request.stream = True + responses = await run(jamai.generate_chat_completions, request) + assert len(responses) > 0 + assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r.text, str) for r in responses) + assert len("".join(r.text for r in responses)) == 0 + assert all(r.references is None for r in responses) + response = responses[-1] + assert all(isinstance(r.usage, p.CompletionUsage) for r in responses) + assert all(isinstance(r.prompt_tokens, int) for r in responses) + assert all(isinstance(r.completion_tokens, int) for r in responses) + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.usage.total_tokens == response.prompt_tokens + response.completion_tokens + arguments_result = "" + for response in responses: + tool_calls = response.message.tool_calls + assert isinstance(tool_calls, list) or tool_calls is None + if isinstance(tool_calls, list): + assert len(tool_calls) == 1 + arguments_result += tool_calls[0].function.arguments + assert ( + tool_calls[0].function.name == tool_prompt["tool_choice"] + or tool_calls[0].function.name is None + ) + for argument in tool_prompt["response"]: + assert argument in arguments_result.replace(" ", "") + + @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("model", _get_models()) diff --git a/clients/python/tests/oss/test_file.py b/clients/python/tests/oss/test_file.py index 279bedf..d31f90a 100644 --- a/clients/python/tests/oss/test_file.py +++ b/clients/python/tests/oss/test_file.py @@ -16,7 +16,7 @@ GetURLResponse, ) from jamaibase.utils import run -from jamaibase.utils.io import generate_thumbnail +from jamaibase.utils.io import generate_audio_thumbnail, generate_image_thumbnail def read_file_content(file_path): @@ -24,7 +24,7 @@ def read_file_content(file_path): return f.read() -# Define the paths to your test image files +# Define the paths to your test image and audio files IMAGE_FILES = [ "clients/python/tests/files/jpeg/cifar10-deer.jpg", "clients/python/tests/files/png/rabbit.png", @@ -32,12 +32,17 @@ def read_file_content(file_path): "clients/python/tests/files/webp/rabbit_cifar10-deer.webp", ] +AUDIO_FILES = [ + "clients/python/tests/files/wav/turning-a4-size-magazine.wav", + "clients/python/tests/files/mp3/turning-a4-size-magazine.mp3", +] + CLIENT_CLS = [JamAI, JamAIAsync] @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("image_file", IMAGE_FILES) -async def test_upload(client_cls: Type[JamAI | JamAIAsync], image_file: str): +async def test_upload_image(client_cls: Type[JamAI | JamAIAsync], image_file: str): # Initialize the client jamai = client_cls() @@ -64,6 +69,35 @@ async def test_upload(client_cls: Type[JamAI | JamAIAsync], image_file: str): print(f"Returned URI matches the expected format: {upload_response.uri}") +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("audio_file", AUDIO_FILES) +async def test_upload_audio(client_cls: Type[JamAI | JamAIAsync], audio_file: str): + # Initialize the client + jamai = client_cls() + + # Ensure the audio file exists + assert os.path.exists(audio_file), f"Test audio file does not exist: {audio_file}" + # Upload the file + upload_response = await run(jamai.file.upload_file, audio_file) + assert isinstance(upload_response, FileUploadResponse) + assert upload_response.uri.startswith( + ("file://", "s3://") + ), f"Returned URI '{upload_response.uri}' does not start with 'file://' or 's3://'" + + filename = os.path.basename(audio_file) + expected_uri_pattern = re.compile( + r"(file|s3)://[^/]+/raw/default/default/[a-f0-9-]{36}/" + re.escape(filename) + "$" + ) + + # Check if the returned URI matches the expected format + assert expected_uri_pattern.match(upload_response.uri), ( + f"Returned URI '{upload_response.uri}' does not match the expected format: " + f"(file|s3)://file/raw/default/default/{{UUID}}/{filename}" + ) + + print(f"Returned URI matches the expected format: {upload_response.uri}") + + @pytest.mark.parametrize("client_cls", CLIENT_CLS) async def test_upload_large_image_file(client_cls: Type[JamAI | JamAIAsync]): jamai = client_cls() @@ -87,15 +121,15 @@ async def test_get_raw_urls(client_cls: Type[JamAI | JamAIAsync]): jamai = client_cls() # Upload files first uploaded_uris = [] - for file in IMAGE_FILES: + for file in IMAGE_FILES + AUDIO_FILES: response = await run(jamai.file.upload_file, file) uploaded_uris.append(response.uri) # Now test get_raw_urls response = await run(jamai.file.get_raw_urls, uploaded_uris) assert isinstance(response, GetURLResponse) - assert len(response.urls) == len(IMAGE_FILES) - for original_file, url in zip(IMAGE_FILES, response.urls, strict=True): + assert len(response.urls) == len(IMAGE_FILES + AUDIO_FILES) + for original_file, url in zip(IMAGE_FILES + AUDIO_FILES, response.urls, strict=True): if url.startswith(("http://", "https://")): # Handle HTTP/HTTPS URLs HEADERS = {"X-PROJECT-ID": "default"} @@ -129,22 +163,22 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): # Upload files first uploaded_uris = [] - for file in IMAGE_FILES: + for file in IMAGE_FILES + AUDIO_FILES: response = await run(jamai.file.upload_file, file) uploaded_uris.append(response.uri) # Now test get_thumbnail_urls response = await run(jamai.file.get_thumbnail_urls, uploaded_uris) assert isinstance(response, GetURLResponse) - assert len(response.urls) == len(IMAGE_FILES) + assert len(response.urls) == len(IMAGE_FILES + AUDIO_FILES) # Generate thumbnails and compare - for original_file, url in zip(IMAGE_FILES, response.urls, strict=True): + for original_file, url in zip(IMAGE_FILES, response.urls[: len(IMAGE_FILES)], strict=True): # Read original file content original_content = read_file_content(original_file) # Generate thumbnail - expected_thumbnail = generate_thumbnail(original_content) + expected_thumbnail = generate_image_thumbnail(original_content) assert expected_thumbnail is not None, f"Failed to generate thumbnail for {original_file}" if url.startswith(("http://", "https://")): @@ -157,6 +191,27 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): expected_thumbnail == downloaded_thumbnail ), f"Thumbnail mismatch for file: {original_file}" + # Generate audio thumbnails and compare + for original_file, url in zip(AUDIO_FILES, response.urls[len(IMAGE_FILES) :], strict=True): + # Read original file content + original_content = read_file_content(original_file) + + # Generate audio thumbnail + expected_thumbnail = generate_audio_thumbnail(original_content) + assert expected_thumbnail is not None, f"Failed to generate thumbnail for {original_file}" + + if url.startswith(("http://", "https://")): + downloaded_thumbnail = httpx.get(url, headers={"X-PROJECT-ID": "default"}).content + else: + downloaded_thumbnail = read_file_content(url) + + # Compare thumbnails + # TODO: debug the starting of thumbnail mismatch + assert ( + expected_thumbnail[-round(len(expected_thumbnail) * 0.9) :] + == downloaded_thumbnail[-round(len(expected_thumbnail) * 0.9) :] + ), f"Thumbnail mismatch for file: {original_file}" + # Check if the returned URIs are valid for url in response.urls: parsed_uri = urlparse(url) diff --git a/clients/python/tests/oss/test_gen_executor.py b/clients/python/tests/oss/test_gen_executor.py index dbdce44..1ca3d1d 100644 --- a/clients/python/tests/oss/test_gen_executor.py +++ b/clients/python/tests/oss/test_gen_executor.py @@ -1,17 +1,22 @@ import asyncio +import io import time from contextlib import asynccontextmanager +import httpx import pytest from flaky import flaky +from PIL import Image from jamaibase import JamAI, JamAIAsync from jamaibase.exceptions import ResourceNotFoundError from jamaibase.protocol import ( + CodeGenConfig, ColumnSchemaCreate, GenConfigUpdateRequest, GenTableRowsChatCompletionChunks, GenTableStreamChatCompletionChunk, + GetURLResponse, RegenStrategy, RowAddRequest, RowRegenRequest, @@ -539,5 +544,178 @@ async def test_multicols_regen_invalid_column_id( ) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_code_str(client_cls: JamAI | JamAIAsync, stream: bool): + jamai = client_cls() + cols = [ + ColumnSchemaCreate(id="code_column", dtype="str"), + ColumnSchemaCreate( + id="result_column", dtype="str", gen_config=CodeGenConfig(source_column="code_column") + ), + ] + + async with _create_table(jamai, TableType.action, cols) as table_id: + test_cases = [ + {"code": "print('Hello, World!')", "expected": "Hello, World!"}, + {"code": "result = 2 + 2\nprint(result)", "expected": "4"}, + {"code": "import math\nprint(math.pi)", "expected": "3.141592653589793"}, + {"code": "result = 5 * 5", "expected": "25"}, + {"code": "result = 'Python' + ' ' + 'Programming'", "expected": "Python Programming"}, + {"code": "result = [1, 2, 3, 4, 5]\nresult = sum(result)", "expected": "15"}, + # Define factorial function as globals namespace to be able to executed recursive calls. + # exec() creates a new local scope for the code it's executing, and the recursive calls can't access the function name in this temporary scope. + { + "code": "def factorial(n):\n return 1 if n == 0 else n * factorial(n-1)\nglobals()['factorial'] = factorial\nresult = factorial(5)", + "expected": "120", + }, + { + "code": "result = {x: x**2 for x in range(1, 6)}", + "expected": "{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}", + }, + ] + + for case in test_cases: + row_input_data = {"code_column": case["code"]} + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + + if stream: + print(chunks[0]) + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + print(chunks) + assert isinstance(chunks, GenTableRowsChatCompletionChunks) + + # Get rows + rows = await run(jamai.table.list_table_rows, TableType.action, table_id) + row_id = rows.items[0]["ID"] + row = await run(jamai.table.get_table_row, TableType.action, table_id, row_id) + assert row["result_column"]["value"].strip() == case["expected"] + + # Test error handling + error_code = "print(undefined_variable)" + row_input_data = {"code_column": error_code} + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + rows = await run(jamai.table.list_table_rows, TableType.action, table_id) + row_id = rows.items[0]["ID"] + row = await run(jamai.table.get_table_row, TableType.action, table_id, row_id) + assert "name 'undefined_variable' is not defined" in row["result_column"]["value"] + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_code_image(client_cls: JamAI | JamAIAsync, stream: bool): + jamai = client_cls() + cols = [ + ColumnSchemaCreate(id="code_column", dtype="str"), + ColumnSchemaCreate( + id="result_column", + dtype="image", + gen_config=CodeGenConfig(source_column="code_column"), + ), + ] + + async with _create_table(jamai, TableType.action, cols) as table_id: + test_cases = [ + { + "code": """ +import matplotlib.pyplot as plt +import io + +plt.figure(figsize=(10, 5)) +plt.plot([1, 2, 3, 4], [1, 4, 2, 3]) +plt.title('Simple Line Plot') +buf = io.BytesIO() +plt.savefig(buf, format='png') +buf.seek(0) +result = buf.getvalue() +""", + "expected_format": "PNG", + }, + { + "code": """ +from PIL import Image, ImageDraw +import io + +img = Image.new('RGB', (200, 200), color='red') +draw = ImageDraw.Draw(img) +draw.ellipse((50, 50, 150, 150), fill='blue') +buf = io.BytesIO() +img.save(buf, format='JPEG') +buf.seek(0) +result = buf.getvalue() +""", + "expected_format": "JPEG", + }, + { + "code": """ +result = b'This is not a valid image file' +""", + "expected_format": None, + }, + ] + + for case in test_cases: + row_input_data = {"code_column": case["code"]} + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + + if stream: + print(chunks[0]) + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + print(chunks) + assert isinstance(chunks, GenTableRowsChatCompletionChunks) + + # Get rows + rows = await run(jamai.table.list_table_rows, TableType.action, table_id) + row_id = rows.items[0]["ID"] + row = await run(jamai.table.get_table_row, TableType.action, table_id, row_id) + file_uri = row["result_column"]["value"] + + if case["expected_format"] is None: + assert file_uri is None + else: + assert file_uri.startswith(("file://", "s3://")) + + response = await run(jamai.file.get_raw_urls, [file_uri]) + assert isinstance(response, GetURLResponse) + for url in response.urls: + if url.startswith(("http://", "https://")): + # Handle HTTP/HTTPS URLs + HEADERS = {"X-PROJECT-ID": "default"} + with httpx.Client() as client: + downloaded_content = client.get(url, headers=HEADERS).content + + image = Image.open(io.BytesIO(downloaded_content)) + assert image.format == case["expected_format"] + + # Test error handling + error_code = "result = 1 / 0" + row_input_data = {"code_column": error_code} + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + + rows = await run(jamai.table.list_table_rows, TableType.action, table_id) + row_id = rows.items[0]["ID"] + row = await run(jamai.table.get_table_row, TableType.action, table_id, row_id) + + assert row["result_column"]["value"] is None + + if __name__ == "__main__": asyncio.run(test_multicols_regen_invalid_column_id(CLIENT_CLS[-1], REGEN_STRATEGY[1], True)) diff --git a/clients/typescript/__tests__/gentable.test.ts b/clients/typescript/__tests__/gentable.test.ts index 9632938..56936bc 100644 --- a/clients/typescript/__tests__/gentable.test.ts +++ b/clients/typescript/__tests__/gentable.test.ts @@ -178,7 +178,7 @@ describe("APIClient Gentable", () => { model: llmModel, prompt: "Suggest a followup questions on ${question}.", temperature: 1, - max_tokens: 100, + max_tokens: 30, top_p: 0.1 } }, @@ -189,7 +189,7 @@ describe("APIClient Gentable", () => { model: llmModel, temperature: 1, - max_tokens: 100, + max_tokens: 30, top_p: 0.1 } } @@ -312,7 +312,7 @@ describe("APIClient Gentable", () => { model: llmModel, prompt: "Suggest a followup questions on ${question}.", temperature: 1, - max_tokens: 100, + max_tokens: 30, top_p: 0.1 } } diff --git a/clients/typescript/src/resources/files/index.ts b/clients/typescript/src/resources/files/index.ts index 728bdbb..1cfe7d6 100644 --- a/clients/typescript/src/resources/files/index.ts +++ b/clients/typescript/src/resources/files/index.ts @@ -14,7 +14,7 @@ import { export class Files extends Base { public async uploadFile(params: IUploadFileRequest): Promise { - const apiURL = `/api/v1/files/upload/`; + const apiURL = `/api/v1/files/upload`; const parsedParams = UploadFileRequestSchema.parse(params); diff --git a/clients/typescript/src/resources/gen_tables/tables.ts b/clients/typescript/src/resources/gen_tables/tables.ts index b86bb01..5cb2de2 100644 --- a/clients/typescript/src/resources/gen_tables/tables.ts +++ b/clients/typescript/src/resources/gen_tables/tables.ts @@ -17,9 +17,9 @@ export const TableTypesSchema = z.enum(["action", "knowledge", "chat"]); export const IdSchema = z.string().regex(/^[A-Za-z0-9]([A-Za-z0-9 _-]{0,98}[A-Za-z0-9])?$/, "Invalid Id"); export const TableIdSchema = z.string().regex(/^[A-Za-z0-9]([A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/, "Invalid Table Id"); -const DtypeCreateEnumSchema = z.enum(["int", "float", "str", "bool", "file"]); +const DtypeCreateEnumSchema = z.enum(["int", "float", "str", "bool", "image"]); -const DtypeEnumSchema = z.enum(["int", "int8", "float", "float64", "float32", "float16", "bool", "str", "date-time", "file", "bytes"]); +const DtypeEnumSchema = z.enum(["int", "int8", "float", "float64", "float32", "float16", "bool", "str", "date-time", "image", "bytes"]); export const EmbedGenConfigSchema = z.object({ object: z.literal("gen_config.embed").default("gen_config.embed"), diff --git a/clients/typescript/src/resources/llm/model.ts b/clients/typescript/src/resources/llm/model.ts index e43437b..36c6f47 100644 --- a/clients/typescript/src/resources/llm/model.ts +++ b/clients/typescript/src/resources/llm/model.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const ModelInfoRequestSchema = z.object({ model: z.string().optional(), capabilities: z - .array(z.enum(["completion", "chat", "image", "embed", "rerank"])) + .array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])) .nullable() .optional() }); @@ -14,7 +14,7 @@ export const ModelInfoSchema = z.object({ name: z.string(), context_length: z.number().default(16384), languages: z.array(z.string()), - capabilities: z.array(z.enum(["completion", "chat", "image", "embed", "rerank"])).default(["chat"]), + capabilities: z.array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])).default(["chat"]), owned_by: z.string() }); @@ -26,7 +26,7 @@ export const ModelInfoResponseSchema = z.object({ export const ModelNamesRequestSchema = z.object({ prefer: z.string().optional(), capabilities: z - .array(z.enum(["completion", "chat", "image", "embed", "rerank"])) + .array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])) .nullable() .optional() }); diff --git a/docker/Dockerfile.owl b/docker/Dockerfile.owl index 6c797d9..a6ed08f 100644 --- a/docker/Dockerfile.owl +++ b/docker/Dockerfile.owl @@ -1,6 +1,7 @@ FROM python:3.12 RUN pip install --no-cache-dir --upgrade setuptools +RUN apt-get update -qq && apt-get install ffmpeg libavcodec-extra -y WORKDIR /app diff --git a/docker/compose.cpu.yml b/docker/compose.cpu.yml index a7a1e29..6ff3f25 100644 --- a/docker/compose.cpu.yml +++ b/docker/compose.cpu.yml @@ -177,5 +177,20 @@ services: networks: - jamai + # By default, kopi service is not enabled, and only used for testing. use --profile kopi along docker compose up if kopi is needed. + kopi: + profiles: ["kopi"] + image: hoipangg/kopi + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5569/health')"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + ports: + - "5569:5569" + networks: + - jamai + networks: jamai: diff --git a/docker/compose.mac.yml b/docker/compose.mac.yml deleted file mode 100644 index 6eaa8eb..0000000 --- a/docker/compose.mac.yml +++ /dev/null @@ -1,182 +0,0 @@ -services: - infinity: - image: michaelf34/infinity:0.0.70 - container_name: jamai_infinity - command: ["v2", "--engine", "torch", "--port", "6909", "--model-warmup", "--model-id", "${EMBEDDING_MODEL}", "--model-id", "${RERANKER_MODEL}"] - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:6909/health"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - volumes: - - ${PWD}/infinity_cache:/app/.cache - networks: - - jamai - - unstructuredio: - image: downloads.unstructured.io/unstructured-io/unstructured-api:latest - platform: linux/amd64 - entrypoint: ["/usr/bin/env", "bash", "-c", "uvicorn prepline_general.api.app:app --log-config logger_config.yaml --port 6989 --host 0.0.0.0"] - healthcheck: - test: ["CMD-SHELL", "wget http://localhost:6989/healthcheck -O /dev/null || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - networks: - - jamai - - docio: - build: - context: .. - dockerfile: docker/Dockerfile.docio - image: jamai/docio - pull_policy: build - command: ["python", "-m", "docio.entrypoints.api"] - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:6979/health || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - networks: - - jamai - - dragonfly: - image: "docker.dragonflydb.io/dragonflydb/dragonfly" - ulimits: - memlock: -1 - healthcheck: - test: ["CMD-SHELL", "nc -z localhost 6379 || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - # For better performance, consider `host` mode instead `port` to avoid docker NAT. - # `host` mode is NOT currently supported in Swarm Mode. - # https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode - # network_mode: "host" - # volumes: - # - ${PWD}/dragonflydata:/data - networks: - - jamai - - owl: - build: - context: .. - dockerfile: docker/Dockerfile.owl - image: jamai/owl - pull_policy: build - command: ["python", "-m", "owl.entrypoints.api"] - depends_on: - infinity: - condition: service_healthy - unstructuredio: - condition: service_healthy - docio: - condition: service_healthy - dragonfly: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail localhost:6969/api/health || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - volumes: - - ${PWD}/db:/app/api/db - - ${PWD}/logs:/app/api/logs - - ${PWD}/file:/app/api/file - ports: - - "${API_PORT:-6969}:6969" - networks: - - jamai - - starling: - extends: - service: owl - entrypoint: - - /bin/bash - - -c - - | - celery -A owl.entrypoints.starling worker --loglevel=info --max-memory-per-child 65536 --autoscale=2,4 & \ - celery -A owl.entrypoints.starling beat --loglevel=info & \ - FLOWER_UNAUTHENTICATED_API=1 celery -A owl.entrypoints.starling flower --loglevel=info - command: !reset [] - depends_on: - owl: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:5555/api/workers || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - ports: !override - - "${STARLING_PORT:-5555}:5555" - - frontend: - build: - context: .. - dockerfile: docker/Dockerfile.frontend - args: - JAMAI_URL: ${JAMAI_URL} - PUBLIC_JAMAI_URL: ${PUBLIC_JAMAI_URL} - PUBLIC_IS_SPA: ${PUBLIC_IS_SPA} - CHECK_ORIGIN: ${CHECK_ORIGIN} - image: jamai/frontend - pull_policy: build - command: ["node", "server"] - depends_on: - owl: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail localhost:4000 || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - environment: - - NODE_ENV=production - - BODY_SIZE_LIMIT=Infinity - env_file: - - ../.env - ports: - - "${FRONTEND_PORT:-4000}:4000" - networks: - - jamai - - # By default, minio service is not enabled, and only used for testing. use --profile minio along docker compose up if minio is needed. - minio: - profiles: ["minio"] - image: minio/minio - entrypoint: /bin/sh -c " minio server /data --console-address ':9001' & until (mc config host add myminio http://localhost:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}) do echo '...waiting...' && sleep 1; done; mc mb myminio/file; wait " - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - ports: - - "9000:9000" - - "9001:9001" - networks: - - jamai - -networks: - jamai: diff --git a/scripts/migration_v040.py b/scripts/migration_v040.py new file mode 100644 index 0000000..2d09e11 --- /dev/null +++ b/scripts/migration_v040.py @@ -0,0 +1,234 @@ +import os +import shutil +import sqlite3 +from datetime import datetime, timezone +from glob import glob +from os.path import basename, dirname, join + +import lancedb +import orjson +from loguru import logger +from pydantic_settings import BaseSettings, SettingsConfigDict + +from jamaibase.protocol import ColumnSchema + + +class EnvConfig(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore", cli_parse_args=False + ) + owl_db_dir: str = "db" + + +ENV_CONFIG = EnvConfig() +NOW = datetime.now(tz=timezone.utc).isoformat() + + +def backup_db(db_path: str, backup_dir: str): + """Backup SQLite database.""" + db_path_components = db_path.split(os.sep) + if db_path_components[-1] == "main.db": + bak_db_path = join(backup_dir, db_path_components[-1]) + else: + bak_db_path = join(backup_dir, *db_path_components[-3:]) + os.makedirs(dirname(bak_db_path), exist_ok=True) + with sqlite3.connect(db_path) as src, sqlite3.connect(bak_db_path) as dst: + src.backup(dst) + print(f"└─ Backed up SQLite database: {db_path} to {bak_db_path}") + + +def backup_lance_db(lance_dir: str, backup_dir: str): + """Backup LanceDB directory.""" + lance_dir_components = lance_dir.split(os.sep) + bak_lance_dir = join(backup_dir, *lance_dir_components[-3:]) + os.makedirs(dirname(bak_lance_dir), exist_ok=True) + + # Copy the .lance directory + shutil.copytree(lance_dir, bak_lance_dir, ignore=shutil.ignore_patterns("*.lock")) + print(f"└─ Backed up LanceDB directory: {lance_dir} to {bak_lance_dir}") + + +def find_sqlite_files(directory): + """Find all SQLite files in the directory.""" + sqlite_files = [] + for root, dirs, filenames in os.walk(directory, topdown=True): + # Don't visit Lance directories + lance_dirs = [d for d in dirs if d.endswith(".lance")] + for d in lance_dirs: + dirs.remove(d) + for filename in filenames: + if filename.endswith(".lock"): + continue + if filename.endswith(".db"): + sqlite_files.append(join(root, filename)) + return sqlite_files + + +def find_lance_dirs(directory, table_type): + """Find all LanceDB directories in the directory.""" + lance_dirs = [] + for root, dirs, _ in os.walk(directory, topdown=True): + for dir_name in dirs: + if dir_name.endswith(".lance"): + dir_components = dir_name.split(os.sep) + if root.split(os.sep)[-1] == table_type: + lance_dirs.append(join(root, *dir_components[:-1])) + return list(set(lance_dirs)) + + +def reset_column_dtype_from_file_to_image(db_path: str): + """Reset column dtype from 'file' to 'image' in SQLite tables.""" + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Fetch all TableMeta records + cursor.execute("SELECT id, cols FROM TableMeta") + records = cursor.fetchall() + + for i, record in enumerate(records): + table_id = record[0] + cols = orjson.loads(record[1]) + + updated_cols = [] + print(f"└─ (Table {i + 1:,d}/{len(records):,d}) Modifying table: {table_id}") + for col in cols: + col = ColumnSchema.model_validate(col) + if col.dtype == "file": + col.dtype = "image" + col = col.model_dump() + updated_cols.append(col) + + # Update the TableMeta record with the new cols + updated_cols_json = orjson.dumps(updated_cols).decode("utf-8") + cursor.execute( + "UPDATE TableMeta SET cols = ? WHERE id = ?", + (updated_cols_json, table_id), + ) + conn.commit() + print( + f"└─ (Table {i + 1:,d}/{len(records):,d}) Updated 'file' dtype to 'image' in table: {table_id}" + ) + # Checking + cursor.execute("SELECT id, cols FROM TableMeta") + records = cursor.fetchall() + for i, record in enumerate(records): + table_id = record[0] + cols = orjson.loads(record[1]) + print(f"└─ (Table {i + 1:,d}/{len(records):,d}) Checking table: {table_id}") + print( + f"\t└─ Current (column, dtype) pairs: {[(col['id'], col['dtype']) for col in cols]}" + ) + cursor.close() + conn.close() + except Exception as e: + logger.exception(f"└─ Error updating GenTable column due to {e}: {record}") + + +def add_page_column(db_path: str): + """Add 'Page' column to SQLite tables.""" + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + # Fetch all TableMeta records + cursor.execute("SELECT id, cols FROM TableMeta") + records = cursor.fetchall() + PAGE_COLUMN_ID = "Page" + for i, record in enumerate(records): + table_id = record[0] + print(f"└─ (Table {i + 1:,d}/{len(records):,d}) Modifying table: {table_id}") + cols = orjson.loads(record[1]) + has_page_column = False + for col in cols: + col = ColumnSchema.model_validate(col) + if col.id == PAGE_COLUMN_ID: + has_page_column = True + break + if not has_page_column: + cols.append( + ColumnSchema( + id=PAGE_COLUMN_ID, + dtype="int", + ).model_dump() + ) + cols.append( + ColumnSchema( + id=f"{PAGE_COLUMN_ID}_", + dtype="str", + ).model_dump() + ) + updated_cols_json = orjson.dumps(cols).decode("utf-8") + cursor.execute( + "UPDATE TableMeta SET cols = ? WHERE id = ?", + (updated_cols_json, table_id), + ) + conn.commit() + print( + f"└─ (Table {i + 1:,d}/{len(records):,d}) Added '{PAGE_COLUMN_ID}' column to table: {table_id}" + ) + else: + print( + f"└─ (Table {i + 1:,d}/{len(records):,d}) Table: {table_id} already has '{PAGE_COLUMN_ID}' column" + ) + # Checking + cursor.execute("SELECT id, cols FROM TableMeta") + records = cursor.fetchall() + for i, record in enumerate(records): + table_id = record[0] + cols = orjson.loads(record[1]) + print(f"└─ (Table {i + 1:,d}/{len(records):,d}) Checking table: {table_id}") + print(f"\t└─ Current columns: {[col['id'] for col in cols]}") + cursor.close() + conn.close() + except Exception as e: + logger.exception(f"└─ Error adding columns due to {e}") + + +def add_page_column_to_lance_table(lance_dir: str): + """Add 'Page' column to LanceDB tables.""" + try: + db = lancedb.connect(lance_dir) + table_names = [ + basename(table_dir).replace(".lance", "") + for table_dir in glob(join(lance_dir, "*.lance")) + ] + for i, table_name in enumerate(table_names): + print( + f"└─ (Table {i + 1:,d}/{len(table_names):,d}) Modifying LanceDB table: {table_name}" + ) + tbl = db.open_table(table_name) + if "Page" not in tbl.schema.names: + tbl.add_columns( + { + "Page": "cast(NULL as bigint)", + "Page_": "cast('{\"is_null\": true}' as string)", + } + ) + print(f"\t└─ Added 'Page' column to LanceDB table: {table_name}") + else: + print(f"\t└─ LanceDB table: {table_name} already has 'Page' column") + print(f"\t└─ Current columns: {tbl.schema.names}") + except Exception as e: + logger.exception(f"└─ Error adding columns to LanceDB table due to {e}") + + +if __name__ == "__main__": + backup_dir = f"{ENV_CONFIG.owl_db_dir}_BAK_{NOW}" + os.makedirs(backup_dir, exist_ok=False) + + # Backup SQLite files + sqlite_files = find_sqlite_files(ENV_CONFIG.owl_db_dir) + for j, db_file in enumerate(sqlite_files): + print(f"(DB {j + 1:,d}/{len(sqlite_files):,d}): Processing: {db_file}") + backup_db(db_file, backup_dir) + if not db_file.endswith("main.db"): + reset_column_dtype_from_file_to_image(db_file) + if db_file.endswith("knowledge.db"): + add_page_column(db_file) + + # Backup and process knowledge table LanceDB files + kt_lance_dirs = find_lance_dirs(ENV_CONFIG.owl_db_dir, "knowledge") + for k, kt_lance_dir in enumerate(kt_lance_dirs): + print(f"(LanceDB {k + 1:,d}/{len(kt_lance_dirs):,d}): Processing: {kt_lance_dir}") + backup_lance_db(kt_lance_dir, backup_dir) + add_page_column_to_lance_table(kt_lance_dir) diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml index 61ef746..1f45a29 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -25,7 +25,7 @@ filterwarnings = [ [tool.ruff] line-length = 99 indent-width = 4 -target-version = "py310" +target-version = "py312" extend-include = [".pyi?$", ".ipynb"] extend-exclude = ["archive/*"] respect-gitignore = true @@ -84,7 +84,7 @@ description = "Owl: API server for JamAI Base." readme = "README.md" requires-python = "~=3.10" # keywords = ["one", "two"] -license = { text = "Proprietary" } +license = { text = "Apache 2.0" } classifiers = [ # https://pypi.org/classifiers/ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3 :: Only", @@ -110,7 +110,7 @@ dependencies = [ "lancedb==0.12.0", "langchain-community~=0.2.12", "langchain~=0.2.14", - "litellm~=1.48.17", + "litellm~=1.50.0", "loguru~=0.7.2", "natsort[fast]>=8.4.0", "numpy>=1.26.4", @@ -123,6 +123,7 @@ dependencies = [ "pycryptodomex~=3.20.0", "pydantic-settings~=2.4.0", "pydantic[email,timezone]~=2.8.2", + "pydub~=0.25.1", "pyjwt~=2.9.0", # pylance 0.13.0 has issues with row deletion "pylance==0.16.0", diff --git a/services/api/src/owl/configs/manager.py b/services/api/src/owl/configs/manager.py index 30f3677..a434edc 100644 --- a/services/api/src/owl/configs/manager.py +++ b/services/api/src/owl/configs/manager.py @@ -15,9 +15,9 @@ from redis.retry import Retry from owl.protocol import ( - EXAMPLE_CHAT_MODEL, - EXAMPLE_EMBEDDING_MODEL, - EXAMPLE_RERANKING_MODEL, + EXAMPLE_CHAT_MODEL_IDS, + EXAMPLE_EMBEDDING_MODEL_IDS, + EXAMPLE_RERANKING_MODEL_IDS, ModelListConfig, ) @@ -26,7 +26,11 @@ class EnvConfig(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore", cli_parse_args=False + # env_prefix="owl_", # TODO: Enable this + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + cli_parse_args=False, ) # API configs owl_is_prod: bool = False @@ -45,7 +49,9 @@ class EnvConfig(BaseSettings): owl_redis_port: int = 6379 owl_internal_org_id: str = "org_82d01c923f25d5939b9d4188" # Configs - owl_file_upload_max_bytes: int = 20 * 1024 * 1024 # 20MB in bytes + owl_embed_file_upload_max_bytes: int = 200 * 1024 * 1024 # 200MB in bytes + owl_image_file_upload_max_bytes: int = 20 * 1024 * 1024 # 20MB in bytes + owl_audio_file_upload_max_bytes: int = 120 * 1024 * 1024 # 120MB in bytes owl_compute_storage_period_min: float = 1 owl_models_config: str = "models.json" owl_pricing_config: str = "cloud_pricing.json" @@ -63,6 +69,8 @@ class EnvConfig(BaseSettings): owl_concurrent_rows_batch_size: int = 3 owl_concurrent_cols_batch_size: int = 5 owl_max_write_batch_size: int = 1000 + # Code Executor configs + code_executor_endpoint: str = "http://kopi:5569" # Loader configs docio_url: str = "http://docio:6979/api/docio" unstructuredio_url: str = "http://unstructuredio:6989" @@ -98,6 +106,7 @@ class EnvConfig(BaseSettings): hyperbolic_api_key: SecretStr = "" cerebras_api_key: SecretStr = "" sambanova_api_key: SecretStr = "" + deepseek_api_key: SecretStr = "" @model_validator(mode="after") def make_paths_absolute(self): @@ -203,6 +212,10 @@ def cerebras_api_key_plain(self): def sambanova_api_key_plain(self): return self.sambanova_api_key.get_secret_value() + @property + def deepseek_api_key_plain(self): + return self.deepseek_api_key.get_secret_value() + MODEL_CONFIG_KEY = " models" PRICES_KEY = " prices" @@ -341,7 +354,11 @@ class _ModelPrice(BaseModel): 'Unique identifier in the form of "{provider}/{model_id}". ' "Users will specify this to select a model." ), - examples=[EXAMPLE_CHAT_MODEL, EXAMPLE_EMBEDDING_MODEL, EXAMPLE_RERANKING_MODEL], + examples=[ + EXAMPLE_CHAT_MODEL_IDS[0], + EXAMPLE_EMBEDDING_MODEL_IDS[0], + EXAMPLE_RERANKING_MODEL_IDS[0], + ], ) name: str = Field( description="Name of the model.", @@ -482,7 +499,6 @@ def get_model_json(self) -> str: if model_json is None: model_json = self._load_model_config_from_file().model_dump_json() self[MODEL_CONFIG_KEY] = model_json - logger.warning(f"Model config set to: {model_json}") return model_json def get_model_config(self) -> ModelListConfig: diff --git a/services/api/src/owl/configs/models_ci.json b/services/api/src/owl/configs/models_ci.json index d37b069..fcc62a0 100644 --- a/services/api/src/owl/configs/models_ci.json +++ b/services/api/src/owl/configs/models_ci.json @@ -5,7 +5,7 @@ "name": "OpenAI GPT-4o Mini", "context_length": 128000, "languages": ["mul"], - "capabilities": ["chat", "image"], + "capabilities": ["chat", "image", "tool"], "deployments": [ { "litellm_id": "", @@ -19,7 +19,7 @@ "name": "Anthropic Claude 3 Haiku", "context_length": 200000, "languages": ["mul"], - "capabilities": ["chat"], + "capabilities": ["chat", "tool"], "deployments": [ { "litellm_id": "", @@ -29,16 +29,31 @@ ] }, { - "id": "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", - "name": "Together AI Meta Llama 3.1 (8B)", - "context_length": 130000, + "id": "meta/Llama3.2-3b-instruct", + "name": "Meta Llama 3.2 (3B)", + "context_length": 128000, "languages": ["mul"], "capabilities": ["chat"], "deployments": [ { - "litellm_id": "", - "api_base": "", - "provider": "together_ai" + "litellm_id": "openai/meta/Llama3.2-3b-instruct", + "api_base": "https://llmci.embeddedllm.com/chat/v1", + "provider": "custom" + } + ] + }, + { + "id": "ellm/Qwen/Qwen-2-Audio-7B", + "object": "model", + "name": "Qwen 2 Audio 7B (Audio, internal)", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat", "audio"], + "deployments": [ + { + "litellm_id": "openai/Qwen/Qwen-2-Audio-7B", + "api_base": "https://llmci.embeddedllm.com/audio/v1", + "provider": "custom" } ] } diff --git a/services/api/src/owl/db/gen_executor.py b/services/api/src/owl/db/gen_executor.py index 466ebda..493ce07 100644 --- a/services/api/src/owl/db/gen_executor.py +++ b/services/api/src/owl/db/gen_executor.py @@ -21,6 +21,7 @@ ChatCompletionChunk, ChatEntry, ChatRequest, + CodeGenConfig, EmbedGenConfig, ExternalKeys, GenTableChatCompletionChunks, @@ -36,14 +37,15 @@ TableMeta, ) from owl.utils import mask_string, uuid7_draft2_str +from owl.utils.code import code_executor from owl.utils.io import open_uri_async @dataclass(slots=True) class Task: - type: Literal["embed", "chat"] + type: Literal["embed", "chat", "code"] output_column_name: str - body: ChatRequest | EmbedGenConfig + body: ChatRequest | EmbedGenConfig | CodeGenConfig dtype: str @@ -290,9 +292,12 @@ def __init__( self.error_columns = [] self.tag_regen_columns = [] self.skip_regen_columns = [] - self.file_columns = [] - self.img_column_dict = {} - self.doc_column_dict = {} + self.image_columns = [] + self.audio_columns = [] + self.audio_gen_columns = [] + self.image_column_dict = {} + self.document_column_dict = {} + self.audio_column_dict = {} def _log_exception(self, exc: Exception, error_message: str): if not isinstance(exc, (JamaiException, RequestValidationError)): @@ -302,6 +307,52 @@ async def _get_file_binary(self, uri: str) -> bytes: async with open_uri_async(uri) as file_handle: return await file_handle.read() + # TODO: resolve duplicated code + async def _convert_uri_to_base64(self, uri: str, col_id: str) -> tuple[dict, bool]: + """ + Converts a URI to a base64-encoded string with the appropriate prefix and determines the file type. + + Args: + uri (str): The URI of the file. + col_id (str): The column ID for error context. + + Returns: + tuple: A tuple containing: + - dict: A dictionary with the base64-encoded data and its prefix. + - bool: A boolean indicating whether the file is audio. + + Raises: + ValueError: If the file format is unsupported. + """ + if not uri.startswith(("file://", "s3://")): + raise ValueError( + f"Invalid URI format for column {col_id}. URI must start with 'file://' or 's3://'" + ) + + # uri -> file binary -> base64 + file_binary = await self._get_file_binary(uri) + base64_data = self._binary_to_base64(file_binary) + + # uri -> file extension -> prefix + extension = splitext(uri)[1].lower() + + if extension in [".mp3", ".wav"]: + prefix = f"data:audio/{"mpeg" if extension == ".mp3" else "x-wav"};base64," + return { + "data": base64_data, + "format": extension[1:], + "url": prefix + base64_data, + }, True + elif extension in [".jpeg", ".jpg", ".png", ".gif", ".webp"]: + extension = ".jpeg" if extension == ".jpg" else extension + prefix = f"data:image/{extension[1:]};base64," + return {"url": prefix + base64_data}, False + else: + raise ValueError( + "Unsupported file type. Supported formats are: " + "['jpeg/jpg', 'png', 'gif', 'webp'] for images and ['mp3', 'wav'] for audio." + ) + async def gen_row(self) -> Any | tuple[GenTableChatCompletionChunks, dict]: cols = self.meta.cols_schema col_ids = set(c.id for c in cols) @@ -354,32 +405,49 @@ async def gen_row(self) -> Any | tuple[GenTableChatCompletionChunks, dict]: gen_config = ChatRequest( id=self.request.state.id, messages=messages, **col.gen_config.model_dump() ) + if gen_config.model != "": + model_config = self.request.state.all_models.get_llm_model_info( + gen_config.model + ) + if ( + "audio" in model_config.capabilities + and model_config.deployments[0].provider == "openai" + ): + self.audio_gen_columns.append(col.id) + elif isinstance(col.gen_config, CodeGenConfig): + task_type = "code" + gen_config = col.gen_config else: raise ValueError(f'Unexpected "gen_config" type: {type(col.gen_config)}') self.tasks.append( Task(type=task_type, output_column_name=col.id, body=gen_config, dtype=col.dtype) ) - self.file_columns = [col.id for col in cols if col.dtype == "file"] - for col_id in self.file_columns: + self.image_columns = [col.id for col in cols if col.dtype == "image"] + self.audio_columns = [col.id for col in cols if col.dtype == "audio"] + for col_id in self.image_columns + self.audio_columns: if self.column_dict.get(col_id, None) is not None: uri = self.column_dict[col_id] - # uri -> file binary -> base64 - file_binary = await self._get_file_binary(uri) - base64 = self._binary_to_base64(file_binary) - - # uri -> file extension -> prefix - extension = splitext(uri)[1].lower() - if extension in [".jpeg", ".jpg", ".png", ".gif", ".webp"]: - extension = ".jpeg" if extension == ".jpg" else extension - prefix = f"data:image/{extension[1:]};base64," - # url = prefix + base64 - self.img_column_dict[col_id] = prefix + base64 - else: - raise ValueError( - "Unsupported image, make sure the image belongs to " - "one of the following formats: ['jpeg/jpg', 'png', 'gif', 'webp']." + b64, is_audio = await self._convert_uri_to_base64(uri, col_id) + + if is_audio: + if col_id not in self.audio_columns: + raise ValueError( + f"Column {col_id} is not marked as an audio column but contains audio data." + ) + self.audio_column_dict[col_id] = ( + { + "data": b64["data"], + "format": b64["format"], + }, # for audio gen model + {"url": b64["url"]}, # for audio model ) + else: + if col_id not in self.image_columns: + raise ValueError( + f"Column {col_id} is not marked as a file column but contains image data." + ) + self.image_column_dict[col_id] = b64 column_dict_keys = set(self.column_dict.keys()) if len(column_dict_keys - col_ids) > 0: @@ -422,7 +490,7 @@ def _extract_upstream_image_columns(self, text: str) -> list[str]: def _binary_to_base64(self, binary_data: bytes) -> str: return base64.b64encode(binary_data).decode("utf-8") - def _interpolate_column(self, prompt: str) -> str | dict[str, Any]: + def _interpolate_column(self, prompt: str, base_column_name: str) -> str | dict[str, Any]: """ Replaces / interpolates column references in the prompt with their contents. @@ -434,15 +502,22 @@ def _interpolate_column(self, prompt: str) -> str | dict[str, Any]: """ image_column_names = [] + audio_column_names = [] def replace_match(match): column_name = match.group(1) # Extract the column_name from the match try: - if column_name in self.img_column_dict: + if column_name in self.image_column_dict: image_column_names.append(column_name) return "" - elif column_name in self.doc_column_dict: - return self.doc_column_dict[column_name] + elif column_name in self.audio_column_dict: + audio_column_names.append(column_name) + if base_column_name in self.audio_gen_columns: + return "" # follow the content type + else: + return "" + elif column_name in self.document_column_dict: + return self.document_column_dict[column_name] return str(self.column_dict[column_name]) # Data can be non-string except KeyError as e: raise BadInputError(f"Requested column '{column_name}' is not found.") from e @@ -450,6 +525,9 @@ def replace_match(match): content_ = re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, prompt) content = [{"type": "text", "text": content_}] + if len(image_column_names) > 0 and len(audio_column_names) > 0: + raise BadInputError("Either image or audio is supported per completion.") + if len(image_column_names) > 0: if len(image_column_names) > 1: raise BadInputError("Only one image is supported per completion.") @@ -457,10 +535,29 @@ def replace_match(match): content.append( { "type": "image_url", - "image_url": {"url": self.img_column_dict[image_column_names[0]]}, + "image_url": self.image_column_dict[image_column_names[0]], } ) return content + elif len(audio_column_names) > 0: + if len(audio_column_names) > 1: + raise BadInputError("Only one audio is supported per completion.") + + if base_column_name in self.audio_gen_columns: + content.append( + { + "type": "input_audio", + "input_audio": self.audio_column_dict[audio_column_names[0]][0], + } + ) + else: + content.append( + { + "type": "audio_url", + "audio_url": self.audio_column_dict[audio_column_names[0]][1], + } + ) + return content else: return content_ @@ -469,6 +566,53 @@ def _check_upstream_error_chunk(self, content: str) -> None: if any([match in self.error_columns for match in matches]): raise Exception + def _validate_model(self, body: LLMGenConfig, output_column_name: str): + for input_column_name in self.dependencies[output_column_name]: + if input_column_name in self.image_column_dict: + try: + body.model = self.llm.validate_model_id(body.model, ["image"]) + break + except ResourceNotFoundError as e: + raise BadInputError( + f'Column "{output_column_name}" referred to image file input but using a chat model ' + f'"{self.llm.get_model_name(body.model) if self.llm.is_browser else body.model}", ' + "select image model instead.", + ) from e + if input_column_name in self.audio_column_dict: + try: + body.model = self.llm.validate_model_id(body.model, ["audio"]) + break + except ResourceNotFoundError as e: + raise BadInputError( + f'Column "{output_column_name}" referred to audio file input but using a chat model ' + f'"{self.llm.get_model_name(body.model) if self.llm.is_browser else body.model}", ' + "select audio model instead.", + ) from e + + async def _execute_code(self, task: Task) -> str: + output_column_name = task.output_column_name + body: CodeGenConfig = task.body + dtype = task.dtype + source_code = self.column_dict[body.source_column] + + try: + new_column_value = await code_executor(source_code, dtype, self.request) + except Exception as e: + new_column_value = f"[ERROR] {str(e)}" + self._log_exception(e, f'Error executing code for column "{output_column_name}": {e}') + + if dtype == "image" and new_column_value is not None: + try: + ( + self.image_column_dict[output_column_name], + _, + ) = await self._convert_uri_to_base64(new_column_value, output_column_name) + except ValueError as e: + self._log_exception(e, f"Invalid file path for column '{output_column_name}'") + new_column_value = None + + return new_column_value + async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: """ Executes a single task in a streaming manner, returning an asynchronous generator of chunks. @@ -478,38 +622,21 @@ async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: try: logger.debug(f"Processing column: {output_column_name}") - self._check_upstream_error_chunk(body.messages[-1].content) - body.messages[-1].content = self._interpolate_column(body.messages[-1].content) - - if isinstance(body.messages[-1].content, list): - for input_column_name in self.dependencies[output_column_name]: - if input_column_name in self.img_column_dict: - try: - body.model = self.llm.validate_model_id(body.model, ["image"]) - break - except ResourceNotFoundError as e: - raise BadInputError( - f'Column "{output_column_name}" referred to image file input but using a chat model ' - f'"{self.llm.get_model_name(body.model) if self.llm.is_browser else body.model}", ' - "select image model instead.", - ) from e if output_column_name in self.skip_regen_columns: new_column_value = self.column_dict[output_column_name] logger.debug( f"Skipped regen for `{output_column_name}`, value: {new_column_value}" ) - elif output_column_name in self.file_columns: - new_column_value = None - logger.info( - f"Identified output column `{output_column_name}` as file type, set value to {new_column_value}" - ) + elif isinstance(body, CodeGenConfig): + new_column_value = await self._execute_code(task) + logger.info(f"Executed Code Execution Column: '{output_column_name}'") chunk = GenTableStreamChatCompletionChunk( id=self.request.state.id, object="gen_table.completion.chunk", created=int(time()), - model="", + model="code_execution", usage=None, choices=[ ChatCompletionChoiceDelta( @@ -522,36 +649,62 @@ async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: ) yield f"data: {chunk.model_dump_json()}\n\n" - else: - new_column_value = "" - kwargs = body.model_dump() - messages, references = await self.llm.retrieve_references( - messages=kwargs.pop("messages"), - rag_params=kwargs.pop("rag_params", None), - **kwargs, + elif isinstance(body, ChatRequest): + self._check_upstream_error_chunk(body.messages[-1].content) + body.messages[-1].content = self._interpolate_column( + body.messages[-1].content, output_column_name ) - if references is not None: - ref = GenTableStreamReferences( - **references.model_dump(exclude=["object"]), - output_column_name=output_column_name, + + if isinstance(body.messages[-1].content, list): + self._validate_model(body, output_column_name) + + if output_column_name in self.image_columns + self.audio_columns: + new_column_value = None + logger.info( + f"Identified output column `{output_column_name}` as image / audio type, set value to {new_column_value}" ) - yield f"data: {ref.model_dump_json()}\n\n" - async for chunk in self.llm.generate_stream(messages=messages, **kwargs): - new_column_value += chunk.text chunk = GenTableStreamChatCompletionChunk( - **chunk.model_dump(exclude=["object"]), + id=self.request.state.id, + object="gen_table.completion.chunk", + created=int(time()), + model="", + usage=None, + choices=[ + ChatCompletionChoiceDelta( + message=ChatEntry.assistant(new_column_value), + index=0, + ) + ], output_column_name=output_column_name, row_id=self.row_id, ) yield f"data: {chunk.model_dump_json()}\n\n" - if chunk.finish_reason == "error": - self.error_columns.append(output_column_name) - logger.info( - ( - f"{self.request.state.id} - Streamed completion for " - f"{output_column_name}: <{mask_string(new_column_value)}>" + else: + new_column_value = "" + kwargs = body.model_dump() + messages, references = await self.llm.retrieve_references( + messages=kwargs.pop("messages"), + rag_params=kwargs.pop("rag_params", None), + **kwargs, ) - ) + if references is not None: + ref = GenTableStreamReferences( + **references.model_dump(exclude=["object"]), + output_column_name=output_column_name, + ) + yield f"data: {ref.model_dump_json()}\n\n" + async for chunk in self.llm.generate_stream(messages=messages, **kwargs): + new_column_value += chunk.text + chunk = GenTableStreamChatCompletionChunk( + **chunk.model_dump(exclude=["object"]), + output_column_name=output_column_name, + row_id=self.row_id, + ) + yield f"data: {chunk.model_dump_json()}\n\n" + if chunk.finish_reason == "error": + self.error_columns.append(output_column_name) + else: + raise ValueError(f"Unsupported task type: {type(body)}") except Exception as e: error_chunk = GenTableStreamChatCompletionChunk( @@ -580,6 +733,10 @@ async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: # Append new column data for subsequent tasks self.column_dict[output_column_name] = new_column_value self.regen_column_dict[output_column_name] = new_column_value + logger.info( + f"{self.request.state.id} - Streamed completion for " + f"{output_column_name}: <{mask_string(new_column_value)}>" + ) async def _execute_task_nonstream(self, task: Task): """ @@ -589,15 +746,6 @@ async def _execute_task_nonstream(self, task: Task): body: ChatRequest = task.body try: - body.messages[-1].content = self._interpolate_column(body.messages[-1].content) - except IndexError: - pass - try: - if isinstance(body.messages[-1].content, list): - for input_column_name in self.dependencies[output_column_name]: - if input_column_name in self.img_column_dict: - body.model = self.llm.validate_model_id(body.model, ["image"]) - break if output_column_name in self.skip_regen_columns: new_column_value = self.column_dict[output_column_name] response = ChatCompletionChunk( @@ -616,27 +764,60 @@ async def _execute_task_nonstream(self, task: Task): logger.debug( f"Skipped regen for `{output_column_name}`, value: {new_column_value}" ) - elif output_column_name in self.file_columns: - new_column_value = None + + elif isinstance(body, CodeGenConfig): + new_column_value = await self._execute_code(task) response = ChatCompletionChunk( id=self.request.state.id, object="chat.completion.chunk", created=int(time()), - model="", + model="code_execution", usage=None, choices=[ ChatCompletionChoiceDelta( - message=ChatEntry.assistant(new_column_value), index=0, + message=ChatEntry.assistant(new_column_value), ) ], ) - logger.info( - f"Identified output column `{output_column_name}` as file type, set value to {new_column_value}" + logger.debug( + f"Identified as Code Execution Column: {task.output_column_name}, executing code." ) + elif isinstance(body, ChatRequest): + self._check_upstream_error_chunk(body.messages[-1].content) + try: + body.messages[-1].content = self._interpolate_column( + body.messages[-1].content, output_column_name + ) + except IndexError: + pass + + if isinstance(body.messages[-1].content, list): + self._validate_model(body, output_column_name) + + if output_column_name in self.image_columns + self.audio_columns: + new_column_value = None + response = ChatCompletionChunk( + id=self.request.state.id, + object="chat.completion.chunk", + created=int(time()), + model="", + usage=None, + choices=[ + ChatCompletionChoiceDelta( + message=ChatEntry.assistant(new_column_value), + index=0, + ) + ], + ) + logger.debug( + f"Identified output column `{output_column_name}` as image / audio type, set value to {new_column_value}" + ) + else: + response = await self.llm.rag(**body.model_dump()) + new_column_value = response.text else: - response = await self.llm.rag(**body.model_dump()) - new_column_value = response.text + raise ValueError(f"Unsupported task type: {type(body)}") # append new column data for subsequence tasks self.column_dict[output_column_name] = new_column_value @@ -705,14 +886,25 @@ def _setup_dependencies(self) -> None: self.llm_tasks = { task.output_column_name: task for task in self.tasks if task.type == "chat" } + self.code_tasks = { + task.output_column_name: task for task in self.tasks if task.type == "code" + } self.dependencies = { task.output_column_name: self._extract_upstream_columns(task.body.messages[-1].content) for task in self.llm_tasks.values() } + self.dependencies.update( + { + task.output_column_name: [task.body.source_column] + for task in self.code_tasks.values() + } + ) logger.debug(f"Initial dependencies: {self.dependencies}") self.input_column_names = [ - key for key in self.column_dict.keys() if key not in self.llm_tasks.keys() + key + for key in self.column_dict.keys() + if key not in self.llm_tasks.keys() and key not in self.code_tasks.keys() ] def _mark_regen_columns(self) -> None: @@ -722,8 +914,12 @@ def _mark_regen_columns(self) -> None: if self.is_row_add: return + # Get the current column order from the table metadata + cols = self.meta.cols_schema + col_ids = [col.id for col in cols] + if self.body.regen_strategy == RegenStrategy.RUN_ALL: - self.tag_regen_columns = self.llm_tasks.keys() + self.tag_regen_columns = set(self.llm_tasks.keys()).union(self.code_tasks.keys()) elif self.body.regen_strategy == RegenStrategy.RUN_SELECTED: self.tag_regen_columns.append(self.body.output_column_id) @@ -733,13 +929,13 @@ def _mark_regen_columns(self) -> None: RegenStrategy.RUN_AFTER, ): if self.body.regen_strategy == RegenStrategy.RUN_BEFORE: - for column_name in self.column_dict.keys(): + for column_name in col_ids: self.tag_regen_columns.append(column_name) if column_name == self.body.output_column_id: break else: # RegenStrategy.RUN_AFTER reached_column = False - for column_name in self.column_dict.keys(): + for column_name in col_ids: if column_name == self.body.output_column_id: reached_column = True if reached_column: @@ -749,9 +945,7 @@ def _mark_regen_columns(self) -> None: raise ValueError(f"Invalid regeneration strategy: {self.body.regen_strategy}") self.skip_regen_columns = [ - column_name - for column_name in self.column_dict.keys() - if column_name not in self.tag_regen_columns + column_name for column_name in col_ids if column_name not in self.tag_regen_columns ] async def _nonstream_concurrent_execution(self) -> tuple[GenTableChatCompletionChunks, dict]: @@ -766,7 +960,11 @@ async def _nonstream_concurrent_execution(self) -> tuple[GenTableChatCompletionC responses = {} async def execute_task(task_name): - task = self.llm_tasks[task_name] + try: + task = self.llm_tasks[task_name] + except Exception: + task = self.code_tasks[task_name] + try: responses[task_name] = await self._execute_task_nonstream(task) except Exception as e: @@ -775,7 +973,9 @@ async def execute_task(task_name): completed.add(task_name) tasks_in_progress.remove(task_name) - while len(completed) < (len(self.llm_tasks) + len(self.input_column_names)): + while len(completed) < ( + len(self.llm_tasks) + len(self.code_tasks) + len(self.input_column_names) + ): ready_tasks = [ task_name for task_name, deps in self.dependencies.items() @@ -812,8 +1012,20 @@ async def _stream_concurrent_execution(self) -> AsyncGenerator[str, None]: queue = asyncio.Queue() tasks_in_progress = set() + ready_tasks = [ + task_name + for task_name, deps in self.dependencies.items() + if all(dep in completed for dep in deps) + and task_name not in completed + and task_name not in tasks_in_progress + ] + async def execute_task(task_name): - task = self.llm_tasks[task_name] + try: + task = self.llm_tasks[task_name] + except Exception: + task = self.code_tasks[task_name] + try: async for chunk in self._execute_task_stream(task): await queue.put((task_name, chunk)) @@ -824,7 +1036,9 @@ async def execute_task(task_name): await queue.put((task_name, None)) tasks_in_progress.remove(task_name) - while len(completed) < (len(self.llm_tasks) + len(self.input_column_names)): + while len(completed) < ( + len(self.llm_tasks) + len(self.code_tasks) + len(self.input_column_names) + ): ready_tasks = [ task_name for task_name, deps in self.dependencies.items() diff --git a/services/api/src/owl/db/gen_table.py b/services/api/src/owl/db/gen_table.py index abf1c0d..bcf09ab 100644 --- a/services/api/src/owl/db/gen_table.py +++ b/services/api/src/owl/db/gen_table.py @@ -75,7 +75,8 @@ "float16": 0.0, "bool": False, "str": "''", - "file": "''", + "image": "''", + "audio": "''", } @@ -1101,8 +1102,10 @@ def _interpolate_column( def replace_match(match): col_id = match.group(1) try: - if column_dtypes[col_id] == "file": - return "" + if column_dtypes[col_id] == "image": + return "" + elif column_dtypes[col_id] == "audio": + return "" return str(column_contents[col_id]) except KeyError as e: raise KeyError(f'Referenced column "{col_id}" is not found.') from e @@ -1222,7 +1225,7 @@ def dump_parquet( # Convert into Arrow Table pa_table = table._dataset.to_table(offset=None, limit=None) # Add file data into Arrow Table - file_col_ids = [col.id for col in meta.cols_schema if col.dtype == "file"] + file_col_ids = [col.id for col in meta.cols_schema if col.dtype in ["image", "audio"]] for col_id in file_col_ids: file_bytes = [] for uri in pa_table.column(col_id).to_pylist(): @@ -1277,7 +1280,7 @@ async def import_parquet( if session.get(TableMeta, table_id_dst) is not None: raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') # Upload files - file_col_ids = [col.id for col in meta.cols_schema if col.dtype == "file"] + file_col_ids = [col.id for col in meta.cols_schema if col.dtype in ["image", "audio"]] for col_id in file_col_ids: new_uris = [] for old_uri, content in zip( @@ -1789,7 +1792,7 @@ class ActionTable(GenerativeTable): class KnowledgeTable(GenerativeTable): - FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] @override def create_table( @@ -1831,6 +1834,7 @@ def create_table( ), ), ColumnSchema(id="File ID", dtype=ColumnDtype.STR), + ColumnSchema(id="Page", dtype=ColumnDtype.INT), ] + schema.cols, ) diff --git a/services/api/src/owl/entrypoints/api.py b/services/api/src/owl/entrypoints/api.py index 72b3e0e..d9f2678 100644 --- a/services/api/src/owl/entrypoints/api.py +++ b/services/api/src/owl/entrypoints/api.py @@ -5,7 +5,7 @@ import os from typing import Any -from fastapi import FastAPI, Request, status +from fastapi import BackgroundTasks, FastAPI, Request, status from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse @@ -195,9 +195,18 @@ async def log_request(request: Request, call_next): ): return await call_next(request) - # --- Call request --- # + # Call request response = await call_next(request) logger.info(make_request_log_str(request, response.status_code)) + + # Add egress events + request.state.billing.create_egress_events( + float(response.headers.get("content-length", 0)) / (1024**3) + ) + # Process billing (this will run AFTER streaming responses are sent) + tasks = BackgroundTasks() + tasks.add_task(request.state.billing.process_all) + response.background = tasks return response diff --git a/services/api/src/owl/llm.py b/services/api/src/owl/llm.py index c8458fd..96a78e7 100644 --- a/services/api/src/owl/llm.py +++ b/services/api/src/owl/llm.py @@ -27,12 +27,14 @@ from owl.models import CloudEmbedder, CloudReranker from owl.protocol import ( ChatCompletionChoiceDelta, + ChatCompletionChoiceOutput, ChatCompletionChunk, ChatEntry, ChatRole, Chunk, CompletionUsage, ExternalKeys, + LLMModelConfig, ModelInfo, ModelInfoResponse, ModelListConfig, @@ -77,7 +79,7 @@ def _get_llm_router(model_json: str, external_api_keys: str): retry_after=5.0, timeout=ENV_CONFIG.owl_llm_timeout_sec, allowed_fails=3, - cooldown_time=0.0, + cooldown_time=5.5, debug_level="DEBUG", redis_host=ENV_CONFIG.owl_redis_host, redis_port=ENV_CONFIG.owl_redis_port, @@ -288,36 +290,57 @@ async def generate_stream( **hyperparams, ) -> AsyncGenerator[ChatCompletionChunk, None]: api_key = "" + usage = None try: model = model.strip() + # check audio model type + is_audio_gen_model = False + if model != "": + model_config: LLMModelConfig = self.request.state.all_models.get_llm_model_info( + model + ) + if ( + "audio" in model_config.capabilities + and model_config.deployments[0].provider == "openai" + ): + is_audio_gen_model = True hyperparams = self._prepare_hyperparams(model, hyperparams, stream=True) messages = self._prepare_messages(messages) + # omit system prompt for audio input with audio gen + if is_audio_gen_model and messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): + messages = messages[1:] messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] model = self.validate_model_id( model=model, capabilities=capabilities, ) self._log_completion_masked(model, messages, **hyperparams) - response = await self.router.acompletion( - model=model, - messages=messages, - # Fixes discrepancy between stream and non-stream token usage - stream_options={"include_usage": True}, - **hyperparams, - ) - chunks = [] - completion = None + if is_audio_gen_model: + response = await self.router.acompletion( + model=model, + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "pcm16"}, + messages=messages, + # Fixes discrepancy between stream and non-stream token usage + stream_options={"include_usage": True}, + **hyperparams, + ) + else: + response = await self.router.acompletion( + model=model, + messages=messages, + # Fixes discrepancy between stream and non-stream token usage + stream_options={"include_usage": True}, + **hyperparams, + ) output_text = "" usage = CompletionUsage() async for chunk in response: - chunks.append(chunk) - content = chunk.choices[0].delta.content if hasattr(chunk, "usage"): - completion = chunk usage = CompletionUsage( - prompt_tokens=completion.usage.prompt_tokens, - completion_tokens=completion.usage.completion_tokens, - total_tokens=completion.usage.total_tokens, + prompt_tokens=chunk.usage.prompt_tokens, + completion_tokens=chunk.usage.completion_tokens, + total_tokens=chunk.usage.total_tokens, ) yield ChatCompletionChunk( id=self.id, @@ -327,7 +350,16 @@ async def generate_stream( usage=usage, choices=[ ChatCompletionChoiceDelta( - message=ChatEntry.assistant(choice.delta.content), + message=ChatEntry.assistant(choice.delta.audio.get("transcript", "")) + if is_audio_gen_model and choice.delta.audio is not None + else ChatCompletionChoiceOutput.assistant( + choice.delta.content, + tool_calls=[ + tool_call.model_dump() for tool_call in choice.delta.tool_calls + ] + if isinstance(chunk.choices[0].delta.tool_calls, list) + else None, + ), index=choice.index, finish_reason=choice.get( "finish_reason", chunk.get("finish_reason", None) @@ -336,16 +368,17 @@ async def generate_stream( for choice in chunk.choices ], ) - output_text += content if content else "" + if is_audio_gen_model and chunk.choices[0].delta.audio is not None: + output_text += chunk.choices[0].delta.audio.get("transcript", "") + else: + content = chunk.choices[0].delta.content + output_text += content if content else "" logger.info(f"{self.id} - Streamed completion: <{mask_string(output_text)}>") - if completion is None: - logger.warning("`completion` should not be None !!!") - return self._billing.create_llm_events( model=model, - input_tokens=completion.usage.prompt_tokens, - output_tokens=completion.usage.completion_tokens, + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, ) except Exception as e: self._map_and_log_exception(e, model, messages, api_key, **hyperparams) @@ -354,7 +387,7 @@ async def generate_stream( object="chat.completion.chunk", created=int(time()), model=model, - usage=None, + usage=usage, choices=[ ChatCompletionChoiceDelta( message=ChatEntry.assistant(f"[ERROR] {e!r}"), @@ -374,31 +407,59 @@ async def generate( api_key = "" try: model = model.strip() + # check audio model type + is_audio_gen_model = False + if model != "": + model_config: LLMModelConfig = self.request.state.all_models.get_llm_model_info( + model + ) + if ( + "audio" in model_config.capabilities + and model_config.deployments[0].provider == "openai" + ): + is_audio_gen_model = True hyperparams = self._prepare_hyperparams(model, hyperparams, stream=False) messages = self._prepare_messages(messages) + # omit system prompt for audio input with audio gen + if is_audio_gen_model and messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): + messages = messages[1:] messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] model = self.validate_model_id( model=model, capabilities=capabilities, ) self._log_completion_masked(model, messages, **hyperparams) - completion = await self.router.acompletion( - model=model, - messages=messages, - **hyperparams, - ) + if is_audio_gen_model: + completion = await self.router.acompletion( + model=model, + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "pcm16"}, + messages=messages, + **hyperparams, + ) + else: + completion = await self.router.acompletion( + model=model, + messages=messages, + **hyperparams, + ) self._billing.create_llm_events( model=model, input_tokens=completion.usage.prompt_tokens, output_tokens=completion.usage.completion_tokens, ) + choices = [] + for choice in completion.choices: + if is_audio_gen_model and choice.message.audio.transcript is not None: + choice.message.content = choice.message.audio.transcript + choices.append(choice.model_dump()) completion = ChatCompletionChunk( id=self.id, object="chat.completion", created=completion.created, model=model, usage=completion.usage.model_dump(), - choices=[choice.model_dump() for choice in completion.choices], + choices=choices, ) logger.info(f"{self.id} - Generated completion: <{mask_string(completion.text)}>") return completion diff --git a/services/api/src/owl/loaders.py b/services/api/src/owl/loaders.py index 2ddb7ba..f53a4c0 100644 --- a/services/api/src/owl/loaders.py +++ b/services/api/src/owl/loaders.py @@ -29,7 +29,10 @@ def make_printable(s: str) -> str: return s.translate(NOPRINT_TRANS_TABLE) -def format_chunks(documents: list[Document], file_name: str) -> list[Chunk]: +def format_chunks(documents: list[Document], file_name: str, page: int = None) -> list[Chunk]: + if page is not None: + for d in documents: + d.metadata["page"] = page chunks = [ # TODO: Probably can use regex for this # Replace vertical tabs, form feed, Unicode replacement character @@ -84,7 +87,7 @@ async def load_file( loader = DocIOAPIFileLoader(tmp_path, ENV_CONFIG.docio_url) documents = loader.load() logger.debug('File "{file_name}" loaded: {docs}', file_name=file_name, docs=documents) - chunks = format_chunks(documents, file_name) + chunks = format_chunks(documents, file_name, page=1) if ext == ".json": chunks = split_chunks( SplitChunksRequest( diff --git a/services/api/src/owl/models.py b/services/api/src/owl/models.py index 6efd67f..f609dc4 100644 --- a/services/api/src/owl/models.py +++ b/services/api/src/owl/models.py @@ -67,7 +67,7 @@ def _get_embedding_router(model_json: str, external_api_keys: str): retry_after=5.0, timeout=ENV_CONFIG.owl_embed_timeout_sec, allowed_fails=3, - cooldown_time=0.0, + cooldown_time=5.5, ) diff --git a/services/api/src/owl/protocol.py b/services/api/src/owl/protocol.py index ea5b232..622d23d 100644 --- a/services/api/src/owl/protocol.py +++ b/services/api/src/owl/protocol.py @@ -63,22 +63,27 @@ def sanitise_document_id_list(v: list[str]) -> list[str]: DocumentID = Annotated[str, AfterValidator(sanitise_document_id)] DocumentIDList = Annotated[list[str], AfterValidator(sanitise_document_id_list)] -EXAMPLE_CHAT_MODEL = "openai/gpt-4o-mini" - +EXAMPLE_CHAT_MODEL_IDS = ["openai/gpt-4o-mini"] # for openai embedding models doc: https://platform.openai.com/docs/guides/embeddings # for cohere embedding models doc: https://docs.cohere.com/reference/embed # for jina embedding models doc: https://jina.ai/embeddings/ # for voyage embedding models doc: https://docs.voyageai.com/docs/embeddings # for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_EMBEDDING_MODEL = "openai/text-embedding-3-small-512" - +EXAMPLE_EMBEDDING_MODEL_IDS = [ + "openai/text-embedding-3-small-512", + "ellm/sentence-transformers/all-MiniLM-L6-v2", +] # for cohere reranking models doc: https://docs.cohere.com/reference/rerank-1 # for jina reranking models doc: https://jina.ai/reranker # for colbert reranking models doc: https://docs.voyageai.com/docs/reranker # for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_RERANKING_MODEL = "cohere/rerank-multilingual-v3.0" +EXAMPLE_RERANKING_MODEL_IDS = [ + "cohere/rerank-multilingual-v3.0", + "ellm/cross-encoder/ms-marco-TinyBERT-L-2", +] IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] +AUDIO_FILE_EXTENSIONS = [".mp3", ".wav"] DOCUMENT_FILE_EXTENSIONS = [ ".pdf", ".txt", @@ -240,6 +245,7 @@ class ExternalKeys(BaseModel): hyperbolic: str = "" cerebras: str = "" sambanova: str = "" + deepseek: str = "" class OkResponse(BaseModel): @@ -298,6 +304,8 @@ class ModelCapability(str, Enum): COMPLETION = "completion" CHAT = "chat" IMAGE = "image" + AUDIO = "audio" + TOOL = "tool" EMBED = "embed" RERANK = "rerank" @@ -311,7 +319,7 @@ class ModelInfo(BaseModel): 'Unique identifier in the form of "{provider}/{model_id}". ' "Users will specify this to select a model." ), - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ) object: str = Field( default="model", @@ -366,7 +374,7 @@ class ModelDeploymentConfig(BaseModel): 'For example, you can map "openai/gpt-4o" calls to "openai/gpt-4o-2024-08-06". ' 'For vLLM with OpenAI compatible server, use "openai/".' ), - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ) api_base: str = Field( default="", @@ -397,7 +405,7 @@ class ModelConfig(ModelInfo): 'For example, you can map "openai/gpt-4o" calls to "openai/gpt-4o-2024-08-06". ' 'For vLLM with OpenAI compatible server, use "openai/".' ), - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ) api_base: str = Field( default="", @@ -451,7 +459,7 @@ class EmbeddingModelConfig(ModelConfig): 'For self-hosted models with Infinity, use "ellm/{org}/{model}". ' "Users will specify this to select a model." ), - examples=["ellm/sentence-transformers/all-MiniLM-L6-v2", EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) embedding_size: int = Field( description="Embedding size of the model", @@ -491,7 +499,7 @@ class RerankingModelConfig(ModelConfig): 'For self-hosted models with Infinity, use "ellm/{org}/{model}". ' "Users will specify this to select a model." ), - examples=["ellm/cross-encoder/ms-marco-TinyBERT-L-2", EXAMPLE_RERANKING_MODEL], + examples=EXAMPLE_RERANKING_MODEL_IDS, ) capabilities: list[ModelCapability] = Field( default=[ModelCapability.RERANK], @@ -555,6 +563,9 @@ def get_default_model(self, capabilities: list[str] | None = None) -> str: if capabilities is not None: for capability in capabilities: models = [m for m in models if capability in m.capabilities] + # if `capabilities`` is chat only, filter out audio model + if capabilities == ["chat"]: + models = [m for m in models if "audio" not in m.capabilities] if len(models) == 0: raise ResourceNotFoundError(f"No model found with capabilities: {capabilities}") model = natsorted(models, key=self._sort_key_with_priority)[0] @@ -659,7 +670,7 @@ class RAGParams(BaseModel): reranking_model: str | None = Field( default=None, description="Reranking model to use for hybrid search.", - examples=[EXAMPLE_RERANKING_MODEL, None], + examples=[EXAMPLE_RERANKING_MODEL_IDS[0], None], ) search_query: str = Field( default="", @@ -758,6 +769,17 @@ def sanitise_name(v: str) -> str: MessageName = Annotated[str, AfterValidator(sanitise_name)] +class MessageToolCallFunction(BaseModel): + arguments: str + name: str | None + + +class MessageToolCall(BaseModel): + id: str | None + function: MessageToolCallFunction + type: str + + class ChatEntry(BaseModel): """Represents a message in the chat context.""" @@ -799,6 +821,11 @@ def coerce_input(cls, value: Any) -> str | list[dict[str, str | dict[str, str]]] return str(value) +class ChatCompletionChoiceOutput(ChatEntry): + tool_calls: list[MessageToolCall] | None = None + """List of tool calls if the message includes tool call responses.""" + + class ChatThread(BaseModel): object: str = Field( default="chat.thread", @@ -828,7 +855,9 @@ class CompletionUsage(BaseModel): class ChatCompletionChoice(BaseModel): - message: ChatEntry = Field(description="A chat completion message generated by the model.") + message: ChatEntry | ChatCompletionChoiceOutput = Field( + description="A chat completion message generated by the model." + ) index: int = Field(description="The index of the choice in the list of choices.") finish_reason: str | None = Field( default=None, @@ -848,7 +877,7 @@ def text(self) -> str: class ChatCompletionChoiceDelta(ChatCompletionChoice): @computed_field @property - def delta(self) -> ChatEntry: + def delta(self) -> ChatEntry | ChatCompletionChoiceOutput: return self.message @@ -928,7 +957,7 @@ class ChatCompletionChunk(BaseModel): ) @property - def message(self) -> ChatEntry | None: + def message(self) -> ChatEntry | ChatCompletionChoiceOutput | None: return self.choices[0].message if len(self.choices) > 0 else None @property @@ -987,6 +1016,49 @@ class GenTableStreamChatCompletionChunk(ChatCompletionChunk): row_id: str +class FunctionParameter(BaseModel): + type: str = Field( + default="", description="The type of the parameter, e.g., 'string', 'number'." + ) + description: str = Field(default="", description="A description of the parameter.") + enum: list[str] = Field( + default=[], description="An optional list of allowed values for the parameter." + ) + + +class FunctionParameters(BaseModel): + type: str = Field( + default="object", description="The type of the parameters object, usually 'object'." + ) + properties: dict[str, FunctionParameter] = Field( + description="The properties of the parameters object." + ) + required: list[str] = Field(description="A list of required parameter names.") + additionalProperties: bool = Field( + default=False, description="Whether additional properties are allowed." + ) + + +class Function(BaseModel): + name: str = Field(default="", description="The name of the function.") + description: str = Field(default="", description="A description of what the function does.") + parameters: FunctionParameters = Field(description="The parameters for the function.") + + +class Tool(BaseModel): + type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") + function: Function = Field(description="The function details of the tool.") + + +class ToolChoiceFunction(BaseModel): + name: str = Field(default="", description="The name of the function.") + + +class ToolChoice(BaseModel): + type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") + function: ToolChoiceFunction = Field(description="Select a tool for the chat model to use.") + + class ChatRequest(BaseModel): id: str = Field( default="", @@ -1094,6 +1166,48 @@ def convert_stop(cls, v: list[str] | None) -> list[str] | None: return v +class ChatRequestWithTools(ChatRequest): + tools: list[Tool] = Field( + description="A list of tools available for the chat model to use.", + min_length=1, + examples=[ + # --- [Tool Function] --- + # def get_delivery_date(order_id: str) -> datetime: + # # Connect to the database + # conn = sqlite3.connect('ecommerce.db') + # cursor = conn.cursor() + # # ... + [ + Tool( + type="function", + function=Function( + name="get_delivery_date", + description="Get the delivery date for a customer's order.", + parameters=FunctionParameters( + type="object", + properties={ + "order_id": FunctionParameter( + type="string", description="The customer's order ID." + ) + }, + required=["order_id"], + additionalProperties=False, + ), + ), + ) + ], + ], + ) + tool_choice: str | ToolChoice = Field( + default="auto", + description="Set `auto` to let chat model pick a tool or select a tool for the chat model to use.", + examples=[ + "auto", + ToolChoice(type="function", function=ToolChoiceFunction(name="get_delivery_date")), + ], + ) + + class EmbeddingRequest(BaseModel): input: str | list[str] = Field( description=( @@ -1108,7 +1222,7 @@ class EmbeddingRequest(BaseModel): "The ID of the model to use. " "You can use the List models API to see all of your available models." ), - examples=[EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) type: Literal["query", "document"] = Field( default="document", @@ -1248,7 +1362,8 @@ def datetime_str_before_validator(x): "bool": pa.bool_(), "str": pa.utf8(), # Alias for `pa.string()` "chat": pa.utf8(), - "file": pa.utf8(), + "image": pa.utf8(), + "audio": pa.utf8(), } _str_to_py_type = { "int": int, @@ -1261,7 +1376,8 @@ def datetime_str_before_validator(x): "str": str, "date-time": datetime, "chat": str, - "file": str, + "image": str, + "audio": str, } @@ -1299,7 +1415,8 @@ class ColumnDtype(str, Enum, metaclass=MetaEnum): BOOL = "bool" STR = "str" DATE_TIME = "date-time" - FILE = "file" + IMAGE = "image" + AUDIO = "audio" def __str__(self) -> str: return self.value @@ -1310,7 +1427,8 @@ class ColumnDtypeCreate(str, Enum, metaclass=MetaEnum): FLOAT = "float" BOOL = "bool" STR = "str" - FILE = "file" + IMAGE = "image" + AUDIO = "audio" def __str__(self) -> str: return self.value @@ -1463,7 +1581,7 @@ class EmbedGenConfig(BaseModel): ) embedding_model: str = Field( description="The embedding model to use.", - examples=[EXAMPLE_EMBEDDING_MODEL], + examples=EXAMPLE_EMBEDDING_MODEL_IDS, ) source_column: str = Field( description="The source column for embedding.", @@ -1471,6 +1589,10 @@ class EmbedGenConfig(BaseModel): ) +class CodeGenConfig(p.CodeGenConfig): + pass + + def _gen_config_discriminator(x: Any) -> str | None: object_attr = getattr(x, "object", None) if object_attr: @@ -1487,9 +1609,10 @@ def _gen_config_discriminator(x: Any) -> str | None: return None -GenConfig = LLMGenConfig | EmbedGenConfig +GenConfig = LLMGenConfig | EmbedGenConfig | CodeGenConfig DiscriminatedGenConfig = Annotated[ Union[ + Annotated[CodeGenConfig, Tag("gen_config.code")], Annotated[LLMGenConfig, Tag("gen_config.llm")], Annotated[LLMGenConfig, Tag("gen_config.chat")], Annotated[EmbedGenConfig, Tag("gen_config.embed")], @@ -1502,7 +1625,7 @@ class ColumnSchema(BaseModel): id: str = Field(description="Column name.") dtype: ColumnDtype = Field( default=ColumnDtype.STR, - description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time", "file"]', + description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time", "image"]', ) vlen: PositiveInt = Field( # type: ignore default=0, @@ -1537,13 +1660,25 @@ class ColumnSchemaCreate(ColumnSchema): id: ColName = Field(description="Column name.") dtype: ColumnDtypeCreate = Field( default=ColumnDtypeCreate.STR, - description='Column data type, one of ["int", "float", "bool", "str", "file"]', + description='Column data type, one of ["int", "float", "bool", "str", "image", "audio"]', ) + @model_validator(mode="before") + def match_column_dtype_file_to_image(self) -> Self: + if self.get("dtype", "") == "file": + self["dtype"] = ColumnDtype.IMAGE + return self + @model_validator(mode="after") def check_output_column_dtype(self) -> Self: - if self.gen_config is not None and self.vlen == 0 and self.dtype != ColumnDtype.STR: - raise ValueError("Output column must be string column.") + if self.gen_config is not None and self.vlen == 0: + if isinstance(self.gen_config, CodeGenConfig): + if self.dtype not in (ColumnDtype.STR, ColumnDtype.IMAGE): + raise ValueError( + "Output column must be either string or image column when gen_config is CodeGenConfig." + ) + elif self.dtype != ColumnDtype.STR: + raise ValueError("Output column must be string column.") return self @@ -1675,6 +1810,29 @@ def check_gen_configs(self) -> Self: f"Available columns: {col_ids}." ) ) + elif isinstance(gen_config, CodeGenConfig): + source_col = next( + (c for c in available_cols if c.id == gen_config.source_column), None + ) + if source_col is None: + raise ValueError( + ( + f"Table '{self.id}': " + f"Code Execution config of column '{col.id}' referenced " + f"an invalid source column '{gen_config.source_column}'. " + "Make sure you only reference columns on its left. " + f"Available columns: {col_ids}." + ) + ) + if source_col.dtype != ColumnDtype.STR: + raise ValueError( + ( + f"Table '{self.id}': " + f"Code Execution config of column '{col.id}' referenced " + f"a source column '{gen_config.source_column}' with an invalid datatype of '{source_col.dtype}'. " + "Make sure the source column is Str typed." + ) + ) elif isinstance(gen_config, LLMGenConfig): # Insert default prompts if needed system_prompt, user_prompt = self.get_default_prompts( @@ -1734,9 +1892,13 @@ class KnowledgeTableSchemaCreate(TableSchemaCreate): @model_validator(mode="after") def check_cols(self) -> Self: super().check_cols() - num_text_cols = sum(c.id.lower() in ("text", "title", "file id") for c in self.cols) + num_text_cols = sum( + c.id.lower() in ("text", "title", "file id", "page") for c in self.cols + ) if num_text_cols != 0: - raise ValueError("Schema cannot contain column names: 'Text', 'Title', 'File ID'.") + raise ValueError( + "Schema cannot contain column names: 'Text', 'Title', 'File ID', 'Page'." + ) return self @staticmethod @@ -1749,9 +1911,13 @@ class AddKnowledgeColumnSchema(TableSchemaCreate): @model_validator(mode="after") def check_cols(self) -> Self: super().check_cols() - num_text_cols = sum(c.id.lower() in ("text", "title", "file id") for c in self.cols) + num_text_cols = sum( + c.id.lower() in ("text", "title", "file id", "page") for c in self.cols + ) if num_text_cols != 0: - raise ValueError("Schema cannot contain column names: 'Text', 'Title', 'File ID'.") + raise ValueError( + "Schema cannot contain column names: 'Text', 'Title', 'File ID', 'Page'." + ) return self @model_validator(mode="after") @@ -1927,7 +2093,7 @@ def _handle_nulls_and_validate(self, check_missing_cols: bool = True) -> Self: d[k] = 0.0 elif col.dtype == ColumnDtype.BOOL: d[k] = False - elif col.dtype in (ColumnDtype.STR, ColumnDtype.FILE): + elif col.dtype in (ColumnDtype.STR, ColumnDtype.IMAGE): # Store null string as "" # https://github.com/lancedb/lancedb/issues/1160 d[k] = "" @@ -2114,11 +2280,12 @@ def check_data(self) -> Self: value.startswith("s3://") or value.startswith("file://") ): extension = splitext(value)[1].lower() - if extension not in IMAGE_FILE_EXTENSIONS: + if extension not in IMAGE_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS: raise ValueError( "Unsupported file type. Make sure the file belongs to " "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS}" + f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" + f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" ) return self @@ -2160,11 +2327,12 @@ def check_data(self) -> Self: value.startswith("s3://") or value.startswith("file://") ): extension = splitext(value)[1].lower() - if extension not in IMAGE_FILE_EXTENSIONS: + if extension not in IMAGE_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS: raise ValueError( "Unsupported file type. Make sure the file belongs to " "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS}" + f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" + f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" ) return self diff --git a/services/api/src/owl/routers/file.py b/services/api/src/owl/routers/file.py index 495ce00..fed6675 100644 --- a/services/api/src/owl/routers/file.py +++ b/services/api/src/owl/routers/file.py @@ -1,5 +1,6 @@ import mimetypes import os +from os.path import splitext from typing import Annotated from urllib.parse import quote, urlparse, urlunparse @@ -14,6 +15,7 @@ from owl.utils.auth import ProjectRead, auth_user_project from owl.utils.exceptions import handle_exception from owl.utils.io import ( + AUDIO_WHITE_LIST_EXT, LOCAL_FILE_DIR, S3_CLIENT, UPLOAD_WHITE_LIST_MIME, @@ -91,7 +93,8 @@ async def proxy_file(request: Request, path: str) -> Response: raise ResourceNotFoundError("Neither S3 nor local file store is configured") -@router.options("/v1/files/upload/") +@router.options("/v1/files/upload") +@router.options("/v1/files/upload/", deprecated=True) @handle_exception async def upload_file_options(): headers = { @@ -103,7 +106,8 @@ async def upload_file_options(): return JSONResponse(content={"accepted_types": list(UPLOAD_WHITE_LIST_MIME)}, headers=headers) -@router.post("/v1/files/upload/") +@router.post("/v1/files/upload") +@router.post("/v1/files/upload/", deprecated=True) @handle_exception async def upload_file( project: Annotated[ProjectRead, Depends(auth_user_project)], @@ -164,9 +168,11 @@ async def get_thumbnail_urls(body: GetURLRequest, request: Request) -> GetURLRes file_url = "" if uri.startswith("s3://"): try: + ext = splitext(uri)[1].lower() bucket_name, key = uri[5:].split("/", 1) + thumb_ext = "mp3" if ext in AUDIO_WHITE_LIST_EXT else "webp" thumb_key = key.replace("raw", "thumb") - thumb_key = f"{os.path.splitext(thumb_key)[0]}.webp" + thumb_key = f"{os.path.splitext(thumb_key)[0]}.{thumb_ext}" file_url = await _generate_presigned_url(aclient, bucket_name, thumb_key) except Exception as e: logger.exception( @@ -179,9 +185,11 @@ async def get_thumbnail_urls(body: GetURLRequest, request: Request) -> GetURLRes file_url = "" if uri.startswith("file://"): try: + ext = splitext(uri)[1].lower() local_path = os.path.abspath(uri[7:]) + thumb_ext = "mp3" if ext in AUDIO_WHITE_LIST_EXT else "webp" thumb_path = local_path.replace("raw", "thumb") - thumb_path = f"{os.path.splitext(thumb_path)[0]}.webp" + thumb_path = f"{os.path.splitext(thumb_path)[0]}.{thumb_ext}" if os.path.exists(thumb_path): relative_path = os.path.relpath(thumb_path, LOCAL_FILE_DIR) file_url = str(request.url_for("proxy_file", path=relative_path)) diff --git a/services/api/src/owl/routers/gen_table.py b/services/api/src/owl/routers/gen_table.py index c7a6296..48f65f8 100644 --- a/services/api/src/owl/routers/gen_table.py +++ b/services/api/src/owl/routers/gen_table.py @@ -47,6 +47,7 @@ ChatEntry, ChatTableSchemaCreate, ChatThread, + CodeGenConfig, ColName, ColumnDropRequest, ColumnDtype, @@ -85,7 +86,8 @@ def _validate_gen_config( gen_config: GenConfig | None, table_type: TableType, column_id: str, - file_column_ids: list[str], + image_column_ids: list[str], + audio_column_ids: list[str], ) -> GenConfig | None: if gen_config is None: return gen_config @@ -98,8 +100,10 @@ def _validate_gen_config( capabilities = ["chat"] for message in (gen_config.system_prompt, gen_config.prompt): for col_id in re.findall(GEN_CONFIG_VAR_PATTERN, message): - if col_id in file_column_ids: + if col_id in image_column_ids: capabilities = ["image"] + if col_id in audio_column_ids: + capabilities = ["audio"] break gen_config.model = llm.validate_model_id( model=gen_config.model, @@ -141,6 +145,8 @@ def _validate_gen_config( raise ResourceNotFoundError( f'Column {column_id} used a reranking model "{reranking_model}" that is not available.' ) from e + elif isinstance(gen_config, CodeGenConfig): + pass elif isinstance(gen_config, EmbedGenConfig): pass return gen_config @@ -155,8 +161,15 @@ def _create_table( ) -> TableMetaResponse: # Validate llm = LLMEngine(request=request) - file_column_ids = [ - col.id for col in schema.cols if col.dtype == ColumnDtype.FILE and not col.id.endswith("_") + image_column_ids = [ + col.id + for col in schema.cols + if col.dtype == ColumnDtype.IMAGE and not col.id.endswith("_") + ] + audio_column_ids = [ + col.id + for col in schema.cols + if col.dtype == ColumnDtype.AUDIO and not col.id.endswith("_") ] for col in schema.cols: col.gen_config = _validate_gen_config( @@ -164,7 +177,8 @@ def _create_table( gen_config=col.gen_config, table_type=table_type, column_id=col.id, - file_column_ids=file_column_ids, + image_column_ids=image_column_ids, + audio_column_ids=audio_column_ids, ) if table_type == TableType.KNOWLEDGE: try: @@ -497,10 +511,15 @@ def update_gen_config( with table.create_session() as session: meta = table.open_meta(session, updates.table_id) llm = LLMEngine(request=request) - file_column_ids = [ + image_column_ids = [ + col["id"] + for col in meta.cols + if col["dtype"] == ColumnDtype.IMAGE and not col["id"].endswith("_") + ] + audio_column_ids = [ col["id"] for col in meta.cols - if col["dtype"] == ColumnDtype.FILE and not col["id"].endswith("_") + if col["dtype"] == ColumnDtype.AUDIO and not col["id"].endswith("_") ] if table_type == TableType.KNOWLEDGE: @@ -519,7 +538,8 @@ def update_gen_config( gen_config=gen_config, table_type=table_type, column_id=col_id, - file_column_ids=file_column_ids, + image_column_ids=image_column_ids, + audio_column_ids=audio_column_ids, ) for col_id, gen_config in updates.column_map.items() } @@ -544,8 +564,11 @@ def _add_columns( cols = TableSchema( id=meta.id, cols=[c.model_dump() for c in meta.cols_schema + schema.cols] ).cols - file_column_ids = [ - col.id for col in cols if col.dtype == ColumnDtype.FILE and not col.id.endswith("_") + image_column_ids = [ + col.id for col in cols if col.dtype == ColumnDtype.IMAGE and not col.id.endswith("_") + ] + audio_column_ids = [ + col.id for col in cols if col.dtype == ColumnDtype.AUDIO and not col.id.endswith("_") ] schema.cols = [col for col in cols if col.id in set(c.id for c in schema.cols)] for col in schema.cols: @@ -554,7 +577,8 @@ def _add_columns( gen_config=col.gen_config, table_type=table_type, column_id=col.id, - file_column_ids=file_column_ids, + image_column_ids=image_column_ids, + audio_column_ids=audio_column_ids, ) # Create _, meta = table.add_columns(session, schema) @@ -1126,6 +1150,7 @@ async def _embed_file( "Title": title, "Title Embed": title_embed, "File ID": file_uri, + "Page": chunk.page, } for chunk, text_embed in zip(chunks, text_embeds, strict=True) ] @@ -1197,6 +1222,10 @@ async def embed_file( file_name = file.filename or file_name if splitext(file_name)[1].lower() == ".jsonl": file_content_type = "application/jsonl" + elif splitext(file_name)[1].lower() == ".md": + file_content_type = "text/markdown" + elif splitext(file_name)[1].lower() == ".tsv": + file_content_type = "text/tab-separated-values" else: file_content_type = file.content_type if file_content_type not in EMBED_WHITE_LIST_MIME: @@ -1303,7 +1332,7 @@ async def import_table_data( if dtype == "str": df[col_id] = df[col_id].apply(lambda x: str(x) if not pd.isna(x) else x) else: - if dtype == ColumnDtype.FILE: + if dtype in [ColumnDtype.IMAGE, ColumnDtype.AUDIO]: dtype = "str" df[col_id] = df[col_id].astype(dtype, errors="raise") except ValueError as e: diff --git a/services/api/src/owl/routers/llm.py b/services/api/src/owl/routers/llm.py index a27bd7d..35d754a 100644 --- a/services/api/src/owl/routers/llm.py +++ b/services/api/src/owl/routers/llm.py @@ -13,8 +13,9 @@ from owl.llm import LLMEngine from owl.models import CloudEmbedder from owl.protocol import ( - EXAMPLE_CHAT_MODEL, + EXAMPLE_CHAT_MODEL_IDS, ChatRequest, + ChatRequestWithTools, EmbeddingRequest, EmbeddingResponse, EmbeddingResponseData, @@ -39,7 +40,7 @@ async def get_model_info( str, Query( description="ID of the requested model.", - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ), ] = "", capabilities: Annotated[ @@ -79,7 +80,7 @@ async def get_model_names( str, Query( description="ID of the preferred model.", - examples=[EXAMPLE_CHAT_MODEL], + examples=EXAMPLE_CHAT_MODEL_IDS, ), ] = "", capabilities: Annotated[ @@ -109,7 +110,7 @@ async def get_model_names( description="Given a list of messages comprising a conversation, the model will return a response.", ) @handle_exception -async def generate_completions(request: Request, body: ChatRequest): +async def generate_completions(request: Request, body: ChatRequest | ChatRequestWithTools): # Check quota request.state.billing.check_llm_quota(body.model) request.state.billing.check_egress_quota() diff --git a/services/api/src/owl/utils/auth.py b/services/api/src/owl/utils/auth.py index dc3ab57..06f8e9e 100644 --- a/services/api/src/owl/utils/auth.py +++ b/services/api/src/owl/utils/auth.py @@ -2,7 +2,7 @@ from secrets import compare_digest from typing import Annotated, AsyncGenerator -from fastapi import Header, Request, Response +from fastapi import BackgroundTasks, Header, Request, Response from httpx import RequestError from loguru import logger from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential @@ -169,6 +169,7 @@ def _get_external_keys(organization: OrganizationRead) -> ExternalKeys: hyperbolic=get_non_empty(ext_keys, "hyperbolic", ENV_CONFIG.hyperbolic_api_key_plain), cerebras=get_non_empty(ext_keys, "cerebras", ENV_CONFIG.cerebras_api_key_plain), sambanova=get_non_empty(ext_keys, "sambanova", ENV_CONFIG.sambanova_api_key_plain), + deepseek=get_non_empty(ext_keys, "deepseek", ENV_CONFIG.deepseek_api_key_plain), ) @@ -270,6 +271,7 @@ def _get_valid_modellistconfig(all_models: str, external_keys: str) -> ModelList "sambanova", "cerebras", "hyperbolic", + "deepseek", ] # remove providers without credentials available_providers = [ @@ -346,6 +348,7 @@ async def auth_user_project_oss( async def auth_user_project_cloud( + bg_tasks: BackgroundTasks, request: Request, response: Response, project_id: Annotated[ @@ -361,7 +364,6 @@ async def auth_user_project_cloud( user_id: Annotated[str, Header(alias="X-USER-ID", description="User ID.")] = "", ) -> AsyncGenerator[ProjectRead, None]: route = request.url.path - user_id = "" project_id = project_id.strip() bearer_token = bearer_token.strip() user_id = user_id.strip() @@ -450,21 +452,20 @@ async def auth_user_project_cloud( yield project - # Add egress events - request.state.billing.create_egress_events( - float(response.headers.get("content-length", 0)) / (1024**3) - ) - # Process all billing events - await request.state.billing.process_all() + # NOTE that billing processing is done in middleware where response headers are available # Set project updated at datetime - if "gen_tables" in route and request.method in WRITE_METHODS: - try: - await CLIENT.admin.organization.set_project_updated_at(project_id) - except Exception as e: - logger.warning( - f'{request.state.id} - Error setting project "{project_id}" last updated time: {e}' - ) + async def _set_project_updated_at() -> None: + if "gen_tables" in route and request.method in WRITE_METHODS: + try: + await CLIENT.admin.organization.set_project_updated_at(project_id) + except Exception as e: + logger.warning( + f'{request.state.id} - Error setting project "{project_id}" last updated time: {e}' + ) + + # This will run AFTER streaming responses are sent + bg_tasks.add_task(_set_project_updated_at) auth_user_project = auth_user_project_oss if ENV_CONFIG.is_oss else auth_user_project_cloud diff --git a/services/api/src/owl/utils/code.py b/services/api/src/owl/utils/code.py new file mode 100644 index 0000000..76a764e --- /dev/null +++ b/services/api/src/owl/utils/code.py @@ -0,0 +1,58 @@ +import base64 +import uuid + +import filetype +import httpx +from fastapi import Request +from loguru import logger + +from owl.configs.manager import ENV_CONFIG +from owl.utils.io import upload_file_to_s3 + + +async def code_executor(source_code: str, dtype: str, request: Request) -> str | None: + response = None + + try: + if dtype == "image": + dtype = "file" # for code execution endpoint usage + async with httpx.AsyncClient() as client: + response = await client.post( + f"{ENV_CONFIG.code_executor_endpoint}/execute", + json={"code": source_code}, + ) + response.raise_for_status() + result = response.json() + + if dtype == "file": + if result["type"].startswith("image"): + image_content = base64.b64decode(result["result"]) + content_type = filetype.guess(image_content) + if content_type is None: + raise ValueError("Unable to determine file type") + filename = f"{uuid.uuid4()}.{content_type.extension}" + + # Upload the file + uri = await upload_file_to_s3( + organization_id=request.state.org_id, + project_id=request.state.project_id, + content=image_content, + content_type=content_type.mime, + filename=filename, + ) + response = uri + else: + logger.warning( + f"Code Executor: {request.state.id} - Unsupported file type: {result['type']}" + ) + response = None + else: + response = str(result["result"]) + + logger.info(f"Code Executor: {request.state.id} - Python code execution completed") + + except Exception as e: + logger.error(f"Code Executor: {request.state.id} - An unexpected error occurred: {e}") + response = None + + return response diff --git a/services/api/src/owl/utils/io.py b/services/api/src/owl/utils/io.py index 7541017..91fef1e 100644 --- a/services/api/src/owl/utils/io.py +++ b/services/api/src/owl/utils/io.py @@ -15,7 +15,7 @@ from loguru import logger from jamaibase.exceptions import BadInputError, ResourceNotFoundError -from jamaibase.utils.io import generate_thumbnail +from jamaibase.utils.io import generate_audio_thumbnail, generate_image_thumbnail from owl.configs.manager import ENV_CONFIG from owl.utils import uuid7_str @@ -61,12 +61,22 @@ "image/gif": [".gif"], "image/webp": [".webp"], } -UPLOAD_WHITE_LIST = {**EMBED_WHITE_LIST, **IMAGE_WHITE_LIST} +AUDIO_WHITE_LIST = { + "audio/mpeg": [".mp3"], + "audio/vnd.wav": [".wav"], + "audio/x-wav": [".wav"], + "audio/x-pn-wav": [".wav"], + "audio/wave": [".wav"], + "audio/vnd.wave": [".wav"], +} +UPLOAD_WHITE_LIST = {**EMBED_WHITE_LIST, **IMAGE_WHITE_LIST, **AUDIO_WHITE_LIST} EMBED_WHITE_LIST_MIME = set(EMBED_WHITE_LIST.keys()) EMBED_WHITE_LIST_EXT = set(ext for exts in EMBED_WHITE_LIST.values() for ext in exts) IMAGE_WHITE_LIST_MIME = set(IMAGE_WHITE_LIST.keys()) IMAGE_WHITE_LIST_EXT = set(ext for exts in IMAGE_WHITE_LIST.values() for ext in exts) +AUDIO_WHITE_LIST_MIME = set(AUDIO_WHITE_LIST.keys()) +AUDIO_WHITE_LIST_EXT = set(ext for exts in AUDIO_WHITE_LIST.values() for ext in exts) UPLOAD_WHITE_LIST_MIME = set(UPLOAD_WHITE_LIST.keys()) UPLOAD_WHITE_LIST_EXT = set(ext for exts in UPLOAD_WHITE_LIST.values() for ext in exts) @@ -253,19 +263,40 @@ async def upload_file_to_s3( raise BadInputError( f"Unsupported file extension: {file_extension}. Allowed types are: {', '.join(UPLOAD_WHITE_LIST_EXT)}" ) - - if len(content) > ENV_CONFIG.owl_file_upload_max_bytes: - raise BadInputError( - f"File size exceeds {ENV_CONFIG.owl_file_upload_max_bytes/1024**2} MB limit: {len(content)/1024**2} MB" - ) + else: + if ( + file_extension in EMBED_WHITE_LIST_EXT + and len(content) > ENV_CONFIG.owl_embed_file_upload_max_bytes + ): + raise BadInputError( + f"File size exceeds {ENV_CONFIG.owl_embed_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + ) + elif ( + file_extension in AUDIO_WHITE_LIST_EXT + and len(content) > ENV_CONFIG.owl_audio_file_upload_max_bytes + ): + raise BadInputError( + f"File size exceeds {ENV_CONFIG.owl_audio_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + ) + elif ( + file_extension in IMAGE_WHITE_LIST_EXT + and len(content) > ENV_CONFIG.owl_image_file_upload_max_bytes + ): + raise BadInputError( + f"File size exceeds {ENV_CONFIG.owl_image_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + ) uuid = uuid7_str() raw_path = os.path.join("raw", organization_id, project_id, uuid, filename) raw_key = os_path_to_s3_key(raw_path) - thumb_filename = f"{os.path.splitext(filename)[0]}.webp" + thumb_ext = "mp3" if file_extension in AUDIO_WHITE_LIST_EXT else "webp" + thumb_filename = f"{os.path.splitext(filename)[0]}.{thumb_ext}" thumb_path = os.path.join("thumb", organization_id, project_id, uuid, thumb_filename) thumb_key = os_path_to_s3_key(thumb_path) - thumbnail_task = asyncio.create_task(asyncio.to_thread(generate_thumbnail, content)) + if file_extension in AUDIO_WHITE_LIST_EXT: + thumbnail_task = asyncio.create_task(asyncio.to_thread(generate_audio_thumbnail, content)) + else: + thumbnail_task = asyncio.create_task(asyncio.to_thread(generate_image_thumbnail, content)) thumbnail = await thumbnail_task if S3_CLIENT: @@ -282,7 +313,7 @@ async def upload_file_to_s3( Body=thumbnail, Bucket=S3_BUCKET_NAME, Key=thumb_key, - ContentType="image/webp", + ContentType=f"{content_type.split('/')[0]}/{"mpeg" if thumb_ext == "mp3" else thumb_ext}", ) logger.info( f"File Uploaded: [{organization_id}/{project_id}] " diff --git a/services/api/src/owl/utils/jwt.py b/services/api/src/owl/utils/jwt.py index 5e2df6e..b443e57 100644 --- a/services/api/src/owl/utils/jwt.py +++ b/services/api/src/owl/utils/jwt.py @@ -2,7 +2,6 @@ from typing import Any import jwt -from fastapi import Request from loguru import logger from jamaibase.exceptions import AuthorizationError @@ -19,7 +18,7 @@ def decode_jwt( token: str, expired_token_message: str, invalid_token_message: str, - request: Request | None = None, + request_id: str | None = None, ) -> dict[str, Any]: try: data = jwt.decode( @@ -33,10 +32,10 @@ def decode_jwt( except jwt.exceptions.PyJWTError as e: raise AuthorizationError(invalid_token_message) from e except Exception as e: - if request is None: + if request_id is None: logger.exception(f'Failed to decode "{token}" due to {e.__class__.__name__}: {e}') else: logger.exception( - f'{request.state.id} - Failed to decode "{token}" due to {e.__class__.__name__}: {e}' + f'{request_id} - Failed to decode "{token}" due to {e.__class__.__name__}: {e}' ) raise AuthorizationError(invalid_token_message) from e diff --git a/services/app/package-lock.json b/services/app/package-lock.json index 4115254..258406c 100644 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -19,6 +19,7 @@ "chartjs-adapter-moment": "^1.0.1", "clsx": "^2.1.0", "cors": "^2.8.5", + "dexie": "^4.0.10", "dotenv": "^16.4.5", "electron-serve": "^2.0.0", "express": "^4.19.2", @@ -5646,6 +5647,12 @@ "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "dev": true }, + "node_modules/dexie": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.10.tgz", + "integrity": "sha512-eM2RzuR3i+M046r2Q0Optl3pS31qTWf8aFuA7H9wnsHTwl8EPvroVLwvQene/6paAs39Tbk6fWZcn2aZaHkc/w==", + "license": "Apache-2.0" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", diff --git a/services/app/package.json b/services/app/package.json index a534027..f89ec85 100644 --- a/services/app/package.json +++ b/services/app/package.json @@ -80,6 +80,7 @@ "chartjs-adapter-moment": "^1.0.1", "clsx": "^2.1.0", "cors": "^2.8.5", + "dexie": "^4.0.10", "dotenv": "^16.4.5", "electron-serve": "^2.0.0", "express": "^4.19.2", diff --git a/services/app/src/hooks.server.ts b/services/app/src/hooks.server.ts index 399d9b7..ca9c53f 100644 --- a/services/app/src/hooks.server.ts +++ b/services/app/src/hooks.server.ts @@ -1,7 +1,7 @@ import { PUBLIC_IS_LOCAL } from '$env/static/public'; import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; import { dev } from '$app/environment'; -import { json, type Handle } from '@sveltejs/kit'; +import { json, redirect, type Handle } from '@sveltejs/kit'; import { Agent } from 'undici'; import { getPrices } from '$lib/server/nodeCache'; import logger from '$lib/logger'; @@ -82,25 +82,40 @@ const handleApiProxy: Handle = async ({ event }) => { }; export const handle: Handle = async ({ event, resolve }) => { - if (dev && !event.request.url.includes('/api/v1/files')) - console.log('Connecting', event.request.url); + const { cookies, locals, request, url } = event; + if (dev && !request.url.includes('/api/v1/files')) console.log('Connecting', request.url); if (PUBLIC_IS_LOCAL === 'false') { //? Workaround for event.platform unavailable in development if (dev) { const user = await ( - await fetch(`${event.url.origin}/dev-profile`, { - headers: { cookie: `appSession=${event.cookies.get('appSession')}` } + await fetch(`${url.origin}/dev-profile`, { + headers: { cookie: `appSession=${cookies.get('appSession')}` } }) ).json(); - event.locals.user = Object.keys(user).length ? user : undefined; + locals.user = Object.keys(user).length ? user : undefined; } else { // @ts-expect-error missing type - event.locals.user = event.platform?.req?.res?.locals?.user; + locals.user = event.platform?.req?.res?.locals?.user; } } - if (PROXY_PATHS.some((p) => event.url.pathname.startsWith(p.path))) { + if (PUBLIC_IS_LOCAL === 'false' && !url.pathname.startsWith('/api')) { + if (!locals.user) { + const originalUrl = + url.pathname + (url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ''); + throw redirect(302, `/login${originalUrl ? `?returnTo=${originalUrl}` : ''}`); + } else { + if (!locals.user.email_verified && !url.pathname.startsWith('/verify-email')) { + throw redirect( + 302, + `/verify-email${url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ''}` + ); + } + } + } + + if (PROXY_PATHS.some((p) => url.pathname.startsWith(p.path))) { return await handleApiProxy({ event, resolve }); } diff --git a/services/app/src/lib/components/preset/ModelSelect.svelte b/services/app/src/lib/components/preset/ModelSelect.svelte index c430f62..5452464 100644 --- a/services/app/src/lib/components/preset/ModelSelect.svelte +++ b/services/app/src/lib/components/preset/ModelSelect.svelte @@ -119,9 +119,11 @@ {#each $modelsAvailable as { id, name, languages, capabilities, owned_by }} {@const isDisabled = owned_by !== 'ellm' && - $page.data.organizationData?.tier === 'free' && - !$page.data.organizationData?.credit && - !$page.data.organizationData?.external_keys?.[owned_by]} + owned_by !== 'custom' && + $page.data.organizationData && + $page.data.organizationData.credit === 0 && + $page.data.organizationData.credit_grant === 0 && + !$page.data.organizationData.external_keys?.[owned_by]} {#if !capabilityFilter || capabilities.includes(capabilityFilter)} diff --git a/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte b/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte index b15ce22..c4b7f53 100644 --- a/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte +++ b/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte @@ -3,9 +3,9 @@ import { page } from '$app/stores'; import toUpper from 'lodash/toUpper'; import Trash2 from 'lucide-svelte/icons/trash-2'; - import { genTableRows } from '../tablesStore'; + import { genTableRows, tableState } from '../tablesStore'; import logger from '$lib/logger'; - import { chatTableStaticCols, knowledgeTableStaticCols } from '$lib/constants'; + import { tableStaticCols } from '$lib/constants'; import type { GenTable, GenTableCol, GenTableStreamEvent } from '$lib/types'; import { CustomToastDesc, toast } from '$lib/components/ui/sonner'; @@ -17,13 +17,8 @@ import StarIcon from '$lib/icons/StarIcon.svelte'; export let tableType: 'action' | 'knowledge' | 'chat'; - export let column: GenTableCol; export let tableData: GenTable | undefined; - export let selectedRows: string[]; - export let streamingRows: Record; - export let isColumnSettingsOpen: { column: any; showMenu: boolean }; - export let isRenamingColumn: string | null; - export let isDeletingColumn: string | null; + export let column: GenTableCol; export let refetchTable: () => Promise; export let readonly; @@ -31,12 +26,12 @@ async function handleRegen(regenStrategy: 'run_before' | 'run_selected' | 'run_after') { if (!tableData || !$genTableRows) return; - if (Object.keys(streamingRows).length !== 0) return; + if (Object.keys($tableState.streamingRows).length !== 0) return; - const toRegenRowIds = selectedRows.filter((i) => !streamingRows[i]); + const toRegenRowIds = $tableState.selectedRows.filter((i) => !$tableState.streamingRows[i]); if (toRegenRowIds.length === 0) return toast.info('Select a row to start generating', { id: 'row-select-req' }); - selectedRows = []; + tableState.setSelectedRows([]); let colsToClear: string[]; switch (regenStrategy) { @@ -58,16 +53,15 @@ } } - streamingRows = { - ...streamingRows, - ...toRegenRowIds.reduce( + tableState.addStreamingRows( + toRegenRowIds.reduce( (acc, curr) => ({ ...acc, [curr]: colsToClear }), {} ) - }; + ); //? Optimistic update, clear row const originalValues = toRegenRowIds.map((toRegenRowId) => ({ @@ -148,12 +142,16 @@ break; } default: { - streamingRows = { - ...streamingRows, - [parsedValue.row_id]: streamingRows[parsedValue.row_id].filter( - (col) => col !== parsedValue.output_column_name - ) - }; + const streamingCols = $tableState.streamingRows[parsedValue.row_id].filter( + (col) => col !== parsedValue.output_column_name + ); + if (streamingCols.length === 0) { + tableState.delStreamingRows([parsedValue.row_id]); + } else { + tableState.addStreamingRows({ + [parsedValue.row_id]: streamingCols + }); + } break; } } @@ -176,12 +174,7 @@ // logger.error(toUpper(`${tableType}TBL_ROW_REGENSTREAM`), err); console.error(err); - //? Below necessary for retry - for (const toRegenRowId of toRegenRowIds) { - delete streamingRows[toRegenRowId]; - } - streamingRows = streamingRows; - + tableState.delStreamingRows(toRegenRowIds); refetchTable(); throw err; @@ -191,11 +184,7 @@ refetchTable(); } - for (const toRegenRowId of toRegenRowIds) { - delete streamingRows[toRegenRowId]; - } - streamingRows = streamingRows; - + tableState.delStreamingRows(toRegenRowIds); refetchTable(); } @@ -219,20 +208,20 @@ > {#if colType === 'output'} - (isColumnSettingsOpen = { column, showMenu: true })}> + tableState.setColumnSettings({ column, isOpen: true })}> Open settings {/if} - {#if colType === 'output' && !readonly && (tableType !== 'chat' || !chatTableStaticCols.includes(column.id)) && (tableType !== 'knowledge' || !knowledgeTableStaticCols.includes(column.id))} + {#if colType === 'output' && !readonly && !tableStaticCols[tableType].includes(column.id)} {/if} - {#if !readonly && (tableType !== 'chat' || !chatTableStaticCols.includes(column.id)) && (tableType !== 'knowledge' || !knowledgeTableStaticCols.includes(column.id))} + {#if !readonly && !tableStaticCols[tableType].includes(column.id)} - {#if selectedRows.length > 0} + {#if colType === 'output' && $tableState.selectedRows.length > 0} @@ -254,7 +243,7 @@ { - isRenamingColumn = column.id; + tableState.setRenamingCol(column.id); //? Tick doesn't work setTimeout(() => document.getElementById('column-id-edit')?.focus(), 100); }} @@ -262,7 +251,10 @@ Rename - (isDeletingColumn = column.id)} class="!text-[#F04438]"> + tableState.setDeletingCol(column.id)} + class="!text-[#F04438]" + > Delete column diff --git a/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte b/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte new file mode 100644 index 0000000..a996446 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte @@ -0,0 +1,425 @@ + + + { + if ($tableState.resizingCol) { + db[`${tableType}_table`].put({ + id: tableData.id, + columns: $tableState.colSizes + }); + $tableState.resizingCol = null; + } + }} +/> + +{#each tableData.cols as column, index (column.id)} + {@const colType = !column.gen_config ? 'input' : 'output'} + {@const isCustomCol = column.id !== 'ID' && column.id !== 'Updated at'} + + +
handleColumnHeaderClick(column)} + on:dragover={(e) => { + if (isCustomCol) { + e.preventDefault(); + hoveredColumnIndex = index; + } + }} + class={cn( + 'relative [&>*]:z-[-5] flex items-center gap-1 [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333] cursor-default', + isCustomCol && !readonly ? 'px-1' : 'pl-2 pr-1', + $tableState.columnSettings.column?.id == column.id && + $tableState.columnSettings.isOpen && + 'bg-[#30A8FF33]', + draggingColumn?.id == column.id && 'opacity-0' + )} + > + {#if isCustomCol} + + {/if} + + {#if isCustomCol && !readonly} + + {/if} + + {#if column.id !== 'ID' && column.id !== 'Updated at'} + {#if !$tableState.colSizes[column.id] || $tableState.colSizes[column.id] >= 150} + + + {colType} + + {#if !$tableState.colSizes[column.id] || $tableState.colSizes[column.id] >= 220} + + {column.dtype} + + {/if} + + {#if column.gen_config?.object === 'gen_config.llm' && column.gen_config.multi_turn} +
+
+ +
+ {/if} +
+ {/if} + {/if} + + {#if $tableState.renamingCol === column.id} + + { + if (e.key === 'Enter') { + e.preventDefault(); + + handleSaveColumnTitle(e); + } else if (e.key === 'Escape') { + tableState.setRenamingCol(null); + } + }} + on:blur={() => setTimeout(() => tableState.setRenamingCol(null), 100)} + class="w-full bg-transparent border-0 outline outline-1 outline-[#4169e1] data-dark:outline-[#5b7ee5] rounded-[2px]" + /> + {:else} + + {column.id} + + {/if} + + {#if (!tableStaticCols[tableType].includes(column.id) || colType === 'output') && !readonly} + + {/if} +
+{/each} + +{#if dragMouseCoords && draggingColumn} + {@const colType = !draggingColumn.gen_config /* || Object.keys(column.gen_config).length === 0 */ + ? 'input' + : 'output'} + +
+ + + {#if !$tableState.colSizes[draggingColumn.id] || $tableState.colSizes[draggingColumn.id] >= 150} + + + {colType} + + {#if !$tableState.colSizes[draggingColumn.id] || $tableState.colSizes[draggingColumn.id] >= 220} + + {draggingColumn.dtype} + + {/if} + + {#if draggingColumn.gen_config?.object === 'gen_config.llm' && draggingColumn.gen_config.multi_turn} +
+
+ +
+ {/if} +
+ {/if} + + + {draggingColumn.id} + + + +
+
+{/if} diff --git a/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte b/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte index 49250ac..7ce4c18 100644 --- a/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte +++ b/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte @@ -1,8 +1,10 @@ + + + +
+ +
+ +
+ + + + +
diff --git a/services/app/src/lib/components/tables/(sub)/Conversations.svelte b/services/app/src/lib/components/tables/(sub)/Conversations.svelte new file mode 100644 index 0000000..9e77283 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/Conversations.svelte @@ -0,0 +1,383 @@ + + +Chat history + +
+ { + //@ts-expect-error Generic type + debouncedSearchConv(e.target?.value ?? ''); + }} + bind:value={searchQuery} + type="search" + placeholder="Search" + class="pl-8 h-9 placeholder:not-italic placeholder:text-[#98A2B3] bg-[#F2F4F7] rounded-full" + > + + {#if isLoadingSearch} +
+ +
+ {:else} + + {/if} +
+
+
+ +
+ { + currentOffset = 0; + moreConversationsFinished = false; + pastConversations = []; + getPastConversations(); + }} + class="h-[20px] w-[30px] [&>[data-switch-thumb]]:h-4 [&>[data-switch-thumb]]:data-[state=checked]:translate-x-2.5" + /> + +
+ +{#if searchResults.length || isNoResults} + {#if isNoResults} +
+ No results found +
+ {:else} + + Search results: + {searchQuery} + + +
+ {/if} +{/if} + + { + autoAnimateController = autoAnimate(e.detail[0].elements().viewport); + }} + class="grow flex flex-col my-3 rounded-md overflow-auto os-dark" +> + {#each !searchResults.length && !isNoResults ? pastConversations : searchResults as conversation, index (conversation.id)} + {#if !searchResults.length && !isNoResults} + {#each timestampKeys as time (time)} + {#if timestamps[time] == index} +
+ + {timestampsDisplayName[time]} + +
+ {/if} + {/each} + {/if} + {#if isEditingTitle === conversation.id} +
+ +
+ + {/if} {/if}
diff --git a/services/app/src/lib/components/tables/(sub)/TablePagination.svelte b/services/app/src/lib/components/tables/(sub)/TablePagination.svelte index 63e972b..291fc77 100644 --- a/services/app/src/lib/components/tables/(sub)/TablePagination.svelte +++ b/services/app/src/lib/components/tables/(sub)/TablePagination.svelte @@ -1,8 +1,9 @@ @@ -320,7 +158,7 @@ const editingCell = document.querySelector('[data-editing="true"]'); //@ts-ignore if (e.target && editingCell && !editingCell.contains(e.target)) { - isEditingCell = null; + tableState.setEditingCell(null); } }} on:keydown={keyboardNavigate} @@ -329,7 +167,7 @@ {#if tableData}
+ >
+ >
{#if !readonly} { if ($genTableRows) { - return $genTableRows.every((row) => selectedRows.includes(row.ID)) - ? (selectedRows = selectedRows.filter( - (i) => !$genTableRows?.some(({ ID }) => ID === i) - )) - : (selectedRows = [ - ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), - ...$genTableRows.map(({ ID }) => ID) - ]); + return tableState.selectAllRows($genTableRows); } else return false; }} - checked={($genTableRows ?? []).every((row) => selectedRows.includes(row.ID))} + checked={($genTableRows ?? []).every((row) => + $tableState.selectedRows.includes(row.ID) + )} class="h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" /> {/if}
- {#each tableData.cols as column, index (column.id)} - {@const colType = !column.gen_config ? 'input' : 'output'} - {@const isCustomCol = column.id !== 'ID' && column.id !== 'Updated at'} - - -
handleColumnHeaderClick(column)} - on:dragover={(e) => { - if (isCustomCol) { - e.preventDefault(); - hoveredColumnIndex = index; - } - }} - class="flex items-center gap-1 {isCustomCol && !readonly - ? 'px-1' - : 'pl-2 pr-1'} cursor-default [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333] {isColumnSettingsOpen - .column?.id == column.id && isColumnSettingsOpen.showMenu - ? 'bg-[#30A8FF33]' - : ''} {draggingColumn?.id == column.id ? 'opacity-0' : ''}" - > - {#if isCustomCol} - {#if !readonly} - - {/if} - - - - {colType} - - - {column.dtype} - - - {#if column.gen_config?.object === 'gen_config.llm' && column.gen_config.multi_turn} -
-
- -
- {/if} -
- {/if} - - {#if isRenamingColumn === column.id} - - { - if (e.key === 'Enter') { - e.preventDefault(); - - handleSaveColumnTitle(e); - } else if (e.key === 'Escape') { - isRenamingColumn = null; - } - }} - on:blur={() => setTimeout(() => (isRenamingColumn = null), 100)} - class="w-full bg-transparent border-0 outline outline-1 outline-[#4169e1] data-dark:outline-[#5b7ee5] rounded-[2px]" - /> - {:else} - - {column.id} - - {/if} - - {#if (!actionTableStaticCols.includes(column.id) || colType === 'output') && !readonly} - - {/if} -
- {/each} +
{#if $genTableRows} {#if !readonly} - + {/if} {#each $genTableRows as row (row.ID)}
- {#if streamingRows[row.ID]} + {#if $tableState.streamingRows[row.ID]}
+ >
{/if}
+ class={cn( + 'absolute -z-10 top-0 -left-4 h-full w-[calc(100%_+_16px)]', + $tableState.streamingRows[row.ID] + ? 'bg-[#FDEFF4]' + : 'bg-[#FAFBFC] data-dark:bg-[#1E2024] group-hover:bg-[#ECEDEE]' + )} + >
{#if !readonly} handleSelectRow(e, row)} - checked={!!selectedRows.find((i) => i === row.ID)} + checked={!!$tableState.selectedRows.find((i) => i === row.ID)} class="mt-[1px] h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" /> {/if}
{#each tableData.cols as column} {@const editMode = - isEditingCell && - isEditingCell.rowID === row.ID && - isEditingCell.columnID === column.id} + $tableState.editingCell && + $tableState.editingCell.rowID === row.ID && + $tableState.editingCell.columnID === column.id} {@const isValidFileUri = isValidUri(row[column.id]?.value)}
{ if (column.id === 'ID' || column.id === 'Updated at') return; - if (column.dtype === 'file' && row[column.id]?.value && isValidFileUri) return; + if ( + (column.dtype === 'file' || column.dtype === 'audio') && + row[column.id]?.value && + isValidFileUri + ) + return; if (uploadController) return; - if (streamingRows[row.ID] || isEditingCell) return; + if ($tableState.streamingRows[row.ID] || $tableState.editingCell) return; if (e.detail > 1) { e.preventDefault(); @@ -602,45 +326,59 @@ if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; - if (column.dtype === 'file' && row[column.id]?.value && isValidFileUri) return; + if ( + (column.dtype === 'file' || column.dtype === 'audio') && + row[column.id]?.value && + isValidFileUri + ) + return; if (uploadController) return; - if (!streamingRows[row.ID]) { - isEditingCell = { rowID: row.ID, columnID: column.id }; + if (!$tableState.streamingRows[row.ID]) { + tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} on:keydown={(e) => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; - if (column.dtype === 'file' && row[column.id]?.value && isValidFileUri) return; + if ( + (column.dtype === 'file' || column.dtype === 'audio') && + row[column.id]?.value && + isValidFileUri + ) + return; if (uploadController) return; - if (!editMode && e.key == 'Enter' && !streamingRows[row.ID]) { - isEditingCell = { rowID: row.ID, columnID: column.id }; + if (!editMode && e.key == 'Enter' && !$tableState.streamingRows[row.ID]) { + tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - style={isColumnSettingsOpen.column?.id == column.id && isColumnSettingsOpen.showMenu + style={$tableState.columnSettings.column?.id == column.id && + $tableState.columnSettings.isOpen ? 'background-color: #30A8FF17;' : ''} - class="flex flex-col justify-start gap-1 {editMode - ? 'p-0 bg-black/5 data-dark:bg-white/5' - : 'p-2 overflow-auto whitespace-pre-line'} h-full max-h-[99px] sm:max-h-[149px] w-full break-words {streamingRows[ - row.ID - ] - ? 'bg-[#FDEFF4]' - : 'group-hover:bg-[#ECEDEE] data-dark:group-hover:bg-white/5'} [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333]" + class={cn( + 'flex flex-col justify-start gap-1 h-full max-h-[99px] sm:max-h-[149px] w-full break-words [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333]', + editMode + ? 'p-0 bg-black/5 data-dark:bg-white/5' + : 'p-2 overflow-auto whitespace-pre-line', + $tableState.streamingRows[row.ID] + ? 'bg-[#FDEFF4]' + : 'group-hover:bg-[#ECEDEE] data-dark:group-hover:bg-white/5' + )} > - {#if streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} + {#if $tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} {/if} {#if editMode} - {#if column.dtype === 'file'} + {#if column.dtype === 'file' || column.dtype === 'audio'} {:else} @@ -656,9 +394,9 @@ } }} class="min-h-[100px] sm:min-h-[150px] h-full w-full p-2 bg-transparent outline outline-secondary resize-none" - /> + > {/if} - {:else if column.dtype === 'file'} + {:else if column.dtype === 'file' || column.dtype === 'audio'} {/if} @@ -715,52 +453,7 @@
{/if} -{#if dragMouseCoords && draggingColumn} - {@const colType = !draggingColumn.gen_config /* || Object.keys(column.gen_config).length === 0 */ - ? 'input' - : 'output'} - -
- - - - - {colType} - - - {draggingColumn.dtype} - - - - - {draggingColumn.id} - - - -
-
-{/if} - - + { diff --git a/services/app/src/lib/components/tables/ChatTable.svelte b/services/app/src/lib/components/tables/ChatTable.svelte index f937898..a9b3795 100644 --- a/services/app/src/lib/components/tables/ChatTable.svelte +++ b/services/app/src/lib/components/tables/ChatTable.svelte @@ -2,15 +2,13 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; import { onDestroy } from 'svelte'; import { page } from '$app/stores'; - import GripVertical from 'lucide-svelte/icons/grip-vertical'; - import { genTableRows } from '$lib/components/tables/tablesStore'; - import { isValidUri } from '$lib/utils'; - import { chatTableStaticCols } from '$lib/constants'; + import { genTableRows, tableState } from '$lib/components/tables/tablesStore'; + import { cn, isValidUri } from '$lib/utils'; import logger from '$lib/logger'; - import type { GenTable, GenTableCol, GenTableRow, UserRead } from '$lib/types'; + import type { GenTable, GenTableRow, UserRead } from '$lib/types'; import { - ColumnDropdown, + ColumnHeader, DeleteFileDialog, FileColumnView, FileSelect, @@ -18,34 +16,14 @@ NewRow } from '$lib/components/tables/(sub)'; import Checkbox from '$lib/components/Checkbox.svelte'; - import Portal from '$lib/components/Portal.svelte'; import FoundProjectOrgSwitcher from '$lib/components/preset/FoundProjectOrgSwitcher.svelte'; import RowStreamIndicator from '$lib/components/preset/RowStreamIndicator.svelte'; import { toast, CustomToastDesc } from '$lib/components/ui/sonner'; - import { Button } from '$lib/components/ui/button'; import LoadingSpinner from '$lib/icons/LoadingSpinner.svelte'; - import MoreVertIcon from '$lib/icons/MoreVertIcon.svelte'; - import MultiturnChatIcon from '$lib/icons/MultiturnChatIcon.svelte'; export let userData: UserRead | undefined; - export let table: Promise< - | { - error: number; - message: any; - data?: undefined; - } - | { - data: GenTable; - error?: undefined; - message?: undefined; - } - >; export let tableData: GenTable | undefined; - export let tableError: { error: number; message: Awaited['message'] } | undefined; - export let selectedRows: string[]; - export let streamingRows: Record; - export let isColumnSettingsOpen: { column: any; showMenu: boolean }; - export let isDeletingColumn: string | null; + export let tableError: { error: number; message?: any } | undefined; export let readonly = false; export let refetchTable: (hideColumnSettings?: boolean) => Promise; @@ -56,150 +34,16 @@ //? Expanding ID and Updated at columns let focusedCol: string | null = null; - //? Column header click handler - let isRenamingColumn: string | null = null; - let dblClickTimer: NodeJS.Timeout | null = null; - function handleColumnHeaderClick(column: GenTableCol) { - if (!tableData) return; - if (isRenamingColumn) return; - - if (dblClickTimer) { - clearTimeout(dblClickTimer); - dblClickTimer = null; - if (!readonly && !chatTableStaticCols.includes(column.id)) { - isRenamingColumn = column.id; - } - } else { - dblClickTimer = setTimeout(() => { - if (column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config) { - isColumnSettingsOpen = { column, showMenu: true }; - } - dblClickTimer = null; - }, 200); - } - } - - async function handleSaveColumnTitle( - e: KeyboardEvent & { - currentTarget: EventTarget & HTMLInputElement; - } - ) { - if (!tableData || !$genTableRows) return; - if (!isRenamingColumn) return; - - const response = await fetch(`${PUBLIC_JAMAI_URL}/api/v1/gen_tables/chat/columns/rename`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-project-id': $page.params.project_id - }, - body: JSON.stringify({ - table_id: $page.params.table_id, - column_map: { - [isRenamingColumn]: e.currentTarget.value - } - }) - }); - - if (response.ok) { - refetchTable(); - tableData = { - ...tableData, - cols: tableData.cols.map((col) => - col.id === isRenamingColumn ? { ...col, id: e.currentTarget.value } : col - ) - }; - isRenamingColumn = null; - } else { - const responseBody = await response.json(); - logger.error('CHATTBL_COLUMN_RENAME', responseBody); - toast.error('Failed to rename column', { - id: responseBody.message || JSON.stringify(responseBody), - description: CustomToastDesc, - componentProps: { - description: responseBody.message || JSON.stringify(responseBody), - requestID: responseBody.request_id - } - }); - } - } - - //? Reorder columns - let isReorderLoading = false; - let dragMouseCoords: { - x: number; - y: number; - startX: number; - startY: number; - width: number; - } | null = null; - let draggingColumn: GenTable['cols'][number] | null = null; - let draggingColumnIndex: number | null = null; - let hoveredColumnIndex: number | null = null; - - $: if ( - tableData && - draggingColumnIndex != null && - hoveredColumnIndex != null && - draggingColumnIndex != hoveredColumnIndex - ) { - [tableData.cols[draggingColumnIndex], tableData.cols[hoveredColumnIndex]] = [ - tableData.cols[hoveredColumnIndex], - tableData.cols[draggingColumnIndex] - ]; - - draggingColumnIndex = hoveredColumnIndex; - } - - async function handleSaveOrder() { - if (!tableData || !$genTableRows) return; - if (isReorderLoading) return; - isReorderLoading = true; - - const response = await fetch(`${PUBLIC_JAMAI_URL}/api/v1/gen_tables/chat/columns/reorder`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-project-id': $page.params.project_id - }, - body: JSON.stringify({ - table_id: tableData.id, - column_names: tableData.cols.flatMap(({ id }) => - id === 'ID' || id === 'Updated at' ? [] : id - ) - }) - }); - - if (!response.ok) { - const responseBody = await response.json(); - logger.error('CHATTBL_TBL_REORDER', responseBody); - toast.error('Failed to reorder columns', { - id: responseBody.message || JSON.stringify(responseBody), - description: CustomToastDesc, - componentProps: { - description: responseBody.message || JSON.stringify(responseBody), - requestID: responseBody.request_id - } - }); - tableData = (await table)?.data; - } else { - refetchTable(); - } - - isReorderLoading = false; - } - - let isEditingCell: { rowID: string; columnID: string } | null = null; async function handleSaveEdit( e: KeyboardEvent & { currentTarget: EventTarget & HTMLTextAreaElement; } ) { if (!tableData || !$genTableRows) return; - if (!isEditingCell) return; + if (!$tableState.editingCell) return; const editedValue = e.currentTarget.value; - const cellToUpdate = isEditingCell; + const cellToUpdate = $tableState.editingCell; await saveEditCell(cellToUpdate, editedValue); } @@ -246,7 +90,7 @@ //? Revert back to original value genTableRows.setCell(cellToUpdate, originalValue); } else { - isEditingCell = null; + tableState.setEditingCell(null); refetchTable(); } } @@ -260,32 +104,24 @@ if (!tableData || !$genTableRows) return; //? Select multiple rows with shift key const rowIndex = $genTableRows.findIndex(({ ID }) => ID === row.ID); - if (e.detail.event.shiftKey && selectedRows.length && shiftOrigin != null) { + if (e.detail.event.shiftKey && $tableState.selectedRows.length && shiftOrigin != null) { if (shiftOrigin < rowIndex) { - selectedRows = [ - ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), + tableState.setSelectedRows([ + ...$tableState.selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), ...$genTableRows.slice(shiftOrigin, rowIndex + 1).map(({ ID }) => ID) - ]; + ]); } else if (shiftOrigin > rowIndex) { - selectedRows = [ - ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), + tableState.setSelectedRows([ + ...$tableState.selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), ...$genTableRows.slice(rowIndex, shiftOrigin + 1).map(({ ID }) => ID) - ]; + ]); } else { - selectOne(); + tableState.toggleRowSelection(row.ID); } } else { - selectOne(); + tableState.toggleRowSelection(row.ID); shiftOrigin = rowIndex; } - - function selectOne() { - if (selectedRows.find((i) => i === row.ID)) { - selectedRows = selectedRows.filter((i) => i !== row.ID); - } else { - selectedRows = [...selectedRows, row.ID]; - } - } } function keyboardNavigate(e: KeyboardEvent) { @@ -296,21 +132,22 @@ if (isCtrl && e.key === 'a' && !isInputActive) { e.preventDefault(); - if (Object.keys(streamingRows).length !== 0) return; + if (Object.keys($tableState.streamingRows).length !== 0) return; - selectedRows = [ - ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), + tableState.setSelectedRows([ + ...$tableState.selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), ...$genTableRows.map(({ ID }) => ID) - ]; + ]); } if (e.key === 'Escape') { - isEditingCell = null; + tableState.setEditingCell(null); } } onDestroy(() => { $genTableRows = undefined; + tableState.reset(); }); @@ -319,7 +156,7 @@ const editingCell = document.querySelector('[data-editing="true"]'); //@ts-ignore if (e.target && editingCell && !editingCell.contains(e.target)) { - isEditingCell = null; + tableState.setEditingCell(null); } }} on:keydown={keyboardNavigate} @@ -328,7 +165,7 @@ {#if tableData}
{ if ($genTableRows) { - return $genTableRows.every((row) => selectedRows.includes(row.ID)) - ? (selectedRows = selectedRows.filter( - (i) => !$genTableRows?.some(({ ID }) => ID === i) - )) - : (selectedRows = [ - ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), - ...$genTableRows.map(({ ID }) => ID) - ]); + return tableState.selectAllRows($genTableRows); } else return false; }} - checked={($genTableRows ?? []).every((row) => selectedRows.includes(row.ID))} + checked={($genTableRows ?? []).every((row) => + $tableState.selectedRows.includes(row.ID) + )} class="h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" /> {/if}
- {#each tableData.cols as column, index (column.id)} - {@const colType = !column.gen_config ? 'input' : 'output'} - {@const isCustomCol = column.id !== 'ID' && column.id !== 'Updated at'} - - -
handleColumnHeaderClick(column)} - on:dragover={(e) => { - if (isCustomCol) { - e.preventDefault(); - hoveredColumnIndex = index; - } - }} - class="flex items-center gap-1 {isCustomCol && !readonly - ? 'px-1' - : 'pl-2 pr-1'} cursor-default [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333] {isColumnSettingsOpen - .column?.id == column.id && isColumnSettingsOpen.showMenu - ? 'bg-[#30A8FF33]' - : ''} {draggingColumn?.id == column.id ? 'opacity-0' : ''}" - > - {#if isCustomCol && !readonly} - - {/if} - - {#if column.id !== 'ID' && column.id !== 'Updated at'} - - - {colType} - - - {column.dtype} - - - {#if column.gen_config?.object === 'gen_config.llm' && column.gen_config.multi_turn} -
-
- -
- {/if} -
- {/if} - - {#if isRenamingColumn === column.id} - - { - if (e.key === 'Enter') { - e.preventDefault(); - - handleSaveColumnTitle(e); - } else if (e.key === 'Escape') { - isRenamingColumn = null; - } - }} - on:blur={() => setTimeout(() => (isRenamingColumn = null), 100)} - class="w-full bg-transparent border-0 outline outline-1 outline-[#4169e1] data-dark:outline-[#5b7ee5] rounded-[2px]" - /> - {:else} - - {column.id} - - {/if} - - {#if (!chatTableStaticCols.includes(column.id) || colType === 'output') && !readonly} - - {/if} -
- {/each} +
{#if $genTableRows} {#if !readonly} - + {/if}