From f132431da6d7c34f53a6c22c25b55ab3b10e61dd Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Wed, 20 Nov 2024 15:53:31 +0800 Subject: [PATCH] Merge changes for v0.2 release (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What's Changed This is a huge release 🚀 Main features: - Multimodal image input column: Now you can insert an image alongside text as input to LLMs - OSS now supports multiple projects: You are now able to create projects to manage your tables and files - Added ability to turn any column into multi-turn chat via the `multi_turn` parameter in `LLMGenConfig` - Added default prompts when creating Generative Tables: Setup time from table creation to running worlflows is now even shorter. - Added support for templates: We will progresively add more templates that showcase various use cases - Support for search query when listing projects, tables, rows - Table and project import and export - Various improvements to backend and frontend There are several breaking changes and deprecations as well, some highlights are listed here, see CHANGELOG for a complete list: - Added `version` and `meta` to table metadata. Please run the provided migration script to upgrade your existing DB files. - Delete endpoints will return 404 if resource is not found - `/v1/gen_tables/{table_type}/{table_id}/thread` has one new required query parameter: `column_id` - Table list endpoint now defaults to not counting table rows - Output columns must be string type - Deprecations: - Endpoint `/v1/gen_tables/{table_type}/duplicate/{table_id_src}/{table_id_dst}` ## New Contributors * @zec0816 * @Hoipang ## Contributors * @deafnv * @haoshan98 * @kamil-hassan201 * @noobHappylife * @jiahuei ## Full Changelog https://github.com/EmbeddedLLM/JamAIBase/compare/v0.2...v0.3 --- .env => .env.example | 19 +- .flake8 | 4 +- .gitattributes | 3 + .github/workflows/ci-win.yml | 100 +- .github/workflows/ci.yml | 199 +- .github/workflows/lint.yml | 17 +- .gitignore | 8 +- .prettierignore | 3 + CHANGELOG.md | 195 +- MIGRATION_GUIDE.md | 33 + README.md | 12 +- clients/python/README.md | 437 +- clients/python/pyproject.toml | 90 +- clients/python/src/jamaibase/client.py | 5213 ++++++++-- clients/python/src/jamaibase/exceptions.py | 119 + clients/python/src/jamaibase/protocol.py | 1379 ++- .../python/src/jamaibase/utils/__init__.py | 22 + clients/python/src/jamaibase/utils/io.py | 29 +- clients/python/src/jamaibase/version.py | 2 +- ...oader__Swire_AR22_e_230406_sample.pdf.json | 67 - .../pdfloader__background-checks.pdf.json | 15 - .../pdfloader__sample_tables.pdf.json | 59 - ...ments__Swire_AR22_e_230406_sample.pdf.json | 184 - ...plit_documents__background-checks.pdf.json | 28 - .../split_documents__sample_tables.pdf.json | 249 - clients/python/tests/cloud/test_admin.py | 1428 +++ clients/python/tests/cloud/test_org_admin.py | 848 ++ .../python/tests/files/bmp/cifar10-deer.bmp | Bin 0 -> 12730 bytes .../tests/{ => files}/csv/company-profile.csv | 0 .../{__init__.py => files/csv/empty.csv} | 0 .../csv/weather_observations_long.csv | 0 .../{ => files}/doc/Recommendation Letter.doc | Bin .../docx/Recommendation Letter.docx | Bin .../tests/files/gif/rabbit_cifar10-deer.gif | Bin 0 -> 53843 bytes .../html/RAG and LLM Integration Guide.html | 0 .../html/multilingual-code-examples.html | 0 .../python/tests/{ => files}/html/table.html | 0 .../python/tests/files/jpeg/cifar10-deer.jpg | Bin 0 -> 929 bytes clients/python/tests/files/jpeg/rabbit.jpeg | Bin 0 -> 60109 bytes .../{ => files}/json/company-profile.json | 0 .../jsonl/ChatMed_TCM-v0.2-5records.jsonl | 5 + .../tests/{ => files}/jsonl/llm-models.jsonl | 0 .../tests/{ => files}/md/creative-story.md | 0 .../pdf/1970_PSS_ThAT_mechanism.pdf | Bin ..._PRB_phonon-assisted_tunnel_ionization.pdf | Bin ...Models as Optimizers [DeepMind ; 2023].pdf | Bin .../pdf/Swire_AR22_e_230406_sample.pdf | Bin ... Design Blueprint - The Ultimate Guide.pdf | Bin .../pdf/Vehicle Detail - MyPUSPAKOM.pdf | Bin .../pdf/ag-energy-round-up-2017-02-24.pdf | Bin .../{ => files}/pdf/background-checks.pdf | Bin .../tests/{ => files}/pdf/ca-warn-report.pdf | Bin clients/python/tests/files/pdf/empty.pdf | Bin 0 -> 516 bytes .../python/tests/files/pdf/empty_3pages.pdf | Bin 0 -> 924 bytes .../pdf/salary \346\200\273\347\273\223.pdf" | Bin .../tests/{ => files}/pdf/sample_tables.pdf | Bin .../pdf/san-jose-pd-firearm-sample.pdf | Bin .../tests/{ => files}/pdf/statement_card.pdf | Bin .../{ => files}/pdf/statement_ewallet.pdf | Bin .../pdf_mixed/digital_scan_combined.pdf | Bin .../pdf_scan/1978_APL_FP_detrapping.PDF | Bin .../python/tests/files/png/cifar10-deer.png | Bin 0 -> 3518 bytes .../tests/files/png/github-mark-white.png | Bin 0 -> 4837 bytes clients/python/tests/files/png/rabbit.png | Bin 0 -> 40878 bytes ...e Translation in Linear Time (ByteNet).ppt | Bin ... Translation in Linear Time (ByteNet).pptx | Bin .../python/tests/files/tiff/cifar10-deer.tiff | Bin 0 -> 11094 bytes clients/python/tests/files/tiff/rabbit.tiff | Bin 0 -> 47244 bytes .../{ => files}/tsv/weather_observations.tsv | 0 .../tests/{ => files}/txt/creative-story.txt | 0 clients/python/tests/files/txt/empty.txt | 1 + .../python/tests/{ => files}/txt/weather.txt | 0 .../tests/files/webp/rabbit_cifar10-deer.webp | Bin 0 -> 8318 bytes .../tests/{ => files}/xls/Claims Form.xls | Bin .../tests/{ => files}/xlsx/Claims Form.xlsx | Bin .../xml/weather-forecast-service.xml | 0 .../tests/oss/gen_table/test_export_ops.py | 1164 +++ .../tests/oss/gen_table/test_row_ops.py | 2362 +++++ .../tests/oss/gen_table/test_table_ops.py | 2524 +++++ clients/python/tests/oss/test_admin.py | 79 + clients/python/tests/{ => oss}/test_chat.py | 187 +- .../python/tests/{ => oss}/test_embeddings.py | 39 +- clients/python/tests/oss/test_file.py | 186 + clients/python/tests/oss/test_gen_executor.py | 543 + clients/python/tests/{ => oss}/test_io.py | 1 - clients/python/tests/oss/test_template.py | 316 + clients/python/tests/test_gen_executor.py | 899 -- clients/python/tests/test_gen_table.py | 3220 ------ clients/typescript/.gitignore | 3 +- clients/typescript/.prettierignore | 5 + clients/typescript/README.md | 37 +- clients/typescript/__tests__/embeddedLogo.png | Bin 0 -> 2129 bytes clients/typescript/__tests__/file.test.ts | 158 + clients/typescript/__tests__/gentable.test.ts | 408 +- clients/typescript/__tests__/llm.test.ts | 208 +- clients/typescript/__tests__/template.test.ts | 216 + clients/typescript/build | 19 +- clients/typescript/jest.config.ts | 4 +- clients/typescript/package-lock.json | 8753 +++++++++++++++++ clients/typescript/package.json | 105 + .../typescript/scripts/fix-index-exports.cjs | 18 +- .../scripts/include-tests-tsconfig.cjs | 43 + .../scripts/make-dist-package-json.cjs | 2 +- .../typescript/scripts/postprocess-files.cjs | 228 +- ...tsconfig.cjs => remove-tests-tsconfig.cjs} | 2 +- .../typescript/src/helpers/utils.browser.ts | 34 + clients/typescript/src/helpers/utils.node.ts | 49 + clients/typescript/src/{ => helpers}/utils.ts | 23 + clients/typescript/src/index.ts | 76 +- clients/typescript/src/resources/base.ts | 123 +- .../typescript/src/resources/files/index.ts | 65 + .../typescript/src/resources/files/types.ts | 30 + .../src/resources/gen_tables/action.ts | 4 +- .../src/resources/gen_tables/chat.ts | 12 +- .../src/resources/gen_tables/index.ts | 882 +- .../src/resources/gen_tables/knowledge.ts | 20 +- .../src/resources/gen_tables/tables.ts | 142 +- clients/typescript/src/resources/llm/chat.ts | 34 +- clients/typescript/src/resources/llm/index.ts | 162 +- clients/typescript/src/resources/llm/model.ts | 2 +- .../src/resources/templates/index.ts | 81 + .../src/resources/templates/types.ts | 71 + clients/typescript/tsconfig.build.json | 26 + clients/typescript/tsconfig.json | 51 + docker/Dockerfile.docio | 15 +- docker/Dockerfile.frontend | 10 +- docker/Dockerfile.owl | 2 +- docker/amd.yml | 60 + docker/compose.amd.yml | 4 + docker/compose.cpu.ollama.yml | 43 + docker/compose.cpu.yml | 63 +- docker/compose.nvidia.yml | 60 +- docker/nvidia.yml | 24 + docker/ollama.yml | 4 + scripts/compile_api_exe.ps1 | 2 +- scripts/compile_jamaibase_app.ps1 | 13 + .../owl/scripts => scripts}/compile_reqs.py | 0 scripts/copy_repo.sh | 1 + scripts/migrate_model_json.py | 56 + scripts/migration_v030.py | 172 + scripts/remove_cloud_modules.ps1 | 9 +- scripts/remove_cloud_modules.sh | 7 +- services/api/MANIFEST.in | 1 + services/api/api.spec | 11 +- services/api/pyproject.toml | 150 +- services/api/src/owl/__init__.py | 6 + services/api/src/owl/billing.py | 668 +- services/api/src/owl/configs/manager.py | 402 +- services/api/src/owl/configs/models.json | 216 +- services/api/src/owl/configs/models_aipc.json | 241 + .../api/src/owl/configs/models_ollama.json | 171 + services/api/src/owl/db/__init__.py | 57 +- services/api/src/owl/db/file.py | 162 - services/api/src/owl/db/gen_executor.py | 792 +- services/api/src/owl/db/gen_table.py | 1419 ++- services/api/src/owl/db/oss_admin.py | 171 + services/api/src/owl/db/template.py | 55 + services/api/src/owl/docio.py | 3 - services/api/src/owl/entrypoints/api.py | 844 +- services/api/src/owl/entrypoints/chat_echo.py | 121 + .../api/src/owl/entrypoints/chat_python.py | 137 + services/api/src/owl/entrypoints/starling.py | 114 + services/api/src/owl/llm.py | 522 +- services/api/src/owl/loaders.py | 77 +- services/api/src/owl/models.py | 304 +- services/api/src/owl/protocol.py | 1446 ++- services/api/src/owl/routers/file.py | 193 + services/api/src/owl/routers/gen_table.py | 2638 ++--- services/api/src/owl/routers/llm.py | 245 +- services/api/src/owl/routers/org_admin.py | 703 ++ services/api/src/owl/routers/oss_admin.py | 94 + services/api/src/owl/routers/template.py | 417 + services/api/src/owl/scripts/backup_db.py | 78 + services/api/src/owl/scripts/create_meters.py | 126 +- services/api/src/owl/scripts/update_db.py | 103 +- services/api/src/owl/tasks/genitor.py | 194 + services/api/src/owl/tasks/restore.py | 239 + services/api/src/owl/tasks/storage.py | 192 + services/api/src/owl/templates/.gitignore | 2 + .../action/Due_Diligence_ARM.parquet | 3 + .../knowledge/Form_F1_ARM.parquet | 3 + .../f1_due_diligence/template_meta.json | 6 + services/api/src/owl/unstructuredio.py | 6 +- services/api/src/owl/utils/__init__.py | 104 +- services/api/src/owl/utils/auth.py | 384 + services/api/src/owl/utils/crypt.py | 1 - services/api/src/owl/utils/exceptions.py | 185 +- services/api/src/owl/utils/io.py | 312 + services/api/src/owl/utils/jwt.py | 42 + services/api/src/owl/utils/kb.py | 4 +- services/api/src/owl/utils/logging.py | 43 +- services/api/src/owl/utils/responses.py | 360 + services/api/src/owl/utils/tasks.py | 23 + services/api/src/owl/version.py | 2 +- services/api/tests/test_io.py | 0 services/api/tests/test_lance.py | 31 + .../page_data.lance | Bin 0 -> 1214 bytes .../page_lookup.lance | Bin 0 -> 675 bytes .../page_data.lance | Bin 0 -> 3412 bytes .../page_lookup.lance | Bin 0 -> 1326 bytes .../page_data.lance | Bin 0 -> 3412 bytes .../page_lookup.lance | Bin 0 -> 1326 bytes .../page_data.lance | Bin 0 -> 1214 bytes .../page_lookup.lance | Bin 0 -> 675 bytes .../tests/test_table.lance/_latest.manifest | Bin 0 -> 777 bytes ...5-83679c50-04f5-4ad6-a5d3-c90147f82175.txn | Bin 0 -> 212 bytes ...6-2a19e3d8-6397-4d41-8667-3f8c005bdb47.txn | 1 + ...6-c54f1ffd-ae26-4520-9639-0d08015a5dce.txn | Bin 0 -> 3693 bytes .../test_table.lance/_versions/56.manifest | Bin 0 -> 4417 bytes .../test_table.lance/_versions/57.manifest | Bin 0 -> 4417 bytes .../test_table.lance/_versions/58.manifest | Bin 0 -> 777 bytes ...0218dfb5-7d6a-4594-be2f-b6da21fc2991.lance | Bin 0 -> 12443 bytes ...0841f676-2d34-4a0d-beb2-b66eee322f5b.lance | Bin 0 -> 12388 bytes ...0c4d5f53-dce1-4c49-8095-fe16471dfe92.lance | Bin 0 -> 12850 bytes ...0cab0285-7b8d-4019-8662-662342476266.lance | Bin 0 -> 12344 bytes ...195821c2-10b0-4681-9b60-50f79fa017a9.lance | Bin 0 -> 12862 bytes ...2ac6344b-650c-4991-9bd1-48046519287a.lance | Bin 0 -> 12854 bytes ...2fe78714-ed7b-4dd7-8388-889e58479f7c.lance | Bin 0 -> 490087 bytes ...30fb0867-31d2-4cf6-8d7e-f8c68bcd4882.lance | Bin 0 -> 12264 bytes ...375faaf4-394d-4c1d-a217-dce02e301028.lance | Bin 0 -> 12867 bytes ...3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance | Bin 0 -> 11998 bytes ...3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance | Bin 0 -> 12837 bytes ...4038dc14-3f39-4076-b537-d262314ce58e.lance | Bin 0 -> 12337 bytes ...476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance | Bin 0 -> 12859 bytes ...4c5cd4e2-3156-4e17-8129-2e600e91d019.lance | Bin 0 -> 11998 bytes ...4fb6cee8-9f15-4070-a709-02db49fb21b1.lance | Bin 0 -> 12158 bytes ...556e81d3-9067-4d1d-b17d-a4cc0a3eeefc.lance | Bin 0 -> 12338 bytes ...5bb527a1-3c37-4cc7-8db9-85e610247143.lance | Bin 0 -> 12267 bytes ...5e04331f-8465-40b0-af53-455928d381a8.lance | Bin 0 -> 12845 bytes ...62661a16-01fe-4513-b58d-9699f70b5ae4.lance | Bin 0 -> 12054 bytes ...67bff393-906c-432b-bf15-5b854effe0a7.lance | Bin 0 -> 12517 bytes ...681de983-0000-4db1-a193-ee04cee80253.lance | Bin 0 -> 12825 bytes ...6b47eefb-b495-444c-aec6-626a0ed8e441.lance | Bin 0 -> 12499 bytes ...70ba4ca1-f11d-4a37-8bf2-33e642d64baf.lance | Bin 0 -> 12454 bytes ...800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance | Bin 0 -> 12292 bytes ...8263336e-11dc-47c1-a0ab-37dced034d86.lance | Bin 0 -> 12842 bytes ...833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance | Bin 0 -> 12521 bytes ...85d3b452-5002-4793-bc66-fced85c77ebd.lance | Bin 0 -> 12348 bytes ...94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance | Bin 0 -> 12827 bytes ...9583d125-a754-4104-ad58-670223a4cfd4.lance | Bin 0 -> 12259 bytes ...9ae5a155-6864-4aee-9622-7c1d04913ae9.lance | Bin 0 -> 12031 bytes ...a20c5619-719e-48c8-a249-607527257890.lance | Bin 0 -> 12485 bytes ...a4a314e7-13e0-4a60-9d6f-b459576cc577.lance | Bin 0 -> 12867 bytes ...abbdcc8c-93da-4e2e-ac61-91be0874a587.lance | Bin 0 -> 12801 bytes ...af0d8632-619d-4e09-bb2f-ba13d0f568e5.lance | Bin 0 -> 12733 bytes ...af70162a-d319-403c-bd26-251353c9966c.lance | Bin 0 -> 12339 bytes ...b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance | Bin 0 -> 12829 bytes ...bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance | Bin 0 -> 12828 bytes ...beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance | Bin 0 -> 12867 bytes ...c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance | Bin 0 -> 12799 bytes ...c6729c82-8cb2-44d5-993a-7b09bf678703.lance | Bin 0 -> 12590 bytes ...ca3909ff-b21b-45c5-bf85-4911bba60fde.lance | Bin 0 -> 12807 bytes ...ca71816d-8322-43a4-9502-bca6cd2e020c.lance | Bin 0 -> 12038 bytes ...d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance | Bin 0 -> 12102 bytes ...da9c0e9e-7f8b-44ea-82a4-160c98ca17ae.lance | Bin 0 -> 12823 bytes ...db6054f5-4108-4f23-b508-565b32020f68.lance | Bin 0 -> 12809 bytes ...e4c544bd-eb55-4af4-a2dc-f91d7863e671.lance | Bin 0 -> 12789 bytes ...e628d9da-95ab-4b1f-8d21-47ca21154b81.lance | Bin 0 -> 12005 bytes ...e6541d20-5c18-4b80-8d71-84f899310512.lance | Bin 0 -> 12836 bytes ...e856769d-4dac-492c-bd12-679434292b04.lance | Bin 0 -> 12309 bytes ...ea967c5f-1d05-4069-8fef-a1d090e42730.lance | Bin 0 -> 11766 bytes ...ee2829e0-90b5-416b-8c05-203ee77b75aa.lance | Bin 0 -> 12846 bytes ...fc8dc641-5cba-4570-8ef2-8b10992f2cf8.lance | Bin 0 -> 12823 bytes ...fd13dd8d-4f5c-4040-b8da-af081fa49e9d.lance | Bin 0 -> 12130 bytes ...fe71820f-1a4e-4345-8df1-777443efb74a.lance | Bin 0 -> 12829 bytes services/app/.env.example | 5 +- services/app/.gitignore | 1 + services/app/README.md | 118 +- services/app/build.bat | 74 +- services/app/build.sh | 20 +- services/app/electron/icons/icon.icns | Bin 0 -> 224090 bytes services/app/electron/icons/icon.ico | Bin 0 -> 24067 bytes services/app/electron/icons/icon.png | Bin 0 -> 37581 bytes services/app/electron/main.js | 137 + services/app/forge.config.cjs | 30 + services/app/package-lock.json | 7045 +++++++++++-- services/app/package.json | 24 +- services/app/playwright.config.ts | 48 +- services/app/src/app.css | 4 - services/app/src/app.html | 1 + services/app/src/globalStore.ts | 32 +- services/app/src/hooks.server.ts | 109 +- .../src/lib/assets/jamai-onboarding-bg.svg | 6 +- .../app/src/lib/components/Checkbox.svelte | 2 +- .../app/src/lib/components/CustomToast.svelte | 47 - .../src/lib/components/DraggableList.svelte | 132 + .../app/src/lib/components/InputText.svelte | 13 +- services/app/src/lib/components/Range.svelte | 20 +- .../app/src/lib/components/Tooltip.svelte | 5 +- .../app/src/lib/components/UserRole.svelte | 12 - .../preset/FoundProjectOrgSwitcher.svelte | 7 +- .../lib/components/preset/ModelSelect.svelte | 108 +- .../lib/components/preset/PlanSelect.svelte | 56 + .../lib/components/preset/SearchBar.svelte | 45 + .../lib/components/preset/SorterSelect.svelte | 67 + .../tables/(sub)/ColumnDropdown.svelte | 275 + .../tables/(sub)/ColumnSettings.svelte | 730 ++ .../tables/(sub)/DeleteFileDialog.svelte | 70 + .../tables/(sub)/FileColumnView.svelte | 164 + .../components/tables/(sub)/FileSelect.svelte | 189 + .../tables/(sub)/FileThumbsFetch.svelte | 124 + .../lib/components/tables/(sub)/NewRow.svelte | 484 + .../(sub)/SelectKnowledgeTableDialog.svelte | 263 + .../tables/(sub)/TablePagination.svelte | 134 + .../src/lib/components/tables/(sub)/index.ts | 20 + .../tables}/(svg)/NoRowsGraphic.svelte | 0 .../lib/components/tables/ActionTable.svelte | 780 ++ .../components/tables/ChatTable.svelte} | 539 +- .../components/tables/KnowledgeTable.svelte | 815 ++ .../components/tables}/tablesStore.ts | 9 +- .../app/src/lib/components/ui/button/index.ts | 14 +- .../ui/dialog/dialog-actions.svelte | 2 +- .../ui/dialog/dialog-content.svelte | 2 +- .../components/ui/dialog/dialog-header.svelte | 13 +- .../components/ui/dialog/dialog-root.svelte | 22 + .../app/src/lib/components/ui/dialog/index.ts | 2 +- .../dropdown-menu-content.svelte | 14 +- .../dropdown-menu/dropdown-menu-item.svelte | 5 +- .../dropdown-menu-separator.svelte | 2 +- .../dropdown-menu-sub-trigger.svelte | 14 +- .../ui/pagination/pagination-ellipsis.svelte | 10 +- .../ui/select/select-content.svelte | 2 +- .../components/ui/select/select-item.svelte | 32 +- .../components/ui/skeleton/skeleton.svelte | 12 +- .../ui/sonner/CustomToastDesc.svelte | 38 + .../app/src/lib/components/ui/sonner/index.ts | 5 +- services/app/src/lib/constants.ts | 54 +- services/app/src/lib/helpers.ts | 18 - .../app/src/lib/icons/ActionTableIcon.svelte | 28 +- .../app/src/lib/icons/AddColumnIcon.svelte | 49 + .../src/lib/icons/AngleBracketsIcon.svelte | 12 - .../app/src/lib/icons/ArrowBackIcon.svelte | 15 + .../app/src/lib/icons/AssignmentIcon.svelte | 25 +- .../app/src/lib/icons/CalendarIcon.svelte | 11 - .../app/src/lib/icons/ChatBubbleIcon.svelte | 14 - services/app/src/lib/icons/ClockIcon.svelte | 25 - services/app/src/lib/icons/ColumnIcon.svelte | 13 - services/app/src/lib/icons/CopyIcon.svelte | 10 +- .../src/lib/icons/DblArrowRightIcon.svelte | 11 - .../app/src/lib/icons/DocumentIcon.svelte | 17 - .../app/src/lib/icons/DownloadIcon.svelte | 25 - services/app/src/lib/icons/ExpandIcon.svelte | 22 - services/app/src/lib/icons/ExportIcon.svelte | 20 +- .../app/src/lib/icons/FileExplorerIcon.svelte | 14 - services/app/src/lib/icons/FolderIcon.svelte | 14 - services/app/src/lib/icons/FunnelIcon.svelte | 11 - services/app/src/lib/icons/GraphIcon.svelte | 11 - services/app/src/lib/icons/ImportIcon.svelte | 20 +- .../src/lib/icons/KnowledgeTableIcon.svelte | 19 + services/app/src/lib/icons/ListIcon.svelte | 11 - .../app/src/lib/icons/LoadingSpinner.svelte | 1 + services/app/src/lib/icons/LockIcon.svelte | 32 + services/app/src/lib/icons/LogoutIcon.svelte | 35 +- .../app/src/lib/icons/ManageUsersIcon.svelte | 14 - .../src/lib/icons/MessageSquareIcon.svelte | 17 - .../src/lib/icons/MultiturnChatIcon.svelte | 19 + .../src/lib/icons/NotificationsIcon.svelte | 25 - services/app/src/lib/icons/OpenIcon.svelte | 11 - services/app/src/lib/icons/ReembedIcon.svelte | 16 - .../app/src/lib/icons/ReferencesIcon.svelte | 33 - services/app/src/lib/icons/RefreshIcon.svelte | 11 - .../app/src/lib/icons/RightArrowIcon.svelte | 25 - services/app/src/lib/icons/RowIcon.svelte | 13 - services/app/src/lib/icons/SaveIcon.svelte | 18 - .../app/src/lib/icons/SettingsIcon.svelte | 12 +- services/app/src/lib/icons/SideBarIcon.svelte | 29 + .../app/src/lib/icons/SideDockIcon.svelte | 25 - .../app/src/lib/icons/SortAlphabetIcon.svelte | 63 + services/app/src/lib/icons/SortByIcon.svelte | 87 + services/app/src/lib/icons/StarIcon.svelte | 20 + .../app/src/lib/icons/StickyNoteIcon.svelte | 2 + services/app/src/lib/icons/TaskIcon.svelte | 25 - services/app/src/lib/icons/UsageIcon.svelte | 39 - services/app/src/lib/icons/UserIcon.svelte | 31 - services/app/src/lib/nodeCache.ts | 5 - services/app/src/lib/server/nodeCache.ts | 30 + services/app/src/lib/server/utils.ts | 34 + services/app/src/lib/showdown/codeblock.ts | 10 +- services/app/src/lib/types.ts | 126 +- services/app/src/lib/utils.ts | 16 +- services/app/src/routes/(main)/+layout.svelte | 145 +- .../src/routes/(main)/BreadcrumbsBar.svelte | 272 +- .../app/src/routes/(main)/SideDock.svelte | 332 +- .../app/src/routes/(main)/UploadTab.svelte | 113 +- .../app/src/routes/(main)/UserDetails.svelte | 27 +- .../src/routes/(main)/project/+layout.svelte | 56 + .../project/{+layout.server.ts => +layout.ts} | 0 .../src/routes/(main)/project/+page.svelte | 643 +- .../app/src/routes/(main)/project/+page.ts | 8 - .../(main)/project/ProjectDialogs.svelte | 305 + .../(components)/ActionsDropdown.svelte | 376 +- .../(components)/ColumnSettings.svelte | 570 -- .../(components)/GenerateButton.svelte | 345 + .../[project_id]/(components)/NewRow.svelte | 314 - .../SelectKnowledgeTableDialog.svelte | 160 - .../(components)/TablePagination.svelte | 106 - .../[project_id]/(components)/index.ts | 7 +- .../(dialogs)/AddColumnDialog.svelte | 240 +- .../(dialogs)/AddRowDialog.svelte | 233 - .../(dialogs)/ColumnMatchDialog.svelte | 174 +- .../(dialogs)/DeleteDialogs.svelte | 59 +- .../(dialogs)/DeleteTableDialog.svelte | 49 +- .../(dialogs)/ImportTableDialog.svelte | 199 + .../(dialogs)/RenameTableDialog.svelte | 40 +- .../project/[project_id]/(dialogs)/index.ts | 4 +- .../project/[project_id]/+layout.svelte | 225 +- .../(dialogs)/AddTableDialog.svelte | 308 +- .../[project_id]/action-table/+page.svelte | 259 +- .../action-table/[table_id]/+page.ts | 82 +- .../[table_id]/+page@(main).svelte | 224 - .../[table_id]/+page@project.svelte | 266 + .../[table_id]/ActionTable.svelte | 645 -- .../(dialogs)/AddAgentDialog.svelte | 222 +- .../(dialogs)/AddConversationDialog.svelte | 174 +- .../[project_id]/chat-table/+page.svelte | 865 +- .../chat-table/[table_id]/+page.ts | 121 +- .../chat-table/[table_id]/+page@(main).svelte | 283 - .../[table_id]/+page@project.svelte | 330 + .../chat-table/[table_id]/ChatMode.svelte | 391 +- .../chat-table/[table_id]/ChatTable.svelte | 653 -- .../chat-table/[table_id]/ModeToggle.svelte | 67 +- .../(dialogs)/AddTableDialog.svelte | 338 +- .../(dialogs)/UploadingFileDialog.svelte | 4 +- .../[project_id]/knowledge-table/+page.svelte | 263 +- .../knowledge-table/[table_id]/+page.ts | 83 +- .../[table_id]/+page@(main).svelte | 299 - .../[table_id]/+page@project.svelte | 412 + .../src/routes/(main)/settings/+layout.svelte | 29 +- .../app/src/routes/(main)/settings/+layout.ts | 5 + .../routes/(main)/settings/theme/page.svelte | 13 +- services/app/src/routes/+layout.server.ts | 59 +- services/app/src/routes/+layout.svelte | 4 +- services/app/src/routes/_layout.ts | 14 + services/app/src/showdown-theme.css | 40 +- services/app/static/favicon.ico | Bin 0 -> 4286 bytes services/app/tailwind.config.js | 33 +- services/app/tests/auth.setup.ts | 33 +- services/app/tests/fixtures/sample-csv.csv | 2 + services/app/tests/fixtures/sample-doc.txt | 1 + services/app/tests/fixtures/sample-img.jpg | Bin 0 -> 18547 bytes services/app/tests/main.setup.ts | 203 + services/app/tests/main.teardown.ts | 47 + services/app/tests/pages/layout.page.ts | 29 + services/app/tests/pages/project.page.ts | 91 + services/app/tests/pages/table.page.ts | 303 + services/app/tests/pages/tableList.page.ts | 136 + services/app/tests/tableList.spec.ts | 280 + services/app/tests/tables/actionTable.spec.ts | 304 + services/app/tests/tables/chatTable.spec.ts | 388 + .../app/tests/tables/knowledgeTable.spec.ts | 249 + services/docio/MANIFEST.in | 1 + services/docio/docio.spec | 1 - services/docio/pyproject.toml | 75 +- services/docio/scripts/validate_exe.py | 5 +- services/docio/src/docio/entrypoints/api.py | 5 +- .../docio/src/docio/langchain/jsonloader.py | 5 +- .../docio/src/docio/langchain/pdfplumber.py | 22 +- .../docio/src/docio/langchain/tsvloader.py | 22 +- services/docio/src/docio/protocol.py | 8 + services/docio/src/docio/routers/loader.py | 25 +- services/docio/tests/test_loader.py | 87 - services/replication/docker-compose.yaml | 15 + services/replication/entrypoint.sh | 28 + services/replication/litestream.yaml | 9 - services/replication/utils/restore.sh | 38 + 465 files changed, 57879 insertions(+), 20020 deletions(-) rename .env => .env.example (54%) create mode 100644 MIGRATION_GUIDE.md create mode 100644 clients/python/src/jamaibase/exceptions.py delete mode 100644 clients/python/tests/_loader_check/pdfloader__Swire_AR22_e_230406_sample.pdf.json delete mode 100644 clients/python/tests/_loader_check/pdfloader__background-checks.pdf.json delete mode 100644 clients/python/tests/_loader_check/pdfloader__sample_tables.pdf.json delete mode 100644 clients/python/tests/_loader_check/split_documents__Swire_AR22_e_230406_sample.pdf.json delete mode 100644 clients/python/tests/_loader_check/split_documents__background-checks.pdf.json delete mode 100644 clients/python/tests/_loader_check/split_documents__sample_tables.pdf.json create mode 100644 clients/python/tests/cloud/test_admin.py create mode 100644 clients/python/tests/cloud/test_org_admin.py create mode 100644 clients/python/tests/files/bmp/cifar10-deer.bmp rename clients/python/tests/{ => files}/csv/company-profile.csv (100%) rename clients/python/tests/{__init__.py => files/csv/empty.csv} (100%) rename clients/python/tests/{ => files}/csv/weather_observations_long.csv (100%) rename clients/python/tests/{ => files}/doc/Recommendation Letter.doc (100%) rename clients/python/tests/{ => files}/docx/Recommendation Letter.docx (100%) create mode 100644 clients/python/tests/files/gif/rabbit_cifar10-deer.gif rename clients/python/tests/{ => files}/html/RAG and LLM Integration Guide.html (100%) rename clients/python/tests/{ => files}/html/multilingual-code-examples.html (100%) rename clients/python/tests/{ => files}/html/table.html (100%) create mode 100644 clients/python/tests/files/jpeg/cifar10-deer.jpg create mode 100644 clients/python/tests/files/jpeg/rabbit.jpeg rename clients/python/tests/{ => files}/json/company-profile.json (100%) create mode 100644 clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl rename clients/python/tests/{ => files}/jsonl/llm-models.jsonl (100%) rename clients/python/tests/{ => files}/md/creative-story.md (100%) rename clients/python/tests/{ => files}/pdf/1970_PSS_ThAT_mechanism.pdf (100%) rename clients/python/tests/{ => files}/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf (100%) rename clients/python/tests/{ => files}/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf (100%) rename clients/python/tests/{ => files}/pdf/Swire_AR22_e_230406_sample.pdf (100%) rename clients/python/tests/{ => files}/pdf/System Design Blueprint - The Ultimate Guide.pdf (100%) rename clients/python/tests/{ => files}/pdf/Vehicle Detail - MyPUSPAKOM.pdf (100%) rename clients/python/tests/{ => files}/pdf/ag-energy-round-up-2017-02-24.pdf (100%) rename clients/python/tests/{ => files}/pdf/background-checks.pdf (100%) rename clients/python/tests/{ => files}/pdf/ca-warn-report.pdf (100%) create mode 100644 clients/python/tests/files/pdf/empty.pdf create mode 100644 clients/python/tests/files/pdf/empty_3pages.pdf rename "clients/python/tests/pdf/salary \346\200\273\347\273\223.pdf" => "clients/python/tests/files/pdf/salary \346\200\273\347\273\223.pdf" (100%) rename clients/python/tests/{ => files}/pdf/sample_tables.pdf (100%) rename clients/python/tests/{ => files}/pdf/san-jose-pd-firearm-sample.pdf (100%) rename clients/python/tests/{ => files}/pdf/statement_card.pdf (100%) rename clients/python/tests/{ => files}/pdf/statement_ewallet.pdf (100%) rename clients/python/tests/{ => files}/pdf_mixed/digital_scan_combined.pdf (100%) rename clients/python/tests/{ => files}/pdf_scan/1978_APL_FP_detrapping.PDF (100%) create mode 100644 clients/python/tests/files/png/cifar10-deer.png create mode 100644 clients/python/tests/files/png/github-mark-white.png create mode 100644 clients/python/tests/files/png/rabbit.png rename clients/python/tests/{ => files}/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt (100%) rename clients/python/tests/{ => files}/pptx/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).pptx (100%) create mode 100644 clients/python/tests/files/tiff/cifar10-deer.tiff create mode 100644 clients/python/tests/files/tiff/rabbit.tiff rename clients/python/tests/{ => files}/tsv/weather_observations.tsv (100%) rename clients/python/tests/{ => files}/txt/creative-story.txt (100%) create mode 100644 clients/python/tests/files/txt/empty.txt rename clients/python/tests/{ => files}/txt/weather.txt (100%) create mode 100644 clients/python/tests/files/webp/rabbit_cifar10-deer.webp rename clients/python/tests/{ => files}/xls/Claims Form.xls (100%) rename clients/python/tests/{ => files}/xlsx/Claims Form.xlsx (100%) rename clients/python/tests/{ => files}/xml/weather-forecast-service.xml (100%) create mode 100644 clients/python/tests/oss/gen_table/test_export_ops.py create mode 100644 clients/python/tests/oss/gen_table/test_row_ops.py create mode 100644 clients/python/tests/oss/gen_table/test_table_ops.py create mode 100644 clients/python/tests/oss/test_admin.py rename clients/python/tests/{ => oss}/test_chat.py (65%) rename clients/python/tests/{ => oss}/test_embeddings.py (72%) create mode 100644 clients/python/tests/oss/test_file.py create mode 100644 clients/python/tests/oss/test_gen_executor.py rename clients/python/tests/{ => oss}/test_io.py (99%) create mode 100644 clients/python/tests/oss/test_template.py delete mode 100644 clients/python/tests/test_gen_executor.py delete mode 100644 clients/python/tests/test_gen_table.py create mode 100644 clients/typescript/.prettierignore create mode 100644 clients/typescript/__tests__/embeddedLogo.png create mode 100644 clients/typescript/__tests__/file.test.ts create mode 100644 clients/typescript/__tests__/template.test.ts create mode 100644 clients/typescript/package-lock.json create mode 100644 clients/typescript/package.json create mode 100644 clients/typescript/scripts/include-tests-tsconfig.cjs rename clients/typescript/scripts/{fix-test-include-tsconfig.cjs => remove-tests-tsconfig.cjs} (97%) create mode 100644 clients/typescript/src/helpers/utils.browser.ts create mode 100644 clients/typescript/src/helpers/utils.node.ts rename clients/typescript/src/{ => helpers}/utils.ts (55%) create mode 100644 clients/typescript/src/resources/files/index.ts create mode 100644 clients/typescript/src/resources/files/types.ts create mode 100644 clients/typescript/src/resources/templates/index.ts create mode 100644 clients/typescript/src/resources/templates/types.ts create mode 100644 clients/typescript/tsconfig.build.json create mode 100644 clients/typescript/tsconfig.json create mode 100644 docker/amd.yml create mode 100644 docker/compose.amd.yml create mode 100644 docker/compose.cpu.ollama.yml create mode 100644 docker/nvidia.yml create mode 100644 docker/ollama.yml create mode 100644 scripts/compile_jamaibase_app.ps1 rename {services/api/src/owl/scripts => scripts}/compile_reqs.py (100%) create mode 100644 scripts/migrate_model_json.py create mode 100644 scripts/migration_v030.py create mode 100644 services/api/MANIFEST.in create mode 100644 services/api/src/owl/configs/models_aipc.json create mode 100644 services/api/src/owl/configs/models_ollama.json delete mode 100644 services/api/src/owl/db/file.py create mode 100644 services/api/src/owl/db/oss_admin.py create mode 100644 services/api/src/owl/db/template.py create mode 100644 services/api/src/owl/entrypoints/chat_echo.py create mode 100644 services/api/src/owl/entrypoints/chat_python.py create mode 100644 services/api/src/owl/entrypoints/starling.py create mode 100644 services/api/src/owl/routers/file.py create mode 100644 services/api/src/owl/routers/org_admin.py create mode 100644 services/api/src/owl/routers/oss_admin.py create mode 100644 services/api/src/owl/routers/template.py create mode 100644 services/api/src/owl/scripts/backup_db.py create mode 100644 services/api/src/owl/tasks/genitor.py create mode 100644 services/api/src/owl/tasks/restore.py create mode 100644 services/api/src/owl/tasks/storage.py create mode 100644 services/api/src/owl/templates/.gitignore create mode 100644 services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet create mode 100644 services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet create mode 100644 services/api/src/owl/templates/f1_due_diligence/template_meta.json create mode 100644 services/api/src/owl/utils/auth.py create mode 100644 services/api/src/owl/utils/jwt.py create mode 100644 services/api/src/owl/utils/responses.py delete mode 100644 services/api/tests/test_io.py create mode 100644 services/api/tests/test_lance.py create mode 100644 services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_data.lance create mode 100644 services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_lookup.lance create mode 100644 services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_data.lance create mode 100644 services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_lookup.lance create mode 100644 services/api/tests/test_table.lance/_indices/cf06ed0f-70eb-479f-9824-dfee71a61680/page_data.lance create mode 100644 services/api/tests/test_table.lance/_indices/cf06ed0f-70eb-479f-9824-dfee71a61680/page_lookup.lance create mode 100644 services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_data.lance create mode 100644 services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_lookup.lance create mode 100644 services/api/tests/test_table.lance/_latest.manifest create mode 100644 services/api/tests/test_table.lance/_transactions/55-83679c50-04f5-4ad6-a5d3-c90147f82175.txn create mode 100644 services/api/tests/test_table.lance/_transactions/56-2a19e3d8-6397-4d41-8667-3f8c005bdb47.txn create mode 100644 services/api/tests/test_table.lance/_transactions/56-c54f1ffd-ae26-4520-9639-0d08015a5dce.txn create mode 100644 services/api/tests/test_table.lance/_versions/56.manifest create mode 100644 services/api/tests/test_table.lance/_versions/57.manifest create mode 100644 services/api/tests/test_table.lance/_versions/58.manifest create mode 100644 services/api/tests/test_table.lance/data/0218dfb5-7d6a-4594-be2f-b6da21fc2991.lance create mode 100644 services/api/tests/test_table.lance/data/0841f676-2d34-4a0d-beb2-b66eee322f5b.lance create mode 100644 services/api/tests/test_table.lance/data/0c4d5f53-dce1-4c49-8095-fe16471dfe92.lance create mode 100644 services/api/tests/test_table.lance/data/0cab0285-7b8d-4019-8662-662342476266.lance create mode 100644 services/api/tests/test_table.lance/data/195821c2-10b0-4681-9b60-50f79fa017a9.lance create mode 100644 services/api/tests/test_table.lance/data/2ac6344b-650c-4991-9bd1-48046519287a.lance create mode 100644 services/api/tests/test_table.lance/data/2fe78714-ed7b-4dd7-8388-889e58479f7c.lance create mode 100644 services/api/tests/test_table.lance/data/30fb0867-31d2-4cf6-8d7e-f8c68bcd4882.lance create mode 100644 services/api/tests/test_table.lance/data/375faaf4-394d-4c1d-a217-dce02e301028.lance create mode 100644 services/api/tests/test_table.lance/data/3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance create mode 100644 services/api/tests/test_table.lance/data/3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance create mode 100644 services/api/tests/test_table.lance/data/4038dc14-3f39-4076-b537-d262314ce58e.lance create mode 100644 services/api/tests/test_table.lance/data/476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance create mode 100644 services/api/tests/test_table.lance/data/4c5cd4e2-3156-4e17-8129-2e600e91d019.lance create mode 100644 services/api/tests/test_table.lance/data/4fb6cee8-9f15-4070-a709-02db49fb21b1.lance create mode 100644 services/api/tests/test_table.lance/data/556e81d3-9067-4d1d-b17d-a4cc0a3eeefc.lance create mode 100644 services/api/tests/test_table.lance/data/5bb527a1-3c37-4cc7-8db9-85e610247143.lance create mode 100644 services/api/tests/test_table.lance/data/5e04331f-8465-40b0-af53-455928d381a8.lance create mode 100644 services/api/tests/test_table.lance/data/62661a16-01fe-4513-b58d-9699f70b5ae4.lance create mode 100644 services/api/tests/test_table.lance/data/67bff393-906c-432b-bf15-5b854effe0a7.lance create mode 100644 services/api/tests/test_table.lance/data/681de983-0000-4db1-a193-ee04cee80253.lance create mode 100644 services/api/tests/test_table.lance/data/6b47eefb-b495-444c-aec6-626a0ed8e441.lance create mode 100644 services/api/tests/test_table.lance/data/70ba4ca1-f11d-4a37-8bf2-33e642d64baf.lance create mode 100644 services/api/tests/test_table.lance/data/800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance create mode 100644 services/api/tests/test_table.lance/data/8263336e-11dc-47c1-a0ab-37dced034d86.lance create mode 100644 services/api/tests/test_table.lance/data/833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance create mode 100644 services/api/tests/test_table.lance/data/85d3b452-5002-4793-bc66-fced85c77ebd.lance create mode 100644 services/api/tests/test_table.lance/data/94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance create mode 100644 services/api/tests/test_table.lance/data/9583d125-a754-4104-ad58-670223a4cfd4.lance create mode 100644 services/api/tests/test_table.lance/data/9ae5a155-6864-4aee-9622-7c1d04913ae9.lance create mode 100644 services/api/tests/test_table.lance/data/a20c5619-719e-48c8-a249-607527257890.lance create mode 100644 services/api/tests/test_table.lance/data/a4a314e7-13e0-4a60-9d6f-b459576cc577.lance create mode 100644 services/api/tests/test_table.lance/data/abbdcc8c-93da-4e2e-ac61-91be0874a587.lance create mode 100644 services/api/tests/test_table.lance/data/af0d8632-619d-4e09-bb2f-ba13d0f568e5.lance create mode 100644 services/api/tests/test_table.lance/data/af70162a-d319-403c-bd26-251353c9966c.lance create mode 100644 services/api/tests/test_table.lance/data/b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance create mode 100644 services/api/tests/test_table.lance/data/bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance create mode 100644 services/api/tests/test_table.lance/data/beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance create mode 100644 services/api/tests/test_table.lance/data/c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance create mode 100644 services/api/tests/test_table.lance/data/c6729c82-8cb2-44d5-993a-7b09bf678703.lance create mode 100644 services/api/tests/test_table.lance/data/ca3909ff-b21b-45c5-bf85-4911bba60fde.lance create mode 100644 services/api/tests/test_table.lance/data/ca71816d-8322-43a4-9502-bca6cd2e020c.lance create mode 100644 services/api/tests/test_table.lance/data/d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance create mode 100644 services/api/tests/test_table.lance/data/da9c0e9e-7f8b-44ea-82a4-160c98ca17ae.lance create mode 100644 services/api/tests/test_table.lance/data/db6054f5-4108-4f23-b508-565b32020f68.lance create mode 100644 services/api/tests/test_table.lance/data/e4c544bd-eb55-4af4-a2dc-f91d7863e671.lance create mode 100644 services/api/tests/test_table.lance/data/e628d9da-95ab-4b1f-8d21-47ca21154b81.lance create mode 100644 services/api/tests/test_table.lance/data/e6541d20-5c18-4b80-8d71-84f899310512.lance create mode 100644 services/api/tests/test_table.lance/data/e856769d-4dac-492c-bd12-679434292b04.lance create mode 100644 services/api/tests/test_table.lance/data/ea967c5f-1d05-4069-8fef-a1d090e42730.lance create mode 100644 services/api/tests/test_table.lance/data/ee2829e0-90b5-416b-8c05-203ee77b75aa.lance create mode 100644 services/api/tests/test_table.lance/data/fc8dc641-5cba-4570-8ef2-8b10992f2cf8.lance create mode 100644 services/api/tests/test_table.lance/data/fd13dd8d-4f5c-4040-b8da-af081fa49e9d.lance create mode 100644 services/api/tests/test_table.lance/data/fe71820f-1a4e-4345-8df1-777443efb74a.lance create mode 100644 services/app/electron/icons/icon.icns create mode 100644 services/app/electron/icons/icon.ico create mode 100644 services/app/electron/icons/icon.png create mode 100644 services/app/electron/main.js create mode 100644 services/app/forge.config.cjs delete mode 100644 services/app/src/lib/components/CustomToast.svelte create mode 100644 services/app/src/lib/components/DraggableList.svelte delete mode 100644 services/app/src/lib/components/UserRole.svelte create mode 100644 services/app/src/lib/components/preset/PlanSelect.svelte create mode 100644 services/app/src/lib/components/preset/SearchBar.svelte create mode 100644 services/app/src/lib/components/preset/SorterSelect.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/DeleteFileDialog.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/FileColumnView.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/FileSelect.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/NewRow.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/TablePagination.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/index.ts rename services/app/src/{routes/(main)/project/[project_id]/knowledge-table/[table_id] => lib/components/tables}/(svg)/NoRowsGraphic.svelte (100%) create mode 100644 services/app/src/lib/components/tables/ActionTable.svelte rename services/app/src/{routes/(main)/project/[project_id]/knowledge-table/[table_id]/KnowledgeTable.svelte => lib/components/tables/ChatTable.svelte} (51%) create mode 100644 services/app/src/lib/components/tables/KnowledgeTable.svelte rename services/app/src/{routes/(main)/project/[project_id] => lib/components/tables}/tablesStore.ts (92%) create mode 100644 services/app/src/lib/components/ui/dialog/dialog-root.svelte create mode 100644 services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte delete mode 100644 services/app/src/lib/helpers.ts create mode 100644 services/app/src/lib/icons/AddColumnIcon.svelte delete mode 100644 services/app/src/lib/icons/AngleBracketsIcon.svelte create mode 100644 services/app/src/lib/icons/ArrowBackIcon.svelte delete mode 100644 services/app/src/lib/icons/CalendarIcon.svelte delete mode 100644 services/app/src/lib/icons/ChatBubbleIcon.svelte delete mode 100644 services/app/src/lib/icons/ClockIcon.svelte delete mode 100644 services/app/src/lib/icons/ColumnIcon.svelte delete mode 100644 services/app/src/lib/icons/DblArrowRightIcon.svelte delete mode 100644 services/app/src/lib/icons/DocumentIcon.svelte delete mode 100644 services/app/src/lib/icons/DownloadIcon.svelte delete mode 100644 services/app/src/lib/icons/ExpandIcon.svelte delete mode 100644 services/app/src/lib/icons/FileExplorerIcon.svelte delete mode 100644 services/app/src/lib/icons/FolderIcon.svelte delete mode 100644 services/app/src/lib/icons/FunnelIcon.svelte delete mode 100644 services/app/src/lib/icons/GraphIcon.svelte create mode 100644 services/app/src/lib/icons/KnowledgeTableIcon.svelte delete mode 100644 services/app/src/lib/icons/ListIcon.svelte create mode 100644 services/app/src/lib/icons/LockIcon.svelte delete mode 100644 services/app/src/lib/icons/ManageUsersIcon.svelte delete mode 100644 services/app/src/lib/icons/MessageSquareIcon.svelte create mode 100644 services/app/src/lib/icons/MultiturnChatIcon.svelte delete mode 100644 services/app/src/lib/icons/NotificationsIcon.svelte delete mode 100644 services/app/src/lib/icons/OpenIcon.svelte delete mode 100644 services/app/src/lib/icons/ReembedIcon.svelte delete mode 100644 services/app/src/lib/icons/ReferencesIcon.svelte delete mode 100644 services/app/src/lib/icons/RefreshIcon.svelte delete mode 100644 services/app/src/lib/icons/RightArrowIcon.svelte delete mode 100644 services/app/src/lib/icons/RowIcon.svelte delete mode 100644 services/app/src/lib/icons/SaveIcon.svelte create mode 100644 services/app/src/lib/icons/SideBarIcon.svelte delete mode 100644 services/app/src/lib/icons/SideDockIcon.svelte create mode 100644 services/app/src/lib/icons/SortAlphabetIcon.svelte create mode 100644 services/app/src/lib/icons/SortByIcon.svelte create mode 100644 services/app/src/lib/icons/StarIcon.svelte delete mode 100644 services/app/src/lib/icons/TaskIcon.svelte delete mode 100644 services/app/src/lib/icons/UsageIcon.svelte delete mode 100644 services/app/src/lib/icons/UserIcon.svelte delete mode 100644 services/app/src/lib/nodeCache.ts create mode 100644 services/app/src/lib/server/nodeCache.ts create mode 100644 services/app/src/lib/server/utils.ts create mode 100644 services/app/src/routes/(main)/project/+layout.svelte rename services/app/src/routes/(main)/project/{+layout.server.ts => +layout.ts} (100%) delete mode 100644 services/app/src/routes/(main)/project/+page.ts create mode 100644 services/app/src/routes/(main)/project/ProjectDialogs.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/ColumnSettings.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/NewRow.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/SelectKnowledgeTableDialog.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/(components)/TablePagination.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddRowDialog.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@(main).svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/ActionTable.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@(main).svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatTable.svelte delete mode 100644 services/app/src/routes/(main)/project/[project_id]/knowledge-table/[table_id]/+page@(main).svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/knowledge-table/[table_id]/+page@project.svelte create mode 100644 services/app/src/routes/(main)/settings/+layout.ts create mode 100644 services/app/src/routes/_layout.ts create mode 100644 services/app/static/favicon.ico create mode 100644 services/app/tests/fixtures/sample-csv.csv create mode 100644 services/app/tests/fixtures/sample-doc.txt create mode 100644 services/app/tests/fixtures/sample-img.jpg create mode 100644 services/app/tests/main.setup.ts create mode 100644 services/app/tests/main.teardown.ts create mode 100644 services/app/tests/pages/layout.page.ts create mode 100644 services/app/tests/pages/project.page.ts create mode 100644 services/app/tests/pages/table.page.ts create mode 100644 services/app/tests/pages/tableList.page.ts create mode 100644 services/app/tests/tableList.spec.ts create mode 100644 services/app/tests/tables/actionTable.spec.ts create mode 100644 services/app/tests/tables/chatTable.spec.ts create mode 100644 services/app/tests/tables/knowledgeTable.spec.ts create mode 100644 services/docio/MANIFEST.in create mode 100644 services/docio/src/docio/protocol.py delete mode 100644 services/docio/tests/test_loader.py create mode 100644 services/replication/docker-compose.yaml create mode 100755 services/replication/entrypoint.sh delete mode 100644 services/replication/litestream.yaml create mode 100755 services/replication/utils/restore.sh diff --git a/.env b/.env.example similarity index 54% rename from .env rename to .env.example index 56f5fa2..06bb65d 100644 --- a/.env +++ b/.env.example @@ -3,19 +3,28 @@ 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=1 -OWL_DB_DIR=db -OWL_LOG_DIR=logs +OWL_WORKERS=3 DOCIO_WORKERS=1 DOCIO_DEVICE=cpu EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 RERANKER_MODEL=cross-encoder/ms-marco-TinyBERT-L-2 -OWL_CONCURRENT_ROWS_BATCH_SIZE=3 -OWL_CONCURRENT_COLS_BATCH_SIZE=5 \ No newline at end of file +OWL_CONCURRENT_ROWS_BATCH_SIZE=5 +OWL_CONCURRENT_COLS_BATCH_SIZE=5 +OWL_MAX_WRITE_BATCH_SIZE=1000 diff --git a/.flake8 b/.flake8 index 38cfbc2..d7fd8b6 100644 --- a/.flake8 +++ b/.flake8 @@ -4,10 +4,12 @@ select = C,E,F,W,B,B950 extend-ignore = E203,E402,E501,F541,W503,F401 extend-exclude = .vscode/, + __ref__/ archive/, build/, - configs/, + clients/typescript/, dependencies/, + services/app/, venv/, **/dist **/build diff --git a/.gitattributes b/.gitattributes index 3557970..08e60cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -54,3 +54,6 @@ # These files should not be processed by Linguist for language detection on GitHub.com *.p linguist-detectable=false *.gz linguist-detectable=false + +# Track with Git LFS +*.parquet filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci-win.yml b/.github/workflows/ci-win.yml index 9f0b9de..4671b82 100644 --- a/.github/workflows/ci-win.yml +++ b/.github/workflows/ci-win.yml @@ -10,13 +10,95 @@ on: tags: - "v*" +# 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: + pyinstaller_electron_app: + name: PyInstaller JamAIBase Electron App Compilation + runs-on: windows-11-desktop + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + + - name: Install Git + run: | + $installer_url = "https://github.com/git-for-windows/git/releases/download/v2.45.2.windows.1/Git-2.45.2-64-bit.exe" + Invoke-WebRequest -Uri $installer_url -OutFile "GitInstaller.exe" + Start-Process -FilePath "GitInstaller.exe" -Args "/VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS /COMPONENTS='icons,ext\reg\shellhere,assoc,assoc_sh'" -Wait + Remove-Item "GitInstaller.exe" + + # Add Git to PATH + $gitPath = "C:\Program Files\Git\cmd" + $env:PATH = "$gitPath;$env:PATH" + [Environment]::SetEnvironmentVariable("PATH", $env:PATH, [EnvironmentVariableTarget]::Machine) + + # Output the new PATH to a step output + echo "PATH=$env:PATH" >> $env:GITHUB_ENV + + # Verify Git installation + git --version + shell: powershell + + - name: Verify Git in PATH + run: | + Write-Host "Current PATH: $env:PATH" + $gitPath = (Get-Command git -ErrorAction SilentlyContinue).Path + if ($gitPath) { + Write-Host "Git found at: $gitPath" + } else { + Write-Host "Git not found in PATH" + exit 1 + } + shell: powershell + + - name: Inspect git version + run: | + git --version + + - name: Remove cloud-only modules and start compiling JamAIBase Electron App + run: | + mv .env.example .env + $ErrorActionPreference = "Stop" + .\scripts\compile_jamaibase_app.ps1 + shell: powershell + + - name: Validate jamaibase.exe is healthy + run: | + cd services\app\build-electron\make\zip\win32\x64\ + + Expand-Archive -Path 'jamaibase-app-win32-x64-0.2.0.zip' -DestinationPath 'jamaibase-app-win32-x64-0.2.0' + + $process = Start-Process -NoNewWindow -FilePath ".\jamaibase-app-win32-x64-0.2.0\jamaibase-app.exe" -PassThru + + $processId = $process.Id + Write-Output "Process ID: $processId" + + # Wait for 5 seconds + Start-Sleep -Seconds 10 + + # Check if the process is still running + if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { + Write-Output "The process is still running." + } else { + Write-Output "The process has exited." + } + shell: powershell + pyinstaller_api: name: PyInstaller API Service Compilation runs-on: windows-11-desktop - strategy: - matrix: - python-version: ["3.10"] + timeout-minutes: 60 steps: - name: Checkout code @@ -25,7 +107,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Inspect Python version run: python --version @@ -67,6 +149,7 @@ jobs: - name: Remove cloud-only modules and start compiling API service run: | + mv .env.example .env $ErrorActionPreference = "Stop" .\scripts\compile_api_exe.ps1 shell: powershell @@ -75,7 +158,7 @@ jobs: run: | $env:OWL_WORKERS=1 $process = Start-Process -NoNewWindow -FilePath ".\services\api\dist\api\api.exe" -PassThru - Start-Sleep -Seconds 10 + Start-Sleep -Seconds 60 Write-Output "API process ID: $($process.Id)" Get-Process Test-NetConnection -ComputerName localhost -Port 6969 @@ -96,9 +179,7 @@ jobs: pyinstaller_docio: name: PyInstaller DocIO Service Compilation runs-on: windows-11-desktop - strategy: - matrix: - python-version: ["3.10"] + timeout-minutes: 60 steps: - name: Checkout code @@ -107,7 +188,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: "3.10" - name: Display Python version run: python --version @@ -150,6 +231,7 @@ jobs: - name: Remove cloud-only modules and start compiling DocIO service run: | + mv .env.example .env $ErrorActionPreference = "Stop" .\scripts\compile_docio_exe.ps1 shell: powershell diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e788d9..f6d8270 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,17 +11,27 @@ on: tags: - "v*" +# 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: - python_tests: - name: Python unit tests + sdk_tests: + name: SDK unit tests runs-on: ubuntu-latest-l + # runs-on: namespace-profile-ubuntu-latest-8cpu-16gb-96gb strategy: matrix: python-version: ["3.10"] + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@v4 + with: + lfs: true - name: Set up Python uses: actions/setup-python@v5 @@ -37,7 +47,7 @@ jobs: set -e bash scripts/remove_cloud_modules.sh cd clients/python - python -m pip install .[all] + python -m pip install .[test] - name: Check Docker Version run: docker version @@ -50,15 +60,11 @@ jobs: env: JH_PAT: ${{ secrets.JH_PAT }} - - name: Launch services - timeout-minutes: 15 + - name: Edit env file run: | set -e - export API_DEVICE=cpu - export EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 - export RERANKER_MODEL=cross-encoder/ms-marco-TinyBERT-L-2 + mv .env.example .env - # Edit .env file ORGS=$(printenv | grep API_KEY | xargs -I {} echo {} | cut -d '=' -f 1) KEYS=$(printenv | grep API_KEY | xargs -I {} echo {} | cut -d '=' -f 2-) # Convert them into arrays @@ -72,93 +78,124 @@ jobs: # Replace the org with the key in the .env file sed -i "s/$org=.*/$org=$key/g" .env done - - docker compose -f docker/compose.cpu.yml up --quiet-pull -d - - # Wait for the service to finish starting up - set +e - while true; do - docker ps - response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:${OWL_PORT:-6969}/api/health) - echo $response - if [ $response -eq 200 ]; then - break - fi - printf "> Waiting for API service ...\n" - sleep 10 - done - printf "> DONE\n" env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + HYPERBOLIC_API_KEY: ${{ secrets.HYPERBOLIC_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 + + env: COMPOSE_DOCKER_CLI_BUILD: 1 DOCKER_BUILDKIT: 1 - - name: Inspect owl environment - run: docker exec jamai_owl pip list + - name: Inspect owl Python version + run: docker exec jamai-owl-1 python -V - # - name: Pytest (owl) - # run: | - # set -e - # python -m pytest -vv --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov-report=xml services/api/tests + - name: Inspect owl environment + run: docker exec jamai-owl-1 pip list - - name: Pytest (Python client) + - name: Python SDK tests (OSS) + id: python_sdk_test_oss + if: always() && steps.launch_oss.outcome == 'success' run: | set -e export JAMAI_API_BASE=http://localhost:6969/api - python -m pytest -vv --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov-report=xml clients/python/tests + python -m pytest -vv \ + --timeout 300 \ + --doctest-modules \ + --junitxml=junit/test-results-${{ matrix.python-version }}.xml \ + --cov-report=xml \ + --no-flaky-report \ + clients/python/tests/oss + + - name: Inspect owl logs if Python SDK tests failed + if: failure() && steps.python_sdk_test_oss.outcome == 'failure' + run: docker exec jamai-owl-1 cat /app/api/logs/owl.log - name: Upload Pytest Test Results uses: actions/upload-artifact@v4 with: name: pytest-results-${{ matrix.python-version }} path: junit/test-results-${{ matrix.python-version }}.xml - # Use always() to always run this step to publish test results when there are test failures - if: ${{ always() }} - - #- name: Generate OpenAPI Json Schema - # run: mkdir -p artifacts && curl http://127.0.0.1:7770/api/openapi.json >> ./artifacts/openapi.json - - #- name: Upload OpenAPI Json Schema - # uses: actions/upload-artifact@v4 - # with: - # name: openapi-json - # path: artifacts/openapi.json - - # readmeio_update: - # needs: python_tests - # name: Push OpenAPI to ReadMe.io - # runs-on: ubuntu-latest-l - # strategy: - # matrix: - # python-version: ["3.10"] - # steps: - # - name: Checkout code - # uses: actions/checkout@v4 - - # - name: Download OpenAPI Json Schema - # uses: actions/download-artifact@v4 - # with: - # name: openapi-json - # path: artifacts - - # # - name: Display structure of downloaded files - # # run: ls -R artifacts - - # # Run GitHub Action to sync OpenAPI file at ./path-to-file.json - # - name: Update ReadMe API Reference Documentation - # # We recommend specifying a fixed version, i.e. @v8 - # # Docs: https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#example-using-versioned-actions - # uses: readmeio/rdme@v8 - # with: - # rdme: openapi artifacts/openapi.json --key=${{ secrets.README_API_KEY }} --id=${{ secrets.API_DEFINITION_ID }} - - # # - name: Generate Stainless SDK - # # uses: stainless-api/upload-openapi-spec-action@main - # # with: - # # stainless_api_key: ${{ secrets.STAINLESS_API_KEY }} - # # input_path: "artifacts/openapi.json" - # # config_path: "ellm.stainless.yaml" - # # project_name: "jamai" + # Always run this step to publish test results even when there are test failures + if: always() + + - name: TS/JS SDK tests (OSS) + id: ts_sdk_test_oss + if: always() && steps.launch_oss.outcome == 'success' + run: | + cd clients/typescript + echo "BASEURL=http://localhost:6969" >> __tests__/.env + npm install + npm run test + + - name: Inspect owl logs if TS/JS SDK tests failed + if: failure() && steps.ts_sdk_test_oss.outcome == 'failure' + run: docker exec jamai-owl-1 cat /app/api/logs/owl.log + + - name: Update owl service for S3 test + run: | + # Update the .env file to include the new environment variable + echo 'OWL_FILE_DIR=s3://file' >> .env + echo 'S3_ENDPOINT=http://minio:9000' >> .env + echo 'S3_ACCESS_KEY_ID=minioadmin' >> .env + echo 'S3_SECRET_ACCESS_KEY=minioadmin' >> .env + + # Restart the owl service with the updated environment + docker compose -p jamai -f docker/compose.cpu.yml up --quiet-pull -d --wait --no-deps --build --force-recreate owl + + - name: Python SDK tests (File API, OSS) + id: python_sdk_test_oss_file + if: always() && steps.launch_oss.outcome == 'success' + run: | + set -e + export JAMAI_API_BASE=http://localhost:6969/api + python -m pytest -vv \ + --timeout 300 \ + --doctest-modules \ + --junitxml=junit/test-results-${{ matrix.python-version }}.xml \ + --cov-report=xml \ + --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' + run: docker exec jamai-owl-1 cat /app/api/logs/owl.log + + lance_tests: + name: Lance tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Inspect git version + run: | + git --version + + - name: Install owl + run: | + set -e + cd services/api + python -m pip install .[test] + + - name: Run tests + run: pytest services/api/tests/test_lance.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1f984cf..85a0754 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.12"] steps: - name: Checkout code @@ -28,20 +28,11 @@ jobs: cd clients/python python3 -m pip install .[lint] - - name: Check Python files using Black + - name: Check Python files using Ruff run: | set -e - black --check --verbose --diff --config clients/python/pyproject.toml . - - - name: Run flake8 lint tests (errors) - run: | - set -e - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - - - name: Run flake8 lint tests (warnings) - run: | - set -e - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=99 --statistics + ruff check --output-format github --config clients/python/pyproject.toml . + ruff format --diff --config clients/python/pyproject.toml . prettier_lint: name: Prettier Checks diff --git a/.gitignore b/.gitignore index 911488b..bbb9d49 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,11 @@ venv/ *.geojson *.laz *.db +*.parquet # Internal references, dependencies, temporary folders & files /db*/ +file/ /infinity_cache/ **/__ref__/ /dependencies/ @@ -22,7 +24,8 @@ logs/ /milvus_data/ /vespa*/ *.swp - +.env +*.lock # pytest-cov **/.coverage* @@ -48,4 +51,5 @@ clients/typescript/**/*.d.ts **/node_modules/ **/docs-autogen/ **/docs-autogen-ts/ -**/dist \ No newline at end of file +**/dist + diff --git a/.prettierignore b/.prettierignore index c4ffc32..42af708 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,9 @@ venv/ services/app clients/typescript +# Test files +clients/python/tests/**/* + # Internal references, dependencies, temporary folders & files archive/ /dependencies/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cec6c..1326f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,194 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The version number mentioned here refers to the cloud version. For each release, all SDKs will have the same major and minor version, but their patch version may differ. For example, latest Python SDK might be `v0.2.0` whereas TS SDK might be `v0.2.1`, but both will be compatible with release `v0.2`. +## [v0.3] (2024-11-20) + +### ADDED + +Python SDK - jamaibase + +- Added `missing_ok` to all delete methods +- Added Organization Admin API via `admin.organization` methods +- Added Backend Admin API via `admin.backend` methods +- Added Templates API via `template` methods +- Added File API via `file` methods +- Added `GenConfig` protocols: `LLMGenConfig` and `EmbedGenConfig` +- `list_tables` method: + - Added `parent_id` param. Resolved #252 + - Added `search_query` param to search table IDs +- Added `timeout` and `file_upload_timeout` parameters in client init method. +- Added PAT methods + +TS SDK - jamaibase + +- Added `create_child_table` method to create conversation table as a child table. Resolves #283 +- Grouped methods into `table`, `llm`, `file`, `template` + +UI + +- Electron App + - Added script for compiling JamAIBase Electron App. + - Added detailed instructions for building and running the JamAIBase Electron App in the `services/app/README.md`. + - Added Electron main process initialization script. + - Added Electron Forge configuration for packaging and making redistributables. + - Added `.gitignore` entries for Electron build artifacts. + +Backend - owl (API server) + +- Projects are now available in OSS +- GenTable + - **Breaking**: Added `version` and `meta` to table metadata and associated migration script + - Added ability to turn any column into multi-turn chat via the `multi_turn` parameter in `LLMGenConfig` + - Added table ID search when listing tables + - Added `GenConfig` protocols: `LLMGenConfig` and `EmbedGenConfig` + - Added default prompts for table creation and column add + - Write rows to table with dynamically decided batch size, capped at `max_write_batch_size` that speeds up file uploading. Resolves #225 + - Added ability to sort by table attribute or row column in ascending or descending order when listing tables or rows + - Support file type input column. #120 + - image file extensions: `jpeg/jpg`, `png`, `webp`, `gif` + - restriction: single image file per output completion +- Templates gallery +- File API +- GenExecutor + - Added regeneration mode: `run_all`, `run_before`, `run_selected` and `run_after`. #221 +- LLM + - OSS model list patch API & Cloud per-org model list API + - Internal-only models + - Include `name` into `EmbeddingModelConfig` and `RerankingModelConfig` + - Added "openai/gpt-4o-mini", "openai/gpt-4-turbo", "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo" + - Added model priority for default assignment +- Admin + - Added get and set methods for internal organization ID + - Added project list endpoint with ability to search within project names + - Added ability to sort by attribute in ascending or descending order when listing organizations or projects + - Added user RBAC for backend admin endpoints +- Billing + - Added pricing and usage tracking for embedding & reranking +- Auth + - Implement Personal Access Token, deprecate organization API keys + +AIPC + +- Added new models and configurations in `models_aipc.json`. + +CI/CD + +- Added jamaibase-app compilation CI. +- Added cloud unit tests +- Pinned `torch` dependency to version `~2.2.0` in `services/docio/pyproject.toml`. #265 +- Added Lance tests +- Added TS/JS SDK tests + +### CHANGES / FIXED + +Python SDK - jamaibase + +- **Breaking**: `get_conversation_thread()` has two new required arguments: `table_type` and `column_id` +- `duplicate_table()` arguments changed: + - Added `create_as_child` argument, and deprecated `deploy`. Resolves #196 + - `table_id_dst` is now an optional argument +- Default values changed + - `ChatRequest.stop`: `[]` -> `None` + - `ChatRequest.temperature`: `1.0` -> `0.2` + - `ChatRequest.top_p`: `1.0` -> `0.6` +- Vector columns can now be excluded from the results of `list_table_rows()`, `get_table_row()`, `hybrid_search()` methods by passing in a negative `vec_decimals` value. +- Exception classes are now moved into `jamaibase` from `owl` + +TS SDK - jamaibase + +- Changed COl_ID and TABLE_ID regex. #296 +- Removed browser-only modules + +UI + +- Bump `app` version from `0.1.0` to `0.2.0`. +- Fix `CORS Error` and JamAI Base Electron App compilation steps. #260 + +Backend - owl (API server) + +- **Breaking**: Delete endpoints will raise 404 if the resource is not found +- GenTable + - **Breaking**: Table list endpoint now defaults to not counting table rows + - **Breaking**: Duplicate table endpoint `/v1/gen_tables/{table_type}/duplicate/{table_id_src}/{table_id_dst}` is deprecated in favour of `/v1/gen_tables/{table_type}/duplicate/{table_id_src}` + - **Breaking**: `/v1/gen_tables/{table_type}/{table_id}/thread` has one new required query parameter: `column_id` + - It also supports `action` and `knowledge` table now. + - Changed the search method for row filtering from FTS to regex + - Add deprecation warning for `deploy` param to the "duplicate table" endpoint + - Default models + - If any of chat, embedding, or reranking model is set to "", then a default model is dynamically assigned + - Prioritise ELLM models when setting default model + - Refactor column validation + - Allow single character table and column ID + - Vector columns can now be excluded from the results of `list_table_rows()`, `get_table_row()`, `hybrid_search()` methods by passing in a negative `vec_decimals` value. + - Bug fixes + - CSV import with vector data now works correctly + - Full-text-search (FTS) now properly executes term query rather than phrase query + - CSV import numeric data as string now works correctly. #300 + - Ensure chat table sequential regen +- LLM + - Default model will prefer ELLM models + - Model list are now sorted by ID + - Changed target of "openai/gpt-4o" to "openai/gpt-4o-2024-08-06" + - Set `stream_options={"include_usage": True}` to fix discrepancy between stream and non-stream token usage + - Added model availability check + - Added support for internal models + - Make setting `owned_by` optional in model config JSON + - Reduce context exceed log verbosity + - Bug fixes + - Model config has been updated +- Billing + - Logic has been rewritten + - New pricing model. Resolves #235 + - Event now accepts deltas and values which can update multiple fields at once + - Revamp LLM cost computation to be based on LiteLLM + - Defined `owl_internal_org_id` to control internal resources +- Implement separate janitor process. Resolves #233 + - Compute storage usage + - Perform Lance table periodic reindexing and optimisation +- DB + - Change `NullPoll` -> `QueuePool` for better performance on admin DB +- Auth + - Refactored auth logic to be based on FastAPI Dependency injection + - Set auth timeout to 60s and return 503 if timeout +- Only enable logging when called via entrypoints +- Use `Annotated` with `Depends` to get DB session + +Backend - starling (Janitor) + +- Don't timeout for Lance periodic tasks + +CI/CD + +- Fix `api` and `docio`. Updated `scripts/compile_api_exe.ps1` and `scripts/compile_docio_exe.ps1` to use specific versions of `pyinstaller` and `cryptography` and install `python-magic`. +- Python lint: + - Use Ruff instead of `black` + `flake8` + `isort` + - Update Python lint rules +- Cancel in-progress CI jobs if there is a new push +- Set timeouts + - PyTest per-test timeout at 90 seconds + - GitHub Action per-job timeout at 60 minutes + +### REMOVED + +Python SDK - jamaibase + +- Remove unused protocols +- Remove client-side gen config validation +- Removed redundant "file_name" Form fields from gen table methods + +TS SDK - jamaibase + +- Removed redundant "file_name" Form fields from gen table methods + +Backend - owl (API server) + +- Removed owl client, it is merged into `jamaibase` +- LLM + - Removed "together_ai/Qwen/Qwen1.5" series models +- GenTable + - Removed redundant "file_name" Form field + - Removed File Table, raw files will be stored in S3 instead + ## [Python] [v0.2.1] - 2024-08-18 ### CHANGED / FIXED @@ -48,7 +236,10 @@ TS/JS SDK - jamaibase - `listRows()` and `getRow()` methods and `hybridSearch()` request body now accepts 2 additional arguments: - `float_decimals` (int, optional): Number of decimals for float values. Defaults to 0 (no rounding). - `vec_decimals` (int, optional): Number of decimals for vectors. Defaults to 0 (no rounding). -- Add `search_query` param in `listRows()` +- Added `search_query` param in `listRows()` +- Added unit tests for the TypeScript/JavaScript SDK, including both OSS and Cloud environments +- Enhanced the Base class to generate a user agent string +- Added checks and methods to ensure the SDK works in both Node.js and browser environments `Embeddings` endpoint @@ -178,7 +369,7 @@ Backend - owl (API server) - pdf: digital/scanned/mixed - docio loader and unstructured-io loader with (elements) `fast`, `ocr_only` and `hi_res` chunkers - enabled `split_pdf_pages` setting to speed up partitioning - - calculate recall to differenciate between digital pdf (recall > 0.9) and scanned/mixed pdf + - calculate recall to differentiate between digital pdf (recall > 0.9) and scanned/mixed pdf - digital pdf - `fast` chunks and `hi_res` table only chunks - scanned/mixed pdf diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..34b7161 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,33 @@ +# Migration Guide + +This guide provides instructions to perform a database migration that adds a `version` column and `object` attribute to all gen_config in all action tables. (Migration from owl version earlier than v0.3.0). + +## Prerequisites + +1. Ensure **owl/jamaibase** has been updated to at least **v0.3.0**. + +## Steps to Perform the Migration + +1. Navigate to the **JamAIBase** repository directory (with `./db` and `./scripts` in it). + + ```bash + cd + ``` + +2. Run the migration script (ensure the current Python environment is the one with **owl** installed): + ```bash + python scripts/migration_v030.py + ``` + +## Expected Output + +- The script will print messages indicating whether the `version` column was added or if it already exists in each database. +- The script will print messages indicating whether the `object` attribute was added into each `gen_config`. +- If any errors occur, they will be printed to the console. + +## Troubleshooting + +- Ensure that the migration script is run in the **JamAIBase** repository directory (`./db` and `./scripts` directories should be in this working directory). +- Ensure the Python environment is the one with **owl** installed. +- Check the script's error messages for any issues encountered during the migration process. +- Contact us for further assistance. diff --git a/README.md b/README.md index 382d721..b50dabc 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ ![JamAI Base Cover](JamAI_Base_Cover.png) - + ![Linting](https://github.com/EmbeddedLLM/JamAIBase/actions/workflows/lint.yml/badge.svg) ![CI](https://github.com/EmbeddedLLM/JamAIBase/actions/workflows/ci.yml/badge.svg) - > [!TIP] > [Explore our docs](#explore-the-documentation) + + ## Overview JamAI Base is an open-source RAG (Retrieval-Augmented Generation) backend platform that integrates an embedded database (SQLite) and an embedded vector database (LanceDB) with managed memory and RAG capabilities. It features built-in LLM, vector embeddings, and reranker orchestration and management, all accessible through a convenient, intuitive, spreadsheet-like UI and a simple REST API. @@ -84,7 +85,7 @@ Focus on defining "what" you want to achieve rather than "how" to achieve it. ### Flexibility -- **LLM Support**: Supports any LLMs, including OpenAI GPT-4, Anthropic Claude 3, Mistral AI Mixtral, and Meta Llama3. +- **LLM Support**: Supports any LLMs, including OpenAI GPT-4, Anthropic Claude 3, and Meta Llama3. - **Capabilities**: Leverage state-of-the-art AI capabilities effortlessly. ### Declarative Paradigm @@ -137,11 +138,6 @@ Join our vibrant developer community for comprehensive documentation, tutorials, - **Discord**: [Join our Discord](https://discord.gg/rV6DECA8Dw) - **GitHub**: [Star our GitHub repository](https://github.com/EmbeddedLLM/JamAIBase) -### Community Videos - -- [Build Mixture of Agents (MoA) & RAG with Open Source Models in Minutes with JamAI Base](https://www.youtube.com/watch?v=PWNEYUkFYog) -- [Llama 3.1 405B & 70B vs MacBook Pro. Apple Silicon is overpowered! Bonus: Apple's OpenELM](https://www.youtube.com/watch?v=fXHje7gFGK4) - ## Contributing We welcome contributions! Please read our [Contributing Guide](Contributing_Guide_Link) to get started. diff --git a/clients/python/README.md b/clients/python/README.md index 45bf225..413e7bf 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -28,7 +28,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y ```python from jamaibase import JamAI, protocol as p - jamai = JamAI(api_key="your_api_key", project_id="your_project_id") + jamai = JamAI(token="your_pat", project_id="your_project_id") ``` Async is supported too: @@ -36,7 +36,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y ```python from jamaibase import JamAIAsync, protocol as p - jamai = JamAIAsync(api_key="your_api_key", project_id="your_project_id") + jamai = JamAIAsync(token="your_pat", project_id="your_project_id") ``` ### OSS @@ -91,7 +91,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y ```python from jamaibase import JamAI, protocol as p - jamai = JamAI(api_base="http://localhost:6969") + jamai = JamAI(api_base="http://localhost:6969/api") ``` Async is supported too: @@ -99,7 +99,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y ```python from jamaibase import JamAIAsync, protocol as p - jamai = JamAIAsync(api_base="http://localhost:6969") + jamai = JamAIAsync(api_base="http://localhost:6969/api") ``` ### Tips @@ -112,7 +112,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y from jamaibase import JamAI # Cloud - client = JamAI(project_id="...", api_key="...") + client = JamAI(project_id="...", token="...") print(client.api_base) # OSS @@ -137,12 +137,13 @@ We will guide you through the steps of leveraging Generative Tables to unleash t Let's start with creating simple tables. Create a table by defining a schema. - + + > [!NOTE] > When it comes to table names, there are some restrictions: > -> - At most 100 characters -> - Must start and end with alphabets +> - Must have at least 1 character and up to 100 characters +> - Must start and end with an alphabet or number > - Middle characters can contain alphabets, numbers, underscores `_`, dashes `-`, dots `.` > > Column names have almost the same restrictions, except that: @@ -151,28 +152,28 @@ Let's start with creating simple tables. Create a table by defining a schema. > - Dots `.` are not accepted > - Cannot be called "ID" or "Updated at" (case-insensitive) + + ```python # Create an Action Table -table = jamai.create_action_table( +table = jamai.table.create_action_table( p.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="length", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate(id="text", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="image", dtype="file"), # Image input + p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input + p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user("Summarise this in ${length} words:\n\n${text}"), - ], + id="answer", + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a concise assistant.", + prompt="Image: ${image}\n\nQuestion: ${question}\n\nAnswer the question in ${length} words.", temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -180,7 +181,7 @@ table = jamai.create_action_table( print(table) # Create a Knowledge Table -table = jamai.create_knowledge_table( +table = jamai.table.create_knowledge_table( p.KnowledgeTableSchemaCreate( id="knowledge-simple", cols=[], @@ -190,21 +191,21 @@ table = jamai.create_knowledge_table( print(table) # Create a Chat Table -table = jamai.create_chat_table( +table = jamai.table.create_chat_table( p.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="User", dtype="str"), p.ColumnSchemaCreate( id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[p.ChatEntry.system("You are a pirate.")], + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a pirate.", temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -212,48 +213,78 @@ table = jamai.create_chat_table( print(table) ``` -### Adding rows to tables +### Adding rows Now that we have our tables, we can start adding rows to them and receive the LLM responses. First let's try adding to Action Table: ```python -text_a = '"Arrival" is a 2016 science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' -text_b = "Dune: Part Two is a 2024 epic science fiction film directed by Denis Villeneuve." +text_a = 'Summarize this: "Arrival" is a 2016 science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' +text_b = 'Summarize this: "Dune: Part Two is a 2024 epic science fiction film directed by Denis Villeneuve."' +text_c = "Identify the subject of the image." +# --- Action Table --- # # Streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-simple", - data=[dict(length=5, text=text_a)], + data=[dict(length=5, question=text_a)], stream=True, ), ) for chunk in completion: - if chunk.output_column_name != "summary": + if chunk.output_column_name != "answer": continue print(chunk.text, end="", flush=True) print("") # Non-streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-simple", - data=[dict(length=5, text=text_b)], + data=[dict(length=5, question=text_b)], stream=False, ), ) -print(completion.rows[0].columns["summary"].text) +print(completion.rows[0].columns["answer"].text) + +# Streaming (with image input) +upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") +completion = jamai.table.add_table_rows( + "action", + p.RowAddRequest( + table_id="action-simple", + data=[dict(image=upload_response.uri, length=5, question=text_c)], + stream=True, + ), +) +for chunk in completion: + if chunk.output_column_name != "answer": + continue + print(chunk.text, end="", flush=True) +print("") + +# Non-streaming (with image input) +completion = jamai.table.add_table_rows( + "action", + p.RowAddRequest( + table_id="action-simple", + data=[dict(image=upload_response.uri, length=5, question=text_c)], + stream=False, + ), +) +print(completion.rows[0].columns["answer"].text) ``` Next let's try adding to Chat Table: ```python +# --- Chat Table --- # # Streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "chat", p.RowAddRequest( table_id="chat-simple", @@ -268,7 +299,7 @@ for chunk in completion: print("") # Non-streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "chat", p.RowAddRequest( table_id="chat-simple", @@ -281,13 +312,17 @@ print(completion.rows[0].columns["AI"].text) Finally we can add rows to Knowledge Table too: - + + > [!TIP] > Uploading files is the main way to add data into a Knowledge Table. Having said so, adding rows works too! + + ```python +# --- Knowledge Table --- # # Streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "knowledge", p.RowAddRequest( table_id="knowledge-simple", @@ -298,7 +333,7 @@ completion = jamai.add_table_rows( assert len(list(completion)) == 0 # Non-streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "knowledge", p.RowAddRequest( table_id="knowledge-simple", @@ -316,34 +351,30 @@ We can retrieve table rows by listing the rows or by fetching a specific row. ```python # --- List rows -- # # Action -rows = jamai.list_table_rows("action", "action-simple") -assert len(rows.items) == 2 +rows = jamai.table.list_table_rows("action", "action-simple") # Paginated items for row in rows.items: - print(row["ID"], row["summary"]["value"]) + print(row["ID"], row["answer"]["value"]) # Knowledge -rows = jamai.list_table_rows("knowledge", "knowledge-simple") -assert len(rows.items) == 2 +rows = jamai.table.list_table_rows("knowledge", "knowledge-simple") for row in rows.items: print(row["ID"], row["Title"]["value"]) print(row["Title Embed"]["value"][:3]) # Knowledge Table has embeddings # Chat -rows = jamai.list_table_rows("chat", "chat-simple") -assert len(rows.items) == 2 +rows = jamai.table.list_table_rows("chat", "chat-simple") for row in rows.items: print(row["ID"], row["User"]["value"], row["AI"]["value"]) # --- Fetch a specific row -- # -row = jamai.get_table_row("chat", "chat-simple", rows.items[0]["ID"]) +row = jamai.table.get_table_row("chat", "chat-simple", rows.items[0]["ID"]) print(row["ID"], row["AI"]["value"]) # --- Filter using a search term -- # -rows = jamai.list_table_rows("action", "action-simple", search_query="Dune") -assert len(rows.items) == 1 +rows = jamai.table.list_table_rows("action", "action-simple", search_query="Dune") for row in rows.items: - print(row["ID"], row["summary"]["value"]) + print(row["ID"], row["answer"]["value"]) ``` ### Retrieving columns @@ -352,8 +383,7 @@ We can retrieve columns by filtering them. ```python # --- Only fetch specific columns -- # -rows = jamai.list_table_rows("action", "action-simple", columns=["length"]) -assert len(rows.items) == 2 +rows = jamai.table.list_table_rows("action", "action-simple", columns=["length"]) for row in rows.items: # "ID" and "Updated at" will always be fetched print(row["ID"], row["length"]["value"]) @@ -372,30 +402,22 @@ with TemporaryDirectory() as tmp_dir: with open(file_path, "w") as f: f.write("I bought a Mofusand book in 2024.\n\n") f.write("I went to Italy in 2018.\n\n") - - response = jamai.upload_file( - p.FileUploadRequest( - file_path=file_path, - table_id="knowledge-simple", - ) - ) + response = jamai.table.embed_file(file_path, "knowledge-simple") assert response.ok # Create an Action Table with RAG -table = jamai.create_action_table( +table = jamai.table.create_action_table( p.ActionTableSchemaCreate( id="action-rag", cols=[ - p.ColumnSchemaCreate(id="question", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( id="answer", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("${question}"), - ], + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a concise assistant.", + prompt="${question}", rag_params=p.RAGParams( table_id="knowledge-simple", k=2, @@ -403,7 +425,7 @@ table = jamai.create_action_table( temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -411,7 +433,7 @@ table = jamai.create_action_table( print(table) # Ask a question with streaming -completion = jamai.add_table_rows( +completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-rag", @@ -439,27 +461,27 @@ We can retrieve tables by listing the tables or by fetching a specific tables. ```python # --- List tables -- # # Action -tables = jamai.list_tables("action") +tables = jamai.table.list_tables("action", count_rows=True) assert len(tables.items) == 2 # Paginated items for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # Knowledge -tables = jamai.list_tables("knowledge") +tables = jamai.table.list_tables("knowledge") assert len(tables.items) == 1 for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # Chat -tables = jamai.list_tables("chat") +tables = jamai.table.list_tables("chat") assert len(tables.items) == 1 for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # --- Fetch a specific table -- # -table = jamai.get_table("action", "action-rag") -print(table.id, table.num_rows) +table = jamai.table.get_table("action", "action-rag") +print(f"{table.id=}, {table.num_rows=}") ``` ### Deleting rows @@ -468,8 +490,8 @@ Now that you know how to add rows into tables, let's see how to delete them inst ```python # Delete all rows -rows = jamai.list_table_rows("action", "action-simple") -response = jamai.delete_table_rows( +rows = jamai.table.list_table_rows("action", "action-simple") +response = jamai.table.delete_table_rows( "action", p.RowDeleteRequest( table_id="action-simple", @@ -478,7 +500,7 @@ response = jamai.delete_table_rows( ) assert response.ok # Assert that the table is empty -rows = jamai.list_table_rows("action", "action-simple") +rows = jamai.table.list_table_rows("action", "action-simple") assert len(rows.items) == 0 ``` @@ -486,19 +508,22 @@ assert len(rows.items) == 0 Let's see how to delete tables. - + + > [!TIP] > Deletion will return "OK" even if the table does not exist. + + ```python # Delete tables -response = jamai.delete_table("action", "action-simple") +response = jamai.table.delete_table("action", "action-simple") assert response.ok -response = jamai.delete_table("knowledge", "knowledge-simple") +response = jamai.table.delete_table("knowledge", "knowledge-simple") assert response.ok -response = jamai.delete_table("chat", "chat-simple") +response = jamai.table.delete_table("chat", "chat-simple") assert response.ok -response = jamai.delete_table("action", "action-rag") +response = jamai.table.delete_table("action", "action-rag") assert response.ok ``` @@ -509,10 +534,10 @@ batch_size = 100 for table_type in ["action", "knowledge", "chat"]: offset, total = 0, 1 while offset < total: - tables = jamai.list_tables(table_type, offset, batch_size) + tables = jamai.table.list_tables(table_type, offset=offset, limit=batch_size) assert isinstance(tables.items, list) for table in tables.items: - jamai.delete_table(table_type, table.id) + jamai.table.delete_table(table_type, table.id) total = tables.total offset += batch_size ``` @@ -522,34 +547,30 @@ for table_type in ["action", "knowledge", "chat"]: The full script is as follows: ```python -import os - from jamaibase import JamAI from jamaibase import protocol as p def create_tables(jamai: JamAI): # Create an Action Table - table = jamai.create_action_table( + table = jamai.table.create_action_table( p.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="length", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate(id="text", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="image", dtype="file"), # Image input + p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input + p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user("Summarise this in ${length} words:\n\n${text}"), - ], + id="answer", + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a concise assistant.", + prompt="Image: ${image}\n\nQuestion: ${question}\n\nAnswer the question in ${length} words.", temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -557,7 +578,7 @@ def create_tables(jamai: JamAI): print(table) # Create a Knowledge Table - table = jamai.create_knowledge_table( + table = jamai.table.create_knowledge_table( p.KnowledgeTableSchemaCreate( id="knowledge-simple", cols=[], @@ -567,21 +588,21 @@ def create_tables(jamai: JamAI): print(table) # Create a Chat Table - table = jamai.create_chat_table( + table = jamai.table.create_chat_table( p.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="User", dtype="str"), p.ColumnSchemaCreate( id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[p.ChatEntry.system("You are a pirate.")], + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a pirate.", temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -590,37 +611,67 @@ def create_tables(jamai: JamAI): def add_rows(jamai: JamAI): - text_a = '"Arrival" is a 2016 science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' - text_b = "Dune: Part Two is a 2024 epic science fiction film directed by Denis Villeneuve." + text_a = 'Summarize this: "Arrival" is a 2016 science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' + text_b = 'Summarize this: "Dune: Part Two is a 2024 epic science fiction film directed by Denis Villeneuve."' + text_c = "Identify the subject of the image." + # --- Action Table --- # # Streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-simple", - data=[dict(length=5, text=text_a)], + data=[dict(length=5, question=text_a)], stream=True, ), ) for chunk in completion: - if chunk.output_column_name != "summary": + if chunk.output_column_name != "answer": continue print(chunk.text, end="", flush=True) print("") # Non-streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( + "action", + p.RowAddRequest( + table_id="action-simple", + data=[dict(length=5, question=text_b)], + stream=False, + ), + ) + print(completion.rows[0].columns["answer"].text) + + # Streaming (with image input) + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + completion = jamai.table.add_table_rows( + "action", + p.RowAddRequest( + table_id="action-simple", + data=[dict(image=upload_response.uri, length=5, question=text_c)], + stream=True, + ), + ) + for chunk in completion: + if chunk.output_column_name != "answer": + continue + print(chunk.text, end="", flush=True) + print("") + + # Non-streaming (with image input) + completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-simple", - data=[dict(length=5, text=text_b)], + data=[dict(image=upload_response.uri, length=5, question=text_c)], stream=False, ), ) - print(completion.rows[0].columns["summary"].text) + print(completion.rows[0].columns["answer"].text) + # --- Chat Table --- # # Streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "chat", p.RowAddRequest( table_id="chat-simple", @@ -635,7 +686,7 @@ def add_rows(jamai: JamAI): print("") # Non-streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "chat", p.RowAddRequest( table_id="chat-simple", @@ -645,8 +696,9 @@ def add_rows(jamai: JamAI): ) print(completion.rows[0].columns["AI"].text) + # --- Knowledge Table --- # # Streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "knowledge", p.RowAddRequest( table_id="knowledge-simple", @@ -657,7 +709,7 @@ def add_rows(jamai: JamAI): assert len(list(completion)) == 0 # Non-streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "knowledge", p.RowAddRequest( table_id="knowledge-simple", @@ -671,40 +723,40 @@ def add_rows(jamai: JamAI): def fetch_rows(jamai: JamAI): # --- List rows -- # # Action - rows = jamai.list_table_rows("action", "action-simple") - assert len(rows.items) == 2 + rows = jamai.table.list_table_rows("action", "action-simple") + assert len(rows.items) == 4 # Paginated items for row in rows.items: - print(row["ID"], row["summary"]["value"]) + print(row["ID"], row["answer"]["value"]) # Knowledge - rows = jamai.list_table_rows("knowledge", "knowledge-simple") + rows = jamai.table.list_table_rows("knowledge", "knowledge-simple") assert len(rows.items) == 2 for row in rows.items: print(row["ID"], row["Title"]["value"]) print(row["Title Embed"]["value"][:3]) # Knowledge Table has embeddings # Chat - rows = jamai.list_table_rows("chat", "chat-simple") + rows = jamai.table.list_table_rows("chat", "chat-simple") assert len(rows.items) == 2 for row in rows.items: print(row["ID"], row["User"]["value"], row["AI"]["value"]) # --- Fetch a specific row -- # - row = jamai.get_table_row("chat", "chat-simple", rows.items[0]["ID"]) + row = jamai.table.get_table_row("chat", "chat-simple", rows.items[0]["ID"]) print(row["ID"], row["AI"]["value"]) # --- Filter using a search term -- # - rows = jamai.list_table_rows("action", "action-simple", search_query="Dune") + rows = jamai.table.list_table_rows("action", "action-simple", search_query="Dune") assert len(rows.items) == 1 for row in rows.items: - print(row["ID"], row["summary"]["value"]) + print(row["ID"], row["answer"]["value"]) def fetch_columns(jamai: JamAI): # --- Only fetch specific columns -- # - rows = jamai.list_table_rows("action", "action-simple", columns=["length"]) - assert len(rows.items) == 2 + rows = jamai.table.list_table_rows("action", "action-simple", columns=["length"]) + assert len(rows.items) == 4 for row in rows.items: # "ID" and "Updated at" will always be fetched print(row["ID"], row["length"]["value"]) @@ -719,31 +771,22 @@ def rag(jamai: JamAI): with open(file_path, "w") as f: f.write("I bought a Mofusand book in 2024.\n\n") f.write("I went to Italy in 2018.\n\n") - - response = jamai.upload_file( - p.FileUploadRequest( - file_path=file_path, - table_id="knowledge-simple", - ) - ) + response = jamai.table.embed_file(file_path, "knowledge-simple") assert response.ok # Create an Action Table with RAG - table = jamai.create_action_table( + table = jamai.table.create_action_table( p.ActionTableSchemaCreate( id="action-rag", cols=[ - p.ColumnSchemaCreate(id="question", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="question", dtype="str"), p.ColumnSchemaCreate( id="answer", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-4o", - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user("${question}"), - ], + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a concise assistant.", + prompt="${question}", rag_params=p.RAGParams( table_id="knowledge-simple", k=2, @@ -751,7 +794,7 @@ def rag(jamai: JamAI): temperature=0.001, top_p=0.001, max_tokens=100, - ).model_dump(), + ), ), ], ) @@ -759,7 +802,7 @@ def rag(jamai: JamAI): print(table) # Ask a question with streaming - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "action", p.RowAddRequest( table_id="action-rag", @@ -783,33 +826,33 @@ def rag(jamai: JamAI): def fetch_tables(jamai: JamAI): # --- List tables -- # # Action - tables = jamai.list_tables("action") + tables = jamai.table.list_tables("action", count_rows=True) assert len(tables.items) == 2 # Paginated items for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # Knowledge - tables = jamai.list_tables("knowledge") + tables = jamai.table.list_tables("knowledge") assert len(tables.items) == 1 for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # Chat - tables = jamai.list_tables("chat") + tables = jamai.table.list_tables("chat") assert len(tables.items) == 1 for table in tables.items: - print(table.id, table.num_rows) + print(f"{table.id=}, {table.num_rows=}") # --- Fetch a specific table -- # - table = jamai.get_table("action", "action-rag") - print(table.id, table.num_rows) + table = jamai.table.get_table("action", "action-rag") + print(f"{table.id=}, {table.num_rows=}") def delete_rows(jamai: JamAI): # Delete all rows - rows = jamai.list_table_rows("action", "action-simple") - response = jamai.delete_table_rows( + rows = jamai.table.list_table_rows("action", "action-simple") + response = jamai.table.delete_table_rows( "action", p.RowDeleteRequest( table_id="action-simple", @@ -818,19 +861,19 @@ def delete_rows(jamai: JamAI): ) assert response.ok # Assert that the table is empty - rows = jamai.list_table_rows("action", "action-simple") + rows = jamai.table.list_table_rows("action", "action-simple") assert len(rows.items) == 0 def delete_tables(jamai: JamAI): # Delete tables - response = jamai.delete_table("action", "action-simple") + response = jamai.table.delete_table("action", "action-simple") assert response.ok - response = jamai.delete_table("knowledge", "knowledge-simple") + response = jamai.table.delete_table("knowledge", "knowledge-simple") assert response.ok - response = jamai.delete_table("chat", "chat-simple") + response = jamai.table.delete_table("chat", "chat-simple") assert response.ok - response = jamai.delete_table("action", "action-rag") + response = jamai.table.delete_table("action", "action-rag") assert response.ok @@ -839,43 +882,39 @@ def delete_all_tables(jamai: JamAI): for table_type in ["action", "knowledge", "chat"]: offset, total = 0, 1 while offset < total: - tables = jamai.list_tables(table_type, offset, batch_size) + tables = jamai.table.list_tables(table_type, offset=offset, limit=batch_size) assert isinstance(tables.items, list) for table in tables.items: - jamai.delete_table(table_type, table.id) + jamai.table.delete_table(table_type, table.id) total = tables.total offset += batch_size def duplicate_tables(jamai: JamAI): # By default, both schema (like generation config) and data are included - table = jamai.duplicate_table( + table = jamai.table.duplicate_table( "action", "action-rag", "action-rag-copy", ) assert table.id == "action-rag-copy" - rows = jamai.list_table_rows("action", "action-rag-copy") + rows = jamai.table.list_table_rows("action", "action-rag-copy") assert rows.total > 0 # We can also duplicate a table without its data - table = jamai.duplicate_table( + table = jamai.table.duplicate_table( "action", "action-rag", "action-rag-copy-schema-only", include_data=False, ) assert table.id == "action-rag-copy-schema-only" - rows = jamai.list_table_rows("action", "action-rag-copy-schema-only") + rows = jamai.table.list_table_rows("action", "action-rag-copy-schema-only") assert rows.total == 0 def main(): - jamai = JamAI( - project_id=os.getenv("JAMAI_PROJECT_ID"), - api_key=os.getenv("JAMAI_API_KEY"), - api_base="http://192.168.80.86/api", - ) + jamai = JamAI() delete_all_tables(jamai) create_tables(jamai) @@ -901,24 +940,24 @@ We can create copies of tables under the same project. By default, the method co ```python # By default, both schema (like generation config) and data are included -table = jamai.duplicate_table( +table = jamai.table.duplicate_table( "action", "action-rag", "action-rag-copy", ) assert table.id == "action-rag-copy" -rows = jamai.list_table_rows("action", "action-rag-copy") +rows = jamai.table.list_table_rows("action", "action-rag-copy") assert len(rows.total) > 0 # We can also duplicate a table without its data -table = jamai.duplicate_table( +table = jamai.table.duplicate_table( "action", "action-rag", "action-rag-copy-schema-only", include_data=False, ) assert table.id == "action-rag-copy-schema-only" -rows = jamai.list_table_rows("action", "action-rag-copy-schema-only") +rows = jamai.table.list_table_rows("action", "action-rag-copy-schema-only") assert len(rows.total) == 0 ``` @@ -933,7 +972,7 @@ Generate chat completions using various models. Supports streaming and non-strea ```python # Streaming request = p.ChatRequest( - model="openai/gpt-3.5-turbo", + model="openai/gpt-4o-mini", messages=[ p.ChatEntry.system("You are a concise assistant."), p.ChatEntry.user("What is a llama?"), @@ -950,7 +989,7 @@ print("") # Non-streaming request = p.ChatRequest( - model="openai/gpt-3.5-turbo", + model="openai/gpt-4o-mini", messages=[ p.ChatEntry.system("You are a concise assistant."), p.ChatEntry.user("What is a llama?"), @@ -994,12 +1033,12 @@ Retrieve information about available models. models = jamai.model_info() model = models.data[0] print(f"Model: {model.id} Context length: {model.context_length}") -# Model: openai/gpt-4o Context length: 8192 +# Model: openai/gpt-4o Context length: 128000 # Get specific model info models = jamai.model_info(name="openai/gpt-4o") print(models.data[0]) -# id='openai/gpt-4o' object='model' name='OpenAI GPT-4' context_length=8192 languages=['en', 'cn'] capabilities=['chat'] owned_by='openai' +# id='openai/gpt-4o' object='model' name='OpenAI GPT-4' context_length=128000 languages=['en', 'cn'] capabilities=['chat'] owned_by='openai' # Filter based on capability: "chat", "embed", "rerank" models = jamai.model_info(capabilities=["chat"]) @@ -1023,7 +1062,7 @@ Get a list of available model IDs / names. # Get all model IDs model_names = jamai.model_names() print(model_names) -# ['ellm/meta-llama/Llama-3-8B-Instruct', 'ellm/meta-llama/Llama-3-70B-Instruct', 'openai/gpt-3.5-turbo', ..., 'cohere/rerank-english-v3.0', 'cohere/rerank-multilingual-v3.0'] +# ['ellm/meta-llama/Llama-3-8B-Instruct', 'ellm/meta-llama/Llama-3-70B-Instruct', 'openai/gpt-4o-mini', ..., 'cohere/rerank-english-v3.0', 'cohere/rerank-multilingual-v3.0'] # Model IDs with the preferred model at the top if available model_names = jamai.model_names(prefer="openai/gpt-4o") @@ -1054,21 +1093,21 @@ st.title("Simple chat") try: # Create a Chat Table - jamai.create_chat_table( + jamai.table.create_chat_table( p.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), + p.ColumnSchemaCreate(id="User", dtype="str"), p.ColumnSchemaCreate( id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="openai/gpt-3.5-turbo", - messages=[p.ChatEntry.system("You are a pirate.")], + dtype="str", + gen_config=p.LLMGenConfig( + model="openai/gpt-4o-mini", # Leave this out to use a default model + system_prompt="You are a pirate.", temperature=0.001, top_p=0.001, max_tokens=500, - ).model_dump(), + ), ), ], ) @@ -1087,7 +1126,7 @@ for message in st.session_state.messages: st.markdown(message["content"]) def response_generator(_prompt): - completion = jamai.add_table_rows( + completion = jamai.table.add_table_rows( "chat", p.RowAddRequest( table_id="chat-simple", diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 0d67824..93aebe1 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -5,6 +5,7 @@ # https://docs.pytest.org/en/latest/customize.html?highlight=pyproject#pyproject-toml [tool.pytest.ini_options] +timeout = 90 log_cli = true asyncio_mode = "auto" # log_cli_level = "DEBUG" @@ -18,34 +19,55 @@ filterwarnings = [ ] # ----------------------------------------------------------------------------- -# Black (Option-less formatter) configuration -# https://black.readthedocs.io/en/stable/index.html +# Ruff configuration +# https://docs.astral.sh/ruff/ -[tool.black] +[tool.ruff] line-length = 99 -target-version = ["py310"] -include = '\.pyi?$|\.ipynb' -extend-exclude = 'archive/*' +indent-width = 4 +target-version = "py310" +extend-include = [".pyi?$", ".ipynb"] +extend-exclude = ["archive/*"] +respect-gitignore = true -# ----------------------------------------------------------------------------- -# For sorting imports -# This is used by VS Code to sort imports -# https://code.visualstudio.com/docs/python/editing#_sort-imports -# https://timothycrosley.github.io/isort/ - -[tool.isort] -# Profile -# Base profile type to use for configuration. Profiles include: black, django, -# pycharm, google, open_stack, plone, attrs, hug. As well as any shared profiles. -# Default: `` -profile = "black" -# Treat project as a git repository and ignore files listed in .gitignore -# Default: `False` -skip_gitignore = true -# The max length of an import line (used for wrapping long imports). -# Default: `79` -line_length = 99 -known_first_party = ["jamaibase", "owl", "docio"] +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +docstring-code-format = true + +[tool.ruff.lint] +# 1. Enable flake8-bugbear (`B`) rules, in addition to the defaults. +select = ["E1", "E4", "E7", "E9", "F", "I", "W1", "W2", "W3", "W6", "B"] + +# 2. Avoid enforcing line-length violations (`E501`) +ignore = ["E501"] + +# 3. Avoid trying to fix flake8-bugbear (`B`) violations. +unfixable = ["B"] + +# 4. Ignore `E402` (import violations) in all `__init__.py` files, and in selected subdirectories. +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["E402"] +"**/{tests,docs,tools}/*" = ["E402"] + +[tool.ruff.lint.isort] +known-first-party = ["jamaibase", "owl", "docio"] + +[tool.ruff.lint.flake8-bugbear] +# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.File", + "fastapi.Form", + "fastapi.Path", + "fastapi.Query", +] # ----------------------------------------------------------------------------- # setuptools @@ -70,6 +92,7 @@ classifiers = [ # https://pypi.org/classifiers/ ] # Sort your dependencies https://sortmylist.com/ dependencies = [ + "filetype~=1.2.0", "httpx>=0.25.0", "loguru>=0.7.2", "numpy>=1.26.0,<2.0.0", @@ -85,21 +108,22 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -lint = ["black~=24.4.2", "flake8~=7.0.0"] +lint = ["ruff~=0.5.7"] test = [ "flaky~=3.8.1", - "mypy~=1.10.1", - "pytest-asyncio>=0.23.7", + "mypy~=1.11.1", + "pytest-asyncio>=0.23.8", "pytest-cov~=5.0.0", + "pytest-timeout>=2.3.1", "pytest~=8.2.2", ] docs = [ - "furo~=2023.9.10", # Sphinx theme (nice looking, with dark mode) - "myst-parser~=2.0.0", - "sphinx-autobuild~=2021.3.14", + "furo~=2024.8.6", # Sphinx theme (nice looking, with dark mode) + "myst-parser~=4.0.0", + "sphinx-autobuild~=2024.4.16", "sphinx-copybutton~=0.5.2", - "sphinx~=7.2.6", - "sphinx_rtd_theme~=1.3.0", # Sphinx theme + "sphinx>=7.0.0", + "sphinx_rtd_theme~=2.0.0", # Sphinx theme ] build = [ "build", diff --git a/clients/python/src/jamaibase/client.py b/clients/python/src/jamaibase/client.py index 9697d96..68f70d5 100644 --- a/clients/python/src/jamaibase/client.py +++ b/clients/python/src/jamaibase/client.py @@ -1,18 +1,25 @@ +import platform from mimetypes import guess_type from os.path import split -from typing import Any, AsyncGenerator, Generator, Type +from typing import Any, AsyncGenerator, BinaryIO, Generator, Literal, Type from urllib.parse import quote from warnings import warn +import filetype import httpx from pydantic import BaseModel, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import deprecated +from jamaibase.exceptions import ResourceNotFoundError from jamaibase.protocol import ( ActionTableSchemaCreate, AddActionColumnSchema, AddChatColumnSchema, AddKnowledgeColumnSchema, + AdminOrderBy, + ApiKeyCreate, + ApiKeyRead, ChatCompletionChunk, ChatRequest, ChatTableSchemaCreate, @@ -22,37 +29,71 @@ ColumnReorderRequest, EmbeddingRequest, EmbeddingResponse, + EventCreate, + EventRead, FileUploadRequest, + FileUploadResponse, GenConfigUpdateRequest, + GenTableOrderBy, GenTableRowsChatCompletionChunks, GenTableStreamChatCompletionChunk, GenTableStreamReferences, + GetURLRequest, + GetURLResponse, KnowledgeTableSchemaCreate, ModelInfoResponse, + ModelListConfig, + ModelPrice, OkResponse, + OrganizationCreate, + OrganizationRead, + OrganizationUpdate, + OrgMemberCreate, + OrgMemberRead, Page, + PATCreate, + PATRead, + Price, + ProjectCreate, + ProjectRead, + ProjectUpdate, References, RowAddRequest, RowDeleteRequest, RowRegenRequest, RowUpdateRequest, SearchRequest, + StringResponse, TableDataImportRequest, + TableImportRequest, TableMetaResponse, TableType, + Template, + UserCreate, + UserRead, + UserUpdate, ) from jamaibase.utils.io import json_loads +from jamaibase.version import __version__ + +USER_AGENT = f"SDK/{__version__} (Python/{platform.python_version()}; {platform.system()} {platform.release()}; {platform.machine()})" +ORG_API_KEY_DEPRECATE = "Organization API keys are deprecated, use Personal Access Tokens instead." +TABLE_METHOD_DEPRECATE = "This method is deprecated, use `client.table.` instead." class EnvConfig(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + jamai_token: SecretStr = "" jamai_api_key: SecretStr = "" - jamai_project_id: str = "default" jamai_api_base: str = "https://api.jamaibase.com/api" + jamai_project_id: str = "default" + jamai_timeout_sec: float = 5 * 60.0 + jamai_file_upload_timeout_sec: float = 60 * 60.0 @property - def jamai_api_key_plain(self): - return self.jamai_api_key.get_secret_value() + def jamai_token_plain(self): + api_key = self.jamai_api_key.get_secret_value().strip() + return self.jamai_token.get_secret_value().strip() or api_key ENV_CONFIG = EnvConfig() @@ -62,45 +103,45 @@ def jamai_api_key_plain(self): ) -class JamAI: +class _Client: def __init__( self, - project_id: str = ENV_CONFIG.jamai_project_id, - api_key: str = ENV_CONFIG.jamai_api_key_plain, - api_base: str = ENV_CONFIG.jamai_api_base, - headers: dict | None = None, - timeout: float | None = None, + project_id: str, + token: str, + api_base: str, + headers: dict | None, + http_client: httpx.Client | httpx.AsyncClient, + file_upload_timeout: float | None, ) -> None: """ - Initialize the JamAI client. + Base client. Args: - project_id (str, optional): The project ID. Defaults to "default". - api_key (str, optional): The API key for authentication. - Defaults to `JAMAI_API_KEY` var in environment or `.env` file. - api_base (str, optional): The base URL for the API. - Defaults to `JAMAI_API_BASE` var in environment or `.env` file. - headers (dict | None, optional): Additional headers to include in requests. - Defaults to None. - timeout (float | None, optional): The timeout to use when sending requests. - Defaults to None (no timeout). + project_id (str): The project ID. + token (str): Personal Access Token or organization API key (deprecated) for authentication. + api_base (str): The base URL for the API. + headers (dict | None): Additional headers to include in requests. + http_client (httpx.Client | httpx.AsyncClient): The HTTPX client. + file_upload_timeout (float | None, optional): The timeout to use when sending file upload requests. """ if api_base.endswith("/"): api_base = api_base[:-1] self.project_id = project_id - self.api_key = api_key + self.token = token self.api_base = api_base - self.headers = {"X-PROJECT-ID": project_id} - if api_key != "": - self.headers["Authorization"] = f"Bearer {api_key}" + self.headers = {"X-PROJECT-ID": project_id, "User-Agent": USER_AGENT} + if token != "": + self.headers["Authorization"] = f"Bearer {token}" if headers is not None: if not isinstance(headers, dict): raise TypeError("`headers` must be None or a dict.") self.headers.update(headers) - self.http_client = httpx.Client( - timeout=timeout, - transport=httpx.HTTPTransport(retries=3), - ) + self.http_client = http_client + self.file_upload_timeout = file_upload_timeout + + @property + def api_key(self) -> str: + return self.token def close(self) -> None: """ @@ -109,32 +150,40 @@ def close(self) -> None: self.http_client.close() @staticmethod - def raise_exception(response: httpx.Response) -> httpx.Response: + def raise_exception( + response: httpx.Response, + *, + ignore_code: int | None = None, + ) -> httpx.Response: """ Raise an exception if the response status code is not 200. Args: response (httpx.Response): The HTTP response. + ignore_code (int | None, optional): HTTP code to ignore. Raises: - RuntimeError: If the response status code is not 200. + RuntimeError: If the response status code is not 200 and is not ignored by `ignore_code`. Returns: response (httpx.Response): The HTTP response. """ - if response.status_code == 200: - if "warning" in response.headers: - warn(response.headers["warning"], stacklevel=2) - return response if "warning" in response.headers: warn(response.headers["warning"], stacklevel=2) + code = response.status_code + if (200 <= code < 300) or code == ignore_code: + return response try: - err_mssg = response.text + error = response.text except httpx.ResponseNotRead: - err_mssg = response.read().decode() - raise RuntimeError( - f"Endpoint {response.url} returned {response.status_code} error: {err_mssg}" - ) + error = response.read().decode() + error = json_loads(error) + err_mssg = error.get("message", error.get("detail", str(error))) + if code == 404: + exc = ResourceNotFoundError + else: + exc = RuntimeError + raise exc(err_mssg) @staticmethod def _filter_params(params: dict[str, Any] | None): @@ -190,7 +239,7 @@ def _post( address: str, endpoint: str, *, - request: BaseModel | None, + body: BaseModel | None, params: dict[str, Any] | None = None, response_model: Type[BaseModel] | None = None, **kwargs, @@ -201,7 +250,7 @@ def _post( Args: address (str): The base address of the API. endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. + body (BaseModel | None): The request body. response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. params (dict[str, Any] | None, optional): Query parameters. Defaults to None. **kwargs (Any): Keyword arguments for `httpx.post`. @@ -209,11 +258,11 @@ def _post( Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - if request is not None: - request = request.model_dump() + if body is not None: + body = body.model_dump() response = self.http_client.post( f"{address}{endpoint}", - json=request, + json=body, headers=self.headers, params=self._filter_params(params), **kwargs, @@ -224,12 +273,46 @@ def _post( else: return response_model.model_validate_json(response.text) + def _options( + self, + address: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + response_model: Type[BaseModel] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an OPTIONS request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. + **kwargs (Any): Keyword arguments for `httpx.options`. + + Returns: + response (httpx.Response | BaseModel): The response or Pydantic response object. + """ + response = self.http_client.options( + f"{address}{endpoint}", + params=self._filter_params(params), + headers=self.headers, + **kwargs, + ) + response = self.raise_exception(response) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + def _patch( self, address: str, endpoint: str, *, - request: BaseModel | None, + body: BaseModel | None, params: dict[str, Any] | None = None, response_model: Type[BaseModel] | None = None, **kwargs, @@ -240,7 +323,7 @@ def _patch( Args: address (str): The base address of the API. endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. + body (BaseModel | None): The request body. response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. params (dict[str, Any] | None, optional): Query parameters. Defaults to None. **kwargs (Any): Keyword arguments for `httpx.patch`. @@ -248,11 +331,11 @@ def _patch( Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - if request is not None: - request = request.model_dump() + if body is not None: + body = body.model_dump() response = self.http_client.patch( f"{address}{endpoint}", - json=request, + json=body, headers=self.headers, params=self._filter_params(params), **kwargs, @@ -268,7 +351,7 @@ def _stream( address: str, endpoint: str, *, - request: BaseModel | None, + body: BaseModel | None, params: dict[str, Any] | None = None, **kwargs, ) -> Generator[str, None, None]: @@ -278,19 +361,19 @@ def _stream( Args: address (str): The base address of the API. endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. + body (BaseModel | None): The request body. params (dict[str, Any] | None, optional): Query parameters. Defaults to None. **kwargs (Any): Keyword arguments for `httpx.stream`. Yields: str: The response chunks. """ - if request is not None: - request = request.model_dump() + if body is not None: + body = body.model_dump() with self.http_client.stream( "POST", f"{address}{endpoint}", - json=request, + json=body, headers=self.headers, params=self._filter_params(params), **kwargs, @@ -309,6 +392,7 @@ def _delete( *, params: dict[str, Any] | None = None, response_model: Type[BaseModel] | None = None, + ignore_code: int | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ @@ -319,6 +403,7 @@ def _delete( endpoint (str): The API endpoint. params (dict[str, Any] | None, optional): Query parameters. Defaults to None. response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. + ignore_code (int | None, optional): HTTP code to ignore. **kwargs (Any): Keyword arguments for `httpx.delete`. Returns: @@ -330,308 +415,3838 @@ def _delete( headers=self.headers, **kwargs, ) - response = self.raise_exception(response) + response = self.raise_exception(response, ignore_code=ignore_code) if response_model is None: return response else: return response_model.model_validate_json(response.text) - # --- Models and chat --- # - def model_info( - self, - name: str = "", - capabilities: list[str] | None = None, - ) -> ModelInfoResponse: - """ - Get information about available models. +class _BackendAdminClient(_Client): + """Backend administration methods.""" - Args: - name (str, optional): The model name. Defaults to "". - capabilities (list[str] | None, optional): List of model capabilities to filter by. - Defaults to None. + def create_user(self, request: UserCreate) -> UserRead: + return self._post( + self.api_base, + "/admin/backend/v1/users", + body=request, + response_model=UserRead, + ) - Returns: - response (ModelInfoResponse): The model information response. - """ - params = {"model": name, "capabilities": capabilities} - return self._get( + def update_user(self, request: UserUpdate) -> UserRead: + return self._patch( self.api_base, - "/v1/models", - params=params, - response_model=ModelInfoResponse, + "/admin/backend/v1/users", + body=request, + response_model=UserRead, ) - def model_names( + def list_users( self, - prefer: str = "", - capabilities: list[str] | None = None, - ) -> list[str]: + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[UserRead]: """ - Get the names of available models. + List users. Args: - prefer (str, optional): Preferred model name. Defaults to "". - capabilities (list[str] | None, optional): List of model capabilities to filter by. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of users to return (min 1, max 100). Defaults to 100. + order_by (str, optional): Sort users by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. Returns: - response (list[str]): List of model names. + response (Page[UserRead]): The paginated user metadata response. """ - params = {"prefer": prefer, "capabilities": capabilities} - response = self._get( + return self._get( self.api_base, - "/v1/model_names", - params=params, - response_model=None, + "/admin/backend/v1/users", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[UserRead], ) - return json_loads(response.text) - def generate_chat_completions( - self, request: ChatRequest - ) -> ChatCompletionChunk | Generator[References | ChatCompletionChunk, None, None]: - """ - Generates chat completions. + def get_user(self, user_id: str) -> UserRead: + return self._get( + self.api_base, + f"/admin/backend/v1/users/{quote(user_id)}", + params=None, + response_model=UserRead, + ) - Args: - request (ChatRequest): The request. + def delete_user( + self, + user_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = self._delete( + self.api_base, + f"/admin/backend/v1/users/{quote(user_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - Returns: - completion (ChatCompletionChunk | Generator): The chat completion. - In streaming mode, it is a generator that yields a `References` object - followed by zero or more `ChatCompletionChunk` objects. - In non-streaming mode, it is a `ChatCompletionChunk` object. - """ - if request.stream: + def create_pat(self, request: PATCreate) -> PATRead: + return self._post( + self.api_base, + "/admin/backend/v1/pats", + body=request, + response_model=PATRead, + ) - def gen(): - for chunk in self._stream( - self.api_base, - "/v1/chat/completions", - request=request, - ): - chunk = json_loads(chunk[5:]) - if chunk["object"] == "chat.references": - yield References.model_validate(chunk) - elif chunk["object"] == "chat.completion.chunk": - yield ChatCompletionChunk.model_validate(chunk) - else: - raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + def get_pat(self, pat: str) -> PATRead: + return self._get( + self.api_base, + f"/admin/backend/v1/pats/{quote(pat)}", + params=None, + response_model=PATRead, + ) - return gen() + def delete_pat( + self, + pat: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = self._delete( + self.api_base, + f"/admin/backend/v1/pats/{quote(pat)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() else: - return self._post( - self.api_base, - "/v1/chat/completions", - request=request, - response_model=ChatCompletionChunk, - ) - - def generate_embeddings(self, request: EmbeddingRequest) -> EmbeddingResponse: - """ - Generate embeddings for the given input. - - Args: - request (EmbeddingRequest): The embedding request. + return OkResponse.model_validate_json(response.text) - Returns: - response (EmbeddingResponse): The embedding response. - """ + def create_organization(self, request: OrganizationCreate) -> OrganizationRead: return self._post( self.api_base, - "/v1/embeddings", - request=request, - response_model=EmbeddingResponse, + "/admin/backend/v1/organizations", + body=request, + response_model=OrganizationRead, ) - # --- Gen Table --- # + def update_organization(self, request: OrganizationUpdate) -> OrganizationRead: + return self._patch( + self.api_base, + "/admin/backend/v1/organizations", + body=request, + response_model=OrganizationRead, + ) - def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: - """ - Create an Action Table. + def list_organizations( + self, + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[OrganizationRead]: + return self._get( + self.api_base, + "/admin/backend/v1/organizations", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[OrganizationRead], + ) - Args: - request (ActionTableSchemaCreate): The action table schema. + def get_organization(self, organization_id: str) -> OrganizationRead: + return self._get( + self.api_base, + f"/admin/backend/v1/organizations/{quote(organization_id)}", + params=None, + response_model=OrganizationRead, + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return self._post( + def delete_organization( + self, + organization_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = self._delete( self.api_base, - "/v1/gen_tables/action", - request=request, - response_model=TableMetaResponse, + f"/admin/backend/v1/organizations/{quote(organization_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - def create_knowledge_table(self, request: KnowledgeTableSchemaCreate) -> TableMetaResponse: + def generate_invite_token( + self, + organization_id: str, + user_email: str = "", + valid_days: int = 7, + ) -> str: """ - Create a Knowledge Table. + Generates an invite token to join an organization. Args: - request (KnowledgeTableSchemaCreate): The knowledge table schema. + organization_id (str): Organization ID. + user_email (str, optional): User email. + Leave blank to disable email check and generate a public invite. Defaults to "". + valid_days (int, optional): How many days should this link be valid for. Defaults to 7. Returns: - response (TableMetaResponse): The table metadata response. + token (str): _description_ """ - return self._post( + response = self._get( self.api_base, - "/v1/gen_tables/knowledge", - request=request, - response_model=TableMetaResponse, + "/admin/backend/v1/invite_tokens", + params=dict( + organization_id=organization_id, user_email=user_email, valid_days=valid_days + ), + response_model=None, ) + return response.text - def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: - """ - Create a Chat Table. + def join_organization(self, request: OrgMemberCreate) -> OrgMemberRead: + return self._post( + self.api_base, + "/admin/backend/v1/organizations/link", + body=request, + response_model=OrgMemberRead, + ) - Args: - request (ChatTableSchemaCreate): The chat table schema. + def leave_organization(self, user_id: str, organization_id: str) -> OkResponse: + return self._delete( + self.api_base, + f"/admin/backend/v1/organizations/link/{quote(user_id)}/{quote(organization_id)}", + params=None, + response_model=OkResponse, + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ + def create_api_key(self, request: ApiKeyCreate) -> ApiKeyRead: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) return self._post( self.api_base, - "/v1/gen_tables/chat", - request=request, - response_model=TableMetaResponse, + "/admin/backend/v1/api_keys", + body=request, + response_model=ApiKeyRead, ) - def get_table( + def get_api_key(self, api_key: str) -> ApiKeyRead: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + return self._get( + self.api_base, + f"/admin/backend/v1/api_keys/{quote(api_key)}", + params=None, + response_model=ApiKeyRead, + ) + + def delete_api_key( self, - table_type: str | TableType, - table_id: str, - ) -> TableMetaResponse: - """ - Get metadata for a specific Generative Table. + api_key: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + response = self._delete( + self.api_base, + f"/admin/backend/v1/api_keys/{quote(api_key)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - Args: - table_type (str | TableType): The type of the table. - table_id (str): The ID of the table. + def refresh_quota( + self, + organization_id: str, + reset_usage: bool = True, + ) -> OrganizationRead: + return self._post( + self.api_base, + f"/admin/backend/v1/quotas/refresh/{quote(organization_id)}", + body=None, + params=dict(reset_usage=reset_usage), + response_model=OrganizationRead, + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ + def get_event(self, event_id: str) -> EventRead: return self._get( self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}", + f"/admin/backend/v1/events/{quote(event_id)}", params=None, - response_model=TableMetaResponse, + response_model=EventRead, ) - def list_tables( + def add_event(self, request: EventCreate) -> OkResponse: + return self._post( + self.api_base, + "/admin/backend/v1/events", + body=request, + response_model=OkResponse, + ) + + def mark_event_as_done(self, event_id: str) -> OkResponse: + return self._patch( + self.api_base, + f"/admin/backend/v1/events/done/{quote(event_id)}", + body=None, + response_model=OkResponse, + ) + + def get_internal_organization_id(self) -> StringResponse: + return self._get( + self.api_base, + "/admin/backend/v1/internal_organization_id", + params=None, + response_model=StringResponse, + ) + + def set_internal_organization_id(self, organization_id: str) -> OkResponse: + return self._patch( + self.api_base, + f"/admin/backend/v1/internal_organization_id/{quote(organization_id)}", + body=None, + response_model=OkResponse, + ) + + def get_pricing(self) -> Price: + return self._get( + self.api_base, + "/public/v1/prices/plans", + params=None, + response_model=Price, + ) + + def set_pricing(self, request: Price) -> OkResponse: + return self._patch( + self.api_base, + "/admin/backend/v1/prices/plans", + body=request, + response_model=OkResponse, + ) + + def get_model_pricing(self) -> ModelPrice: + return self._get( + self.api_base, + "/public/v1/prices/models", + params=None, + response_model=ModelPrice, + ) + + def get_model_config(self) -> ModelListConfig: + return self._get( + self.api_base, + "/admin/backend/v1/models", + params=None, + response_model=ModelListConfig, + ) + + def set_model_config(self, request: ModelListConfig) -> OkResponse: + return self._patch( + self.api_base, + "/admin/backend/v1/models", + body=request, + response_model=OkResponse, + ) + + def add_template( + self, + source: str | BinaryIO, + template_id_dst: str, + exist_ok: bool = False, + ) -> OkResponse: + """ + Upload a template Parquet file to add a new template into gallery. + + Args: + source (str | BinaryIO): The path to the template Parquet file or a file-like object. + template_id_dst (str): The ID of the new template. + exist_ok (bool, optional): Whether to overwrite existing template. Defaults to False. + + Returns: + response (OkResponse): The response indicating success. + """ + kwargs = dict( + address=self.api_base, + endpoint="/admin/backend/v1/templates/import", + body=None, + response_model=OkResponse, + data={"template_id_dst": template_id_dst, "exist_ok": exist_ok}, + timeout=self.file_upload_timeout, + ) + mime_type = "application/octet-stream" + if isinstance(source, str): + filename = split(source)[-1] + # Open the file in binary mode + with open(source, "rb") as f: + return self._post(files={"file": (filename, f, mime_type)}, **kwargs) + else: + filename = "import.parquet" + return self._post(files={"file": (filename, source, mime_type)}, **kwargs) + + def populate_templates(self, timeout: float = 30.0) -> OkResponse: + """ + Re-populates the template gallery. + + Args: + timeout (float, optional): Timeout in seconds, must be >= 0. Defaults to 30.0. + + Returns: + response (OkResponse): The response indicating success. + """ + return self._post( + self.api_base, + "/admin/backend/v1/templates/populate", + body=None, + params=dict(timeout=timeout), + response_model=OkResponse, + ) + + +class _OrgAdminClient(_Client): + """Organization administration methods.""" + + def get_org_model_config(self, organization_id: str) -> ModelListConfig: + return self._get( + self.api_base, + f"/admin/org/v1/models/{quote(organization_id)}", + params=None, + response_model=ModelListConfig, + ) + + def set_org_model_config( + self, + organization_id: str, + config: ModelListConfig, + ) -> OkResponse: + return self._patch( + self.api_base, + f"/admin/org/v1/models/{quote(organization_id)}", + body=config, + response_model=OkResponse, + ) + + def create_project(self, request: ProjectCreate) -> ProjectRead: + return self._post( + self.api_base, + "/admin/org/v1/projects", + body=request, + response_model=ProjectRead, + ) + + def update_project(self, request: ProjectUpdate) -> ProjectRead: + return self._patch( + self.api_base, + "/admin/org/v1/projects", + body=request, + response_model=ProjectRead, + ) + + def set_project_updated_at( + self, + project_id: str, + updated_at: str | None = None, + ) -> OkResponse: + return self._patch( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + body=None, + params=dict(updated_at=updated_at), + response_model=OkResponse, + ) + + def list_projects( + self, + organization_id: str = "default", + search_query: str = "", + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[ProjectRead]: + return self._get( + self.api_base, + "/admin/org/v1/projects", + params=dict( + organization_id=organization_id, + search_query=search_query, + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[ProjectRead], + ) + + def get_project(self, project_id: str) -> ProjectRead: + return self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + params=None, + response_model=ProjectRead, + ) + + def delete_project( + self, + project_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = self._delete( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + def import_project( + self, + source: str | BinaryIO, + organization_id: str, + project_id_dst: str = "", + ) -> ProjectRead: + """ + Imports a project. + + Args: + source (str | BinaryIO): The parquet file path or file-like object. + It can be a Project or Template file. + organization_id (str): Organization ID "org_xxx". + project_id_dst (str, optional): ID of the project to import tables into. + Defaults to creating new project. + + Returns: + response (ProjectRead): The imported project. + """ + kwargs = dict( + address=self.api_base, + endpoint=f"/admin/org/v1/projects/import/{quote(organization_id)}", + body=None, + response_model=ProjectRead, + data={"project_id_dst": project_id_dst}, + timeout=self.file_upload_timeout, + ) + mime_type = "application/octet-stream" + if isinstance(source, str): + filename = split(source)[-1] + # Open the file in binary mode + with open(source, "rb") as f: + return self._post(files={"file": (filename, f, mime_type)}, **kwargs) + else: + filename = "import.parquet" + return self._post(files={"file": (filename, source, mime_type)}, **kwargs) + + def export_project( + self, + project_id: str, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + ) -> bytes: + """ + Exports a project as a Project Parquet file. + + Args: + project_id (str): Project ID "proj_xxx". + compression (str, optional): Parquet compression codec. Defaults to "ZSTD". + + Returns: + response (bytes): The Parquet file. + """ + response = self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}/export", + params=dict(compression=compression), + response_model=None, + ) + return response.content + + def import_project_from_template( + self, + organization_id: str, + template_id: str, + project_id_dst: str = "", + ) -> ProjectRead: + """ + Imports a project from a template. + + Args: + organization_id (str): Organization ID "org_xxx". + template_id (str): ID of the template to import from. + project_id_dst (str, optional): ID of the project to import tables into. + Defaults to creating new project. + + Returns: + response (ProjectRead): The imported project. + """ + return self._post( + self.api_base, + f"/admin/org/v1/projects/import/{quote(organization_id)}/templates/{quote(template_id)}", + body=None, + params=dict(project_id_dst=project_id_dst), + response_model=ProjectRead, + ) + + def export_project_as_template( + self, + project_id: str, + *, + name: str, + tags: list[str], + description: str, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + ) -> bytes: + """ + Exports a project as a template Parquet file. + + Args: + project_id (str): Project ID "proj_xxx". + name (str): Template name. + tags (list[str]): Template tags. + description (str): Template description. + compression (str, optional): Parquet compression codec. Defaults to "ZSTD". + + Returns: + response (bytes): The template Parquet file. + """ + response = self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}/export/template", + params=dict( + name=name, + tags=tags, + description=description, + compression=compression, + ), + response_model=None, + ) + return response.content + + +class _AdminClient(_Client): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.backend = _BackendAdminClient(*args, **kwargs) + self.organization = _OrgAdminClient(*args, **kwargs) + + +class _TemplateClient(_Client): + """Template methods.""" + + def list_templates(self, search_query: str = "") -> Page[Template]: + """ + List all templates. + + Args: + search_query (str, optional): A string to search for within template names. + + Returns: + templates (Page[Template]): A page of templates. + """ + return self._get( + self.api_base, + "/public/v1/templates", + params=dict(search_query=search_query), + response_model=Page[Template], + ) + + def get_template(self, template_id: str) -> Template: + """ + Get a template by its ID. + + Args: + template_id (str): Template ID. + + Returns: + template (Template): The template. + """ + return self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}", + params=None, + response_model=Template, + ) + + def list_tables( + self, + template_id: str, + table_type: str, + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[TableMetaResponse]: + """ + List all tables in a template. + + Args: + template_id (str): Template ID. + table_type (str): Table type. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. + search_query (str, optional): A string to search for within table IDs as a filter. + Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + + Returns: + tables (Page[TableMetaResponse]): A page of tables. + """ + return self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}", + params=dict( + offset=offset, + limit=limit, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[TableMetaResponse], + ) + + def get_table(self, template_id: str, table_type: str, table_id: str) -> TableMetaResponse: + """ + Get a table in a template. + + Args: + template_id (str): Template ID. + table_type (str): Table type. + table_id (str): Table ID. + + Returns: + table (TableMetaResponse): The table. + """ + return self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}/{quote(table_id)}", + params=None, + response_model=TableMetaResponse, + ) + + def list_table_rows( + self, + template_id: str, + table_type: str, + table_id: str, + *, + starting_after: str | None = None, + offset: int = 0, + limit: int = 100, + order_by: str = "Updated at", + order_descending: bool = True, + float_decimals: int = 0, + vec_decimals: int = 0, + ) -> Page[dict[str, Any]]: + """ + List rows in a template table. + + Args: + template_id (str): Template ID. + table_type (str): Table type. + table_id (str): Table ID. + starting_after (str | None, optional): A cursor for use in pagination. + Only rows with ID > `starting_after` will be returned. + For instance, if your call receives 100 rows ending with ID "x", + your subsequent call can include `starting_after="x"` in order to fetch the next page of the list. + Defaults to None. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. + order_by (str, optional): Sort rows by this column. Defaults to "Updated at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + + Returns: + rows (Page[dict[str, Any]]): The rows. + """ + return self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}/{quote(table_id)}/rows", + params=dict( + starting_after=starting_after, + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=Page[dict[str, Any]], + ) + + +class _FileClient(_Client): + """File methods.""" + + def upload_file(self, file_path: str) -> FileUploadResponse: + """ + Uploads a file to the server. + + Args: + file_path (str): Path to the file to be uploaded. + + Returns: + response (FileUploadResponse): The response containing the file URI. + """ + filename = split(file_path)[-1] + mime_type = filetype.guess(file_path).mime + if mime_type is None: + mime_type = "application/octet-stream" # Default MIME type + + with open(file_path, "rb") as f: + return self._post( + self.api_base, + "/v1/files/upload/", + body=None, + response_model=FileUploadResponse, + files={ + "file": (filename, f, mime_type), + }, + timeout=self.file_upload_timeout, + ) + + def get_raw_urls(self, uris: list[str]) -> GetURLResponse: + """ + Get download URLs for raw files. + + Args: + uris (List[str]): List of file URIs to download. + + Returns: + response (GetURLResponse): The response containing download information for the files. + """ + return self._post( + self.api_base, + "/v1/files/url/raw", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + ) + + def get_thumbnail_urls(self, uris: list[str]) -> GetURLResponse: + """ + Get download URLs for file thumbnails. + + Args: + uris (List[str]): List of file URIs to get thumbnails for. + + Returns: + response (GetURLResponse): The response containing download information for the thumbnails. + """ + return self._post( + self.api_base, + "/v1/files/url/thumb", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + ) + + +class _GenTableClient(_Client): + """Generative Table methods.""" + + def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: + """ + Create an Action Table. + + Args: + request (ActionTableSchemaCreate): The action table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/action", + body=request, + response_model=TableMetaResponse, + ) + + def create_knowledge_table(self, request: KnowledgeTableSchemaCreate) -> TableMetaResponse: + """ + Create a Knowledge Table. + + Args: + request (KnowledgeTableSchemaCreate): The knowledge table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/knowledge", + body=request, + response_model=TableMetaResponse, + ) + + def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: + """ + Create a Chat Table. + + Args: + request (ChatTableSchemaCreate): The chat table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/chat", + body=request, + response_model=TableMetaResponse, + ) + + def get_table( + self, + table_type: str | TableType, + table_id: str, + ) -> TableMetaResponse: + """ + Get metadata for a specific Generative Table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}", + params=None, + response_model=TableMetaResponse, + ) + + def list_tables( + self, + table_type: str | TableType, + *, + offset: int = 0, + limit: int = 100, + parent_id: str | None = None, + search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + count_rows: bool = False, + ) -> Page[TableMetaResponse]: + """ + List Generative Tables of a specific type. + + Args: + table_type (str | TableType): The type of the table. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. + parent_id (str | None, optional): Parent ID of tables to return. + Additionally for Chat Table, you can list: + (1) all chat agents by passing in "_agent_"; or + (2) all chats by passing in "_chat_". + Defaults to None (return all tables). + search_query (str, optional): A string to search for within table IDs as a filter. + Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + count_rows (bool, optional): Whether to count the rows of the tables. Defaults to False. + + Returns: + response (Page[TableMetaResponse]): The paginated table metadata response. + """ + return self._get( + self.api_base, + f"/v1/gen_tables/{table_type}", + params=dict( + offset=offset, + limit=limit, + parent_id=parent_id, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + count_rows=count_rows, + ), + response_model=Page[TableMetaResponse], + ) + + def delete_table( + self, + table_type: str | TableType, + table_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + """ + Delete a specific table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + missing_ok (bool, optional): Ignore resource not found error. + + Returns: + response (OkResponse): The response indicating success. + """ + response = self._delete( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + def duplicate_table( + self, + table_type: str | TableType, + table_id_src: str, + table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, + ) -> TableMetaResponse: + """ + Duplicate a table. + + Args: + table_type (str | TableType): The type of the table. + table_id_src (str): The source table ID. + table_id_dst (str | None, optional): The destination / new table ID. + Defaults to None (create a new table ID automatically). + include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. + create_as_child (bool, optional): Whether the new table is a child table. + If this is True, then `include_data` will be set to True. Defaults to False. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + if "deploy" in kwargs: + warn( + 'The "deploy" argument is deprecated, use "create_as_child" instead.', + FutureWarning, + stacklevel=2, + ) + create_as_child = create_as_child or kwargs.pop("deploy") + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/duplicate/{quote(table_id_src)}", + body=None, + params=dict( + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + ), + response_model=TableMetaResponse, + ) + + def rename_table( + self, + table_type: str | TableType, + table_id_src: str, + table_id_dst: str, + ) -> TableMetaResponse: + """ + Rename a table. + + Args: + table_type (str | TableType): The type of the table. + table_id_src (str): The source table ID. + table_id_dst (str): The destination / new table ID. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/rename/{quote(table_id_src)}/{quote(table_id_dst)}", + body=None, + response_model=TableMetaResponse, + ) + + def update_gen_config( + self, + table_type: str | TableType, + request: GenConfigUpdateRequest, + ) -> TableMetaResponse: + """ + Update the generation configuration for a table. + + Args: + table_type (str | TableType): The type of the table. + request (GenConfigUpdateRequest): The generation configuration update request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/gen_config/update", + body=request, + response_model=TableMetaResponse, + ) + + def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: + """ + Add columns to an Action Table. + + Args: + request (AddActionColumnSchema): The action column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/action/columns/add", + body=request, + response_model=TableMetaResponse, + ) + + def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: + """ + Add columns to a Knowledge Table. + + Args: + request (AddKnowledgeColumnSchema): The knowledge column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/knowledge/columns/add", + body=request, + response_model=TableMetaResponse, + ) + + def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: + """ + Add columns to a Chat Table. + + Args: + request (AddChatColumnSchema): The chat column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + "/v1/gen_tables/chat/columns/add", + body=request, + response_model=TableMetaResponse, + ) + + def drop_columns( + self, + table_type: str | TableType, + request: ColumnDropRequest, + ) -> TableMetaResponse: + """ + Drop columns from a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnDropRequest): The column drop request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/columns/drop", + body=request, + response_model=TableMetaResponse, + ) + + def rename_columns( + self, + table_type: str | TableType, + request: ColumnRenameRequest, + ) -> TableMetaResponse: + """ + Rename columns in a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnRenameRequest): The column rename request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/columns/rename", + body=request, + response_model=TableMetaResponse, + ) + + def reorder_columns( + self, + table_type: str | TableType, + request: ColumnReorderRequest, + ) -> TableMetaResponse: + """ + Reorder columns in a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnReorderRequest): The column reorder request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/columns/reorder", + body=request, + response_model=TableMetaResponse, + ) + + def list_table_rows( + self, + table_type: str | TableType, + table_id: str, + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + order_descending: bool = True, + ) -> Page[dict[str, Any]]: + """ + List rows in a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. + search_query (str, optional): A string to search for within the rows as a filter. + Defaults to "" (no filter). + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + + Returns: + response (Page[dict[str, Any]]): The paginated rows response. + """ + return self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows", + params=dict( + offset=offset, + limit=limit, + search_query=search_query, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + order_descending=order_descending, + ), + response_model=Page[dict[str, Any]], + ) + + def get_table_row( + self, + table_type: str | TableType, + table_id: str, + row_id: str, + *, + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + ) -> dict[str, Any]: + """ + Get a specific row in a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + row_id (str): The ID of the row. + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + + Returns: + response (dict[str, Any]): The row data. + """ + response = self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", + params=dict( + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=None, + ) + return json_loads(response.text) + + def add_table_rows( + self, + table_type: str | TableType, + request: RowAddRequest, + ) -> GenTableChatResponseType: + """ + Add rows to a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowAddRequest): The row add request. + + Returns: + response (GenTableChatResponseType): The row completion. + In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + followed by zero or more `GenTableStreamChatCompletionChunk` objects. + In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. + """ + if request.stream: + + def gen(): + for chunk in self._stream( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/add", + body=request, + ): + chunk = json_loads(chunk[5:]) + if chunk["object"] == "gen_table.references": + yield GenTableStreamReferences.model_validate(chunk) + elif chunk["object"] == "gen_table.completion.chunk": + yield GenTableStreamChatCompletionChunk.model_validate(chunk) + else: + raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + + return gen() + else: + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/add", + body=request, + response_model=GenTableRowsChatCompletionChunks, + ) + + def regen_table_rows( + self, + table_type: str | TableType, + request: RowRegenRequest, + ) -> GenTableChatResponseType: + """ + Regenerate rows in a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowRegenRequest): The row regenerate request. + + Returns: + response (GenTableChatResponseType): The row completion. + In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + followed by zero or more `GenTableStreamChatCompletionChunk` objects. + In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. + """ + if request.stream: + + def gen(): + for chunk in self._stream( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/regen", + body=request, + ): + chunk = json_loads(chunk[5:]) + if chunk["object"] == "gen_table.references": + yield GenTableStreamReferences.model_validate(chunk) + elif chunk["object"] == "gen_table.completion.chunk": + yield GenTableStreamChatCompletionChunk.model_validate(chunk) + else: + raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + + return gen() + else: + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/regen", + body=request, + response_model=GenTableRowsChatCompletionChunks, + ) + + def update_table_row( + self, + table_type: str | TableType, + request: RowUpdateRequest, + ) -> OkResponse: + """ + Update a specific row in a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowUpdateRequest): The row update request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/update", + body=request, + response_model=OkResponse, + ) + + def delete_table_rows( + self, + table_type: str | TableType, + request: RowDeleteRequest, + ) -> OkResponse: + """ + Delete rows from a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowDeleteRequest): The row delete request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/rows/delete", + body=request, + response_model=OkResponse, + ) + + def delete_table_row( + self, + table_type: str | TableType, + table_id: str, + row_id: str, + ) -> OkResponse: + """ + Delete a specific row from a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + row_id (str): The ID of the row. + + Returns: + response (OkResponse): The response indicating success. + """ + return self._delete( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", + params=None, + response_model=OkResponse, + ) + + def get_conversation_thread( + self, + table_type: str | TableType, + table_id: str, + column_id: str, + *, + row_id: str = "", + include: bool = True, + ) -> ChatThread: + """ + Get the conversation thread for a chat table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): ID / name of the chat table. + column_id (str): ID / name of the column to fetch. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. + + Returns: + response (ChatThread): The conversation thread. + """ + return self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/thread", + params=dict(column_id=column_id, row_id=row_id, include=include), + response_model=ChatThread, + ) + + def hybrid_search( + self, + table_type: str | TableType, + request: SearchRequest, + ) -> list[dict[str, Any]]: + """ + Perform a hybrid search on a table. + + Args: + table_type (str | TableType): The type of the table. + request (SearchRequest): The search request. + + Returns: + response (list[dict[str, Any]]): The search results. + """ + response = self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/hybrid_search", + body=request, + response_model=None, + ) + return json_loads(response.text) + + def embed_file_options(self) -> httpx.Response: + """ + Get options for embedding a file to a Knowledge Table. + + Returns: + response (httpx.Response): The response containing options information. + """ + response = self._options( + self.api_base, + "/v1/gen_tables/knowledge/embed_file", + ) + return response + + def embed_file( + self, + file_path: str, + table_id: str, + *, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> OkResponse: + """ + Embed a file into a Knowledge Table. + + Args: + file_path (str): File path of the document to be embedded. + table_id (str): Knowledge Table ID / name. + chunk_size (int, optional): Maximum chunk size (number of characters). Must be > 0. + Defaults to 1000. + chunk_overlap (int, optional): Overlap in characters between chunks. Must be >= 0. + Defaults to 200. + + Returns: + response (OkResponse): The response indicating success. + """ + # Guess the MIME type of the file based on its extension + mime_type, _ = guess_type(file_path) + if mime_type is None: + mime_type = ( + "application/jsonl" if file_path.endswith(".jsonl") else "application/octet-stream" + ) # Default MIME type + # Extract the filename from the file path + filename = split(file_path)[-1] + # Open the file in binary mode + with open(file_path, "rb") as f: + response = self._post( + self.api_base, + "/v1/gen_tables/knowledge/embed_file", + body=None, + response_model=OkResponse, + files={ + "file": (filename, f, mime_type), + }, + data={ + "table_id": table_id, + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, + # "overwrite": request.overwrite, + }, + timeout=self.file_upload_timeout, + ) + return response + + def import_table_data( + self, + table_type: str | TableType, + request: TableDataImportRequest, + ) -> GenTableChatResponseType: + """ + Imports CSV or TSV data into a table. + + Args: + file_path (str): CSV or TSV file path. + table_type (str | TableType): Table type. + request (TableDataImportRequest): Data import request. + + Returns: + response (OkResponse): The response indicating success. + """ + # Guess the MIME type of the file based on its extension + mime_type, _ = guess_type(request.file_path) + if mime_type is None: + mime_type = "application/octet-stream" # Default MIME type + # Extract the filename from the file path + filename = split(request.file_path)[-1] + data = { + "table_id": request.table_id, + "stream": request.stream, + # "column_names": request.column_names, + # "columns": request.columns, + "delimiter": request.delimiter, + } + if request.stream: + + def gen(): + # Open the file in binary mode + with open(request.file_path, "rb") as f: + for chunk in self._stream( + self.api_base, + f"/v1/gen_tables/{table_type}/import_data", + body=None, + files={ + "file": (filename, f, mime_type), + }, + data=data, + timeout=self.file_upload_timeout, + ): + chunk = json_loads(chunk[5:]) + if chunk["object"] == "gen_table.references": + yield GenTableStreamReferences.model_validate(chunk) + elif chunk["object"] == "gen_table.completion.chunk": + yield GenTableStreamChatCompletionChunk.model_validate(chunk) + else: + raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + + return gen() + else: + # Open the file in binary mode + with open(request.file_path, "rb") as f: + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/import_data", + body=None, + response_model=GenTableRowsChatCompletionChunks, + files={ + "file": (filename, f, mime_type), + }, + data=data, + timeout=self.file_upload_timeout, + ) + + def export_table_data( + self, + table_type: str | TableType, + table_id: str, + *, + columns: list[str] | None = None, + delimiter: Literal[",", "\t"] = ",", + ) -> bytes: + """ + Exports the row data of a table as a CSV or TSV file. + + Args: + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + delimiter (str, optional): The delimiter of the file: can be "," or "\\t". Defaults to ",". + columns (list[str], optional): A list of columns to be exported. Defaults to None (export all columns). + + Returns: + response (list[dict[str, Any]]): The search results. + """ + response = self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data", + params=dict(delimiter=delimiter, columns=columns), + response_model=None, + ) + return response.content + + def import_table( + self, + table_type: str | TableType, + request: TableImportRequest, + ) -> TableMetaResponse: + """ + Imports a table (data and schema) from a parquet file. + + Args: + file_path (str): The parquet file path. + table_type (str | TableType): Table type. + request (TableImportRequest): Table import request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + mime_type = "application/octet-stream" + filename = split(request.file_path)[-1] + data = {"table_id_dst": request.table_id_dst} + # Open the file in binary mode + with open(request.file_path, "rb") as f: + return self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/import", + body=None, + response_model=TableMetaResponse, + files={ + "file": (filename, f, mime_type), + }, + data=data, + timeout=self.file_upload_timeout, + ) + + def export_table( + self, + table_type: str | TableType, + table_id: str, + ) -> bytes: + """ + Exports a table (data and schema) as a parquet file. + + Args: + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + + Returns: + response (list[dict[str, Any]]): The search results. + """ + response = self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export", + params=None, + response_model=None, + ) + return response.content + + +class JamAI(_Client): + def __init__( + self, + project_id: str = ENV_CONFIG.jamai_project_id, + token: str = ENV_CONFIG.jamai_token_plain, + api_base: str = ENV_CONFIG.jamai_api_base, + headers: dict | None = None, + timeout: float | None = ENV_CONFIG.jamai_timeout_sec, + file_upload_timeout: float | None = ENV_CONFIG.jamai_file_upload_timeout_sec, + *, + api_key: str = "", + ) -> None: + """ + Initialize the JamAI client. + + Args: + project_id (str, optional): The project ID. + Defaults to "default", but can be overridden via + `JAMAI_PROJECT_ID` var in environment or `.env` file. + token (str, optional): Your Personal Access Token or organization API key (deprecated) for authentication. + Defaults to "", but can be overridden via + `JAMAI_TOKEN` var in environment or `.env` file. + api_base (str, optional): The base URL for the API. + Defaults to "https://api.jamaibase.com/api", but can be overridden via + `JAMAI_API_BASE` var in environment or `.env` file. + headers (dict | None, optional): Additional headers to include in requests. + Defaults to None. + timeout (float | None, optional): The timeout to use when sending requests. + Defaults to 15 minutes, but can be overridden via + `JAMAI_TIMEOUT_SEC` var in environment or `.env` file. + file_upload_timeout (float | None, optional): The timeout to use when sending file upload requests. + Defaults to 60 minutes, but can be overridden via + `JAMAI_FILE_UPLOAD_TIMEOUT_SEC` var in environment or `.env` file. + api_key (str, optional): (Deprecated) Organization API key for authentication. + """ + if api_key: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + http_client = httpx.Client( + timeout=timeout, + transport=httpx.HTTPTransport(retries=3), + ) + kwargs = dict( + project_id=project_id, + token=token or api_key, + api_base=api_base, + headers=headers, + http_client=http_client, + file_upload_timeout=file_upload_timeout, + ) + super().__init__(**kwargs) + self.admin = _AdminClient(**kwargs) + self.template = _TemplateClient(**kwargs) + self.file = _FileClient(**kwargs) + self.table = _GenTableClient(**kwargs) + + def health(self) -> dict[str, Any]: + """ + Get health status. + + Returns: + response (dict[str, Any]): Health status. + """ + response = self._get(self.api_base, "/health", response_model=None) + return json_loads(response.text) + + # --- Models and chat --- # + + def model_info( + self, + name: str = "", + capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + | None = None, + ) -> ModelInfoResponse: + """ + Get information about available models. + + Args: + name (str, optional): The model name. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. + + Returns: + response (ModelInfoResponse): The model information response. + """ + params = {"model": name, "capabilities": capabilities} + return self._get( + self.api_base, + "/v1/models", + params=params, + response_model=ModelInfoResponse, + ) + + def model_names( + self, + prefer: str = "", + capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + | None = None, + ) -> list[str]: + """ + Get the names of available models. + + Args: + prefer (str, optional): Preferred model name. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. + + Returns: + response (list[str]): List of model names. + """ + params = {"prefer": prefer, "capabilities": capabilities} + response = self._get( + self.api_base, + "/v1/model_names", + params=params, + response_model=None, + ) + return json_loads(response.text) + + def generate_chat_completions( + self, request: ChatRequest + ) -> ChatCompletionChunk | Generator[References | ChatCompletionChunk, None, None]: + """ + Generates chat completions. + + Args: + request (ChatRequest): The request. + + Returns: + completion (ChatCompletionChunk | Generator): The chat completion. + In streaming mode, it is a generator that yields a `References` object + followed by zero or more `ChatCompletionChunk` objects. + In non-streaming mode, it is a `ChatCompletionChunk` object. + """ + if request.stream: + + def gen(): + for chunk in self._stream( + self.api_base, + "/v1/chat/completions", + body=request, + ): + chunk = json_loads(chunk[5:]) + if chunk["object"] == "chat.references": + yield References.model_validate(chunk) + elif chunk["object"] == "chat.completion.chunk": + yield ChatCompletionChunk.model_validate(chunk) + else: + raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + + return gen() + else: + return self._post( + self.api_base, + "/v1/chat/completions", + body=request, + response_model=ChatCompletionChunk, + ) + + def generate_embeddings(self, request: EmbeddingRequest) -> EmbeddingResponse: + """ + Generate embeddings for the given input. + + Args: + request (EmbeddingRequest): The embedding request. + + Returns: + response (EmbeddingResponse): The embedding response. + """ + return self._post( + self.api_base, + "/v1/embeddings", + body=request, + response_model=EmbeddingResponse, + ) + + # --- Gen Table --- # + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: + """ + Create an Action Table. + + Args: + request (ActionTableSchemaCreate): The action table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.create_action_table(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def create_knowledge_table(self, request: KnowledgeTableSchemaCreate) -> TableMetaResponse: + """ + Create a Knowledge Table. + + Args: + request (KnowledgeTableSchemaCreate): The knowledge table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.create_knowledge_table(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: + """ + Create a Chat Table. + + Args: + request (ChatTableSchemaCreate): The chat table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.create_chat_table(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def get_table( + self, + table_type: str | TableType, + table_id: str, + ) -> TableMetaResponse: + """ + Get metadata for a specific Generative Table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.get_table(table_type, table_id) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def list_tables( + self, + table_type: str | TableType, + offset: int = 0, + limit: int = 100, + parent_id: str | None = None, + search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + count_rows: bool = False, + ) -> Page[TableMetaResponse]: + """ + List Generative Tables of a specific type. + + Args: + table_type (str | TableType): The type of the table. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. + parent_id (str | None, optional): Parent ID of tables to return. + Additionally for Chat Table, you can list: + (1) all chat agents by passing in "_agent_"; or + (2) all chats by passing in "_chat_". + Defaults to None (return all tables). + search_query (str, optional): A string to search for within table IDs as a filter. + Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + count_rows (bool, optional): Whether to count the rows of the tables. Defaults to False. + + Returns: + response (Page[TableMetaResponse]): The paginated table metadata response. + """ + return self.table.list_tables( + table_type, + offset=offset, + limit=limit, + parent_id=parent_id, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + count_rows=count_rows, + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def delete_table( + self, + table_type: str | TableType, + table_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + """ + Delete a specific table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + missing_ok (bool, optional): Ignore resource not found error. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.delete_table(table_type, table_id, missing_ok=missing_ok) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def duplicate_table( + self, + table_type: str | TableType, + table_id_src: str, + table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, + ) -> TableMetaResponse: + """ + Duplicate a table. + + Args: + table_type (str | TableType): The type of the table. + table_id_src (str): The source table ID. + table_id_dst (str | None, optional): The destination / new table ID. + Defaults to None (create a new table ID automatically). + include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. + create_as_child (bool, optional): Whether the new table is a child table. + If this is True, then `include_data` will be set to True. Defaults to False. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.duplicate_table( + table_type, + table_id_src, + table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + **kwargs, + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def rename_table( + self, + table_type: str | TableType, + table_id_src: str, + table_id_dst: str, + ) -> TableMetaResponse: + """ + Rename a table. + + Args: + table_type (str | TableType): The type of the table. + table_id_src (str): The source table ID. + table_id_dst (str): The destination / new table ID. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.rename_table(table_type, table_id_src, table_id_dst) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def update_gen_config( + self, + table_type: str | TableType, + request: GenConfigUpdateRequest, + ) -> TableMetaResponse: + """ + Update the generation configuration for a table. + + Args: + table_type (str | TableType): The type of the table. + request (GenConfigUpdateRequest): The generation configuration update request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.update_gen_config(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: + """ + Add columns to an Action Table. + + Args: + request (AddActionColumnSchema): The action column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.add_action_columns(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: + """ + Add columns to a Knowledge Table. + + Args: + request (AddKnowledgeColumnSchema): The knowledge column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.add_knowledge_columns(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: + """ + Add columns to a Chat Table. + + Args: + request (AddChatColumnSchema): The chat column schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.add_chat_columns(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def drop_columns( + self, + table_type: str | TableType, + request: ColumnDropRequest, + ) -> TableMetaResponse: + """ + Drop columns from a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnDropRequest): The column drop request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.drop_columns(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def rename_columns( + self, + table_type: str | TableType, + request: ColumnRenameRequest, + ) -> TableMetaResponse: + """ + Rename columns in a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnRenameRequest): The column rename request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.rename_columns(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def reorder_columns( + self, + table_type: str | TableType, + request: ColumnReorderRequest, + ) -> TableMetaResponse: + """ + Reorder columns in a table. + + Args: + table_type (str | TableType): The type of the table. + request (ColumnReorderRequest): The column reorder request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.reorder_columns(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def list_table_rows( + self, + table_type: str | TableType, + table_id: str, + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + order_descending: bool = True, + ) -> Page[dict[str, Any]]: + """ + List rows in a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. + search_query (str, optional): A string to search for within the rows as a filter. + Defaults to "" (no filter). + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + """ + return self.table.list_table_rows( + table_type, + table_id, + offset=offset, + limit=limit, + search_query=search_query, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + order_descending=order_descending, + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def get_table_row( + self, + table_type: str | TableType, + table_id: str, + row_id: str, + *, + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + ) -> dict[str, Any]: + """ + Get a specific row in a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + row_id (str): The ID of the row. + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + + Returns: + response (dict[str, Any]): The row data. + """ + return self.table.get_table_row( + table_type, + table_id, + row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def add_table_rows( + self, + table_type: str | TableType, + request: RowAddRequest, + ) -> ( + GenTableRowsChatCompletionChunks + | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] + ): + """ + Add rows to a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowAddRequest): The row add request. + + Returns: + response (GenTableRowsChatCompletionChunks | AsyncGenerator): The row completion. + In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + followed by zero or more `GenTableStreamChatCompletionChunk` objects. + In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. + """ + return self.table.add_table_rows(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def regen_table_rows( + self, + table_type: str | TableType, + request: RowRegenRequest, + ) -> ( + GenTableRowsChatCompletionChunks + | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] + ): + """ + Regenerate rows in a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowRegenRequest): The row regenerate request. + + Returns: + response (GenTableRowsChatCompletionChunks | AsyncGenerator): The row completion. + In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + followed by zero or more `GenTableStreamChatCompletionChunk` objects. + In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. + """ + return self.table.regen_table_rows(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def update_table_row( + self, + table_type: str | TableType, + request: RowUpdateRequest, + ) -> OkResponse: + """ + Update a specific row in a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowUpdateRequest): The row update request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.update_table_row(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def delete_table_rows( + self, + table_type: str | TableType, + request: RowDeleteRequest, + ) -> OkResponse: + """ + Delete rows from a table. + + Args: + table_type (str | TableType): The type of the table. + request (RowDeleteRequest): The row delete request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.delete_table_rows(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def delete_table_row( + self, + table_type: str | TableType, + table_id: str, + row_id: str, + ) -> OkResponse: + """ + Delete a specific row from a table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): The ID of the table. + row_id (str): The ID of the row. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.delete_table_row(table_type, table_id, row_id) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def get_conversation_thread( + self, + table_type: str | TableType, + table_id: str, + column_id: str, + row_id: str = "", + include: bool = True, + ) -> ChatThread: + """ + Get the conversation thread for a chat table. + + Args: + table_type (str | TableType): The type of the table. + table_id (str): ID / name of the chat table. + column_id (str): ID / name of the column to fetch. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. + + Returns: + response (ChatThread): The conversation thread. + """ + return self.table.get_conversation_thread( + table_type, table_id, column_id, row_id=row_id, include=include + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def hybrid_search( + self, + table_type: str | TableType, + request: SearchRequest, + ) -> list[dict[str, Any]]: + """ + Perform a hybrid search on a table. + + Args: + table_type (str | TableType): The type of the table. + request (SearchRequest): The search request. + + Returns: + response (list[dict[str, Any]]): The search results. + """ + return self.table.hybrid_search(table_type, request) + + @deprecated( + "This method is deprecated, use `client.table.embed_file_options` instead.", + category=FutureWarning, + stacklevel=1, + ) + def upload_file_options(self) -> httpx.Response: + """ + Get options for uploading a file to a Knowledge Table. + + Returns: + response (httpx.Response): The response containing options information. + """ + return self.table.embed_file_options() + + @deprecated( + "This method is deprecated, use `client.table.embed_file` instead.", + category=FutureWarning, + stacklevel=1, + ) + def upload_file(self, request: FileUploadRequest) -> OkResponse: + """ + Upload a file to a Knowledge Table. + + Args: + request (FileUploadRequest): The file upload request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.embed_file(request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def import_table_data( + self, + table_type: str | TableType, + request: TableDataImportRequest, + ) -> GenTableChatResponseType: + """ + Imports CSV or TSV data into a table. + + Args: + file_path (str): CSV or TSV file path. + table_type (str | TableType): Table type. + request (TableDataImportRequest): Data import request. + + Returns: + response (OkResponse): The response indicating success. + """ + return self.table.import_table_data(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def export_table_data( + self, + table_type: str | TableType, + table_id: str, + columns: list[str] | None = None, + delimiter: Literal[",", "\t"] = ",", + ) -> bytes: + """ + Exports the row data of a table as a CSV or TSV file. + + Args: + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + delimiter (str, optional): The delimiter of the file: can be "," or "\\t". Defaults to ",". + columns (list[str], optional): A list of columns to be exported. Defaults to None (export all columns). + + Returns: + response (list[dict[str, Any]]): The search results. + """ + return self.table.export_table_data( + table_type, table_id, columns=columns, delimiter=delimiter + ) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def import_table( + self, + table_type: str | TableType, + request: TableImportRequest, + ) -> TableMetaResponse: + """ + Imports a table (data and schema) from a parquet file. + + Args: + file_path (str): The parquet file path. + table_type (str | TableType): Table type. + request (TableImportRequest): Table import request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return self.table.import_table(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + def export_table( + self, + table_type: str | TableType, + table_id: str, + ) -> bytes: + """ + Exports a table (data and schema) as a parquet file. + + Args: + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + + Returns: + response (list[dict[str, Any]]): The search results. + """ + return self.table.export_table(table_type, table_id) + + +class _ClientAsync(_Client): + async def close(self) -> None: + """ + Close the HTTP async client. + """ + await self.http_client.aclose() + + @staticmethod + async def raise_exception( + response: httpx.Response, + *, + ignore_code: int | None = None, + ) -> httpx.Response: + """ + Raise an exception if the response status code is not 200. + + Args: + response (httpx.Response): The HTTP response. + ignore_code (int | None, optional): HTTP code to ignore. + + Raises: + RuntimeError: If the response status code is not 200 and is not ignored by `ignore_code`. + + Returns: + response (httpx.Response): The HTTP response. + """ + if "warning" in response.headers: + warn(response.headers["warning"], stacklevel=2) + code = response.status_code + if (200 <= code < 300) or code == ignore_code: + return response + try: + error = response.text + except httpx.ResponseNotRead: + error = (await response.aread()).decode() + error = json_loads(error) + err_mssg = error.get("message", error.get("detail", str(error))) + if code == 404: + exc = ResourceNotFoundError + else: + exc = RuntimeError + raise exc(err_mssg) + + async def _get( + self, + address: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + response_model: Type[BaseModel] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an asynchronous GET request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + params (dict[str, Any] | None, optional): Query parameters. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. + **kwargs (Any): Keyword arguments for `httpx.get`. + + Returns: + response (httpx.Response | BaseModel): The response text or Pydantic response object. + """ + response = await self.http_client.get( + f"{address}{endpoint}", + params=self._filter_params(params), + headers=self.headers, + **kwargs, + ) + response = await self.raise_exception(response) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + + async def _post( + self, + address: str, + endpoint: str, + *, + body: BaseModel | None, + response_model: Type[BaseModel] | None = None, + params: dict[str, Any] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an asynchronous POST request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + body (BaseModel | None): The request body. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.post`. + + Returns: + response (httpx.Response | BaseModel): The response text or Pydantic response object. + """ + if body is not None: + body = body.model_dump() + response = await self.http_client.post( + f"{address}{endpoint}", + json=body, + headers=self.headers, + params=self._filter_params(params), + **kwargs, + ) + response = await self.raise_exception(response) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + + async def _options( + self, + address: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + response_model: Type[BaseModel] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an asynchronous OPTIONS request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. + **kwargs (Any): Keyword arguments for `httpx.options`. + + Returns: + response (httpx.Response | BaseModel): The response or Pydantic response object. + """ + response = await self.http_client.options( + f"{address}{endpoint}", + params=await self._filter_params(params), + headers=self.headers, + **kwargs, + ) + response = await self.raise_exception(response) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + + async def _patch( + self, + address: str, + endpoint: str, + *, + body: BaseModel | None, + response_model: Type[BaseModel] | None = None, + params: dict[str, Any] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an asynchronous PATCH request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + body (BaseModel | None): The request body. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.patch`. + + Returns: + response (httpx.Response | BaseModel): The response text or Pydantic response object. + """ + if body is not None: + body = body.model_dump() + response = await self.http_client.patch( + f"{address}{endpoint}", + json=body, + headers=self.headers, + params=self._filter_params(params), + **kwargs, + ) + response = await self.raise_exception(response) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + + async def _stream( + self, + address: str, + endpoint: str, + *, + body: BaseModel | None, + params: dict[str, Any] | None = None, + **kwargs, + ) -> AsyncGenerator[str, None]: + """ + Make an asynchronous streaming POST request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + body (BaseModel | None): The request body. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.stream`. + + Yields: + str: The response chunks. + """ + if body is not None: + body = body.model_dump() + async with self.http_client.stream( + "POST", + f"{address}{endpoint}", + json=body, + headers=self.headers, + params=self._filter_params(params), + **kwargs, + ) as response: + response = await self.raise_exception(response) + async for chunk in response.aiter_lines(): + chunk = chunk.strip() + if chunk == "" or chunk == "data: [DONE]": + continue + yield chunk + + async def _delete( + self, + address: str, + endpoint: str, + *, + params: dict[str, Any] | None = None, + response_model: Type[BaseModel] | None = None, + ignore_code: int | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make a DELETE request to the specified endpoint. + + Args: + address (str): The base address of the API. + endpoint (str): The API endpoint. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. + ignore_code (int | None, optional): HTTP code to ignore. + **kwargs (Any): Keyword arguments for `httpx.delete`. + + Returns: + response (httpx.Response | BaseModel): The response text or Pydantic response object. + """ + response = await self.http_client.delete( + f"{address}{endpoint}", + params=self._filter_params(params), + headers=self.headers, + **kwargs, + ) + response = await self.raise_exception(response, ignore_code=ignore_code) + if response_model is None: + return response + else: + return response_model.model_validate_json(response.text) + + +class _BackendAdminClientAsync(_ClientAsync): + """Backend administration methods.""" + + async def create_user(self, request: UserCreate) -> UserRead: + return await self._post( + self.api_base, + "/admin/backend/v1/users", + body=request, + response_model=UserRead, + ) + + async def update_user(self, request: UserUpdate) -> UserRead: + return await self._patch( + self.api_base, + "/admin/backend/v1/users", + body=request, + response_model=UserRead, + ) + + async def list_users( + self, + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[UserRead]: + return await self._get( + self.api_base, + "/admin/backend/v1/users", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[UserRead], + ) + + async def get_user(self, user_id: str) -> UserRead: + return await self._get( + self.api_base, + f"/admin/backend/v1/users/{quote(user_id)}", + params=None, + response_model=UserRead, + ) + + async def delete_user( + self, + user_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = await self._delete( + self.api_base, + f"/admin/backend/v1/users/{quote(user_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def create_pat(self, request: PATCreate) -> PATRead: + return await self._post( + self.api_base, + "/admin/backend/v1/pats", + body=request, + response_model=PATRead, + ) + + async def get_pat(self, pat: str) -> PATRead: + return await self._get( + self.api_base, + f"/admin/backend/v1/pats/{quote(pat)}", + params=None, + response_model=PATRead, + ) + + async def delete_pat( + self, + pat: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = await self._delete( + self.api_base, + f"/admin/backend/v1/pats/{quote(pat)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def create_organization(self, request: OrganizationCreate) -> OrganizationRead: + return await self._post( + self.api_base, + "/admin/backend/v1/organizations", + body=request, + response_model=OrganizationRead, + ) + + async def update_organization(self, request: OrganizationUpdate) -> OrganizationRead: + return await self._patch( + self.api_base, + "/admin/backend/v1/organizations", + body=request, + response_model=OrganizationRead, + ) + + async def list_organizations( + self, + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[OrganizationRead]: + return await self._get( + self.api_base, + "/admin/backend/v1/organizations", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[OrganizationRead], + ) + + async def get_organization(self, organization_id: str) -> OrganizationRead: + return await self._get( + self.api_base, + f"/admin/backend/v1/organizations/{quote(organization_id)}", + params=None, + response_model=OrganizationRead, + ) + + async def delete_organization( + self, + organization_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = await self._delete( + self.api_base, + f"/admin/backend/v1/organizations/{quote(organization_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def generate_invite_token( + self, + organization_id: str, + user_email: str = "", + valid_days: int = 7, + ) -> str: + """ + Generates an invite token to join an organization. + + Args: + organization_id (str): Organization ID. + user_email (str, optional): User email. + Leave blank to disable email check and generate a public invite. Defaults to "". + valid_days (int, optional): How many days should this link be valid for. Defaults to 7. + + Returns: + token (str): _description_ + """ + response = await self._get( + self.api_base, + "/admin/backend/v1/invite_tokens", + params=dict( + organization_id=organization_id, user_email=user_email, valid_days=valid_days + ), + response_model=None, + ) + return response.text + + async def join_organization(self, request: OrgMemberCreate) -> OrgMemberRead: + return await self._post( + self.api_base, + "/admin/backend/v1/organizations/link", + body=request, + response_model=OrgMemberRead, + ) + + async def leave_organization(self, user_id: str, organization_id: str) -> OkResponse: + return await self._delete( + self.api_base, + f"/admin/backend/v1/organizations/link/{quote(user_id)}/{quote(organization_id)}", + params=None, + response_model=OkResponse, + ) + + async def create_api_key(self, request: ApiKeyCreate) -> ApiKeyRead: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + return await self._post( + self.api_base, + "/admin/backend/v1/api_keys", + body=request, + response_model=ApiKeyRead, + ) + + async def get_api_key(self, api_key: str) -> ApiKeyRead: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + return await self._get( + self.api_base, + f"/admin/backend/v1/api_keys/{quote(api_key)}", + params=None, + response_model=ApiKeyRead, + ) + + async def delete_api_key( + self, + api_key: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + response = await self._delete( + self.api_base, + f"/admin/backend/v1/api_keys/{quote(api_key)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def refresh_quota( + self, + organization_id: str, + reset_usage: bool = True, + ) -> OrganizationRead: + return await self._post( + self.api_base, + f"/admin/backend/v1/quotas/refresh/{quote(organization_id)}", + body=None, + params=dict(reset_usage=reset_usage), + response_model=OrganizationRead, + ) + + async def get_event(self, event_id: str) -> EventRead: + return await self._get( + self.api_base, + f"/admin/backend/v1/events/{quote(event_id)}", + params=None, + response_model=EventRead, + ) + + async def add_event(self, request: EventCreate) -> OkResponse: + return await self._post( + self.api_base, + "/admin/backend/v1/events", + body=request, + response_model=OkResponse, + ) + + async def mark_event_as_done(self, event_id: str) -> OkResponse: + return await self._patch( + self.api_base, + f"/admin/backend/v1/events/done/{quote(event_id)}", + body=None, + response_model=OkResponse, + ) + + async def get_internal_organization_id(self) -> StringResponse: + return await self._get( + self.api_base, + "/admin/backend/v1/internal_organization_id", + params=None, + response_model=StringResponse, + ) + + async def set_internal_organization_id(self, organization_id: str) -> OkResponse: + return await self._patch( + self.api_base, + f"/admin/backend/v1/internal_organization_id/{quote(organization_id)}", + body=None, + response_model=OkResponse, + ) + + async def get_pricing(self) -> Price: + return await self._get( + self.api_base, + "/public/v1/prices/plans", + params=None, + response_model=Price, + ) + + async def set_pricing(self, request: Price) -> OkResponse: + return await self._patch( + self.api_base, + "/admin/backend/v1/prices/plans", + body=request, + response_model=OkResponse, + ) + + async def get_model_pricing(self) -> ModelPrice: + return await self._get( + self.api_base, + "/public/v1/prices/models", + params=None, + response_model=ModelPrice, + ) + + async def get_model_config(self) -> ModelListConfig: + return await self._get( + self.api_base, + "/admin/backend/v1/models", + params=None, + response_model=ModelListConfig, + ) + + async def set_model_config(self, request: ModelListConfig) -> OkResponse: + return await self._patch( + self.api_base, + "/admin/backend/v1/models", + body=request, + response_model=OkResponse, + ) + + async def add_template( + self, + source: str | BinaryIO, + template_id_dst: str, + exist_ok: bool = False, + ) -> OkResponse: + """ + Upload a template Parquet file to add a new template into gallery. + + Args: + source (str | BinaryIO): The path to the template Parquet file or a file-like object. + template_id_dst (str): The ID of the new template. + exist_ok (bool, optional): Whether to overwrite existing template. Defaults to False. + + Returns: + response (OkResponse): The response indicating success. + """ + kwargs = dict( + address=self.api_base, + endpoint="/admin/backend/v1/templates/import", + body=None, + response_model=OkResponse, + data={"template_id_dst": template_id_dst, "exist_ok": exist_ok}, + timeout=self.file_upload_timeout, + ) + mime_type = "application/octet-stream" + if isinstance(source, str): + filename = split(source)[-1] + # Open the file in binary mode + with open(source, "rb") as f: + return await self._post(files={"file": (filename, f, mime_type)}, **kwargs) + else: + filename = "import.parquet" + return await self._post(files={"file": (filename, source, mime_type)}, **kwargs) + + async def populate_templates(self, timeout: float = 30.0) -> OkResponse: + """ + Re-populates the template gallery. + + Args: + timeout (float, optional): Timeout in seconds, must be >= 0. Defaults to 30.0. + + Returns: + response (OkResponse): The response indicating success. + """ + return await self._post( + self.api_base, + "/admin/backend/v1/templates/populate", + body=None, + params=dict(timeout=timeout), + response_model=OkResponse, + ) + + +class _OrgAdminClientAsync(_ClientAsync): + """Organization administration methods.""" + + async def get_org_model_config(self, organization_id: str) -> ModelListConfig: + return await self._get( + self.api_base, + f"/admin/org/v1/models/{quote(organization_id)}", + params=None, + response_model=ModelListConfig, + ) + + async def set_org_model_config( + self, + organization_id: str, + config: ModelListConfig, + ) -> OkResponse: + return await self._patch( + self.api_base, + f"/admin/org/v1/models/{quote(organization_id)}", + body=config, + response_model=OkResponse, + ) + + async def create_project(self, request: ProjectCreate) -> ProjectRead: + return await self._post( + self.api_base, + "/admin/org/v1/projects", + body=request, + response_model=ProjectRead, + ) + + async def update_project(self, request: ProjectUpdate) -> ProjectRead: + return await self._patch( + self.api_base, + "/admin/org/v1/projects", + body=request, + response_model=ProjectRead, + ) + + async def set_project_updated_at( + self, + project_id: str, + updated_at: str | None = None, + ) -> OkResponse: + return await self._patch( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + body=None, + params=dict(updated_at=updated_at), + response_model=OkResponse, + ) + + async def list_projects( + self, + organization_id: str = "default", + search_query: str = "", + offset: int = 0, + limit: int = 100, + order_by: str = AdminOrderBy.UPDATED_AT, + order_descending: bool = True, + ) -> Page[ProjectRead]: + return await self._get( + self.api_base, + "/admin/org/v1/projects", + params=dict( + organization_id=organization_id, + search_query=search_query, + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + ), + response_model=Page[ProjectRead], + ) + + async def get_project(self, project_id: str) -> ProjectRead: + return await self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + params=None, + response_model=ProjectRead, + ) + + async def delete_project( + self, + project_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: + response = await self._delete( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def import_project( + self, + source: str | BinaryIO, + organization_id: str, + project_id_dst: str = "", + ) -> ProjectRead: + """ + Imports a project. + + Args: + source (str | BinaryIO): The parquet file path or file-like object. + It can be a Project or Template file. + organization_id (str): Organization ID "org_xxx". + project_id_dst (str, optional): ID of the project to import tables into. + Defaults to creating new project. + + Returns: + response (ProjectRead): The imported project. + """ + kwargs = dict( + address=self.api_base, + endpoint=f"/admin/org/v1/projects/import/{quote(organization_id)}", + body=None, + response_model=ProjectRead, + data={"project_id_dst": project_id_dst}, + timeout=self.file_upload_timeout, + ) + mime_type = "application/octet-stream" + if isinstance(source, str): + filename = split(source)[-1] + # Open the file in binary mode + with open(source, "rb") as f: + return await self._post(files={"file": (filename, f, mime_type)}, **kwargs) + else: + filename = "import.parquet" + return await self._post(files={"file": (filename, source, mime_type)}, **kwargs) + + async def export_project( + self, + project_id: str, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + ) -> bytes: + """ + Exports a project as a Project Parquet file. + + Args: + project_id (str): Project ID "proj_xxx". + compression (str, optional): Parquet compression codec. Defaults to "ZSTD". + + Returns: + response (bytes): The Parquet file. + """ + response = await self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}/export", + params=dict(compression=compression), + response_model=None, + ) + return response.content + + async def import_project_from_template( + self, + organization_id: str, + template_id: str, + project_id_dst: str = "", + ) -> ProjectRead: + """ + Imports a project from a template. + + Args: + organization_id (str): Organization ID "org_xxx". + template_id (str): ID of the template to import from. + project_id_dst (str, optional): ID of the project to import tables into. + Defaults to creating new project. + + Returns: + response (ProjectRead): The imported project. + """ + return await self._post( + self.api_base, + f"/admin/org/v1/projects/import/{quote(organization_id)}/templates/{quote(template_id)}", + body=None, + params=dict(project_id_dst=project_id_dst), + response_model=ProjectRead, + ) + + async def export_project_as_template( self, - table_type: str | TableType, + project_id: str, + *, + name: str, + tags: list[str], + description: str, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + ) -> bytes: + """ + Exports a project as a template Parquet file. + + Args: + project_id (str): Project ID "proj_xxx". + name (str): Template name. + tags (list[str]): Template tags. + description (str): Template description. + compression (str, optional): Parquet compression codec. Defaults to "ZSTD". + + Returns: + response (bytes): The template Parquet file. + """ + response = await self._get( + self.api_base, + f"/admin/org/v1/projects/{quote(project_id)}/export/template", + params=dict( + name=name, + tags=tags, + description=description, + compression=compression, + ), + response_model=None, + ) + return response.content + + +class _AdminClientAsync(_ClientAsync): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.backend = _BackendAdminClientAsync(*args, **kwargs) + self.organization = _OrgAdminClientAsync(*args, **kwargs) + + +class _TemplateClientAsync(_ClientAsync): + """Template methods.""" + + async def list_templates(self, search_query: str = "") -> Page[Template]: + """ + List all templates. + + Args: + search_query (str, optional): A string to search for within template names. + + Returns: + templates (Page[Template]): A page of templates. + """ + return await self._get( + self.api_base, + "/public/v1/templates", + params=dict(search_query=search_query), + response_model=Page[Template], + ) + + async def get_template(self, template_id: str) -> Template: + """ + Get a template by its ID. + + Args: + template_id (str): Template ID. + + Returns: + template (Template): The template. + """ + return await self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}", + params=None, + response_model=Template, + ) + + async def list_tables( + self, + template_id: str, + table_type: str, + *, offset: int = 0, limit: int = 100, - parent_id: str | None = None, search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, ) -> Page[TableMetaResponse]: """ - List Generative Tables of a specific type. + List all tables in a template. Args: - table_type (str | TableType): The type of the table. - offset (int, optional): Pagination offset. Defaults to 0. + template_id (str): Template ID. + table_type (str): Table type. + offset (int, optional): Item offset. Defaults to 0. limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. - parent_id (str | None, optional): Parent ID of tables to return. - Additionally for Chat Table, you can list: - (1) all chat agents by passing in "_agent_"; or - (2) all chats by passing in "_chat_". - Defaults to None (return all tables). search_query (str, optional): A string to search for within table IDs as a filter. Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. Returns: - response (Page[TableMetaResponse]): The paginated table metadata response. + tables (Page[TableMetaResponse]): A page of tables. """ - return self._get( + return await self._get( self.api_base, - f"/v1/gen_tables/{table_type}", + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}", params=dict( - offset=offset, limit=limit, parent_id=parent_id, search_query=search_query + offset=offset, + limit=limit, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, ), response_model=Page[TableMetaResponse], ) - def delete_table( + async def get_table( + self, template_id: str, table_type: str, table_id: str + ) -> TableMetaResponse: + """ + Get a table in a template. + + Args: + template_id (str): Template ID. + table_type (str): Table type. + table_id (str): Table ID. + + Returns: + table (TableMetaResponse): The table. + """ + return await self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}/{quote(table_id)}", + params=None, + response_model=TableMetaResponse, + ) + + async def list_table_rows( + self, + template_id: str, + table_type: str, + table_id: str, + *, + starting_after: str | None = None, + offset: int = 0, + limit: int = 100, + order_by: str = "Updated at", + order_descending: bool = True, + float_decimals: int = 0, + vec_decimals: int = 0, + ) -> Page[dict[str, Any]]: + """ + List rows in a template table. + + Args: + template_id (str): Template ID. + table_type (str): Table type. + table_id (str): Table ID. + starting_after (str | None, optional): A cursor for use in pagination. + Only rows with ID > `starting_after` will be returned. + For instance, if your call receives 100 rows ending with ID "x", + your subsequent call can include `starting_after="x"` in order to fetch the next page of the list. + Defaults to None. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. + order_by (str, optional): Sort rows by this column. Defaults to "Updated at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + float_decimals (int, optional): Number of decimals for float values. + Defaults to 0 (no rounding). + vec_decimals (int, optional): Number of decimals for vectors. + If its negative, exclude vector columns. Defaults to 0 (no rounding). + + Returns: + rows (Page[dict[str, Any]]): The rows. + """ + return await self._get( + self.api_base, + f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}/{quote(table_id)}/rows", + params=dict( + starting_after=starting_after, + offset=offset, + limit=limit, + order_by=order_by, + order_descending=order_descending, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=Page[dict[str, Any]], + ) + + +class _FileClientAsync(_ClientAsync): + """File methods.""" + + async def upload_file(self, file_path: str) -> FileUploadResponse: + """ + Uploads a file to the server. + + Args: + file_path (str): Path to the file to be uploaded. + + Returns: + response (FileUploadResponse): The response containing the file URI. + """ + filename = split(file_path)[-1] + mime_type = filetype.guess(file_path).mime + if mime_type is None: + mime_type = "application/octet-stream" # Default MIME type + + with open(file_path, "rb") as f: + return await self._post( + self.api_base, + "/v1/files/upload/", + body=None, + response_model=FileUploadResponse, + files={ + "file": (filename, f, mime_type), + }, + timeout=self.file_upload_timeout, + ) + + async def get_raw_urls(self, uris: list[str]) -> GetURLResponse: + """ + Get download URLs for raw files. + + Args: + uris (List[str]): List of file URIs to download. + + Returns: + response (GetURLResponse): The response containing download information for the files. + """ + return await self._post( + self.api_base, + "/v1/files/url/raw", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + ) + + async def get_thumbnail_urls(self, uris: list[str]) -> GetURLResponse: + """ + Get download URLs for file thumbnails. + + Args: + uris (List[str]): List of file URIs to get thumbnails for. + + Returns: + response (GetURLResponse): The response containing download information for the thumbnails. + """ + return await self._post( + self.api_base, + "/v1/files/url/thumb", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + ) + + +class _GenTableClientAsync(_ClientAsync): + """Generative Table methods.""" + + async def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: + """ + Create an Action Table. + + Args: + request (ActionTableSchemaCreate): The action table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return await self._post( + self.api_base, + "/v1/gen_tables/action", + body=request, + response_model=TableMetaResponse, + ) + + async def create_knowledge_table( + self, request: KnowledgeTableSchemaCreate + ) -> TableMetaResponse: + """ + Create a Knowledge Table. + + Args: + request (KnowledgeTableSchemaCreate): The knowledge table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return await self._post( + self.api_base, + "/v1/gen_tables/knowledge", + body=request, + response_model=TableMetaResponse, + ) + + async def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: + """ + Create a Chat Table. + + Args: + request (ChatTableSchemaCreate): The chat table schema. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return await self._post( + self.api_base, + "/v1/gen_tables/chat", + body=request, + response_model=TableMetaResponse, + ) + + async def get_table( self, table_type: str | TableType, table_id: str, - ) -> OkResponse: + ) -> TableMetaResponse: """ - Delete a specific table. + Get metadata for a specific Generative Table. Args: table_type (str | TableType): The type of the table. table_id (str): The ID of the table. Returns: - response (OkResponse): The response indicating success. + response (TableMetaResponse): The table metadata response. """ - return self._delete( + return await self._get( self.api_base, f"/v1/gen_tables/{table_type}/{quote(table_id)}", params=None, - response_model=OkResponse, + response_model=TableMetaResponse, ) - def duplicate_table( + async def list_tables( self, table_type: str | TableType, - table_id_src: str, - table_id_dst: str, - include_data: bool = True, - deploy: bool = False, - ) -> TableMetaResponse: + *, + offset: int = 0, + limit: int = 100, + parent_id: str | None = None, + search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + count_rows: bool = False, + ) -> Page[TableMetaResponse]: + """ + List Generative Tables of a specific type. + + Args: + table_type (str | TableType): The type of the table. + offset (int, optional): Item offset. Defaults to 0. + limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. + parent_id (str | None, optional): Parent ID of tables to return. + Additionally for Chat Table, you can list: + (1) all chat agents by passing in "_agent_"; or + (2) all chats by passing in "_chat_". + Defaults to None (return all tables). + search_query (str, optional): A string to search for within table IDs as a filter. + Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + count_rows (bool, optional): Whether to count the rows of the tables. Defaults to False. + + Returns: + response (Page[TableMetaResponse]): The paginated table metadata response. + """ + return await self._get( + self.api_base, + f"/v1/gen_tables/{table_type}", + params=dict( + offset=offset, + limit=limit, + parent_id=parent_id, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + count_rows=count_rows, + ), + response_model=Page[TableMetaResponse], + ) + + async def delete_table( + self, + table_type: str | TableType, + table_id: str, + *, + missing_ok: bool = True, + ) -> OkResponse: """ - Duplicate a table. + Delete a specific table. Args: table_type (str | TableType): The type of the table. - table_id_src (str): The source table ID. - table_id_dst (str): The destination / new table ID. - include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. - deploy (bool, optional): Whether to deploy the duplicated table. Defaults to False. + table_id (str): The ID of the table. + missing_ok (bool, optional): Ignore resource not found error. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return self._post( + response = await self._delete( self.api_base, - f"/v1/gen_tables/{table_type}/duplicate/{quote(table_id_src)}/{quote(table_id_dst)}", - request=None, - params=dict(include_data=include_data, deploy=deploy), - response_model=TableMetaResponse, + f"/v1/gen_tables/{table_type}/{quote(table_id)}", + params=None, + response_model=None, + ignore_code=404 if missing_ok else None, ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - def create_child_table( + async def duplicate_table( self, table_type: str | TableType, table_id_src: str, table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, ) -> TableMetaResponse: """ - Create a child table from a parent chat table. - Schema and existing rows are copied over from the parent. + Duplicate a table. Args: table_type (str | TableType): The type of the table. table_id_src (str): The source table ID. table_id_dst (str | None, optional): The destination / new table ID. - Will be generated if not provided. + Defaults to None (create a new table ID automatically). + include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. + create_as_child (bool, optional): Whether the new table is a child table. + If this is True, then `include_data` will be set to True. Defaults to False. Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + if "deploy" in kwargs: + warn( + 'The "deploy" argument is deprecated, use "create_as_child" instead.', + FutureWarning, + stacklevel=2, + ) + create_as_child = create_as_child or kwargs.pop("deploy") + return await self._post( self.api_base, - f"/v1/gen_tables/{table_type}/child/{table_id_src}", - request=None, - params=dict(table_id_dst=table_id_dst), + f"/v1/gen_tables/{table_type}/duplicate/{quote(table_id_src)}", + body=None, + params=dict( + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + ), response_model=TableMetaResponse, ) - def rename_table( + async def rename_table( self, table_type: str | TableType, table_id_src: str, @@ -648,14 +4263,14 @@ def rename_table( Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/rename/{quote(table_id_src)}/{quote(table_id_dst)}", - request=None, + body=None, response_model=TableMetaResponse, ) - def update_gen_config( + async def update_gen_config( self, table_type: str | TableType, request: GenConfigUpdateRequest, @@ -670,14 +4285,14 @@ def update_gen_config( Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/gen_config/update", - request=request, + body=request, response_model=TableMetaResponse, ) - def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: + async def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: """ Add columns to an Action Table. @@ -687,14 +4302,14 @@ def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaRespons Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, "/v1/gen_tables/action/columns/add", - request=request, + body=request, response_model=TableMetaResponse, ) - def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: + async def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: """ Add columns to a Knowledge Table. @@ -704,14 +4319,14 @@ def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaR Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, "/v1/gen_tables/knowledge/columns/add", - request=request, + body=request, response_model=TableMetaResponse, ) - def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: + async def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: """ Add columns to a Chat Table. @@ -721,14 +4336,14 @@ def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, "/v1/gen_tables/chat/columns/add", - request=request, + body=request, response_model=TableMetaResponse, ) - def drop_columns( + async def drop_columns( self, table_type: str | TableType, request: ColumnDropRequest, @@ -743,14 +4358,14 @@ def drop_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/columns/drop", - request=request, + body=request, response_model=TableMetaResponse, ) - def rename_columns( + async def rename_columns( self, table_type: str | TableType, request: ColumnRenameRequest, @@ -765,14 +4380,14 @@ def rename_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/columns/rename", - request=request, + body=request, response_model=TableMetaResponse, ) - def reorder_columns( + async def reorder_columns( self, table_type: str | TableType, request: ColumnReorderRequest, @@ -787,23 +4402,25 @@ def reorder_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/columns/reorder", - request=request, + body=request, response_model=TableMetaResponse, ) - def list_table_rows( + async def list_table_rows( self, table_type: str | TableType, table_id: str, + *, offset: int = 0, limit: int = 100, search_query: str = "", columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, + order_descending: bool = True, ) -> Page[dict[str, Any]]: """ List rows in a table. @@ -811,7 +4428,7 @@ def list_table_rows( Args: table_type (str | TableType): The type of the table. table_id (str): The ID of the table. - offset (int, optional): Pagination offset. Defaults to 0. + offset (int, optional): Item offset. Defaults to 0. limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. search_query (str, optional): A string to search for within the rows as a filter. Defaults to "" (no filter). @@ -821,11 +4438,9 @@ def list_table_rows( Defaults to 0 (no rounding). vec_decimals (int, optional): Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding). - - Returns: - response (Page[dict[str, Any]]): The paginated rows response. + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. """ - return self._get( + return await self._get( self.api_base, f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows", params=dict( @@ -835,11 +4450,12 @@ def list_table_rows( columns=columns, float_decimals=float_decimals, vec_decimals=vec_decimals, + order_descending=order_descending, ), response_model=Page[dict[str, Any]], ) - def get_table_row( + async def get_table_row( self, table_type: str | TableType, table_id: str, @@ -865,7 +4481,7 @@ def get_table_row( Returns: response (dict[str, Any]): The row data. """ - response = self._get( + response = await self._get( self.api_base, f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", params=dict( @@ -877,11 +4493,14 @@ def get_table_row( ) return json_loads(response.text) - def add_table_rows( + async def add_table_rows( self, table_type: str | TableType, request: RowAddRequest, - ) -> GenTableChatResponseType: + ) -> ( + GenTableRowsChatCompletionChunks + | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] + ): """ Add rows to a table. @@ -890,41 +4509,42 @@ def add_table_rows( request (RowAddRequest): The row add request. Returns: - response (GenTableChatResponseType): The row completion. - In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + response (GenTableRowsChatCompletionChunks | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `GenTableStreamReferences` object followed by zero or more `GenTableStreamChatCompletionChunk` objects. In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. """ if request.stream: - def gen(): - for chunk in self._stream( + async def gen(): + async for chunk in self._stream( self.api_base, f"/v1/gen_tables/{table_type}/rows/add", - request=request, + body=request, ): chunk = json_loads(chunk[5:]) if chunk["object"] == "gen_table.references": yield GenTableStreamReferences.model_validate(chunk) elif chunk["object"] == "gen_table.completion.chunk": yield GenTableStreamChatCompletionChunk.model_validate(chunk) - else: - raise RuntimeError(f"Unexpected SSE chunk: {chunk}") return gen() else: - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/rows/add", - request=request, + body=request, response_model=GenTableRowsChatCompletionChunks, ) - def regen_table_rows( + async def regen_table_rows( self, table_type: str | TableType, request: RowRegenRequest, - ) -> GenTableChatResponseType: + ) -> ( + GenTableRowsChatCompletionChunks + | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] + ): """ Regenerate rows in a table. @@ -933,37 +4553,35 @@ def regen_table_rows( request (RowRegenRequest): The row regenerate request. Returns: - response (GenTableChatResponseType): The row completion. - In streaming mode, it is a generator that yields a `GenTableStreamReferences` object + response (GenTableRowsChatCompletionChunks | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `GenTableStreamReferences` object followed by zero or more `GenTableStreamChatCompletionChunk` objects. In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. """ if request.stream: - def gen(): - for chunk in self._stream( + async def gen(): + async for chunk in self._stream( self.api_base, f"/v1/gen_tables/{table_type}/rows/regen", - request=request, + body=request, ): chunk = json_loads(chunk[5:]) if chunk["object"] == "gen_table.references": yield GenTableStreamReferences.model_validate(chunk) elif chunk["object"] == "gen_table.completion.chunk": yield GenTableStreamChatCompletionChunk.model_validate(chunk) - else: - raise RuntimeError(f"Unexpected SSE chunk: {chunk}") return gen() else: - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/rows/regen", - request=request, + body=request, response_model=GenTableRowsChatCompletionChunks, ) - def update_table_row( + async def update_table_row( self, table_type: str | TableType, request: RowUpdateRequest, @@ -978,14 +4596,14 @@ def update_table_row( Returns: response (OkResponse): The response indicating success. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/rows/update", - request=request, + body=request, response_model=OkResponse, ) - def delete_table_rows( + async def delete_table_rows( self, table_type: str | TableType, request: RowDeleteRequest, @@ -1000,14 +4618,14 @@ def delete_table_rows( Returns: response (OkResponse): The response indicating success. """ - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/rows/delete", - request=request, + body=request, response_model=OkResponse, ) - def delete_table_row( + async def delete_table_row( self, table_type: str | TableType, table_id: str, @@ -1024,16 +4642,19 @@ def delete_table_row( Returns: response (OkResponse): The response indicating success. """ - return self._delete( + return await self._delete( self.api_base, f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", params=None, response_model=OkResponse, ) - def get_conversation_thread( + async def get_conversation_thread( self, + table_type: str | TableType, table_id: str, + column_id: str, + *, row_id: str = "", include: bool = True, ) -> ChatThread: @@ -1041,21 +4662,25 @@ def get_conversation_thread( Get the conversation thread for a chat table. Args: - table_id (str): The ID of the chat table. - row_id (str, optional): Row ID for filtering. Defaults to "" (export all rows). - include (bool, optional): Whether to include the row specified by `row_id`. Defaults to True. + table_type (str | TableType): The type of the table. + table_id (str): ID / name of the chat table. + column_id (str): ID / name of the column to fetch. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. Returns: response (ChatThread): The conversation thread. """ - return self._get( + return await self._get( self.api_base, - f"/v1/gen_tables/chat/{quote(table_id)}/thread", - params=dict(row_id=row_id, include=include), + f"/v1/gen_tables/{table_type}/{quote(table_id)}/thread", + params=dict(column_id=column_id, row_id=row_id, include=include), response_model=ChatThread, ) - def hybrid_search( + async def hybrid_search( self, table_type: str | TableType, request: SearchRequest, @@ -1070,53 +4695,78 @@ def hybrid_search( Returns: response (list[dict[str, Any]]): The search results. """ - response = self._post( + response = await self._post( self.api_base, f"/v1/gen_tables/{table_type}/hybrid_search", - request=request, + body=request, response_model=None, ) return json_loads(response.text) - def upload_file(self, request: FileUploadRequest) -> OkResponse: + async def embed_file_options(self) -> httpx.Response: """ - Upload a file to a Knowledge Table. + Get options for embedding a file to a Knowledge Table. + + Returns: + response (httpx.Response): The response containing options information. + """ + response = await self._options( + self.api_base, + "/v1/gen_tables/knowledge/embed_file", + ) + return response + + async def embed_file( + self, + file_path: str, + table_id: str, + *, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> OkResponse: + """ + Embed a file into a Knowledge Table. Args: - request (FileUploadRequest): The file upload request. + file_path (str): File path of the document to be embedded. + table_id (str): Knowledge Table ID / name. + chunk_size (int, optional): Maximum chunk size (number of characters). Must be > 0. + Defaults to 1000. + chunk_overlap (int, optional): Overlap in characters between chunks. Must be >= 0. + Defaults to 200. Returns: response (OkResponse): The response indicating success. """ - file_path = request.file_path # Guess the MIME type of the file based on its extension mime_type, _ = guess_type(file_path) if mime_type is None: - mime_type = "application/octet-stream" # Default MIME type + mime_type = ( + "application/jsonl" if file_path.endswith(".jsonl") else "application/octet-stream" + ) # Default MIME type # Extract the filename from the file path filename = split(file_path)[-1] # Open the file in binary mode with open(file_path, "rb") as f: - response = self._post( + response = await self._post( self.api_base, - "/v1/gen_tables/knowledge/upload_file", - request=None, + "/v1/gen_tables/knowledge/embed_file", + body=None, response_model=OkResponse, files={ "file": (filename, f, mime_type), }, data={ - "file_name": filename, - "table_id": request.table_id, - "chunk_size": request.chunk_size, - "chunk_overlap": request.chunk_overlap, + "table_id": table_id, + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, # "overwrite": request.overwrite, }, - timeout=None, + timeout=self.file_upload_timeout, ) return response - def import_table_data( + async def import_table_data( self, table_type: str | TableType, request: TableDataImportRequest, @@ -1139,7 +4789,6 @@ def import_table_data( # Extract the filename from the file path filename = split(request.file_path)[-1] data = { - "file_name": filename, "table_id": request.table_id, "stream": request.stream, # "column_names": request.column_names, @@ -1148,18 +4797,18 @@ def import_table_data( } if request.stream: - def gen(): + async def gen(): # Open the file in binary mode with open(request.file_path, "rb") as f: - for chunk in self._stream( + async for chunk in self._stream( self.api_base, f"/v1/gen_tables/{table_type}/import_data", - request=None, + body=None, files={ "file": (filename, f, mime_type), }, data=data, - timeout=None, + timeout=self.file_upload_timeout, ): chunk = json_loads(chunk[5:]) if chunk["object"] == "gen_table.references": @@ -1173,285 +4822,183 @@ def gen(): else: # Open the file in binary mode with open(request.file_path, "rb") as f: - return self._post( + return await self._post( self.api_base, f"/v1/gen_tables/{table_type}/import_data", - request=None, - response_model=GenTableRowsChatCompletionChunks, - files={ - "file": (filename, f, mime_type), - }, - data=data, - timeout=None, - ) - - def export_table_data( - self, - table_type: str | TableType, - table_id: str, - columns: list[str] | None = None, - delimiter: str = ",", - ) -> bytes: - """ - Exports the row data of a table as a CSV or TSV file. - - Args: - table_type (str | TableType): Table type. - table_id (str): ID or name of the table to be exported. - delimiter (str, optional): The delimiter of the file: can be "," or "\\t". Defaults to ",". - columns (list[str], optional): A list of columns to be exported. Defaults to None (export all columns). - - Returns: - response (list[dict[str, Any]]): The search results. - """ - response = self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data", - params=dict(delimiter=delimiter, columns=columns), - response_model=None, - ) - return response.content - - -class JamAIAsync(JamAI): - def __init__( - self, - project_id: str = ENV_CONFIG.jamai_project_id, - api_key: str = ENV_CONFIG.jamai_api_key_plain, - api_base: str = ENV_CONFIG.jamai_api_base, - headers: dict | None = None, - timeout: float | None = None, - ) -> None: - """ - Initialize the JamAI asynchronous client. - - Args: - project_id (str, optional): The project ID. Defaults to "default". - api_key (str, optional): The API key for authentication. - Defaults to `JAMAI_API_KEY` var in environment or `.env` file. - api_base (str, optional): The base URL for the API. - Defaults to `JAMAI_API_BASE` var in environment or `.env` file. - headers (dict | None, optional): Additional headers to include in requests. - Defaults to None. - timeout (float | None, optional): The timeout to use when sending requests. - Defaults to None (no timeout). - """ - super().__init__( - project_id=project_id, - api_key=api_key, - api_base=api_base, - headers=headers, - ) - self.http_client = httpx.AsyncClient( - timeout=timeout, - transport=httpx.AsyncHTTPTransport(retries=3), - ) - - async def close(self) -> None: - """ - Close the HTTP async client. - """ - await self.http_client.aclose() - - async def _get( - self, - address: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - response_model: Type[BaseModel] | None = None, - **kwargs, - ) -> httpx.Response | BaseModel: - """ - Make an asynchronous GET request to the specified endpoint. - - Args: - address (str): The base address of the API. - endpoint (str): The API endpoint. - params (dict[str, Any] | None, optional): Query parameters. - response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. - **kwargs (Any): Keyword arguments for `httpx.get`. - - Returns: - response (httpx.Response | BaseModel): The response text or Pydantic response object. - """ - response = await self.http_client.get( - f"{address}{endpoint}", - params=self._filter_params(params), - headers=self.headers, - **kwargs, - ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) + body=None, + response_model=GenTableRowsChatCompletionChunks, + files={ + "file": (filename, f, mime_type), + }, + data=data, + timeout=self.file_upload_timeout, + ) - async def _post( + async def export_table_data( self, - address: str, - endpoint: str, + table_type: str | TableType, + table_id: str, *, - request: BaseModel | None, - response_model: Type[BaseModel] | None = None, - params: dict[str, Any] | None = None, - **kwargs, - ) -> httpx.Response | BaseModel: + columns: list[str] | None = None, + delimiter: Literal[",", "\t"] = ",", + ) -> bytes: """ - Make an asynchronous POST request to the specified endpoint. + Exports the row data of a table as a CSV or TSV file. Args: - address (str): The base address of the API. - endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. - response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. - params (dict[str, Any] | None, optional): Query parameters. Defaults to None. - **kwargs (Any): Keyword arguments for `httpx.post`. + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + delimiter (str, optional): The delimiter of the file: can be "," or "\\t". Defaults to ",". + columns (list[str], optional): A list of columns to be exported. Defaults to None (export all columns). Returns: - response (httpx.Response | BaseModel): The response text or Pydantic response object. + response (list[dict[str, Any]]): The search results. """ - if request is not None: - request = request.model_dump() - response = await self.http_client.post( - f"{address}{endpoint}", - json=request, - headers=self.headers, - params=self._filter_params(params), - **kwargs, + response = await self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data", + params=dict(delimiter=delimiter, columns=columns), + response_model=None, ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) + return response.content - async def _patch( + async def import_table( self, - address: str, - endpoint: str, - *, - request: BaseModel | None, - response_model: Type[BaseModel] | None = None, - params: dict[str, Any] | None = None, - **kwargs, - ) -> httpx.Response | BaseModel: + table_type: str | TableType, + request: TableImportRequest, + ) -> TableMetaResponse: """ - Make an asynchronous PATCH request to the specified endpoint. + Imports a table (data and schema) from a parquet file. Args: - address (str): The base address of the API. - endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. - response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. - params (dict[str, Any] | None, optional): Query parameters. Defaults to None. - **kwargs (Any): Keyword arguments for `httpx.patch`. + file_path (str): The parquet file path. + table_type (str | TableType): Table type. + request (TableImportRequest): Table import request. Returns: - response (httpx.Response | BaseModel): The response text or Pydantic response object. + response (TableMetaResponse): The table metadata response. """ - if request is not None: - request = request.model_dump() - response = await self.http_client.patch( - f"{address}{endpoint}", - json=request, - headers=self.headers, - params=self._filter_params(params), - **kwargs, - ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) + mime_type = "application/octet-stream" + filename = split(request.file_path)[-1] + data = {"table_id_dst": request.table_id_dst} + # Open the file in binary mode + with open(request.file_path, "rb") as f: + return await self._post( + self.api_base, + f"/v1/gen_tables/{table_type}/import", + body=None, + response_model=TableMetaResponse, + files={ + "file": (filename, f, mime_type), + }, + data=data, + timeout=self.file_upload_timeout, + ) - async def _stream( + async def export_table( self, - address: str, - endpoint: str, - *, - request: BaseModel | None, - params: dict[str, Any] | None = None, - **kwargs, - ) -> AsyncGenerator[str, None]: + table_type: str | TableType, + table_id: str, + ) -> bytes: """ - Make an asynchronous streaming POST request to the specified endpoint. + Exports a table (data and schema) as a parquet file. Args: - address (str): The base address of the API. - endpoint (str): The API endpoint. - request (BaseModel | None, optional): The request body. - params (dict[str, Any] | None, optional): Query parameters. Defaults to None. - **kwargs (Any): Keyword arguments for `httpx.stream`. + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. - Yields: - str: The response chunks. + Returns: + response (list[dict[str, Any]]): The search results. """ - if request is not None: - request = request.model_dump() - async with self.http_client.stream( - "POST", - f"{address}{endpoint}", - json=request, - headers=self.headers, - params=self._filter_params(params), - **kwargs, - ) as response: - response = self.raise_exception(response) - async for chunk in response.aiter_lines(): - chunk = chunk.strip() - if chunk == "" or chunk == "data: [DONE]": - continue - yield chunk + response = await self._get( + self.api_base, + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export", + params=None, + response_model=None, + ) + return response.content - async def _delete( + +class JamAIAsync(_ClientAsync): + def __init__( self, - address: str, - endpoint: str, + project_id: str = ENV_CONFIG.jamai_project_id, + token: str = ENV_CONFIG.jamai_token_plain, + api_base: str = ENV_CONFIG.jamai_api_base, + headers: dict | None = None, + timeout: float | None = ENV_CONFIG.jamai_timeout_sec, + file_upload_timeout: float | None = ENV_CONFIG.jamai_file_upload_timeout_sec, *, - params: dict[str, Any] | None = None, - response_model: Type[BaseModel] | None = None, - **kwargs, - ) -> httpx.Response | BaseModel: + api_key: str = "", + ) -> None: """ - Make an asynchronous DELETE request to the specified endpoint. + Initialize the JamAI client. Args: - address (str): The base address of the API. - endpoint (str): The API endpoint. - params (dict[str, Any] | None, optional): Query parameters. Defaults to None. - response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. - **kwargs (Any): Keyword arguments for `httpx.delete`. + project_id (str, optional): The project ID. + Defaults to "default", but can be overridden via + `JAMAI_PROJECT_ID` var in environment or `.env` file. + token (str, optional): Your Personal Access Token or organization API key (deprecated) for authentication. + Defaults to "", but can be overridden via + `JAMAI_TOKEN` var in environment or `.env` file. + api_base (str, optional): The base URL for the API. + Defaults to "https://api.jamaibase.com/api", but can be overridden via + `JAMAI_API_BASE` var in environment or `.env` file. + headers (dict | None, optional): Additional headers to include in requests. + Defaults to None. + timeout (float | None, optional): The timeout to use when sending requests. + Defaults to 15 minutes, but can be overridden via + `JAMAI_TIMEOUT_SEC` var in environment or `.env` file. + file_upload_timeout (float | None, optional): The timeout to use when sending file upload requests. + Defaults to 60 minutes, but can be overridden via + `JAMAI_FILE_UPLOAD_TIMEOUT_SEC` var in environment or `.env` file. + api_key (str, optional): (Deprecated) Organization API key for authentication. + """ + if api_key: + warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) + http_client = httpx.AsyncClient( + timeout=timeout, + transport=httpx.AsyncHTTPTransport(retries=3), + ) + kwargs = dict( + project_id=project_id, + token=token or api_key, + api_base=api_base, + headers=headers, + http_client=http_client, + file_upload_timeout=file_upload_timeout, + ) + super().__init__(**kwargs) + self.admin = _AdminClientAsync(**kwargs) + self.template = _TemplateClientAsync(**kwargs) + self.file = _FileClientAsync(**kwargs) + self.table = _GenTableClientAsync(**kwargs) + + async def health(self) -> dict[str, Any]: + """ + Get health status. Returns: - response (httpx.Response | BaseModel): The response text or Pydantic response object. + response (dict[str, Any]): Health status. """ - response = await self.http_client.delete( - f"{address}{endpoint}", - params=self._filter_params(params), - headers=self.headers, - **kwargs, - ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) + response = await self._get(self.api_base, "/health", response_model=None) + return json_loads(response.text) # --- Models and chat --- # async def model_info( self, name: str = "", - capabilities: list[str] | None = None, + capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + | None = None, ) -> ModelInfoResponse: """ - Get information about available models asynchronously. + Get information about available models. Args: name (str, optional): The model name. Defaults to "". - capabilities (list[str] | None, optional): List of model capabilities to filter by. - Defaults to None. + capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. Returns: response (ModelInfoResponse): The model information response. @@ -1467,14 +5014,16 @@ async def model_info( async def model_names( self, prefer: str = "", - capabilities: list[str] | None = None, + capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] + | None = None, ) -> list[str]: """ - Get the names of available models asynchronously. + Get the names of available models. Args: prefer (str, optional): Preferred model name. Defaults to "". - capabilities (list[str] | None, optional): List of model capabilities to filter by. + capabilities (list[Literal["completion", "chat", "image", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. Returns: response (list[str]): List of model names. @@ -1492,7 +5041,7 @@ async def generate_chat_completions( self, request: ChatRequest ) -> ChatCompletionChunk | AsyncGenerator[References | ChatCompletionChunk, None]: """ - Generates chat completions asynchronously. + Generates chat completions. Args: request (ChatRequest): The request. @@ -1507,7 +5056,7 @@ async def generate_chat_completions( async def gen(): async for chunk in self._stream( - self.api_base, "/v1/chat/completions", request=request + self.api_base, "/v1/chat/completions", body=request ): chunk = json_loads(chunk[5:]) if chunk["object"] == "chat.references": @@ -1520,13 +5069,13 @@ async def gen(): return await self._post( self.api_base, "/v1/chat/completions", - request=request, + body=request, response_model=ChatCompletionChunk, ) async def generate_embeddings(self, request: EmbeddingRequest) -> EmbeddingResponse: """ - Generate embeddings for the given input asynchronously. + Generate embeddings for the given input. Args: request (EmbeddingRequest): The embedding request. @@ -1537,14 +5086,15 @@ async def generate_embeddings(self, request: EmbeddingRequest) -> EmbeddingRespo return await self._post( self.api_base, "/v1/embeddings", - request=request, + body=request, response_model=EmbeddingResponse, ) # --- Gen Table --- # + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: """ - Create an Action Table asynchronously. + Create an Action Table. Args: request (ActionTableSchemaCreate): The action table schema. @@ -1552,18 +5102,14 @@ async def create_action_table(self, request: ActionTableSchemaCreate) -> TableMe Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/action", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.create_action_table(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def create_knowledge_table( self, request: KnowledgeTableSchemaCreate ) -> TableMetaResponse: """ - Create a Knowledge Table asynchronously. + Create a Knowledge Table. Args: request (KnowledgeTableSchemaCreate): The knowledge table schema. @@ -1571,16 +5117,12 @@ async def create_knowledge_table( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/knowledge", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.create_knowledge_table(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: """ - Create a Chat Table asynchronously. + Create a Chat Table. Args: request (ChatTableSchemaCreate): The chat table schema. @@ -1588,20 +5130,16 @@ async def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaRe Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/chat", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.create_chat_table(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def get_table( self, table_type: str | TableType, table_id: str, ) -> TableMetaResponse: """ - Get metadata for a specific Generative Table asynchronously. + Get metadata for a specific Generative Table. Args: table_type (str | TableType): The type of the table. @@ -1610,13 +5148,9 @@ async def get_table( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}", - params=None, - response_model=TableMetaResponse, - ) + return await self.table.get_table(table_type, table_id) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def list_tables( self, table_type: str | TableType, @@ -1624,13 +5158,16 @@ async def list_tables( limit: int = 100, parent_id: str | None = None, search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + count_rows: bool = False, ) -> Page[TableMetaResponse]: """ - List Generative Tables of a specific type asynchronously. + List Generative Tables of a specific type. Args: table_type (str | TableType): The type of the table. - offset (int, optional): Pagination offset. Defaults to 0. + offset (int, optional): Item offset. Defaults to 0. limit (int, optional): Number of tables to return (min 1, max 100). Defaults to 100. parent_id (str | None, optional): Parent ID of tables to return. Additionally for Chat Table, you can list: @@ -1639,98 +5176,81 @@ async def list_tables( Defaults to None (return all tables). search_query (str, optional): A string to search for within table IDs as a filter. Defaults to "" (no filter). + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + count_rows (bool, optional): Whether to count the rows of the tables. Defaults to False. Returns: response (Page[TableMetaResponse]): The paginated table metadata response. """ - return await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}", - params=dict( - offset=offset, limit=limit, parent_id=parent_id, search_query=search_query - ), - response_model=Page[TableMetaResponse], + return await self.table.list_tables( + table_type, + offset=offset, + limit=limit, + parent_id=parent_id, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + count_rows=count_rows, ) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def delete_table( self, table_type: str | TableType, table_id: str, + *, + missing_ok: bool = True, ) -> OkResponse: """ - Delete a specific table asynchronously. + Delete a specific table. Args: table_type (str | TableType): The type of the table. table_id (str): The ID of the table. + missing_ok (bool, optional): Ignore resource not found error. Returns: response (OkResponse): The response indicating success. """ - return await self._delete( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}", - params=None, - response_model=OkResponse, - ) + return await self.table.delete_table(table_type, table_id, missing_ok=missing_ok) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def duplicate_table( - self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str, - include_data: bool = True, - deploy: bool = False, - ) -> TableMetaResponse: - """ - Duplicate a table asynchronously. - - Args: - table_type (str | TableType): The type of the table. - table_id_src (str): The source table ID. - table_id_dst (str): The destination / new table ID. - include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. - deploy (bool, optional): Whether to deploy the duplicated table. Defaults to False. - - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/duplicate/{quote(table_id_src)}/{quote(table_id_dst)}", - request=None, - params=dict(include_data=include_data, deploy=deploy), - response_model=TableMetaResponse, - ) - - async def create_child_table( self, table_type: str | TableType, table_id_src: str, table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, ) -> TableMetaResponse: """ - Create a child table from a parent chat table. - Schema and existing rows are copied over from the parent. + Duplicate a table. Args: table_type (str | TableType): The type of the table. table_id_src (str): The source table ID. table_id_dst (str | None, optional): The destination / new table ID. - Will be generated if not provided. + Defaults to None (create a new table ID automatically). + include_data (bool, optional): Whether to include data in the duplicated table. Defaults to True. + create_as_child (bool, optional): Whether the new table is a child table. + If this is True, then `include_data` will be set to True. Defaults to False. Returns: response (TableMetaResponse): The table metadata response. """ - - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/child/{table_id_src}", - request=None, - params=dict(table_id_dst=table_id_dst), - response_model=TableMetaResponse, + return await self.table.duplicate_table( + table_type, + table_id_src, + table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + **kwargs, ) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def rename_table( self, table_type: str | TableType, @@ -1738,30 +5258,26 @@ async def rename_table( table_id_dst: str, ) -> TableMetaResponse: """ - Rename a table asynchronously. + Rename a table. Args: table_type (str | TableType): The type of the table. table_id_src (str): The source table ID. table_id_dst (str): The destination / new table ID. - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rename/{quote(table_id_src)}/{quote(table_id_dst)}", - request=None, - response_model=TableMetaResponse, - ) + Returns: + response (TableMetaResponse): The table metadata response. + """ + return await self.table.rename_table(table_type, table_id_src, table_id_dst) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def update_gen_config( self, table_type: str | TableType, request: GenConfigUpdateRequest, ) -> TableMetaResponse: """ - Update the generation configuration for a table asynchronously. + Update the generation configuration for a table. Args: table_type (str | TableType): The type of the table. @@ -1770,16 +5286,12 @@ async def update_gen_config( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/gen_config/update", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.update_gen_config(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: """ - Add columns to an Action Table asynchronously. + Add columns to an Action Table. Args: request (AddActionColumnSchema): The action column schema. @@ -1787,16 +5299,12 @@ async def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaR Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/action/columns/add", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.add_action_columns(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: """ - Add columns to a Knowledge Table asynchronously. + Add columns to a Knowledge Table. Args: request (AddKnowledgeColumnSchema): The knowledge column schema. @@ -1804,16 +5312,12 @@ async def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> Tabl Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/knowledge/columns/add", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.add_knowledge_columns(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: """ - Add columns to a Chat Table asynchronously. + Add columns to a Chat Table. Args: request (AddChatColumnSchema): The chat column schema. @@ -1821,20 +5325,16 @@ async def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaRespo Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/gen_tables/chat/columns/add", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.add_chat_columns(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def drop_columns( self, table_type: str | TableType, request: ColumnDropRequest, ) -> TableMetaResponse: """ - Drop columns from a table asynchronously. + Drop columns from a table. Args: table_type (str | TableType): The type of the table. @@ -1843,20 +5343,16 @@ async def drop_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/drop", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.drop_columns(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def rename_columns( self, table_type: str | TableType, request: ColumnRenameRequest, ) -> TableMetaResponse: """ - Rename columns in a table asynchronously. + Rename columns in a table. Args: table_type (str | TableType): The type of the table. @@ -1865,20 +5361,16 @@ async def rename_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/rename", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.rename_columns(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def reorder_columns( self, table_type: str | TableType, request: ColumnReorderRequest, ) -> TableMetaResponse: """ - Reorder columns in a table asynchronously. + Reorder columns in a table. Args: table_type (str | TableType): The type of the table. @@ -1887,31 +5379,29 @@ async def reorder_columns( Returns: response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/reorder", - request=request, - response_model=TableMetaResponse, - ) + return await self.table.reorder_columns(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def list_table_rows( self, table_type: str | TableType, table_id: str, + *, offset: int = 0, limit: int = 100, search_query: str = "", columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, + order_descending: bool = True, ) -> Page[dict[str, Any]]: """ - List rows in a table asynchronously. + List rows in a table. Args: table_type (str | TableType): The type of the table. table_id (str): The ID of the table. - offset (int, optional): Pagination offset. Defaults to 0. + offset (int, optional): Item offset. Defaults to 0. limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. search_query (str, optional): A string to search for within the rows as a filter. Defaults to "" (no filter). @@ -1921,32 +5411,33 @@ async def list_table_rows( Defaults to 0 (no rounding). vec_decimals (int, optional): Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding). - """ - return await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows", - params=dict( - offset=offset, - limit=limit, - search_query=search_query, - columns=columns, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ), - response_model=Page[dict[str, Any]], + order_descending (bool, optional): Whether to sort by descending order. Defaults to True. + """ + return await self.table.list_table_rows( + table_type, + table_id, + offset=offset, + limit=limit, + search_query=search_query, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + order_descending=order_descending, ) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def get_table_row( self, table_type: str | TableType, table_id: str, row_id: str, + *, columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, ) -> dict[str, Any]: """ - Get a specific row in a table asynchronously. + Get a specific row in a table. Args: table_type (str | TableType): The type of the table. @@ -1962,18 +5453,16 @@ async def get_table_row( Returns: response (dict[str, Any]): The row data. """ - response = await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", - params=dict( - columns=columns, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ), - response_model=None, + return await self.table.get_table_row( + table_type, + table_id, + row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, ) - return json_loads(response.text) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def add_table_rows( self, table_type: str | TableType, @@ -1983,7 +5472,7 @@ async def add_table_rows( | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] ): """ - Add rows to a table asynchronously. + Add rows to a table. Args: table_type (str | TableType): The type of the table. @@ -1995,29 +5484,9 @@ async def add_table_rows( followed by zero or more `GenTableStreamChatCompletionChunk` objects. In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. """ - if request.stream: - - async def gen(): - async for chunk in self._stream( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/add", - request=request, - ): - chunk = json_loads(chunk[5:]) - if chunk["object"] == "gen_table.references": - yield GenTableStreamReferences.model_validate(chunk) - elif chunk["object"] == "gen_table.completion.chunk": - yield GenTableStreamChatCompletionChunk.model_validate(chunk) - - return gen() - else: - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/add", - request=request, - response_model=GenTableRowsChatCompletionChunks, - ) + return await self.table.add_table_rows(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def regen_table_rows( self, table_type: str | TableType, @@ -2027,7 +5496,7 @@ async def regen_table_rows( | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] ): """ - Regenerate rows in a table asynchronously. + Regenerate rows in a table. Args: table_type (str | TableType): The type of the table. @@ -2039,36 +5508,16 @@ async def regen_table_rows( followed by zero or more `GenTableStreamChatCompletionChunk` objects. In non-streaming mode, it is a `GenTableRowsChatCompletionChunks` object. """ - if request.stream: - - async def gen(): - async for chunk in self._stream( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/regen", - request=request, - ): - chunk = json_loads(chunk[5:]) - if chunk["object"] == "gen_table.references": - yield GenTableStreamReferences.model_validate(chunk) - elif chunk["object"] == "gen_table.completion.chunk": - yield GenTableStreamChatCompletionChunk.model_validate(chunk) - - return gen() - else: - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/regen", - request=request, - response_model=GenTableRowsChatCompletionChunks, - ) + return await self.table.regen_table_rows(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def update_table_row( self, table_type: str | TableType, request: RowUpdateRequest, ) -> OkResponse: """ - Update a specific row in a table asynchronously. + Update a specific row in a table. Args: table_type (str | TableType): The type of the table. @@ -2077,20 +5526,16 @@ async def update_table_row( Returns: response (OkResponse): The response indicating success. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/update", - request=request, - response_model=OkResponse, - ) + return await self.table.update_table_row(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def delete_table_rows( self, table_type: str | TableType, request: RowDeleteRequest, ) -> OkResponse: """ - Delete rows from a table asynchronously. + Delete rows from a table. Args: table_type (str | TableType): The type of the table. @@ -2099,13 +5544,9 @@ async def delete_table_rows( Returns: response (OkResponse): The response indicating success. """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/delete", - request=request, - response_model=OkResponse, - ) + return await self.table.delete_table_rows(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def delete_table_row( self, table_type: str | TableType, @@ -2113,7 +5554,7 @@ async def delete_table_row( row_id: str, ) -> OkResponse: """ - Delete a specific row from a table asynchronously. + Delete a specific row from a table. Args: table_type (str | TableType): The type of the table. @@ -2123,37 +5564,44 @@ async def delete_table_row( Returns: response (OkResponse): The response indicating success. """ - return await self._delete( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", - params=None, - response_model=OkResponse, - ) + return await self.table.delete_table_row(table_type, table_id, row_id) - async def get_conversation_thread(self, table_id: str) -> ChatThread: + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + async def get_conversation_thread( + self, + table_type: str | TableType, + table_id: str, + column_id: str, + row_id: str = "", + include: bool = True, + ) -> ChatThread: """ - Get the conversation thread for a chat table asynchronously. + Get the conversation thread for a chat table. Args: - table_id (str): The ID of the chat table. + table_type (str | TableType): The type of the table. + table_id (str): ID / name of the chat table. + column_id (str): ID / name of the column to fetch. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. Returns: response (ChatThread): The conversation thread. """ - return await self._get( - self.api_base, - f"/v1/gen_tables/chat/{quote(table_id)}/thread", - params=None, - response_model=ChatThread, + return await self.table.get_conversation_thread( + table_type, table_id, column_id, row_id=row_id, include=include ) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def hybrid_search( self, table_type: str | TableType, request: SearchRequest, ) -> list[dict[str, Any]]: """ - Perform a hybrid search on a table asynchronously. + Perform a hybrid search on a table. Args: table_type (str | TableType): The type of the table. @@ -2162,17 +5610,30 @@ async def hybrid_search( Returns: response (list[dict[str, Any]]): The search results. """ - response = await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/hybrid_search", - request=request, - response_model=None, - ) - return json_loads(response.text) + return await self.table.hybrid_search(table_type, request) + @deprecated( + "This method is deprecated, use `client.table.embed_file_options` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def upload_file_options(self) -> httpx.Response: + """ + Get options for uploading a file to a Knowledge Table. + + Returns: + response (httpx.Response): The response containing options information. + """ + return await self.table.embed_file_options() + + @deprecated( + "This method is deprecated, use `client.table.embed_file` instead.", + category=FutureWarning, + stacklevel=1, + ) async def upload_file(self, request: FileUploadRequest) -> OkResponse: """ - Upload a file to a Knowledge Table asynchronously. + Upload a file to a Knowledge Table. Args: request (FileUploadRequest): The file upload request. @@ -2180,34 +5641,9 @@ async def upload_file(self, request: FileUploadRequest) -> OkResponse: Returns: response (OkResponse): The response indicating success. """ - file_path = request.file_path - # Guess the MIME type of the file based on its extension - mime_type, _ = guess_type(file_path) - if mime_type is None: - mime_type = "application/octet-stream" # Default MIME type - # Extract the filename from the file path - filename = split(file_path)[-1] - # Open the file in binary mode - with open(file_path, "rb") as f: - response = await self._post( - self.api_base, - "/v1/gen_tables/knowledge/upload_file", - request=None, - response_model=OkResponse, - files={ - "file": (filename, f, mime_type), - }, - data={ - "file_name": filename, - "table_id": request.table_id, - "chunk_size": request.chunk_size, - "chunk_overlap": request.chunk_overlap, - # "overwrite": request.overwrite, - }, - timeout=None, - ) - return response + return await self.table.embed_file(request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def import_table_data( self, table_type: str | TableType, @@ -2224,65 +5660,15 @@ async def import_table_data( Returns: response (OkResponse): The response indicating success. """ - # Guess the MIME type of the file based on its extension - mime_type, _ = guess_type(request.file_path) - if mime_type is None: - mime_type = "application/octet-stream" # Default MIME type - # Extract the filename from the file path - filename = split(request.file_path)[-1] - data = { - "file_name": filename, - "table_id": request.table_id, - "stream": request.stream, - # "column_names": request.column_names, - # "columns": request.columns, - "delimiter": request.delimiter, - } - if request.stream: - - async def gen(): - # Open the file in binary mode - with open(request.file_path, "rb") as f: - async for chunk in self._stream( - self.api_base, - f"/v1/gen_tables/{table_type}/import_data", - request=None, - files={ - "file": (filename, f, mime_type), - }, - data=data, - timeout=None, - ): - chunk = json_loads(chunk[5:]) - if chunk["object"] == "gen_table.references": - yield GenTableStreamReferences.model_validate(chunk) - elif chunk["object"] == "gen_table.completion.chunk": - yield GenTableStreamChatCompletionChunk.model_validate(chunk) - else: - raise RuntimeError(f"Unexpected SSE chunk: {chunk}") - - return gen() - else: - # Open the file in binary mode - with open(request.file_path, "rb") as f: - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/import_data", - request=None, - response_model=GenTableRowsChatCompletionChunks, - files={ - "file": (filename, f, mime_type), - }, - data=data, - timeout=None, - ) + return await self.table.import_table_data(table_type, request) + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) async def export_table_data( self, table_type: str | TableType, table_id: str, columns: list[str] | None = None, - delimiter: str = ",", + delimiter: Literal[",", "\t"] = ",", ) -> bytes: """ Exports the row data of a table as a CSV or TSV file. @@ -2296,10 +5682,43 @@ async def export_table_data( Returns: response (list[dict[str, Any]]): The search results. """ - response = await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data", - params=dict(delimiter=delimiter, columns=columns), - response_model=None, + return await self.table.export_table_data( + table_type, table_id, columns=columns, delimiter=delimiter ) - return response.content + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + async def import_table( + self, + table_type: str | TableType, + request: TableImportRequest, + ) -> TableMetaResponse: + """ + Imports a table (data and schema) from a parquet file. + + Args: + file_path (str): The parquet file path. + table_type (str | TableType): Table type. + request (TableImportRequest): Table import request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + return await self.table.import_table(table_type, request) + + @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) + async def export_table( + self, + table_type: str | TableType, + table_id: str, + ) -> bytes: + """ + Exports a table (data and schema) as a parquet file. + + Args: + table_type (str | TableType): Table type. + table_id (str): ID or name of the table to be exported. + + Returns: + response (list[dict[str, Any]]): The search results. + """ + return await self.table.export_table(table_type, table_id) diff --git a/clients/python/src/jamaibase/exceptions.py b/clients/python/src/jamaibase/exceptions.py new file mode 100644 index 0000000..755ebc3 --- /dev/null +++ b/clients/python/src/jamaibase/exceptions.py @@ -0,0 +1,119 @@ +import functools +from typing import Any + +from pydantic import ValidationError +from pydantic_core import InitErrorDetails + + +def docstring_message(cls): + """ + Decorates an exception to make its docstring its default message. + https://stackoverflow.com/a/66491013 + """ + # Must use cls_init name, not cls.__init__ itself, in closure to avoid recursion + cls_init = cls.__init__ + + @functools.wraps(cls.__init__) + def wrapped_init(self, msg=cls.__doc__, *args, **kwargs): + cls_init(self, msg, *args, **kwargs) + + cls.__init__ = wrapped_init + return cls + + +def make_validation_error( + exception: Exception, + *, + object_name: str = "", + loc: tuple = (), + input_value: Any = None, +) -> ValidationError: + return ValidationError.from_exception_data( + object_name, + line_errors=[ + InitErrorDetails( + type="value_error", + loc=loc, + input=input_value, + ctx={"error": exception}, + ) + ], + ) + + +@docstring_message +class JamaiException(RuntimeError): + """Base exception class for JamAIBase errors.""" + + pass + + +@docstring_message +class AuthorizationError(JamaiException): + """You do not have the correct credentials.""" + + +@docstring_message +class ExternalAuthError(JamaiException): + """Authentication with external provider failed.""" + + +@docstring_message +class ForbiddenError(JamaiException): + """You do not have access to this resource.""" + + +@docstring_message +class UpgradeTierError(JamaiException): + """You have exhausted the allocations of your subscribed tier. Please upgrade.""" + + +@docstring_message +class InsufficientCreditsError(JamaiException): + """Please ensure that you have sufficient credits.""" + + +@docstring_message +class ResourceNotFoundError(JamaiException): + """Resource with the specified name is not found.""" + + +@docstring_message +class ResourceExistsError(JamaiException): + """Resource with the specified name already exists.""" + + +@docstring_message +class UnsupportedMediaTypeError(JamaiException): + """This file type is unsupported.""" + + pass + + +@docstring_message +class BadInputError(JamaiException): + """Your input is invalid.""" + + +@docstring_message +class TableSchemaFixedError(JamaiException): + """Table schema cannot be modified.""" + + +@docstring_message +class ContextOverflowError(JamaiException): + """Model's context length has been exceeded.""" + + +@docstring_message +class UnexpectedError(JamaiException): + """We ran into an unexpected error.""" + + pass + + +@docstring_message +class ServerBusyError(JamaiException): + """The server is busy.""" + + pass diff --git a/clients/python/src/jamaibase/protocol.py b/clients/python/src/jamaibase/protocol.py index 260d868..125f548 100644 --- a/clients/python/src/jamaibase/protocol.py +++ b/clients/python/src/jamaibase/protocol.py @@ -11,14 +11,29 @@ from __future__ import annotations import re -from datetime import datetime, timezone +from datetime import datetime +from decimal import Decimal from enum import Enum, EnumMeta -from typing import Annotated, Any, Generic, Literal, Sequence, TypeVar +from os.path import splitext +from typing import Annotated, Any, Generic, Literal, Sequence, TypeVar, Union +from warnings import warn import numpy as np -from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Discriminator, + Field, + Tag, + computed_field, + field_validator, + model_validator, +) from pydantic.functional_validators import AfterValidator -from typing_extensions import Self +from typing_extensions import Self, deprecated + +from jamaibase.utils import datetime_now_iso +from jamaibase.version import __version__ as jamaibase_version PositiveInt = Annotated[int, Field(ge=0, description="Positive integer.")] PositiveNonZeroInt = Annotated[int, Field(gt=0, description="Positive non-zero integer.")] @@ -37,26 +52,556 @@ 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" + +# 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" + +# 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" + +IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] +DOCUMENT_FILE_EXTENSIONS = [ + ".pdf", + ".txt", + ".md", + ".docx", + ".xml", + ".html", + ".json", + ".csv", + ".tsv", + ".jsonl", + ".xlsx", + ".xls", +] + class OkResponse(BaseModel): ok: bool = True -class Document(BaseModel): - """Document class for use in DocIO.""" +class StringResponse(BaseModel): + object: Literal["string"] = Field( + default="string", + description='The object type, which is always "string".', + examples=["string"], + ) + data: str = Field( + description="The string data.", + examples=["text"], + ) + + +class AdminOrderBy(str, Enum): + ID = "id" + """Sort by `id` column.""" + NAME = "name" + """Sort by `name` column.""" + CREATED_AT = "created_at" + """Sort by `created_at` column.""" + UPDATED_AT = "updated_at" + """Sort by `updated_at` column.""" + + def __str__(self) -> str: + return self.value + + +class GenTableOrderBy(str, Enum): + ID = "id" + """Sort by `id` column.""" + UPDATED_AT = "updated_at" + """Sort by `updated_at` column.""" + + def __str__(self) -> str: + return self.value + + +class Tier(BaseModel): + """ + https://docs.stripe.com/api/prices/object#price_object-tiers + """ + + unit_amount_decimal: Decimal = Field( + description="Per unit price for units relevant to the tier.", + ) + up_to: float | None = Field( + description=( + "Up to and including to this quantity will be contained in the tier. " + "None means infinite quantity." + ), + ) + + +class Product(BaseModel): + name: str = Field( + min_length=1, + description="Plan name.", + ) + included: Tier = Tier(unit_amount_decimal=0, up_to=0) + tiers: list[Tier] + unit: str = Field( + description="Unit of measurement.", + ) + + +class Plan(BaseModel): + name: str + stripe_price_id_live: str + stripe_price_id_test: str + flat_amount_decimal: Decimal = Field( + description="Base price for the entire tier.", + ) + credit_grant: float = Field( + description="Credit amount included in USD.", + ) + max_users: int = Field( + description="Maximum number of users per organization.", + ) + products: dict[str, Product] = Field( + description="Mapping of price name to tier list where each element represents a pricing tier.", + ) + + +class Price(BaseModel): + plans: dict[str, Plan] = Field( + description="Mapping of price plan name to price plan.", + ) + + +class _ModelPrice(BaseModel): + id: str = Field( + description=( + '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], + ) + name: str = Field( + description="Name of the model.", + examples=["OpenAI GPT-4o Mini"], + ) + + +class LLMModelPrice(_ModelPrice): + input_cost_per_mtoken: float = Field( + description="Cost in USD per million (mega) input / prompt token.", + ) + output_cost_per_mtoken: float = Field( + description="Cost in USD per million (mega) output / completion token.", + ) + + +class EmbeddingModelPrice(_ModelPrice): + cost_per_mtoken: float = Field( + description="Cost in USD per million embedding tokens.", + ) + + +class RerankingModelPrice(_ModelPrice): + cost_per_ksearch: float = Field(description="Cost in USD for a thousand searches.") + + +class ModelPrice(BaseModel): + object: str = Field( + default="prices.models", + description="Type of API response object.", + examples=["prices.models"], + ) + llm_models: list[LLMModelPrice] = [] + embed_models: list[EmbeddingModelPrice] = [] + rerank_models: list[RerankingModelPrice] = [] + + +class _OrgMemberBase(BaseModel): + user_id: str = Field(description="User ID. Must be unique.") + organization_id: str = Field( + default="", + description="Organization ID. Must be unique.", + ) + role: Literal["admin", "member", "guest"] = "admin" + """User role.""" + + +class OrgMemberCreate(_OrgMemberBase): + invite_token: str = Field( + default="", + description="User-org link creation datetime (ISO 8601 UTC).", + ) + + +class OrgMemberRead(_OrgMemberBase): + created_at: str = Field( + description="User-org link creation datetime (ISO 8601 UTC).", + ) + updated_at: str = Field( + description="User-org link update datetime (ISO 8601 UTC).", + ) + organization_name: str = "" + """Organization name. To be populated later.""" + + +class UserUpdate(BaseModel): + id: str + """User ID. Must be unique.""" + name: str | None = None + """The user's full name or business name.""" + description: str | None = None + """An arbitrary string that you can attach to a customer object.""" + email: Annotated[str, Field(min_length=1, max_length=512)] | None = None + """User's email address. This may be up to 512 characters.""" + meta: dict | None = None + """ + Additional metadata about the user. + """ + + +class UserCreate(BaseModel): + id: str + """User ID. Must be unique.""" + name: str + """The user's full name or business name.""" + description: str = "" + """An arbitrary string that you can attach to a customer object.""" + email: Annotated[str, Field(min_length=1, max_length=512)] + """User's email address. This may be up to 512 characters.""" + meta: dict = {} + """ + Additional metadata about the user. + """ + + +class UserRead(UserCreate): + created_at: str = Field(description="User creation datetime (ISO 8601 UTC).") + updated_at: str = Field(description="User update datetime (ISO 8601 UTC).") + member_of: list[OrgMemberRead] + """List of organizations that this user is associated with and their role.""" + + +class PATCreate(BaseModel): + user_id: str = Field(description="User ID.") + expiry: str = Field( + default="", + description="PAT expiry datetime (ISO 8601 UTC). If empty, never expires.", + ) + + +class PATRead(PATCreate): + id: str = Field(description="The token.") + created_at: str = Field(description="Creation datetime (ISO 8601 UTC).") + # user: UserRead = Field(description="User that this Personal Access Token is associated with.") + + +class ProjectCreate(BaseModel): + name: str = Field( + description="Project name.", + ) + organization_id: str = Field( + description="Organization ID.", + ) + + +class ProjectUpdate(BaseModel): + id: str + """Project ID.""" + name: str | None = Field( + default=None, + description="Project name.", + ) + + +class ProjectRead(ProjectCreate): + id: str = Field( + description="Project ID.", + ) + created_at: str = Field( + description="Project creation datetime (ISO 8601 UTC).", + ) + updated_at: str = Field( + description="Project update datetime (ISO 8601 UTC).", + ) + organization: Union["OrganizationRead", None] = Field( + default=None, + description="Organization that this project is associated with.", + ) + + +class OrganizationCreate(BaseModel): + creator_user_id: str = Field( + default="", + description="User that created this organization.", + ) + name: str = Field( + description="Organization name.", + ) + external_keys: dict[str, str] = Field( + default={}, + description="Mapping of service provider to its API key.", + ) + tier: str = Field( + default="", + description="Subscribed tier.", + ) + active: bool = Field( + default=True, + description="Whether the organization's quota is active (paid).", + ) + timezone: str | None = Field( + default=None, + description="Timezone specifier.", + ) + credit: float = Field( + default=0.0, + description="Credit paid by the customer. Unused credit will be carried forward to the next billing cycle.", + ) + credit_grant: float = Field( + default=0.0, + description="Credit granted to the customer. Unused credit will NOT be carried forward.", + ) + llm_tokens_quota_mtok: float = Field( + default=0.0, + description="LLM token quota in millions of tokens.", + ) + llm_tokens_usage_mtok: float = Field( + default=0.0, + description="LLM token usage in millions of tokens.", + ) + embedding_tokens_quota_mtok: float = Field( + default=0.0, + description="Embedding token quota in millions of tokens", + ) + embedding_tokens_usage_mtok: float = Field( + default=0.0, + description="Embedding token quota in millions of tokens", + ) + reranker_quota_ksearch: float = Field( + default=0.0, + description="Reranker quota for every thousand searches", + ) + reranker_usage_ksearch: float = Field( + default=0.0, + description="Reranker usage for every thousand searches", + ) + db_quota_gib: float = Field( + default=0.0, + description="DB storage quota in GiB.", + ) + db_usage_gib: float = Field( + default=0.0, + description="DB storage usage in GiB.", + ) + file_quota_gib: float = Field( + default=0.0, + description="File storage quota in GiB.", + ) + file_usage_gib: float = Field( + default=0.0, + description="File storage usage in GiB.", + ) + egress_quota_gib: float = Field( + default=0.0, + description="Egress quota in GiB.", + ) + egress_usage_gib: float = Field( + default=0.0, + description="Egress usage in GiB.", + ) + models: dict[str, Any] = Field( + default={}, + description="The organization's custom model list, in addition to the provided default list.", + ) + + +class OrganizationRead(OrganizationCreate): + id: str = Field( + description="Organization ID.", + ) + quota_reset_at: str = Field( + default="", + description="Previous quota reset date. Could be used as event key.", + ) + stripe_id: str | None = Field( + default=None, + description="Organization Stripe ID.", + ) + openmeter_id: str | None = Field( + default=None, + description="Organization OpenMeter ID.", + ) + created_at: str = Field( + description="Organization creation datetime (ISO 8601 UTC).", + ) + updated_at: str = Field( + description="Organization update datetime (ISO 8601 UTC).", + ) + members: list[OrgMemberRead] | None = Field( + default=None, + description="List of organization members and roles.", + ) + api_keys: list["ApiKeyRead"] | None = Field( + default=None, + description="List of API keys.", + ) + projects: list[ProjectRead] | None = Field( + default=None, + description="List of projects.", + ) + quotas: dict[str, dict[str, float]] = Field( + default=None, + description="Entitlements.", + ) + + +class OrganizationUpdate(BaseModel): + id: str + """Organization ID.""" + name: str | None = None + """Organization name.""" + external_keys: dict[str, str] | None = Field( + default=None, + description="Mapping of service provider to its API key.", + ) + credit: float | None = Field( + default=None, + description="Credit paid by the customer. Unused credit will be carried forward to the next billing cycle.", + ) + credit_grant: float | None = Field( + default=None, + description="Credit granted to the customer. Unused credit will NOT be carried forward.", + ) + llm_tokens_quota_mtok: float | None = Field( + default=None, + description="LLM token quota in millions of tokens.", + ) + llm_tokens_usage_mtok: float | None = Field( + default=None, + description="LLM token usage in millions of tokens.", + ) + embedding_tokens_quota_mtok: float | None = Field( + default=None, + description="Embedding token quota in millions of tokens", + ) + embedding_tokens_usage_mtok: float | None = Field( + default=None, + description="Embedding token quota in millions of tokens", + ) + reranker_quota_ksearch: float | None = Field( + default=None, + description="Reranker quota for every thousand searches", + ) + reranker_usage_ksearch: float | None = Field( + default=None, + description="Reranker usage for every thousand searches", + ) + db_quota_gib: float | None = Field( + default=None, + description="DB storage quota in GiB.", + ) + db_usage_gib: float | None = Field( + default=None, + description="DB storage usage in GiB.", + ) + file_quota_gib: float | None = Field( + default=None, + description="File storage quota in GiB.", + ) + file_usage_gib: float | None = Field( + default=None, + description="File storage usage in GiB.", + ) + egress_quota_gib: float | None = Field( + default=None, + description="Egress quota in GiB.", + ) + egress_usage_gib: float | None = Field( + default=None, + description="Egress usage in GiB.", + ) + tier: str | None = Field( + default=None, + description="Subscribed tier.", + ) + active: bool | None = Field( + default=None, + description="Whether the organization's quota is active (paid).", + ) + timezone: str | None = Field(default=None) + """ + Timezone specifier. + """ + stripe_id: str | None = Field(default=None) + """Organization Stripe ID.""" + openmeter_id: str | None = Field(default=None) + """Organization OpenMeter ID.""" + + +class ApiKeyCreate(BaseModel): + organization_id: str = Field(description="Organization ID.") + - page_content: str - metadata: dict = {} +class ApiKeyRead(ApiKeyCreate): + id: str = Field(description="The key.") + created_at: str = Field(description="Creation datetime (ISO 8601 UTC).") + + +class EventCreate(BaseModel): + id: str = Field( + min_length=1, + description="Event ID for idempotency. Must be unique.", + ) + organization_id: str = Field( + description="Organization ID.", + ) + deltas: dict[str, float | int] = Field( + default={}, + description="Delta changes to the values.", + ) + values: dict[str, float | int] = Field( + default={}, + description="New values (in-place update). Note that this will override any delta changes.", + ) + pending: bool = Field( + default=False, + description="Whether the event is pending (in-progress)", + ) + meta: dict[str, Any] = Field( + default={}, + description="Metadata.", + ) + + +class EventRead(EventCreate): + created_at: str = Field( + description="Event creation datetime (ISO 8601 UTC).", + ) + + +class TemplateTag(BaseModel): + id: str = Field(description="Tag ID.") + + +class Template(BaseModel): + id: str = Field(description="Template ID.") + name: str = Field(description="Template name.") + created_at: str = Field(description="Template creation datetime (ISO 8601 UTC).") + tags: list[TemplateTag] = Field(description="List of template tags") class Chunk(BaseModel): """Class for storing a piece of text and associated metadata.""" - text: str = Field(description="Document chunk text.") + text: str = Field(description="Chunk text.") title: str = Field(default="", description='Document title. Defaults to "".') page: int = Field(default=0, description="Page number. Defaults to 0.") - file_name: str = Field(default="", description="Document file name.") - file_path: str = Field(default="", description="Document file path.") + file_name: str = Field(default="", description="File name.") + file_path: str = Field(default="", description="File path.") document_id: str = Field(default="", description="Document ID.") chunk_id: str = Field(default="", description="Chunk ID.") metadata: dict = Field( @@ -126,9 +671,11 @@ def str_trunc(self) -> str: class RAGParams(BaseModel): table_id: str = Field(description="Knowledge Table ID", examples=["my-dataset"], min_length=2) - reranking_model: Annotated[ - str | None, Field(description="Reranking model to use for hybrid search.") - ] = None + reranking_model: str | None = Field( + default=None, + description="Reranking model to use for hybrid search.", + examples=[EXAMPLE_RERANKING_MODEL, None], + ) search_query: str = Field( default="", description="Query used to retrieve items from the KB database. If not provided (default), it will be generated using LLM.", @@ -195,18 +742,13 @@ class VectorSearchResponse(BaseModel): ) -class ModelCapability(str, Enum): - completion = "completion" - chat = "chat" - image = "image" - embed = "embed" - rerank = "rerank" - - class ModelInfo(BaseModel): id: str = Field( - description="Unique identifier of the model.", - examples=["openai/gpt-3.5-turbo"], + description=( + 'Unique identifier in the form of "{provider}/{model_id}". ' + "Users will specify this to select a model." + ), + examples=[EXAMPLE_CHAT_MODEL], ) object: str = Field( default="model", @@ -214,9 +756,8 @@ class ModelInfo(BaseModel): examples=["model"], ) name: str = Field( - default="openai/gpt-3.5-turbo", - description="Name of model.", - examples=["openai/gpt-3.5-turbo"], + description="Name of the model.", + examples=["OpenAI GPT-4o Mini"], ) context_length: int = Field( description="Context length of model.", @@ -226,115 +767,165 @@ class ModelInfo(BaseModel): description="List of languages which the model is well-versed in.", examples=[["en"]], ) - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] = Field( - description="List of capabilities of model.", - examples=[["chat"]], - ) owned_by: str = Field( description="The organization that owns the model.", examples=["openai"], ) - - -class ModelInfoResponse(BaseModel): - object: str = Field( - default="chat.model_info", - description="Type of API response object.", - examples=["chat.model_info"], - ) - data: list[ModelInfo] = Field( - description="List of model information.", + capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] = Field( + description="List of capabilities of model.", + examples=[["chat"]], ) -class LLMModelConfig(ModelInfo): +class ModelDeploymentConfig(BaseModel): litellm_id: str = Field( default="", - description="LiteLLM routing name for self-hosted models.", - # exclude=True, + description=( + "LiteLLM routing / mapping ID. " + '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], ) api_base: str = Field( default="", description="Hosting url for the model.", ) + provider: str = Field( + default="", + description="Provider of the model.", + ) -class EmbeddingModelConfig(BaseModel): - id: str = Field( - description=( - "Provider and model name in this format {provider}/{model}, " - "for self-host model with infinity do ellm/{org}/{model}" - ) +class ModelConfig(ModelInfo): + priority: int = Field( + default=0, + ge=0, + description="Priority when assigning default model. Larger number means higher priority.", ) - litellm_id: str = Field( - description="LiteLLM compatible model ID.", + deployments: list[ModelDeploymentConfig] = Field( + description="List of model deployment configs.", + min_length=1, ) - owned_by: str = Field( - description="The organization that owns the model.", - examples=["openai"], + + +class LLMModelConfig(ModelConfig): + input_cost_per_mtoken: float = Field( + default=-1.0, + description="Cost in USD per million (mega) input / prompt token.", ) - context_length: int = Field( - description="Max context length of the model.", + output_cost_per_mtoken: float = Field( + default=-1.0, + description="Cost in USD per million (mega) output / completion token.", + ) + + @model_validator(mode="after") + def check_cost_per_mtoken(self) -> Self: + # GPT-4o-mini pricing (2024-08-10) + if self.input_cost_per_mtoken <= 0: + self.input_cost_per_mtoken = 0.150 + if self.output_cost_per_mtoken <= 0: + self.output_cost_per_mtoken = 0.600 + return self + + +class EmbeddingModelConfig(ModelConfig): + id: str = Field( + description=( + 'Unique identifier in the form of "{provider}/{model_id}". ' + '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], ) embedding_size: int = Field( description="Embedding size of the model", ) + # Currently only useful for openai dimensions: int | None = Field( default=None, description="Dimensions, a reduced embedding size (openai specs).", - ) # currently only useful for openai - languages: list[str] | None = Field( - default=["en"], - description="Supported language", ) + # Most likely only useful for hf models transform_query: str | None = Field( default=None, description="Transform query that might be needed, esp. for hf models", - ) # most likely only useful for hf models - api_base: str = Field( - default="", - description="Hosting url for the model.", ) - capabilities: list[Literal["embed"]] = Field( - default=["embed"], - description="List of capabilities of model.", - examples=[["embed"]], + cost_per_mtoken: float = Field( + default=-1, description="Cost in USD per million embedding tokens." ) + @model_validator(mode="after") + def check_cost_per_mtoken(self) -> Self: + # OpenAI text-embedding-3-small pricing (2024-09-09) + if self.cost_per_mtoken < 0: + self.cost_per_mtoken = 0.022 + return self + -class RerankingModelConfig(BaseModel): +class RerankingModelConfig(ModelConfig): id: str = Field( description=( - "Provider and model name in this format {provider}/{model}, " - "for self-host model with infinity do ellm/{org}/{model}" - ) - ) - owned_by: str = Field( - description="The organization that owns the model.", - examples=["openai"], - ) - context_length: int = Field( - description="Max context length of the model.", - ) - languages: list[str] | None = Field( - default=["en"], - description="Supported language.", - ) - api_base: str = Field( - default="", - description="Hosting url for the model.", + 'Unique identifier in the form of "{provider}/{model_id}". ' + '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], ) capabilities: list[Literal["rerank"]] = Field( default=["rerank"], description="List of capabilities of model.", examples=[["rerank"]], ) + cost_per_ksearch: float = Field(default=-1, description="Cost in USD for a thousand searches.") + + @model_validator(mode="after") + def check_cost_per_ksearch(self) -> Self: + # Cohere rerank-multilingual-v3.0 pricing (2024-09-09) + if self.cost_per_ksearch < 0: + self.cost_per_ksearch = 2.0 + return self class ModelListConfig(BaseModel): - llm_models: list[LLMModelConfig] - embed_models: list[EmbeddingModelConfig] - rerank_models: list[RerankingModelConfig] + llm_models: list[LLMModelConfig] = [] + embed_models: list[EmbeddingModelConfig] = [] + rerank_models: list[RerankingModelConfig] = [] + + @property + def models(self) -> list[LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig]: + """A list of all the models.""" + return self.llm_models + self.embed_models + self.rerank_models + + def __add__(self, other: ModelListConfig) -> ModelListConfig: + if isinstance(other, ModelListConfig): + self_ids = set(m.id for m in self.models) + other_ids = set(m.id for m in other.models) + repeated_ids = self_ids.intersection(other_ids) + if len(repeated_ids) != 0: + raise ValueError( + f"There are repeated model IDs among the two configs: {list(repeated_ids)}" + ) + return ModelListConfig( + llm_models=self.llm_models + other.llm_models, + embed_models=self.embed_models + other.embed_models, + rerank_models=self.rerank_models + other.rerank_models, + ) + else: + raise TypeError( + f"Unsupported operand type(s) for +: 'ModelListConfig' and '{type(other)}'" + ) + + +class ModelInfoResponse(BaseModel): + object: str = Field( + default="chat.model_info", + description="Type of API response object.", + examples=["chat.model_info"], + ) + data: list[ModelInfo] = Field( + description="List of model information.", + ) class ChatRole(str, Enum): @@ -349,8 +940,8 @@ class ChatRole(str, Enum): # FUNCTION = "function" # """The message is the result of a function call.""" - -pat = re.compile(r"[^a-zA-Z0-9_-]") + def __str__(self) -> str: + return self.value def sanitise_name(v: str) -> str: @@ -362,7 +953,7 @@ def sanitise_name(v: str) -> str: Returns: out (str): Sanitised name string that is safe for OpenAI. """ - return re.sub(pat, "_", v).strip() + return re.sub(r"[^a-zA-Z0-9_-]", "_", v).strip() MessageName = Annotated[str, AfterValidator(sanitise_name)] @@ -371,14 +962,11 @@ def sanitise_name(v: str) -> str: class ChatEntry(BaseModel): """Represents a message in the chat context.""" - model_config = ConfigDict( - use_enum_values=True, - frozen=True, - ) + model_config = ConfigDict(use_enum_values=True) role: ChatRole """Who said the message?""" - content: str + content: str | list[dict[str, str | dict[str, str]]] """The content of the message.""" name: MessageName | None = None """The name of the user who sent the message, if set (user messages only).""" @@ -394,14 +982,22 @@ def user(cls, content: str, **kwargs): return cls(role=ChatRole.USER, content=content, **kwargs) @classmethod - def assistant(cls, content: str | None, **kwargs): + def assistant(cls, content: str | list[dict[str, str]] | None, **kwargs): """Create a new assistant message.""" return cls(role=ChatRole.ASSISTANT, content=content, **kwargs) @field_validator("content", mode="before") @classmethod - def handle_null_content(cls, v: Any) -> Any: - return "" if v is None else v + def coerce_input(cls, value: Any) -> str | list[dict[str, str | dict[str, str]]]: + if isinstance(value, list): + return [cls.coerce_input(v) for v in value] + if isinstance(value, dict): + return {k: cls.coerce_input(v) for k, v in value.items()} + if isinstance(value, str): + return value + if value is None: + return "" + return str(value) class ChatThread(BaseModel): @@ -460,7 +1056,7 @@ def delta(self) -> ChatEntry: class References(BaseModel): object: str = Field( default="chat.references", - description="The object type, which is always `chat.references`.", + description="Type of API response object.", examples=["chat.references"], ) chunks: list[Chunk] = Field( @@ -510,7 +1106,7 @@ def remove_contents(self): class GenTableStreamReferences(References): object: str = Field( default="gen_table.references", - description="The object type, which is always `gen_table.references`.", + description="Type of API response object.", examples=["gen_table.references"], ) output_column_name: str @@ -519,7 +1115,7 @@ class GenTableStreamReferences(References): class GenTableChatCompletionChunks(BaseModel): object: str = Field( default="gen_table.completion.chunks", - description="The object type, which is always `gen_table.completion.chunks`.", + description="Type of API response object.", examples=["gen_table.completion.chunks"], ) columns: dict[str, ChatCompletionChunk] @@ -529,7 +1125,7 @@ class GenTableChatCompletionChunks(BaseModel): class GenTableRowsChatCompletionChunks(BaseModel): object: str = Field( default="gen_table.completion.rows", - description="The object type, which is always `gen_table.completion.rows`.", + description="Type of API response object.", examples=["gen_table.completion.rows"], ) rows: list[GenTableChatCompletionChunks] @@ -541,7 +1137,7 @@ class ChatCompletionChunk(BaseModel): ) object: str = Field( default="chat.completion.chunk", - description="The object type, which is always `chat.completion.chunk`.", + description="Type of API response object.", examples=["chat.completion.chunk"], ) created: int = Field( @@ -573,9 +1169,9 @@ def completion_tokens(self) -> int: return self.usage.completion_tokens @property - def text(self) -> str | None: + def text(self) -> str: """The text of the most recent chat completion.""" - return self.message.content if len(self.choices) > 0 else None + return self.message.content if len(self.choices) > 0 else "" @property def finish_reason(self) -> str | None: @@ -585,7 +1181,7 @@ def finish_reason(self) -> str | None: class GenTableStreamChatCompletionChunk(ChatCompletionChunk): object: str = Field( default="gen_table.completion.chunk", - description="The object type, which is always `gen_table.completion.chunk`.", + description="Type of API response object.", examples=["gen_table.completion.chunk"], ) output_column_name: str @@ -595,7 +1191,7 @@ class GenTableStreamChatCompletionChunk(ChatCompletionChunk): class ChatRequest(BaseModel): id: str = Field( default="", - description="Chat ID. Must be unique against document ID for it to be embeddable. Defaults to ''.", + description='Chat ID. Will be replaced with request ID. Defaults to "".', ) model: str = Field( default="", @@ -611,23 +1207,23 @@ class ChatRequest(BaseModel): examples=[None], ) temperature: Annotated[float, Field(ge=0.001, le=2.0)] = Field( - default=1.0, + default=0.2, description=""" What sampling temperature to use, in [0.001, 2.0]. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. """, - examples=[1.0], + examples=[0.2], ) top_p: Annotated[float, Field(ge=0.001, le=1.0)] = Field( - default=1.0, + default=0.6, description=""" An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. Must be in [0.001, 1.0]. """, - examples=[1.0], + examples=[0.6], ) n: int = Field( default=1, @@ -691,14 +1287,12 @@ class ChatRequest(BaseModel): examples=[""], ) - @model_validator(mode="after") - def convert_stop(self) -> Self: - # TODO: Introduce this in v0.3 - # if isinstance(self.stop, list) and len(self.stop) == 0: - # self.stop = None - if self.stop is None: - self.stop = [] - return self + @field_validator("stop", mode="after") + @classmethod + def convert_stop(cls, v: list[str] | None) -> list[str] | None: + if isinstance(v, list) and len(v) == 0: + v = None + return v class EmbeddingRequest(BaseModel): @@ -715,7 +1309,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=["openai/text-embedding-3-small-512"], + examples=[EXAMPLE_EMBEDDING_MODEL], ) type: Literal["query", "document"] = Field( default="document", @@ -738,7 +1332,7 @@ class EmbeddingRequest(BaseModel): class EmbeddingResponseData(BaseModel): object: str = Field( default="embedding", - description="The object type, which is always `embedding`.", + description="Type of API response object.", examples=["embedding"], ) embedding: list[float] | str = Field( @@ -758,7 +1352,7 @@ class EmbeddingResponseData(BaseModel): class EmbeddingResponse(BaseModel): object: str = Field( default="list", - description="The object type, which is always `list`.", + description="Type of API response object.", examples=["list"], ) data: list[EmbeddingResponseData] = Field( @@ -805,8 +1399,6 @@ def datetime_str_before_validator(x): return x.isoformat() if isinstance(x, datetime) else str(x) -COL_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_ \-]{0,98}[a-zA-Z0-9]$" -TABLE_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,98}[a-zA-Z0-9]$" ODD_SINGLE_QUOTE = r"(? "int".' +) -class DtypeEnum(str, Enum, metaclass=MetaEnum): +@deprecated(ENUM_DEPRECATE_MSSG, category=FutureWarning, stacklevel=1) +class DtypeCreateEnum(str, Enum, metaclass=MetaEnum): int_ = "int" - int8 = "int8" float_ = "float" - float32 = "float32" - float16 = "float16" bool_ = "bool" str_ = "str" - date_time = "date-time" + file_ = "file" + def __getattribute__(cls, *args, **kwargs): + warn(ENUM_DEPRECATE_MSSG, FutureWarning, stacklevel=1) + return super().__getattribute__(*args, **kwargs) -class DtypeCreateEnum(str, Enum, metaclass=MetaEnum): - int_ = "int" - float_ = "float" - bool_ = "bool" - str_ = "str" + def __getitem__(cls, *args, **kwargs): + warn(ENUM_DEPRECATE_MSSG, FutureWarning, stacklevel=1) + return super().__getitem__(*args, **kwargs) + + def __call__(cls, *args, **kwargs): + warn(ENUM_DEPRECATE_MSSG, FutureWarning, stacklevel=1) + return super().__call__(*args, **kwargs) + + def __str__(self) -> str: + return self.value class TableType(str, Enum, metaclass=MetaEnum): @@ -857,11 +1454,190 @@ def __str__(self) -> str: return self.value +class LLMGenConfig(BaseModel): + object: Literal["gen_config.llm"] = Field( + default="gen_config.llm", + description='The object type, which is always "gen_config.llm".', + examples=["gen_config.llm"], + ) + model: str = Field( + default="", + description="ID of the model to use. See the model endpoint compatibility table for details on which models work with the Chat API.", + ) + system_prompt: str = Field( + default="", + description="System prompt for the LLM.", + ) + prompt: str = Field( + default="", + description="Prompt for the LLM.", + ) + multi_turn: bool = Field( + default=False, + description="Whether this column is a multi-turn chat with history along the entire column.", + ) + rag_params: RAGParams | None = Field( + default=None, + description="Retrieval Augmented Generation search params. Defaults to None (disabled).", + examples=[None], + ) + temperature: Annotated[float, Field(ge=0.001, le=2.0)] = Field( + default=0.2, + description=""" +What sampling temperature to use, in [0.001, 2.0]. +Higher values like 0.8 will make the output more random, +while lower values like 0.2 will make it more focused and deterministic. +""", + examples=[0.2], + ) + top_p: Annotated[float, Field(ge=0.001, le=1.0)] = Field( + default=0.6, + description=""" +An alternative to sampling with temperature, called nucleus sampling, +where the model considers the results of the tokens with top_p probability mass. +So 0.1 means only the tokens comprising the top 10% probability mass are considered. +Must be in [0.001, 1.0]. +""", + examples=[0.6], + ) + stop: list[str] | None = Field( + default=None, + description="Up to 4 sequences where the API will stop generating further tokens.", + examples=[None], + ) + max_tokens: PositiveNonZeroInt = Field( + default=2048, + description=""" +The maximum number of tokens to generate in the chat completion. +Must be in [1, context_length - 1). Default is 2048. +The total length of input tokens and generated tokens is limited by the model's context length. +""", + examples=[2048], + ) + presence_penalty: float = Field( + default=0.0, + description=""" +Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, +increasing the model's likelihood to talk about new topics. +""", + examples=[0.0], + ) + frequency_penalty: float = Field( + default=0.0, + description=""" +Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, +decreasing the model's likelihood to repeat the same line verbatim. +""", + examples=[0.0], + ) + logit_bias: dict = Field( + default={}, + description=""" +Modify the likelihood of specified tokens appearing in the completion. +Accepts a json object that maps tokens (specified by their token ID in the tokenizer) +to an associated bias value from -100 to 100. +Mathematically, the bias is added to the logits generated by the model prior to sampling. +The exact effect will vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; +values like -100 or 100 should result in a ban or exclusive selection of the relevant token. +""", + examples=[{}], + ) + + @model_validator(mode="before") + @classmethod + def compat(cls, data: Any) -> Any: + data_type = type(data).__name__ + if isinstance(data, BaseModel): + data = data.model_dump() + if not isinstance(data, dict): + raise TypeError( + f"Input to `LLMGenConfig` must be a dict or BaseModel, received: {data_type}" + ) + if data.get("system_prompt", None) or data.get("prompt", None): + return data + warn( + ( + f'Using {data_type} as input to "gen_config" is deprecated and will be disabled in v0.4, ' + f"use {cls.__name__} instead." + ), + FutureWarning, + stacklevel=3, + ) + messages: list[dict[str, Any]] = data.get("messages", []) + num_prompts = len(messages) + if num_prompts >= 2: + data["system_prompt"] = messages[0]["content"] + data["prompt"] = messages[1]["content"] + elif num_prompts == 1: + if messages[0]["role"] == "system": + data["system_prompt"] = messages[0]["content"] + data["prompt"] = "" + elif messages[0]["role"] == "user": + data["system_prompt"] = "" + data["prompt"] = messages[0]["content"] + else: + raise ValueError( + f'Attribute "messages" cannot contain only assistant messages: {messages}' + ) + data["object"] = "gen_config.llm" + return data + + @field_validator("stop", mode="after") + @classmethod + def convert_stop(cls, v: list[str] | None) -> list[str] | None: + if isinstance(v, list) and len(v) == 0: + v = None + return v + + +class EmbedGenConfig(BaseModel): + object: Literal["gen_config.embed"] = Field( + default="gen_config.embed", + description='The object type, which is always "gen_config.embed".', + examples=["gen_config.embed"], + ) + embedding_model: str = Field( + description="The embedding model to use.", + examples=[EXAMPLE_EMBEDDING_MODEL], + ) + source_column: str = Field( + description="The source column for embedding.", + examples=["text_column"], + ) + + +def _gen_config_discriminator(x: Any) -> str | None: + object_attr = getattr(x, "object", None) + if object_attr: + return object_attr + if isinstance(x, BaseModel): + x = x.model_dump() + if isinstance(x, dict): + if "object" in x: + return x["object"] + if "embedding_model" in x: + return "gen_config.embed" + else: + return "gen_config.llm" + return None + + +GenConfig = LLMGenConfig | EmbedGenConfig +DiscriminatedGenConfig = Annotated[ + Union[ + Annotated[LLMGenConfig, Tag("gen_config.llm")], + Annotated[LLMGenConfig, Tag("gen_config.chat")], + Annotated[EmbedGenConfig, Tag("gen_config.embed")], + ], + Discriminator(_gen_config_discriminator), +] + + class ColumnSchema(BaseModel): id: str = Field(description="Column name.") - dtype: DtypeEnum = Field( - default=DtypeEnum.str_, - description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time"]', + dtype: str = Field( + default="str", + description="Column data type.", ) vlen: PositiveInt = Field( # type: ignore default=0, @@ -877,113 +1653,51 @@ class ColumnSchema(BaseModel): "Only applies to string and vector columns. Defaults to True." ), ) - gen_config: dict[str, Any] | None = Field( + gen_config: DiscriminatedGenConfig | None = Field( default=None, description=( - '_Optional_. Generation config in the form of `ChatRequest`. If provided, then this column will be an "Output Column". ' + '_Optional_. Generation config. If provided, then this column will be an "Output Column". ' "Table columns on its left can be referenced by `${column-name}`." ), ) - @model_validator(mode="after") - def check_vector_column_dtype(self) -> Self: - if self.vlen > 0 and self.dtype not in (DtypeEnum.float32, DtypeEnum.float16): - raise ValueError("Vector columns must contain float32 or float16 only.") - return self - - @model_validator(mode="after") - def validate_gen_config(self) -> Self: - if self.gen_config is not None: - # Validate - if "embedding_model" in self.gen_config: - self.gen_config = EmbedGenConfig.model_validate(self.gen_config).model_dump() - else: - self.gen_config = ChatRequest.model_validate(self.gen_config).model_dump() - return self - class ColumnSchemaCreate(ColumnSchema): id: str = Field(description="Column name.") - dtype: DtypeCreateEnum = Field( - default=DtypeCreateEnum.str_, - description='Column data type, one of ["int", "float", "bool", "str"]', + dtype: Literal["int", "float", "bool", "str", "file"] = Field( + default="str", + description='Column data type, one of ["int", "float", "bool", "str", "file"]', ) + @model_validator(mode="before") + @classmethod + def compat(cls, data: Any) -> Any: + data_type = type(data).__name__ + if isinstance(data, BaseModel): + data = data.model_dump() + if not isinstance(data, dict): + raise TypeError( + f"Input to `ColumnSchemaCreate` must be a dict or BaseModel, received: {data_type}" + ) + if isinstance(data.get("dtype", None), DtypeCreateEnum): + data["dtype"] = data["dtype"].value + return data + class TableBase(BaseModel): id: str = Field(primary_key=True, description="Table name.") - # version: int = 0 + version: str = Field( + default=jamaibase_version, description="Table version, following jamaibase version." + ) + meta: dict[str, Any] = Field( + default={}, + description="Additional metadata about the table.", + ) class TableSchema(TableBase): cols: list[ColumnSchema] = Field(description="List of column schema.") - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - for i, col in enumerate(self.cols): - gen_config = col.gen_config - if gen_config is None: - continue - col_ids = set(col.id for col in self.cols[:i] if not col.id.endswith("_")) - if col.vlen > 0: - gen_config = EmbedGenConfig.model_validate(gen_config) - if gen_config.source_column not in col_ids: - raise ValueError( - ( - f"Table '{self.id}': " - f"Embedding 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: {list(col_ids)}." - ) - ) - else: - num_prompts = len(gen_config["messages"]) - if num_prompts > 2: - self.cols[i].gen_config["messages"] = self.cols[i].gen_config["messages"][:2] - elif num_prompts == 2: - pass - elif num_prompts == 1: - self.cols[i].gen_config["messages"].append( - ChatEntry.user(content=".").model_dump() - ) - else: - raise ValueError( - f"`gen_config.messages` must be a list of at least length 1, received: {num_prompts:,d}" - ) - gen_config = ChatRequest.model_validate(self.cols[i].gen_config) - if gen_config.messages[0].role not in (ChatRole.SYSTEM, ChatRole.SYSTEM.value): - raise ValueError( - ( - f"Table '{self.id}': " - "The first `ChatEntry` in `gen_config.messages` " - f"of column '{col.id}' is not a system prompt. " - f"Saw {gen_config.messages[0].role} message." - ) - ) - if gen_config.messages[1].role not in (ChatRole.USER, ChatRole.USER.value): - raise ValueError( - ( - f"Table '{self.id}': " - "The second `ChatEntry` in `gen_config.messages` " - f"of column '{col.id}' is not a user prompt. " - f"Saw {gen_config.messages[1].role} message." - ) - ) - for message in gen_config.messages: - for key in re.findall(GEN_CONFIG_VAR_PATTERN, message.content): - if key not in col_ids: - raise ValueError( - ( - f"Table '{self.id}': " - f"Generation prompt of column '{col.id}' referenced " - f"an invalid source column '{key}'. " - "Make sure you only reference columns on its left. " - f"Available columns: {list(col_ids)}." - ) - ) - return self - class TableSchemaCreate(TableSchema): id: str = Field(description="Table name.") @@ -1000,22 +1714,12 @@ def check_cols(self) -> Self: return self -class AddColumnSchema(TableSchemaCreate): - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self - - class ActionTableSchemaCreate(TableSchemaCreate): pass class AddActionColumnSchema(ActionTableSchemaCreate): - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self + pass class KnowledgeTableSchemaCreate(TableSchemaCreate): @@ -1039,11 +1743,6 @@ def check_cols(self) -> Self: raise ValueError("Schema cannot contain column names: 'Text', 'Title', 'File ID'.") return self - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self - class ChatTableSchemaCreate(TableSchemaCreate): @model_validator(mode="after") @@ -1061,11 +1760,6 @@ def check_cols(self) -> Self: super().check_cols() return self - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self - class TableMeta(TableBase): cols: list[dict[str, Any]] = Field(description="List of column schema.") @@ -1078,7 +1772,7 @@ class TableMeta(TableBase): description="Chat title. Defaults to ''.", ) updated_at: str = Field( - default_factory=lambda: datetime.now(timezone.utc).isoformat(), + default_factory=datetime_now_iso, description="Table last update timestamp (ISO 8601 UTC).", ) # SQLite does not support TZ indexed_at_fts: str | None = Field( @@ -1119,26 +1813,17 @@ class TableMetaResponse(TableSchema): ) num_rows: int = Field(description="Number of rows in the table.") - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - return self - @model_validator(mode="after") def remove_state_cols(self) -> Self: self.cols = [c for c in self.cols if not c.id.endswith("_")] return self -class EmbedGenConfig(BaseModel): - embedding_model: str - source_column: str - - class GenConfigUpdateRequest(BaseModel): table_id: str = Field(description="Table name or ID.") - column_map: dict[str, dict | None] = Field( + column_map: dict[str, DiscriminatedGenConfig | None] = Field( description=( - "Mapping of column ID to generation config JSON in the form of `ChatRequest`. " + "Mapping of column ID to generation config JSON in the form of `GenConfig`. " "Table columns on its left can be referenced by `${column-name}`." ) ) @@ -1146,7 +1831,7 @@ class GenConfigUpdateRequest(BaseModel): @model_validator(mode="after") def check_column_map(self) -> Self: if sum(n.lower() in ("id", "updated at") for n in self.column_map) > 0: - raise ValueError("`column_map` cannot contain keys: 'ID' or 'Updated at'.") + raise ValueError("column_map cannot contain keys: 'ID' or 'Updated at'.") return self @@ -1220,11 +1905,27 @@ def __repr__(self): ] return ( f"{self.__class__.__name__}(" - f"table_id={self.table_id} stream={self.stream} reindex={self.reindex}" - f"concurrent={self.concurrent} data={_data}" + f"table_id={self.table_id} stream={self.stream} reindex={self.reindex} " + f"concurrent={self.concurrent} data={_data}" ")" ) + @model_validator(mode="after") + def check_data(self) -> Self: + for row in self.data: + for value in row.values(): + if isinstance(value, str) and ( + value.startswith("s3://") or value.startswith("file://") + ): + extension = splitext(value)[1].lower() + if extension not in IMAGE_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}" + ) + return self + class RowAddRequestWithLimit(RowAddRequest): data: list[dict[str, Any]] = Field( @@ -1256,6 +1957,33 @@ class RowUpdateRequest(BaseModel): ), ) + @model_validator(mode="after") + def check_data(self) -> Self: + for value in self.data.values(): + if isinstance(value, str) and ( + value.startswith("s3://") or value.startswith("file://") + ): + extension = splitext(value)[1].lower() + if extension not in IMAGE_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}" + ) + return self + + +class RegenStrategy(str, Enum): + """Strategies for selecting columns during row regeneration.""" + + RUN_ALL = "run_all" + RUN_BEFORE = "run_before" + RUN_SELECTED = "run_selected" + RUN_AFTER = "run_after" + + def __str__(self) -> str: + return self.value + class RowRegen(BaseModel): table_id: str = Field( @@ -1264,6 +1992,27 @@ class RowRegen(BaseModel): row_id: str = Field( description="ID of the row to regenerate.", ) + regen_strategy: RegenStrategy = Field( + default=RegenStrategy.RUN_ALL, + description=( + "_Optional_. Strategy for selecting columns to regenerate." + "Choose `run_all` to regenerate all columns in the specified row; " + "Choose `run_before` to regenerate columns up to the specified column_id; " + "Choose `run_selected` to regenerate only the specified column_id; " + "Choose `run_after` to regenerate columns starting from the specified column_id; " + ), + ) + output_column_id: str | None = Field( + default=None, + description=( + "_Optional_. Output column name to indicate the starting or ending point of regen for `run_before`, " + "`run_selected` and `run_after` strategies. Required if `regen_strategy` is not 'run_all'. " + "Given columns are 'C1', 'C2', 'C3' and 'C4', if column_id is 'C3': " + "`run_before` regenerate columns 'C1', 'C2' and 'C3'; " + "`run_selected` regenerate only column 'C3'; " + "`run_after` regenerate columns 'C3' and 'C4'; " + ), + ) stream: bool = Field( description="Whether or not to stream the LLM generation.", ) @@ -1289,6 +2038,27 @@ class RowRegenRequest(BaseModel): max_length=100, description="List of ID of the row to regenerate. Minimum 1 row, maximum 100 rows.", ) + regen_strategy: RegenStrategy = Field( + default=RegenStrategy.RUN_ALL, + description=( + "_Optional_. Strategy for selecting columns to regenerate." + "Choose `run_all` to regenerate all columns in the specified row; " + "Choose `run_before` to regenerate columns up to the specified column_id; " + "Choose `run_selected` to regenerate only the specified column_id; " + "Choose `run_after` to regenerate columns starting from the specified column_id; " + ), + ) + output_column_id: str | None = Field( + default=None, + description=( + "_Optional_. Output column name to indicate the starting or ending point of regen for `run_before`, " + "`run_selected` and `run_after` strategies. Required if `regen_strategy` is not 'run_all'. " + "Given columns are 'C1', 'C2', 'C3' and 'C4', if column_id is 'C3': " + "`run_before` regenerate columns 'C1', 'C2' and 'C3'; " + "`run_selected` regenerate only column 'C3'; " + "`run_after` regenerate columns 'C3' and 'C4'; " + ), + ) stream: bool = Field( description="Whether or not to stream the LLM generation.", ) @@ -1304,6 +2074,19 @@ class RowRegenRequest(BaseModel): description="_Optional_. Whether or not to concurrently generate the output rows and columns.", ) + @model_validator(mode="after") + def check_output_column_id_provided(self) -> Self: + if self.regen_strategy != RegenStrategy.RUN_ALL and self.output_column_id is None: + raise ValueError( + "`output_column_id` is required for regen_strategy other than 'run_all'." + ) + return self + + @model_validator(mode="after") + def sort_row_ids(self) -> Self: + self.row_ids = sorted(self.row_ids) + return self + class RowDeleteRequest(BaseModel): table_id: str = Field(description="Table name or ID.") @@ -1390,7 +2173,7 @@ class SearchRequest(BaseModel): ) vec_decimals: int = Field( default=0, - description="_Optional_. Number of decimals for vectors. Defaults to 0 (no rounding).", + description="_Optional_. Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", ) reranking_model: Annotated[ str | None, Field(description="Reranking model to use for hybrid search.") @@ -1417,7 +2200,9 @@ class FileUploadRequest(BaseModel): class TableDataImportRequest(BaseModel): file_path: Annotated[str, Field(description="CSV or TSV file path.")] - table_id: Annotated[str, Field(description="Table name / ID.")] + table_id: Annotated[ + str, Field(description="ID or name of the table that the data should be imported into.") + ] stream: Annotated[bool, Field(description="Whether or not to stream the LLM generation.")] = ( True ) @@ -1434,5 +2219,53 @@ class TableDataImportRequest(BaseModel): # ), # ] = None delimiter: Annotated[ - str, Field(description='The delimiter of the file: can be "," or "\\t". Defaults to ",".') + Literal[",", "\t"], + Field(description='The delimiter of the file: can be "," or "\\t". Defaults to ",".'), ] = "," + + +class TableImportRequest(BaseModel): + file_path: Annotated[str, Field(description="The parquet file path.")] + table_id_dst: Annotated[ + str | None, Field(description="_Optional_. The ID or name of the new table.") + ] = None + table_id_dst: Annotated[str, Field(description="The ID or name of the new table.")] + + +class FileUploadResponse(BaseModel): + object: Literal["file.upload"] = Field( + default="file.upload", + description='The object type, which is always "file.upload".', + examples=["file.upload"], + ) + uri: str = Field( + description="The URI of the uploaded file.", + examples=[ + "s3://bucket-name/raw/org_id/project_id/uuid/filename.ext", + "file:///path/to/raw/file.ext", + ], + ) + + +class GetURLRequest(BaseModel): + uris: list[str] = Field( + description=( + "A list of file URIs for which pre-signed URLs or local file paths are requested. " + "The service will return a corresponding list of pre-signed URLs or local file paths." + ), + ) + + +class GetURLResponse(BaseModel): + object: Literal["file.urls"] = Field( + default="file.urls", + description='The object type, which is always "file.urls".', + examples=["file.urls"], + ) + urls: list[str] = Field( + description="A list of pre-signed URLs or local file paths.", + examples=[ + "https://presigned-url-for-file1.ext", + "/path/to/file2.ext", + ], + ) diff --git a/clients/python/src/jamaibase/utils/__init__.py b/clients/python/src/jamaibase/utils/__init__.py index e69de29..0b7677e 100644 --- a/clients/python/src/jamaibase/utils/__init__.py +++ b/clients/python/src/jamaibase/utils/__init__.py @@ -0,0 +1,22 @@ +from asyncio.coroutines import iscoroutine +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, Awaitable, Callable, Generator, TypeVar + +R = TypeVar("R") + + +async def run(fn: Callable[..., R | Awaitable[R]], *args: Any, **kwargs: Any) -> R: + ret = fn(*args, **kwargs) + if isinstance(ret, Generator): + return [item for item in ret] + if iscoroutine(ret): + ret = await ret + if isinstance(ret, AsyncGenerator): + ret = [item async for item in ret] + return ret + else: + return ret + + +def datetime_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/clients/python/src/jamaibase/utils/io.py b/clients/python/src/jamaibase/utils/io.py index f0f1962..83e086f 100644 --- a/clients/python/src/jamaibase/utils/io.py +++ b/clients/python/src/jamaibase/utils/io.py @@ -3,7 +3,7 @@ import csv import logging import pickle -from io import StringIO +from io import BytesIO, StringIO from typing import Any import numpy as np @@ -174,3 +174,30 @@ def read_image(img_path: str) -> tuple[np.ndarray, bool]: if is_rotated: image = image.rotate(180) return np.asarray(image), is_rotated + + +def generate_thumbnail( + file_content: bytes, + size: tuple[float, float] = (450.0, 450.0), +) -> bytes: + try: + with Image.open(BytesIO(file_content)) as img: + # Check image mode + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + # Resize and save + img.thumbnail(size=size) + with BytesIO() as f: + img.save( + f, + format="webp", + lossless=False, + quality=60, + alpha_quality=50, + method=6, + exact=False, + ) + return f.getvalue() + except Exception as e: + logger.exception(f"Failed to generate thumbnail due to {e.__class__.__name__}: {e}") + return b"" diff --git a/clients/python/src/jamaibase/version.py b/clients/python/src/jamaibase/version.py index 3ced358..493f741 100644 --- a/clients/python/src/jamaibase/version.py +++ b/clients/python/src/jamaibase/version.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/clients/python/tests/_loader_check/pdfloader__Swire_AR22_e_230406_sample.pdf.json b/clients/python/tests/_loader_check/pdfloader__Swire_AR22_e_230406_sample.pdf.json deleted file mode 100644 index 718691e..0000000 --- a/clients/python/tests/_loader_check/pdfloader__Swire_AR22_e_230406_sample.pdf.json +++ /dev/null @@ -1,67 +0,0 @@ -[ - { - "page_content": "2022\nStock Codes ‘A’ Shares 00019 ‘B’ Shares 00087\n\nA N N U A L R E P O R T\n", - "metadata": { - "page": 0, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpf7ka5pji.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "1 Corporate Statement\n3 2022 Performance Highlights\n4 Chairman’s Statement\n\nMANAGEMENT DISCUSSION \nAND ANALYSIS\n\n10 2022 Performance Review and Outlook\n59 Financial Review\n69 Financing\n\nCORPORATE GOVERNANCE & \nSUSTAINABILITY\n\n79 Corporate Governance Report\n94 Risk Management\n98 Directors and Officers\n100 Directors’ Report\n109 Sustainable Development Review\n\nFINANCIAL STATEMENTS\n\n117 Independent Auditor’s Report\n125 Consolidated Statement of Profit or Loss\n126 Consolidated Statement of Other \nComprehensive Income\n127 Consolidated Statement of Financial Position\n128 Consolidated Statement of Cash Flows \n129 Consolidated Statement of Changes in Equity\n130 Notes to the Financial Statements\n205 Principal Accounting Policies\n208 Principal Subsidiary, Joint Venture and \nAssociated Companies\n218 Cathay Pacific Airways Limited – \nAbridged Financial Statements\n\nSUPPLEMENTARY INFORMATION\n\n220 Summary of Past Performance\n222 Schedule of Principal Group Properties\n232 Group Structure Chart\n234 Glossary \n236 Financial Calendar and Information for Investors\n236 Disclaimer\n\nCONTENTS\nNote: Definitions of the terms and ratios used \nin this report can be found in the Glossary.\n", - "metadata": { - "page": 1, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpf7ka5pji.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "SWIRE PACIFIC ANNUAL REPORT 2022 1\nOur aims are to deliver sustainable growth in shareholder value, \nachieved through sound returns on equity over the long term, \nand to return value to shareholders through sustainable growth \nin ordinary dividends. Our strategy is focused on Greater China \nand South East Asia, where we seek to grow our core Property, \nBeverages and Aviation divisions. New areas of growth, such as \nhealthcare and sustainable foods, are being targeted. \n\nOur Values\n\nIntegrity, endeavour, excellence, humility, teamwork, continuity. \n\nOur Core Principles\n\n– We focus on Asia, principally Greater China, because of \nits strong growth potential and because it is where the \nGroup has long experience, deep knowledge and strong \nrelationships.\n\n– We mobilise capital, talent and ideas across the Group. \nOur scale and diversity increase our access to investment \nopportunities.\n\n– We are prudent financial managers. This enables us to \nexecute long-term investment plans irrespective of short-\nterm financial market volatility.\n\n– We recruit the best people and invest heavily in their training \nand development. The welfare of our people is critical to our \noperations.\n\n– We build strong and lasting relationships, based on mutual \nbenefit, with those with whom we do business.\n\n– We invest in sustainable development, because it is the \nright thing to do and because it supports long-term growth \nthrough innovation and improved efficiency. \n\n– We are committed to the highest standards of corporate \ngovernance and to the preservation and development of the \nSwire brand and reputation. \n\nOur Investment Principles\n\n– We aim to build a portfolio of businesses that collectively \ndeliver a steady dividend stream over time. \n\n– We are long-term investors. We prefer to have controlling \ninterests in our businesses and to manage them for long-\nterm growth. We do not rule out minority investments in \nappropriate circumstances. \n\n– We concentrate on businesses where we can contribute \nexpertise, and where our expertise can add value.\n\n– We invest in businesses that provide high-quality products \nand services and that are leaders in their markets. \n\n– We divest from businesses which have reached their full \npotential under our ownership, and recycle the capital \nreleased into existing or new businesses. \n\nCORPORATE STATEMENT\n\nSUSTAINABLE GROWTH\n\nSwire Pacific is a Hong Kong-based international conglomerate with a diversified \nportfolio of market leading businesses. The Company has a long history in Greater China, \nwhere the name Swire or å¤ªå¤ has been established for over 150 years.\n", - "metadata": { - "page": 2, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpf7ka5pji.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "SWIRE PACIFIC ANNUAL REPORT 2022 49\n\nFleet profile*\n\n\nAircraft type\n\nNumber at\n31st December 2022 Total\n\nAverage\nage\n\nOrders Total\n\nExpiry of operating leases**\n\nOwned\n\nLeased**\n\nFinance Operating ‘23 ‘24\n\n‘25 and\nbeyond ‘23 ‘24 ‘25 ‘26 ‘27\n\n‘28 and\nbeyond\nCathay Pacific:\nA320-200 4 4 19.3 \nA321-200 2 1 3 19.8 1\nA321-200neo 2 5 7 1.4 5(a) 4 9 5\nA330-300 31 8 4 43 14.3 2 2\nA350-900 19 7 2 28 5.1 2 2 2\nA350-1000 11 7 18 3.1 \n747-400ERF 6 6 14.0\n747-8F 3 11 14 9.9\n777-300 17 17 21.2\n777-300ER 28 2 11 41 10.2 2 3 2 4\n777-9 21 21\nTotal 121 37 23 181 10.8 7 4 21 32 3 3 4 6 7\nHK Express:\nA320-200 5 5 10.5 1 4\nA320-200neo 10 10 3.8 10\nA321-200 11 11 5.2 1 2 8\nA321-200neo 4 8 4 16\nTotal 26 26 5.7 4 8 4 16 1 4 1 2 18\nAir Hong Kong***(b):\nA300-600F 9 9 18.6 7 2\nA330-243F 2 2 11.0 2\nA330-300P2F 4 4 13.7 3 1\nTotal 15 15 16.3 7 2 5 1\nGrand total 121 37 64 222 10.6 11 12 25 48 11 9 5 13 26\n
\n\n* The table does not reflect aircraft movements after 31st December 2022.\n** Leases previously classified as operating leases are accounted for in a similar manner to finance leases under accounting standards. The majority of operating leases in the above table are \nwithin the scope of HKFRS 16.\n*** The contractual arrangements relating to the freighters operated by Air Hong Kong do not constitute leases in accordance with HKFRS 16.\n(a) Two Airbus A321-200neo aircraft were delivered in February 2023. \n(b) The plan is to return the nine A300-600F aircraft between 2023 and 2024 and to replace them with nine second-hand A330F aircraft. This allows the Air Hong Kong fleet to remain the same \n(at 15), at least until 2024.\n", - "metadata": { - "page": 3, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpf7ka5pji.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "CORPORATE GOVERNANCE REPORT82\n5\n\n4\n\n3\n\n2\n\n1\n\n0 0 2 4 6 81 3 5 7\n\n1\n1 5 7\n\nNumber of\nCompanies Number of Directors\n\nResponsibilities of Directors\nOn appointment, the Directors receive information about the Group including:\n\n– the role of the Board and the matters reserved for its attention\n– the role and terms of reference of Board Committees\n– the Group’s corporate governance practices and procedures\n– the powers delegated to management and\n– the latest financial information.\n\nDirectors update their skills, knowledge and understanding of the Company’s businesses through their participation at meetings of \nthe Board and its committees and through regular meetings with management at the head office and in the divisions. Directors are \nregularly updated by the Company Secretary on their legal and other duties as Directors of a listed company.\n\nThrough the Company Secretary, Directors are able to obtain appropriate professional training and advice.\n\nEach Director ensures that he/she can give sufficient time and attention to the affairs of the Group. All Directors disclose to the \nBoard on their first appointment their interests as a Director or otherwise in other companies or organisations and such declarations \nof interests are updated regularly. No Director was a director of more than five other listed companies (excluding the Company) at \n31stDecember 2022.\n\nDetails of Directors’ other appointments are shown in their \nbiographies in the section of this annual report headed Directors \nand Officers.\n\nBoard Processes\nAll committees of the Board follow the same processes as the \nfull Board.\n\nThe dates of the 2022 Board meetings were determined in \n2021 and any amendments to this schedule were notified to \nDirectors at least 14 days before regular meetings. Appropriate \narrangements are in place to allow Directors to include items in \nthe agenda for regular Board meetings.\n\nThe Board met seven times in 2022, including two strategy \nsessions. The attendance of individual Directors at meetings \nof the Board and its committees is set out in the table on page \n83. Attendance at Board meetings was 100%. All Directors \nattended Board meetings in person or through electronic means \nof communication during the year.\n\nAgendas and accompanying Board papers are circulated \nwith sufficient time to allow the Directors to prepare before \nmeetings.\n\nThe Chairman takes the lead to ensure that the Board acts \nin the best interests of the Company, that there is effective \ncommunication with the shareholders and that their views are \ncommunicated to the Board as a whole.\n\nBoard decisions are made by vote at Board meetings and \nsupplemented by the circulation of written resolutions between \nBoard meetings.\n\nMinutes of Board meetings are taken by the Company Secretary \nand, together with any supporting papers, are made available \nto all Directors. The minutes record the matters considered by \nthe Board, the decisions reached, and any concerns raised or \ndissenting views expressed by Directors. Draft and final versions \nof the minutes are sent to all Directors for their comment and \nrecords respectively.\n\nOther Listed Company Directorship(s)\n", - "metadata": { - "page": 4, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpf7ka5pji.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - } -] diff --git a/clients/python/tests/_loader_check/pdfloader__background-checks.pdf.json b/clients/python/tests/_loader_check/pdfloader__background-checks.pdf.json deleted file mode 100644 index 0f3d58c..0000000 --- a/clients/python/tests/_loader_check/pdfloader__background-checks.pdf.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "page_content": "\nState / Territory Permit Handgun Long Gun *Other **Multiple Admin Handgun Long Gun *Other Handgun Long Gun *Other Handgun Long Gun *Other Handgun Long Gun Handgun Long Gun *Other Handgun Long Gun *Other Totals\nAlabama 18,870 23,022 22,650 859 1,178 0 14 15 0 2,179 2,307 11 0 0 0 13 14 0 3 2 0 71,137\nAlaska 209 3,062 3,209 191 184 0 9 3 0 100 100 0 18 9 1 0 0 0 0 0 0 7,095\nArizona 2,303 12,382 9,041 707 618 0 5 3 0 1,273 648 4 76 8 1 9 6 1 1 1 0 27,087\nArkansas 3,298 6,359 11,611 168 376 0 12 6 1 922 2,275 1 0 0 0 6 12 1 0 0 0 25,048\nCalifornia 98 452 41 181 35 007 4 559 0 0 0 0 0 480 433 4 0 0 0 0 0 0 0 0 0 180 116\nColorado 4,144 19,784 16,082 932 1,151 0 0 0 0 0 0 0 144 34 0 0 0 0 0 0 0 42,271\nConnecticut 9,631 11,594 5,072 134 0 7 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 26,438\nDelaware 204 2,152 2,424 65 72 0 3 4 0 17 12 0 0 0 0 59 24 0 4 0 0 5,040\nDistrict of Columbia 8 54 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 64\nFlorida 15,907 50,796 28,981 2,268 1,957 121 8 9 0 2,248 1,135 2 40 5 0 36 19 0 0 0 0 103,532\nGeorgia 14,111 16,635 15,227 448 758 0 10 14 0 1,772 1,796 4 0 0 0 10 9 1 0 0 0 50,795\nGuam 0 100 55 12 3 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 171\nHawaii 1,248 0 0 0 0 0 0 0 0 0 0 0 1 3 0 0 0 0 0 0 0 1,252\nIdaho 1,944 3,609 5,227 190 189 0 0 4 0 273 455 1 22 4 1 1 2 0 0 3 0 11,925\nIllinois 87,190 24,412 17,227 0 1,032 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 129,861\nIndiana 81,935 25,519 20,227 1,113 808 0 2 3 0 13 629 5 28 0 0 31 13 1 2 4 0 130,333\nIowa 8,785 267 4,596 27 4 37 0 1 0 0 71 0 4 2 0 0 0 0 0 0 0 13,794\nKansas 894 7,086 8,702 311 396 3 1 3 1 486 530 2 2 0 0 6 10 0 0 0 0 18,433\nKentucky 264,140 12,155 14,847 254 648 1 9 11 0 1,491 2,315 2 2 2 0 6 8 0 0 0 0 295,891\nLouisiana 1,945 14,708 17,368 697 793 0 5 11 2 884 1,323 3 0 0 0 1 10 1 0 1 0 37,752\nMaine 299 4,048 4,387 135 171 2 0 0 0 70 165 0 6 7 0 4 3 0 0 0 0 9,297\nMariana Islands 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\nMaryland 1,512 3,220 8,310 96 9 0 0 3 0 72 213 2 14 3 1 1 6 1 0 0 0 13,463\nMassachusetts 5,214 6,083 3,931 402 175 0 0 1 0 2 5 0 1 0 1 42 23 5 0 0 0 15,885\nMichigan 16,786 13,634 17,604 385 238 1,025 1 4 0 5 489 2 69 24 0 4 1 1 2 3 1 50,278\nMinnesota 18,939 9,373 14,173 486 330 0 1 2 0 176 512 0 8 11 0 2 6 0 0 2 2 44,023\nMississippi 618 9,785 13,191 267 523 9 28 20 0 1,171 1,874 1 0 0 0 6 6 3 2 0 0 27,504\nMissouri 5,884 21,135 22,852 1,004 1,060 0 11 16 0 1,131 1,754 3 99 11 4 33 25 6 0 3 0 55,031\nMontana 852 2,848 5,170 90 197 10 1 4 6 391 828 1 11 4 0 0 2 0 0 0 0 10,415\nNebraska 4,258 173 3,724 23 10 0 1 0 0 5 96 1 7 0 0 0 0 1 0 0 0 8,299\nNevada 1,543 4,889 3,272 193 287 0 0 0 0 284 163 0 1 0 0 0 0 0 0 0 0 10,632\nNew Hampshire 3,630 4,847 4,165 95 2 1 0 0 0 0 23 1 69 11 1 2 2 0 0 1 0 12,850\nNew Jersey 0 3,462 4,704 147 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8,313\nNew Mexico 829 5,247 4,974 334 277 0 2 3 1 397 618 1 33 0 0 0 4 0 1 0 0 12,721\nNew York 2,906 8,650 23,519 665 149 0 0 3 0 16 25 0 0 0 0 243 220 16 5 4 0 36,421\nNorth Carolina 22,469 1,370 20,107 576 202 0 16 25 0 1,771 2,644 7 0 0 0 2 26 1 1 0 0 49,217\nNorth Dakota 453 1,720 3,744 65 79 0 0 3 0 65 157 0 0 0 0 1 1 0 0 0 0 6,288\nOhio 9,338 34,878 31,817 1,392 1,483 0 24 16 1 1,228 1,389 4 0 0 0 21 22 1 1 0 1 81,616\nOklahoma 0 15,005 14,753 778 998 0 11 7 1 1,639 2,226 6 0 0 0 11 21 0 0 1 0 35,457\nOregon 35 13,586 11,832 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 25,453\nPennsylvania 24,136 62,752 16,297 0 0 58 0 0 0 0 0 0 446 147 0 0 0 0 0 0 0 103,836\nPuerto Rico 0 1,033 170 11 20 0 0 0 0 6 1 0 0 0 0 0 0 0 0 1 0 1,242\nRhode Island 0 954 932 57 135 0 0 0 0 3 4 0 0 0 0 12 2 1 1 0 0 2,101\nSouth Carolina 7,284 11,452 10,393 561 438 0 3 8 0 979 912 3 16 4 0 4 6 0 1 0 0 32,064\nSouth Dakota 819 2,765 5,484 143 151 0 0 1 0 123 239 0 0 0 0 1 1 0 0 0 0 9,727\nTennessee 9,509 28,815 24,023 0 1,300 0 0 0 0 0 0 0 17 1 5 0 0 0 0 0 0 63,670\nTexas 21,550 56,941 51,670 2,532 3,048 0 29 20 0 5,748 5,331 21 1 2 1 42 36 4 1 5 0 146,982\nUtah 10,429 4,314 5,857 195 262 0 0 1 0 209 285 1 140 58 21 0 0 0 0 0 0 21,772\nVermont 0 1,189 1,826 49 70 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 3,136\nVirgin Islands 93 13 2 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 109\nVirginia 622 24,052 22,348 974 0 0 0 0 0 0 0 0 10 4 0 0 0 0 0 0 0 48,010\nWashington 10,314 15,822 12,425 1,238 606 4 8 8 0 1,096 1,129 3 356 159 3 444 340 19 6 9 0 43,989\nWest Virginia 2,217 6,953 11,561 224 478 2 4 9 0 923 2,678 3 0 0 0 3 8 0 0 0 0 25,063\nWisconsin 5,867 13,700 17,759 458 45 0 0 3 0 124 513 3 15 20 4 10 15 1 0 3 1 38,541\nWyoming 383 1,745 2,372 87 104 1 0 4 0 132 184 0 0 0 0 1 2 0 0 2 0 5,017\nTotals 804,006 671,330 636,903 26,597 23,015 1,281 218 249 13 29,905 38,487 102 1,656 533 44 0 0 1,067 905 65 31 45 5 2,236,457\n\nNICS Firearm Background Checks\nNovember - 2015\nPre-Pawn Redemption Returned/Disposition Rentals Private Sale Return to Seller - Private Sale\n
\nNOTES:\n\n*Refers to frames, receivers and other firearms that are not either handguns or long guns (rifles or shotguns), such as firearms having a pistol grip that expel a shotgun shell\n\n**Multiple (multiple types of firearms selected)\n\nDISCLAIMERS:\n\nSome states may reflect lower than expected numbers for handgun checks based on varying state laws pertaining to handgun permits Since the permit check is done in place of the NICS check in most of the affected states, the low handgun statistics are often balanced out by a higher number of handgun permit checks\n\nThese statistics represent the number of firearm background checks initiated through the NICS They do not represent the number of firearms sold Based on varying state laws and purchase scenarios, a one-to-one correlation cannot be made between a firearm background check and a firearm sale\n\nPage 1 of 205\n", - "metadata": { - "page": 0, - "source": "amagpt/background-checks.pdf", - "file_path": "/tmp/tmps2wyf2vq.pdf", - "total_pages": 1, - "Producer": "Mac OS X 10.9.5 Quartz PDFContext", - "CreationDate": "D:20151212184957Z00'00'", - "ModDate": "D:20151212184957Z00'00'", - "document_id": "8162f205156ed68087275ec16284721e" - } - } -] diff --git a/clients/python/tests/_loader_check/pdfloader__sample_tables.pdf.json b/clients/python/tests/_loader_check/pdfloader__sample_tables.pdf.json deleted file mode 100644 index 2bd149a..0000000 --- a/clients/python/tests/_loader_check/pdfloader__sample_tables.pdf.json +++ /dev/null @@ -1,59 +0,0 @@ -[ - { - "page_content": "The Name of the Title is Hope\n\nBENTROVATO∗andG.K.M.TOBIN∗, Institute for Clarity in Documentation, USA\nLARSTHØRVÄLD, The Thørväld Group, Iceland\nVALERIEBÉRANGER, Inria Paris-Rocquencourt, France\n\nA clear and well-documented LATEX document is presented as an article\nformatted for publication by ACM in a conference proceedings or journal\npublication. Based on the “acmart†document class, this article presents and\nexplains many of the common variations, as well as many of the formatting\nelements an author may use in the preparation of the documentation of their\nwork.\n\nAdditional Key Words and Phrases: Image Captioning, Deep Learning\n\nACM Reference Format:\nBen Trovato, G.K.M. Tobin, Lars Thørväld, and Valerie Béranger. 2018. The\nNameoftheTitleisHope.ACMTrans.Graph.37,4,Article111(August2018),\n1 page. https://doi.org/XXXXXXX.XXXXXXX\n\n1 INTRODUCTION\n\nACM’s consolidated article template, introduced in 2017, provides\na consistent LATEX style for use across ACM publications, and in-\ncorporates accessibility and metadata-extraction functionality nec-\nessary for future Digital Library endeavors. Numerous ACM and\nSIG-specific LATEX templates have been examined, and their unique\nfeatures incorporated into this single new template.\n\n2 SHORT TABLE\n\nTable 1. Performance of tokenisation and encoding schemes on MS-COCO.\n\n\nApproach Params.\n(M)\n\nMS-COCO test set scores\nB-1 B-2 ΔC (%)\n\nWord (baseline) 12.2 71.5 54.5 -\nWord (slim) 12.2 70.5 50.5 − 12.0\n\nCharacter 5.1 68.8 51.5 − 10.5\n
\na Decoder size in terms of number of learnable parameters\n(excluding encoder).\n\n∗Both authors contributed equally to this research.\n\nAuthors’addresses:BenTrovato,trovato@corporation.com;G.K.M.Tobin,webmaster@\nmarysville-ohio.com, Institute for Clarity in Documentation, P.O. Box 1212, Dublin,\nOhio, USA, 43017-6221; Lars Thørväld, The Thørväld Group, 1 Thørväld Circle, Hekla,\nIceland, larst@affiliation.org; Valerie Béranger, Inria Paris-Rocquencourt, Rocquen-\ncourt, France.\n\nPermission to make digital or hard copies of all or part of this work for personal or\nclassroom use is granted without fee provided that copies are not made or distributed\nfor profit or commercial advantage and that copies bear this notice and the full citation\non the first page. Copyrights for components of this work owned by others than ACM\nmust be honored. Abstracting with credit is permitted. To copy otherwise, or republish,\nto post on servers or to redistribute to lists, requires prior specific permission and/or a\nfee. Request permissions from permissions@acm.org.\n© 2018 Association for Computing Machinery.\n0730-0301/2018/8-ART111 $15.00\nhttps://doi.org/XXXXXXX.XXXXXXX\n\nFig. 1. 1907 Franklin Model D roadster. Photograph by Harris & Ewing, Inc.\n[Public domain], via Wikimedia Commons. (https://goo.gl/VLCRBB).\n\n\nParameters Model 1 Model 2\nCoefficient 95% CI Coefficient 95% CI\nØ 0.190∗ (0.113, 0.268) 0.171 (0.100, 0.241)\n𜋠0.117 (0.043, 0.191) 0.117 (0.050, 0.183)\n$ 0.210 (0.139, 0.281) 0.190 (0.127, 0.253)Ψ21 0.204 (0.135, 0.273) 0.111 ( 0.049, 0.173)\n
\n\n2.1 Template Styles\n\nJournals use one of three template styles. All but three ACM journals\nuse the acmsmall template style:\n• acmsmall: The default journal template style.\n• acmlarge: Used by JOCCH and TAP.\n• acmtog: Used by TOG.\n\n3 FIGURES\n\nThe “figure†environment should be used for figures. One or more\nimages can be placed within a figure. If your figure contains third-\nparty material, you must clearly identify it as such, as shown in the\nexample below.\nYour figures should contain a caption which describes the figure\nto the reader.\n\nReceived 20 February 2007; revised 12 March 2009; accepted 5 June 2009\n\nACM Trans. Graph., Vol. 37, No. 4, Article 111. Publication date: August 2018.\n", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmpak3aalx7.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "- 3 - \n \n \nPremium = 30 x $4,100 + 50 x $29,700 + 20 x $38,600 = $2,380,000 \n \nIn a case where the claimed exempted balcony floor space is \nsmaller than the permissible floor space under the Joint Practice Note No. 1 the \npremium will be assessed on a pro-rata basis. The following examples illustrate \nhow the premia are assessed in each cases: \n \n In each flat the lot is \nin HK Island Case 1 Case 2 Case 3 \nTotal claimed area of \nexempted balcony \nfloor space \n\n2.1 2.1 2.8 \n\nTotal permissible \nexempted balcony \nfloor space under \nBuildings Ordinance \n/Joint PN1 \n\n2.5 2.6 4 \n\nFormula used Not applicable $15,800 x 2.1/2.6 $28,700 x 2.8/4.0 \nPremium $4,800 $12,761.54 $20,900 \n
\n In addition to the premium, an administrative fee currently of \n$23,000 will be payable. \n \nThe district boundary follows that of the boundary of the District \nLands Offices. For lots straddling between two districts, the higher rate is \napplicable. \n \n5. Subsequent changes \n Upon completion of the modification and issue of the first consent \nletter, subsequent changes to the composition of the exempted floor space or \npermissible exempted floor space will require a further consent letter subject to \npayment of an administrative fee, and possibly additional premium if \nappropriate. \n \n6. Submission of building plans \n APs are also advised to provide a schedule in the building plans \nsubmitted to the Buildings and Lands Departments listing out the permissible \nexempted balcony floor space and the claimed exempted balcony space for \nchecking purposes. \n", - "metadata": { - "page": 1, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmpak3aalx7.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "Premium Per Unit\n\n\nHK/KLN Sha Tin\nSai Kung\n\nTai Po\nNorth\n\nTuen Mun\nYuen Long\n\nKwai Tsing\nTsuen Wan Islands\n\n2.0 $4,100 $2,300 $0 $0 $3,000 $0\n\n2.1 $4,800 $2,900 $0 $0 $3,600 $0\n\n2.2 $5,500 $3,500 $0 $0 $4,200 $0\n\n2.3 $6,100 $4,000 $0 $0 $4,800 $0\n\n2.4 $6,800 $4,600 $0 $0 $5,400 $0\n\n2.5 $7,400 $5,100 $360 $0 $6,000 $360\n\n2.6 $15,800 $8,400 $3,600 $1,200 $8,400 $3,600\n\n2.7 $16,700 $9,000 $4,000 $1,500 $9,000 $4,000\n\n2.8 $17,600 $9,700 $4,500 $1,900 $9,700 $4,500\n\n2.9 $18,500 $10,300 $4,900 $2,200 $10,300 $4,900\n\n3.0 $19,400 $10,900 $5,300 $2,500 $10,900 $5,300\n\n3.1 $20,300 $11,600 $5,800 $2,900 $11,600 $5,800\n\n3.2 $21,200 $12,200 $6,200 $3,200 $12,200 $6,200\n\n3.3 $22,100 $12,900 $6,700 $3,600 $12,900 $6,700\n\n3.4 $23,000 $13,500 $7,100 $3,900 $13,500 $7,100\n\n3.5 $23,900 $14,100 $7,500 $4,200 $14,100 $7,500\n\n3.6 $24,900 $14,800 $8,000 $4,600 $14,800 $8,000\n\n3.7 $25,900 $15,500 $8,500 $5,000 $15,500 $8,500\n\n3.8 $26,800 $16,100 $9,000 $5,400 $16,100 $9,000\n\n3.9 $27,800 $16,800 $9,500 $5,800 $16,800 $9,500\n\n4.0 $28,700 $17,400 $9,900 $6,100 $17,400 $9,900\n\n4.1 $29,700 $18,100 $10,400 $6,500 $18,100 $10,400\n\n4.2 $30,600 $18,800 $10,900 $6,900 $18,800 $10,900\n\n4.3 $31,600 $19,400 $11,400 $7,300 $19,400 $11,400\n\n4.4 $32,500 $20,100 $11,900 $7,700 $20,100 $11,900\n\n4.5 $33,400 $20,700 $12,300 $8,000 $20,700 $12,300\n\n4.6 $34,500 $21,500 $12,900 $8,500 $21,500 $12,900\n\n4.7 $35,500 $22,300 $13,500 $9,000 $22,300 $13,500\n\n4.8 $36,600 $23,000 $14,000 $9,500 $23,000 $14,000\n\n4.9 $37,600 $23,800 $14,600 $10,000 $23,800 $14,600\n\n5.0 $38,600 $24,500 $15,100 $10,400 $24,500 $15,100\n\nwith effect from April 2001\n\n \n District\nTotal Exempted\nBalcony Area (m²) Per Unit\n
\n\nAppendix\n\nStandard Rates for Premium Assessment for Exemption of Balconies\n", - "metadata": { - "page": 2, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmpak3aalx7.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - } -] diff --git a/clients/python/tests/_loader_check/split_documents__Swire_AR22_e_230406_sample.pdf.json b/clients/python/tests/_loader_check/split_documents__Swire_AR22_e_230406_sample.pdf.json deleted file mode 100644 index b948a98..0000000 --- a/clients/python/tests/_loader_check/split_documents__Swire_AR22_e_230406_sample.pdf.json +++ /dev/null @@ -1,184 +0,0 @@ -[ - { - "page_content": "2022\nStock Codes ‘A’ Shares 00019 ‘B’ Shares 00087\n\nA N N U A L R E P O R T", - "metadata": { - "page": 0, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "1 Corporate Statement\n3 2022 Performance Highlights\n4 Chairman’s Statement\n\nMANAGEMENT DISCUSSION \nAND ANALYSIS\n\n10 2022 Performance Review and Outlook\n59 Financial Review\n69 Financing\n\nCORPORATE GOVERNANCE & \nSUSTAINABILITY\n\n79 Corporate Governance Report\n94 Risk Management\n98 Directors and Officers\n100 Directors’ Report\n109 Sustainable Development Review\n\nFINANCIAL STATEMENTS\n\n117 Independent Auditor’s Report\n125 Consolidated Statement of Profit or Loss\n126 Consolidated Statement of Other \nComprehensive Income\n127 Consolidated Statement of Financial Position\n128 Consolidated Statement of Cash Flows \n129 Consolidated Statement of Changes in Equity\n130 Notes to the Financial Statements\n205 Principal Accounting Policies\n208 Principal Subsidiary, Joint Venture and \nAssociated Companies\n218 Cathay Pacific Airways Limited – \nAbridged Financial Statements\n\nSUPPLEMENTARY INFORMATION", - "metadata": { - "page": 1, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "SUPPLEMENTARY INFORMATION\n\n220 Summary of Past Performance\n222 Schedule of Principal Group Properties\n232 Group Structure Chart\n234 Glossary \n236 Financial Calendar and Information for Investors\n236 Disclaimer\n\nCONTENTS\nNote: Definitions of the terms and ratios used \nin this report can be found in the Glossary.", - "metadata": { - "page": 1, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "SWIRE PACIFIC ANNUAL REPORT 2022 1\nOur aims are to deliver sustainable growth in shareholder value, \nachieved through sound returns on equity over the long term, \nand to return value to shareholders through sustainable growth \nin ordinary dividends. Our strategy is focused on Greater China \nand South East Asia, where we seek to grow our core Property, \nBeverages and Aviation divisions. New areas of growth, such as \nhealthcare and sustainable foods, are being targeted. \n\nOur Values\n\nIntegrity, endeavour, excellence, humility, teamwork, continuity. \n\nOur Core Principles\n\n– We focus on Asia, principally Greater China, because of \nits strong growth potential and because it is where the \nGroup has long experience, deep knowledge and strong \nrelationships.\n\n– We mobilise capital, talent and ideas across the Group. \nOur scale and diversity increase our access to investment \nopportunities.", - "metadata": { - "page": 2, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "– We mobilise capital, talent and ideas across the Group. \nOur scale and diversity increase our access to investment \nopportunities.\n\n– We are prudent financial managers. This enables us to \nexecute long-term investment plans irrespective of short-\nterm financial market volatility.\n\n– We recruit the best people and invest heavily in their training \nand development. The welfare of our people is critical to our \noperations.\n\n– We build strong and lasting relationships, based on mutual \nbenefit, with those with whom we do business.\n\n– We invest in sustainable development, because it is the \nright thing to do and because it supports long-term growth \nthrough innovation and improved efficiency. \n\n– We are committed to the highest standards of corporate \ngovernance and to the preservation and development of the \nSwire brand and reputation. \n\nOur Investment Principles\n\n– We aim to build a portfolio of businesses that collectively \ndeliver a steady dividend stream over time.", - "metadata": { - "page": 2, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "Our Investment Principles\n\n– We aim to build a portfolio of businesses that collectively \ndeliver a steady dividend stream over time. \n\n– We are long-term investors. We prefer to have controlling \ninterests in our businesses and to manage them for long-\nterm growth. We do not rule out minority investments in \nappropriate circumstances. \n\n– We concentrate on businesses where we can contribute \nexpertise, and where our expertise can add value.\n\n– We invest in businesses that provide high-quality products \nand services and that are leaders in their markets. \n\n– We divest from businesses which have reached their full \npotential under our ownership, and recycle the capital \nreleased into existing or new businesses. \n\nCORPORATE STATEMENT\n\nSUSTAINABLE GROWTH", - "metadata": { - "page": 2, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "CORPORATE STATEMENT\n\nSUSTAINABLE GROWTH\n\nSwire Pacific is a Hong Kong-based international conglomerate with a diversified \nportfolio of market leading businesses. The Company has a long history in Greater China, \nwhere the name Swire or å¤ªå¤ has been established for over 150 years.", - "metadata": { - "page": 2, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "SWIRE PACIFIC ANNUAL REPORT 2022 49\n\nFleet profile*", - "metadata": { - "page": 3, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "\n\nAircraft type\n\nNumber at\n31st December 2022 Total\n\nAverage\nage\n\nOrders Total\n\nExpiry of operating leases**\n\nOwned\n\nLeased**\n\nFinance Operating ‘23 ‘24\n\n‘25 and\nbeyond ‘23 ‘24 ‘25 ‘26 ‘27\n\n‘28 and\nbeyond\nCathay Pacific:\nA320-200 4 4 19.3 \nA321-200 2 1 3 19.8 1\nA321-200neo 2 5 7 1.4 5(a) 4 9 5\nA330-300 31 8 4 43 14.3 2 2\nA350-900 19 7 2 28 5.1 2 2 2\nA350-1000 11 7 18 3.1 \n747-400ERF 6 6 14.0\n747-8F 3 11 14 9.9\n777-300 17 17 21.2\n777-300ER 28 2 11 41 10.2 2 3 2 4\n777-9 21 21\nTotal 121 37 23 181 10.8 7 4 21 32 3 3 4 6 7\nHK Express:\nA320-200 5 5 10.5 1 4\nA320-200neo 10 10 3.8 10\nA321-200 11 11 5.2 1 2 8\nA321-200neo 4 8 4 16\nTotal 26 26 5.7 4 8 4 16 1 4 1 2 18\nAir Hong Kong***(b):\nA300-600F 9 9 18.6 7 2\nA330-243F 2 2 11.0 2\nA330-300P2F 4 4 13.7 3 1\nTotal 15 15 16.3 7 2 5 1\nGrand total 121 37 64 222 10.6 11 12 25 48 11 9 5 13 26\n
", - "metadata": { - "page": 3, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "* The table does not reflect aircraft movements after 31st December 2022.\n** Leases previously classified as operating leases are accounted for in a similar manner to finance leases under accounting standards. The majority of operating leases in the above table are \nwithin the scope of HKFRS 16.\n*** The contractual arrangements relating to the freighters operated by Air Hong Kong do not constitute leases in accordance with HKFRS 16.\n(a) Two Airbus A321-200neo aircraft were delivered in February 2023. \n(b) The plan is to return the nine A300-600F aircraft between 2023 and 2024 and to replace them with nine second-hand A330F aircraft. This allows the Air Hong Kong fleet to remain the same \n(at 15), at least until 2024.", - "metadata": { - "page": 3, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "CORPORATE GOVERNANCE REPORT82\n5\n\n4\n\n3\n\n2\n\n1\n\n0 0 2 4 6 81 3 5 7\n\n1\n1 5 7\n\nNumber of\nCompanies Number of Directors\n\nResponsibilities of Directors\nOn appointment, the Directors receive information about the Group including:\n\n– the role of the Board and the matters reserved for its attention\n– the role and terms of reference of Board Committees\n– the Group’s corporate governance practices and procedures\n– the powers delegated to management and\n– the latest financial information.\n\nDirectors update their skills, knowledge and understanding of the Company’s businesses through their participation at meetings of \nthe Board and its committees and through regular meetings with management at the head office and in the divisions. Directors are \nregularly updated by the Company Secretary on their legal and other duties as Directors of a listed company.\n\nThrough the Company Secretary, Directors are able to obtain appropriate professional training and advice.", - "metadata": { - "page": 4, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "Through the Company Secretary, Directors are able to obtain appropriate professional training and advice.\n\nEach Director ensures that he/she can give sufficient time and attention to the affairs of the Group. All Directors disclose to the \nBoard on their first appointment their interests as a Director or otherwise in other companies or organisations and such declarations \nof interests are updated regularly. No Director was a director of more than five other listed companies (excluding the Company) at \n31stDecember 2022.\n\nDetails of Directors’ other appointments are shown in their \nbiographies in the section of this annual report headed Directors \nand Officers.\n\nBoard Processes\nAll committees of the Board follow the same processes as the \nfull Board.", - "metadata": { - "page": 4, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "Board Processes\nAll committees of the Board follow the same processes as the \nfull Board.\n\nThe dates of the 2022 Board meetings were determined in \n2021 and any amendments to this schedule were notified to \nDirectors at least 14 days before regular meetings. Appropriate \narrangements are in place to allow Directors to include items in \nthe agenda for regular Board meetings.\n\nThe Board met seven times in 2022, including two strategy \nsessions. The attendance of individual Directors at meetings \nof the Board and its committees is set out in the table on page \n83. Attendance at Board meetings was 100%. All Directors \nattended Board meetings in person or through electronic means \nof communication during the year.\n\nAgendas and accompanying Board papers are circulated \nwith sufficient time to allow the Directors to prepare before \nmeetings.", - "metadata": { - "page": 4, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - }, - { - "page_content": "Agendas and accompanying Board papers are circulated \nwith sufficient time to allow the Directors to prepare before \nmeetings.\n\nThe Chairman takes the lead to ensure that the Board acts \nin the best interests of the Company, that there is effective \ncommunication with the shareholders and that their views are \ncommunicated to the Board as a whole.\n\nBoard decisions are made by vote at Board meetings and \nsupplemented by the circulation of written resolutions between \nBoard meetings.\n\nMinutes of Board meetings are taken by the Company Secretary \nand, together with any supporting papers, are made available \nto all Directors. The minutes record the matters considered by \nthe Board, the decisions reached, and any concerns raised or \ndissenting views expressed by Directors. Draft and final versions \nof the minutes are sent to all Directors for their comment and \nrecords respectively.\n\nOther Listed Company Directorship(s)", - "metadata": { - "page": 4, - "source": "amagpt/Swire_AR22_e_230406_sample.pdf", - "file_path": "/tmp/tmpecqnyb6m.pdf", - "total_pages": 5, - "CreationDate": "D:20240129144143", - "Creator": "PDFium", - "Producer": "PDFium", - "document_id": "47147604964f6b1882c7df59383302ea" - } - } -] diff --git a/clients/python/tests/_loader_check/split_documents__background-checks.pdf.json b/clients/python/tests/_loader_check/split_documents__background-checks.pdf.json deleted file mode 100644 index 80b06d4..0000000 --- a/clients/python/tests/_loader_check/split_documents__background-checks.pdf.json +++ /dev/null @@ -1,28 +0,0 @@ -[ - { - "page_content": "State / Territory Permit Handgun Long Gun *Other **Multiple Admin Handgun Long Gun *Other Handgun Long Gun *Other Handgun Long Gun *Other Handgun Long Gun Handgun Long Gun *Other Handgun Long Gun *Other Totals\nAlabama 18,870 23,022 22,650 859 1,178 0 14 15 0 2,179 2,307 11 0 0 0 13 14 0 3 2 0 71,137\nAlaska 209 3,062 3,209 191 184 0 9 3 0 100 100 0 18 9 1 0 0 0 0 0 0 7,095\nArizona 2,303 12,382 9,041 707 618 0 5 3 0 1,273 648 4 76 8 1 9 6 1 1 1 0 27,087\nArkansas 3,298 6,359 11,611 168 376 0 12 6 1 922 2,275 1 0 0 0 6 12 1 0 0 0 25,048\nCalifornia 98 452 41 181 35 007 4 559 0 0 0 0 0 480 433 4 0 0 0 0 0 0 0 0 0 180 116\nColorado 4,144 19,784 16,082 932 1,151 0 0 0 0 0 0 0 144 34 0 0 0 0 0 0 0 42,271\nConnecticut 9,631 11,594 5,072 134 0 7 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 26,438\nDelaware 204 2,152 2,424 65 72 0 3 4 0 17 12 0 0 0 0 59 24 0 4 0 0 5,040\nDistrict of Columbia 8 54 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 64\nFlorida 15,907 50,796 28,981 2,268 1,957 121 8 9 0 2,248 1,135 2 40 5 0 36 19 0 0 0 0 103,532\nGeorgia 14,111 16,635 15,227 448 758 0 10 14 0 1,772 1,796 4 0 0 0 10 9 1 0 0 0 50,795\nGuam 0 100 55 12 3 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 171\nHawaii 1,248 0 0 0 0 0 0 0 0 0 0 0 1 3 0 0 0 0 0 0 0 1,252\nIdaho 1,944 3,609 5,227 190 189 0 0 4 0 273 455 1 22 4 1 1 2 0 0 3 0 11,925\nIllinois 87,190 24,412 17,227 0 1,032 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 129,861\nIndiana 81,935 25,519 20,227 1,113 808 0 2 3 0 13 629 5 28 0 0 31 13 1 2 4 0 130,333\nIowa 8,785 267 4,596 27 4 37 0 1 0 0 71 0 4 2 0 0 0 0 0 0 0 13,794\nKansas 894 7,086 8,702 311 396 3 1 3 1 486 530 2 2 0 0 6 10 0 0 0 0 18,433\nKentucky 264,140 12,155 14,847 254 648 1 9 11 0 1,491 2,315 2 2 2 0 6 8 0 0 0 0 295,891\nLouisiana 1,945 14,708 17,368 697 793 0 5 11 2 884 1,323 3 0 0 0 1 10 1 0 1 0 37,752\nMaine 299 4,048 4,387 135 171 2 0 0 0 70 165 0 6 7 0 4 3 0 0 0 0 9,297\nMariana Islands 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\nMaryland 1,512 3,220 8,310 96 9 0 0 3 0 72 213 2 14 3 1 1 6 1 0 0 0 13,463\nMassachusetts 5,214 6,083 3,931 402 175 0 0 1 0 2 5 0 1 0 1 42 23 5 0 0 0 15,885\nMichigan 16,786 13,634 17,604 385 238 1,025 1 4 0 5 489 2 69 24 0 4 1 1 2 3 1 50,278\nMinnesota 18,939 9,373 14,173 486 330 0 1 2 0 176 512 0 8 11 0 2 6 0 0 2 2 44,023\nMississippi 618 9,785 13,191 267 523 9 28 20 0 1,171 1,874 1 0 0 0 6 6 3 2 0 0 27,504\nMissouri 5,884 21,135 22,852 1,004 1,060 0 11 16 0 1,131 1,754 3 99 11 4 33 25 6 0 3 0 55,031\nMontana 852 2,848 5,170 90 197 10 1 4 6 391 828 1 11 4 0 0 2 0 0 0 0 10,415\nNebraska 4,258 173 3,724 23 10 0 1 0 0 5 96 1 7 0 0 0 0 1 0 0 0 8,299\nNevada 1,543 4,889 3,272 193 287 0 0 0 0 284 163 0 1 0 0 0 0 0 0 0 0 10,632\nNew Hampshire 3,630 4,847 4,165 95 2 1 0 0 0 0 23 1 69 11 1 2 2 0 0 1 0 12,850\nNew Jersey 0 3,462 4,704 147 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 8,313\nNew Mexico 829 5,247 4,974 334 277 0 2 3 1 397 618 1 33 0 0 0 4 0 1 0 0 12,721\nNew York 2,906 8,650 23,519 665 149 0 0 3 0 16 25 0 0 0 0 243 220 16 5 4 0 36,421\nNorth Carolina 22,469 1,370 20,107 576 202 0 16 25 0 1,771 2,644 7 0 0 0 2 26 1 1 0 0 49,217\nNorth Dakota 453 1,720 3,744 65 79 0 0 3 0 65 157 0 0 0 0 1 1 0 0 0 0 6,288\nOhio 9,338 34,878 31,817 1,392 1,483 0 24 16 1 1,228 1,389 4 0 0 0 21 22 1 1 0 1 81,616\nOklahoma 0 15,005 14,753 778 998 0 11 7 1 1,639 2,226 6 0 0 0 11 21 0 0 1 0 35,457\nOregon 35 13,586 11,832 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 25,453\nPennsylvania 24,136 62,752 16,297 0 0 58 0 0 0 0 0 0 446 147 0 0 0 0 0 0 0 103,836\nPuerto Rico 0 1,033 170 11 20 0 0 0 0 6 1 0 0 0 0 0 0 0 0 1 0 1,242\nRhode Island 0 954 932 57 135 0 0 0 0 3 4 0 0 0 0 12 2 1 1 0 0 2,101\nSouth Carolina 7,284 11,452 10,393 561 438 0 3 8 0 979 912 3 16 4 0 4 6 0 1 0 0 32,064\nSouth Dakota 819 2,765 5,484 143 151 0 0 1 0 123 239 0 0 0 0 1 1 0 0 0 0 9,727\nTennessee 9,509 28,815 24,023 0 1,300 0 0 0 0 0 0 0 17 1 5 0 0 0 0 0 0 63,670\nTexas 21,550 56,941 51,670 2,532 3,048 0 29 20 0 5,748 5,331 21 1 2 1 42 36 4 1 5 0 146,982\nUtah 10,429 4,314 5,857 195 262 0 0 1 0 209 285 1 140 58 21 0 0 0 0 0 0 21,772\nVermont 0 1,189 1,826 49 70 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 3,136\nVirgin Islands 93 13 2 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 109\nVirginia 622 24,052 22,348 974 0 0 0 0 0 0 0 0 10 4 0 0 0 0 0 0 0 48,010\nWashington 10,314 15,822 12,425 1,238 606 4 8 8 0 1,096 1,129 3 356 159 3 444 340 19 6 9 0 43,989\nWest Virginia 2,217 6,953 11,561 224 478 2 4 9 0 923 2,678 3 0 0 0 3 8 0 0 0 0 25,063\nWisconsin 5,867 13,700 17,759 458 45 0 0 3 0 124 513 3 15 20 4 10 15 1 0 3 1 38,541\nWyoming 383 1,745 2,372 87 104 1 0 4 0 132 184 0 0 0 0 1 2 0 0 2 0 5,017\nTotals 804,006 671,330 636,903 26,597 23,015 1,281 218 249 13 29,905 38,487 102 1,656 533 44 0 0 1,067 905 65 31 45 5 2,236,457\n\nNICS Firearm Background Checks\nNovember - 2015\nPre-Pawn Redemption Returned/Disposition Rentals Private Sale Return to Seller - Private Sale\n
", - "metadata": { - "page": 0, - "source": "amagpt/background-checks.pdf", - "file_path": "/tmp/tmp95jzwj0e.pdf", - "total_pages": 1, - "Producer": "Mac OS X 10.9.5 Quartz PDFContext", - "CreationDate": "D:20151212184957Z00'00'", - "ModDate": "D:20151212184957Z00'00'", - "document_id": "8162f205156ed68087275ec16284721e" - } - }, - { - "page_content": "NOTES:\n\n*Refers to frames, receivers and other firearms that are not either handguns or long guns (rifles or shotguns), such as firearms having a pistol grip that expel a shotgun shell\n\n**Multiple (multiple types of firearms selected)\n\nDISCLAIMERS:\n\nSome states may reflect lower than expected numbers for handgun checks based on varying state laws pertaining to handgun permits Since the permit check is done in place of the NICS check in most of the affected states, the low handgun statistics are often balanced out by a higher number of handgun permit checks\n\nThese statistics represent the number of firearm background checks initiated through the NICS They do not represent the number of firearms sold Based on varying state laws and purchase scenarios, a one-to-one correlation cannot be made between a firearm background check and a firearm sale\n\nPage 1 of 205", - "metadata": { - "page": 0, - "source": "amagpt/background-checks.pdf", - "file_path": "/tmp/tmp95jzwj0e.pdf", - "total_pages": 1, - "Producer": "Mac OS X 10.9.5 Quartz PDFContext", - "CreationDate": "D:20151212184957Z00'00'", - "ModDate": "D:20151212184957Z00'00'", - "document_id": "8162f205156ed68087275ec16284721e" - } - } -] diff --git a/clients/python/tests/_loader_check/split_documents__sample_tables.pdf.json b/clients/python/tests/_loader_check/split_documents__sample_tables.pdf.json deleted file mode 100644 index 5b32cc9..0000000 --- a/clients/python/tests/_loader_check/split_documents__sample_tables.pdf.json +++ /dev/null @@ -1,249 +0,0 @@ -[ - { - "page_content": "The Name of the Title is Hope\n\nBENTROVATO∗andG.K.M.TOBIN∗, Institute for Clarity in Documentation, USA\nLARSTHØRVÄLD, The Thørväld Group, Iceland\nVALERIEBÉRANGER, Inria Paris-Rocquencourt, France\n\nA clear and well-documented LATEX document is presented as an article\nformatted for publication by ACM in a conference proceedings or journal\npublication. Based on the “acmart†document class, this article presents and\nexplains many of the common variations, as well as many of the formatting\nelements an author may use in the preparation of the documentation of their\nwork.\n\nAdditional Key Words and Phrases: Image Captioning, Deep Learning\n\nACM Reference Format:\nBen Trovato, G.K.M. Tobin, Lars Thørväld, and Valerie Béranger. 2018. The\nNameoftheTitleisHope.ACMTrans.Graph.37,4,Article111(August2018),\n1 page. https://doi.org/XXXXXXX.XXXXXXX\n\n1 INTRODUCTION", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "1 INTRODUCTION\n\nACM’s consolidated article template, introduced in 2017, provides\na consistent LATEX style for use across ACM publications, and in-\ncorporates accessibility and metadata-extraction functionality nec-\nessary for future Digital Library endeavors. Numerous ACM and\nSIG-specific LATEX templates have been examined, and their unique\nfeatures incorporated into this single new template.\n\n2 SHORT TABLE\n\nTable 1. Performance of tokenisation and encoding schemes on MS-COCO.", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "\n\nApproach Params.\n(M)\n\nMS-COCO test set scores\nB-1 B-2 ΔC (%)\n\nWord (baseline) 12.2 71.5 54.5 -\nWord (slim) 12.2 70.5 50.5 − 12.0\n\nCharacter 5.1 68.8 51.5 − 10.5\n
", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "a Decoder size in terms of number of learnable parameters\n(excluding encoder).\n\n∗Both authors contributed equally to this research.\n\nAuthors’addresses:BenTrovato,trovato@corporation.com;G.K.M.Tobin,webmaster@\nmarysville-ohio.com, Institute for Clarity in Documentation, P.O. Box 1212, Dublin,\nOhio, USA, 43017-6221; Lars Thørväld, The Thørväld Group, 1 Thørväld Circle, Hekla,\nIceland, larst@affiliation.org; Valerie Béranger, Inria Paris-Rocquencourt, Rocquen-\ncourt, France.", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "Permission to make digital or hard copies of all or part of this work for personal or\nclassroom use is granted without fee provided that copies are not made or distributed\nfor profit or commercial advantage and that copies bear this notice and the full citation\non the first page. Copyrights for components of this work owned by others than ACM\nmust be honored. Abstracting with credit is permitted. To copy otherwise, or republish,\nto post on servers or to redistribute to lists, requires prior specific permission and/or a\nfee. Request permissions from permissions@acm.org.\n© 2018 Association for Computing Machinery.\n0730-0301/2018/8-ART111 $15.00\nhttps://doi.org/XXXXXXX.XXXXXXX\n\nFig. 1. 1907 Franklin Model D roadster. Photograph by Harris & Ewing, Inc.\n[Public domain], via Wikimedia Commons. (https://goo.gl/VLCRBB).", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "\n\nParameters Model 1 Model 2\nCoefficient 95% CI Coefficient 95% CI\nØ 0.190∗ (0.113, 0.268) 0.171 (0.100, 0.241)\n𜋠0.117 (0.043, 0.191) 0.117 (0.050, 0.183)\n$ 0.210 (0.139, 0.281) 0.190 (0.127, 0.253)Ψ21 0.204 (0.135, 0.273) 0.111 ( 0.049, 0.173)\n
", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "2.1 Template Styles\n\nJournals use one of three template styles. All but three ACM journals\nuse the acmsmall template style:\n• acmsmall: The default journal template style.\n• acmlarge: Used by JOCCH and TAP.\n• acmtog: Used by TOG.\n\n3 FIGURES\n\nThe “figure†environment should be used for figures. One or more\nimages can be placed within a figure. If your figure contains third-\nparty material, you must clearly identify it as such, as shown in the\nexample below.\nYour figures should contain a caption which describes the figure\nto the reader.\n\nReceived 20 February 2007; revised 12 March 2009; accepted 5 June 2009\n\nACM Trans. Graph., Vol. 37, No. 4, Article 111. Publication date: August 2018.", - "metadata": { - "page": 0, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "- 3 - \n \n \nPremium = 30 x $4,100 + 50 x $29,700 + 20 x $38,600 = $2,380,000 \n \nIn a case where the claimed exempted balcony floor space is \nsmaller than the permissible floor space under the Joint Practice Note No. 1 the \npremium will be assessed on a pro-rata basis. The following examples illustrate \nhow the premia are assessed in each cases:", - "metadata": { - "page": 1, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": " In each flat the lot is \nin HK Island Case 1 Case 2 Case 3 \nTotal claimed area of \nexempted balcony \nfloor space \n\n2.1 2.1 2.8 \n\nTotal permissible \nexempted balcony \nfloor space under \nBuildings Ordinance \n/Joint PN1 \n\n2.5 2.6 4 \n\nFormula used Not applicable $15,800 x 2.1/2.6 $28,700 x 2.8/4.0 \nPremium $4,800 $12,761.54 $20,900 \n
", - "metadata": { - "page": 1, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "In addition to the premium, an administrative fee currently of \n$23,000 will be payable. \n \nThe district boundary follows that of the boundary of the District \nLands Offices. For lots straddling between two districts, the higher rate is \napplicable. \n \n5. Subsequent changes \n Upon completion of the modification and issue of the first consent \nletter, subsequent changes to the composition of the exempted floor space or \npermissible exempted floor space will require a further consent letter subject to \npayment of an administrative fee, and possibly additional premium if \nappropriate. \n \n6. Submission of building plans \n APs are also advised to provide a schedule in the building plans \nsubmitted to the Buildings and Lands Departments listing out the permissible \nexempted balcony floor space and the claimed exempted balcony space for \nchecking purposes.", - "metadata": { - "page": 1, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "Premium Per Unit", - "metadata": { - "page": 2, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "\n\nHK/KLN Sha Tin\nSai Kung\n\nTai Po\nNorth\n\nTuen Mun\nYuen Long\n\nKwai Tsing\nTsuen Wan Islands\n\n2.0 $4,100 $2,300 $0 $0 $3,000 $0\n\n2.1 $4,800 $2,900 $0 $0 $3,600 $0\n\n2.2 $5,500 $3,500 $0 $0 $4,200 $0\n\n2.3 $6,100 $4,000 $0 $0 $4,800 $0\n\n2.4 $6,800 $4,600 $0 $0 $5,400 $0\n\n2.5 $7,400 $5,100 $360 $0 $6,000 $360\n\n2.6 $15,800 $8,400 $3,600 $1,200 $8,400 $3,600\n\n2.7 $16,700 $9,000 $4,000 $1,500 $9,000 $4,000\n\n2.8 $17,600 $9,700 $4,500 $1,900 $9,700 $4,500\n\n2.9 $18,500 $10,300 $4,900 $2,200 $10,300 $4,900\n\n3.0 $19,400 $10,900 $5,300 $2,500 $10,900 $5,300\n\n3.1 $20,300 $11,600 $5,800 $2,900 $11,600 $5,800\n\n3.2 $21,200 $12,200 $6,200 $3,200 $12,200 $6,200\n\n3.3 $22,100 $12,900 $6,700 $3,600 $12,900 $6,700\n\n3.4 $23,000 $13,500 $7,100 $3,900 $13,500 $7,100\n\n3.5 $23,900 $14,100 $7,500 $4,200 $14,100 $7,500\n\n3.6 $24,900 $14,800 $8,000 $4,600 $14,800 $8,000\n\n3.7 $25,900 $15,500 $8,500 $5,000 $15,500 $8,500\n\n3.8 $26,800 $16,100 $9,000 $5,400 $16,100 $9,000\n\n3.9 $27,800 $16,800 $9,500 $5,800 $16,800 $9,500\n\n4.0 $28,700 $17,400 $9,900 $6,100 $17,400 $9,900\n\n4.1 $29,700 $18,100 $10,400 $6,500 $18,100 $10,400\n\n4.2 $30,600 $18,800 $10,900 $6,900 $18,800 $10,900\n\n4.3 $31,600 $19,400 $11,400 $7,300 $19,400 $11,400\n\n4.4 $32,500 $20,100 $11,900 $7,700 $20,100 $11,900\n\n4.5 $33,400 $20,700 $12,300 $8,000 $20,700 $12,300\n\n4.6 $34,500 $21,500 $12,900 $8,500 $21,500 $12,900\n\n4.7 $35,500 $22,300 $13,500 $9,000 $22,300 $13,500\n\n4.8 $36,600 $23,000 $14,000 $9,500 $23,000 $14,000\n\n4.9 $37,600 $23,800 $14,600 $10,000 $23,800 $14,600\n\n5.0 $38,600 $24,500 $15,100 $10,400 $24,500 $15,100\n\nwith effect from April 2001\n\n \n District\nTotal Exempted\nBalcony Area (m²) Per Unit\n
", - "metadata": { - "page": 2, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - }, - { - "page_content": "Appendix\n\nStandard Rates for Premium Assessment for Exemption of Balconies", - "metadata": { - "page": 2, - "source": "amagpt/sample_tables.pdf", - "file_path": "/tmp/tmp1rnkk8m9.pdf", - "total_pages": 3, - "Author": "Ben Trovato", - "CreationDate": "D:20231031072817Z", - "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles for the Association for Computing Machinery and hyperref 2023-07-08 v7.01b Hypertext links for LaTeX", - "Keywords": "Image Captioning, Deep Learning", - "ModDate": "D:20231031073146Z", - "PTEX.Fullbanner": "This is pdfTeX, Version 3.141592653-2.6-1.40.25 (TeX Live 2023) kpathsea version 6.3.5", - "Producer": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)", - "Title": "The Name of the Title is Hope", - "Trapped": "False", - "document_id": "9f17d92a580a15b5f4405d51f2542900" - } - } -] diff --git a/clients/python/tests/cloud/test_admin.py b/clients/python/tests/cloud/test_admin.py new file mode 100644 index 0000000..bfc6ee1 --- /dev/null +++ b/clients/python/tests/cloud/test_admin.py @@ -0,0 +1,1428 @@ +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from inspect import signature +from multiprocessing import Manager, Process +from time import sleep +from typing import Generator, Type + +import pytest +from loguru import logger +from tenacity import retry, stop_after_attempt, wait_exponential + +from jamaibase import JamAI +from jamaibase.protocol import ( + ActionTableSchemaCreate, + AdminOrderBy, + ApiKeyCreate, + ApiKeyRead, + ChatCompletionChunk, + ChatEntry, + ChatRequest, + ChatTableSchemaCreate, + ColumnSchemaCreate, + EmbeddingRequest, + EmbeddingResponse, + EventCreate, + EventRead, + GenTableRowsChatCompletionChunks, + GenTableStreamChatCompletionChunk, + KnowledgeTableSchemaCreate, + LLMGenConfig, + LLMModelConfig, + ModelDeploymentConfig, + ModelListConfig, + ModelPrice, + OkResponse, + OrganizationCreate, + OrganizationRead, + OrganizationUpdate, + OrgMemberCreate, + OrgMemberRead, + PATCreate, + PATRead, + Price, + ProjectCreate, + RowAddRequest, + TableMetaResponse, + TableType, + UserCreate, + UserRead, + UserUpdate, +) +from jamaibase.utils import datetime_now_iso +from owl.configs.manager import PlanName, ProductType +from owl.utils import uuid7_str + +CLIENT_CLS = [JamAI] +USER_ID_A = "duncan" +USER_ID_B = "mama" +USER_ID_C = "sus" +TABLE_TYPES = [TableType.action, TableType.knowledge, TableType.chat] + + +@contextmanager +def _create_user( + owl: JamAI, + user_id: str = USER_ID_A, + **kwargs, +) -> Generator[UserRead, None, None]: + # TODO: Can make this work with OSS too by yielding a dummy UserRead + owl.admin.backend.delete_user(user_id) + try: + user = owl.admin.backend.create_user( + UserCreate( + id=user_id, + name=kwargs.pop("name", "Duncan Idaho"), + description=kwargs.pop("description", "A Ginaz Swordmaster from House Atreides."), + email=kwargs.pop("email", "duncan.idaho@gmail.com"), + meta=kwargs.pop("meta", {}), + ) + ) + yield user + finally: + owl.admin.backend.delete_user(user_id) + + +@contextmanager +def _create_org( + owl: JamAI, + user_id: str, + active: bool = True, + **kwargs, +) -> Generator[OrganizationRead, None, None]: + org_id = None + try: + org = owl.admin.backend.create_organization( + OrganizationCreate( + creator_user_id=user_id, + name=kwargs.pop("name", "Company"), + external_keys=kwargs.pop("external_keys", {}), + tier=kwargs.pop("tier", PlanName.FREE), + active=active, + **kwargs, + ) + ) + org_id = org.id + yield org + finally: + if org_id is not None: + owl.admin.backend.delete_organization(org_id) + + +def _delete_project(owl: JamAI, project_id: str | None): + if project_id is not None: + owl.admin.organization.delete_project(project_id) + + +@contextmanager +def _create_project( + owl: JamAI, + organization_id: str, + name: str = "default", +) -> Generator[OrganizationRead, None, None]: + project_id = None + try: + project = owl.admin.organization.create_project( + ProjectCreate( + organization_id=organization_id, + name=name, + ) + ) + project_id = project.id + yield project + finally: + _delete_project(owl, project_id) + + +@contextmanager +def _set_model_config(owl: JamAI, config: ModelListConfig): + old_config = owl.admin.backend.get_model_config() + try: + response = owl.admin.backend.set_model_config(config) + assert isinstance(response, OkResponse) + yield response + finally: + owl.admin.backend.set_model_config(old_config) + + +def _chat(jamai: JamAI, model_id: str): + request = ChatRequest( + model=model_id, + messages=[ + ChatEntry.system("You are a concise assistant."), + ChatEntry.user("What is a llama?"), + ], + temperature=0.001, + top_p=0.001, + max_tokens=3, + stream=False, + ) + completion = jamai.generate_chat_completions(request) + assert isinstance(completion, ChatCompletionChunk) + assert isinstance(completion.text, str) + assert len(completion.text) > 1 + + +def _embed(jamai: JamAI, model_id: str): + request = EmbeddingRequest( + input="什么是 llama?", + model=model_id, + type="document", + encoding_format="float", + ) + response = jamai.generate_embeddings(request) + assert isinstance(response, EmbeddingResponse) + assert isinstance(response.data, list) + assert isinstance(response.data[0].embedding, list) + assert len(response.data[0].embedding) > 0 + + +@contextmanager +def _create_gen_table( + jamai: JamAI, + table_type: TableType, + table_id: str, + model_id: str = "", + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + embedding_model: str = "", + delete_first: bool = True, + delete: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=LLMGenConfig( + model=model_id, + prompt="${input}", + max_tokens=3, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=model_id, + system_prompt="You are an assistant.", + max_tokens=3, + ), + ), + ] + if table_type == TableType.action: + table = jamai.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == TableType.knowledge: + table = jamai.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + elif table_type == TableType.chat: + table = jamai.table.create_chat_table( + ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + yield table + finally: + if delete: + jamai.table.delete_table(table_type, table_id) + + +def test_cors(): + import httpx + + def _assert_cors(_response: httpx.Response): + assert "Access-Control-Allow-Origin" in _response.headers, _response.headers + assert "Access-Control-Allow-Methods" in _response.headers, _response.headers + assert "Access-Control-Allow-Headers" in _response.headers, _response.headers + assert "Access-Control-Allow-Credentials" in _response.headers, _response.headers + assert _response.headers["Access-Control-Allow-Credentials"].lower() == "true" + + headers = { + "Origin": "http://example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + } + owl = JamAI() + # Preflight + response = httpx.options(owl.api_base, headers=headers) + _assert_cors(response) + + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id) as p0: + assert isinstance(p0.id, str) + endpoint = f"{owl.api_base}/v1/models" + # Assert preflight no auth + response = httpx.options(endpoint, headers=headers) + _assert_cors(response) + # Assert CORS headers in methods with auth + response = httpx.get(endpoint, headers=headers) + assert response.status_code == 401 + response = httpx.get( + endpoint, + headers={ + "Authorization": f"Bearer {owl.api_key}", + "X-PROJECT-ID": p0.id, + **headers, + }, + ) + assert "Access-Control-Allow-Origin" in response.headers, response.headers + assert "Access-Control-Allow-Credentials" in response.headers, response.headers + assert response.headers["Access-Control-Allow-Credentials"].lower() == "true" + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_users(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as user: + assert isinstance(user, UserRead) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_and_list_users(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan, _create_user(owl, USER_ID_B) as mama: + # Test fetch + user = owl.admin.backend.get_user(duncan.id) + assert isinstance(user, UserRead) + assert user.id == duncan.id + + user = owl.admin.backend.get_user(mama.id) + assert isinstance(user, UserRead) + assert user.id == mama.id + + # Test list + users = owl.admin.backend.list_users() + assert isinstance(users.items, list) + assert all(isinstance(r, UserRead) for r in users.items) + assert users.total == 2 + assert users.offset == 0 + assert users.limit == 100 + assert len(users.items) == 2 + + users = owl.admin.backend.list_users(offset=1) + assert isinstance(users.items, list) + assert all(isinstance(r, UserRead) for r in users.items) + assert users.total == 2 + assert users.offset == 1 + assert users.limit == 100 + assert len(users.items) == 1 + + users = owl.admin.backend.list_users(limit=1) + assert isinstance(users.items, list) + assert all(isinstance(r, UserRead) for r in users.items) + assert users.total == 2 + assert users.offset == 0 + assert users.limit == 1 + assert len(users.items) == 1 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_update_user(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + updated_user_request = UserUpdate(id=duncan.id, name="Updated Duncan") + updated_user_response = owl.admin.backend.update_user(updated_user_request) + assert isinstance(updated_user_response, UserRead) + assert updated_user_response.id == duncan.id + assert updated_user_response.name == "Updated Duncan" + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_delete_users(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as user: + assert isinstance(user, UserRead) + # Assert there is a user + users = owl.admin.backend.list_users() + assert isinstance(users.items, list) + assert users.total == 1 + # Delete + response = owl.admin.backend.delete_user(user.id) + assert isinstance(response, OkResponse) + # Assert there is no user + users = owl.admin.backend.list_users() + assert isinstance(users.items, list) + assert users.total == 0 + + with pytest.raises(RuntimeError, match="User .+ is not found."): + owl.admin.backend.update_user(UserUpdate(id=user.id, name="Updated Name")) + + with pytest.raises(RuntimeError, match="User .+ is not found."): + owl.admin.backend.get_user(user.id) + + response = owl.admin.backend.delete_user(user.id) + assert isinstance(response, OkResponse) + with pytest.raises(RuntimeError, match="User .+ is not found."): + owl.admin.backend.delete_user(user.id, missing_ok=False) + + +def test_user_update_pydantic_model(): + sig = signature(UserUpdate) + for name, param in sig.parameters.items(): + if name == "id": + continue + assert ( + param.default is None + ), f'Parameter "{name}" has a default value of {param.default} instead of None.' + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_pat(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as u0, _create_user(owl, USER_ID_B) as u1: + with _create_org(owl, u0.id) as o0, _create_org(owl, u1.id): + with _create_project(owl, o0.id) as p0: + pat0 = owl.admin.backend.create_pat(PATCreate(user_id=u0.id)) + pat0_expire = owl.admin.backend.create_pat( + PATCreate( + user_id=u0.id, + expiry=(datetime.now(tz=timezone.utc) + timedelta(seconds=1)).isoformat(), + ) + ) + assert isinstance(pat0, PATRead) + pat1 = owl.admin.backend.create_pat(PATCreate(user_id=u1.id)) + assert isinstance(pat1, PATRead) + # Make some requests using the PAT + jamai = JamAI(project_id=p0.id, token=pat0.id) + models = jamai.model_names(capabilities=["chat"]) + assert isinstance(models, list) + assert len(models) > 0 + # Fetch the user + user = JamAI().admin.backend.get_user(u0.id) + assert isinstance(user, UserRead) + assert user.id == USER_ID_A + user = JamAI().admin.backend.get_user(u1.id) + assert isinstance(user, UserRead) + assert user.id == USER_ID_B + # Create gen table + with _create_gen_table(jamai, "action", "xx"): + table = jamai.table.get_table("action", "xx") + assert isinstance(table, TableMetaResponse) + # Try using invalid PAT + with pytest.raises(RuntimeError): + JamAI(project_id=p0.id, token=pat1.id).table.get_table("action", "xx") + # Test PAT expiry + while datetime_now_iso() < pat0_expire.expiry: + sleep(1) + with pytest.raises(RuntimeError): + JamAI(project_id=p0.id, token=pat0_expire.id).table.get_table( + "action", "xx" + ) + # Test PAT fetch + pat0_read = owl.admin.backend.get_pat(pat0.id) + assert isinstance(pat0_read, PATRead) + assert pat0_read.id == pat0.id + # Test PAT deletion + response = owl.admin.backend.delete_pat(pat0.id) + assert isinstance(response, OkResponse) + with pytest.raises(RuntimeError): + owl.admin.backend.get_pat(pat0.id) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_organizations(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, external_keys=dict(openai="sk-test")) as org: + assert isinstance(org, OrganizationRead) + assert isinstance(org.id, str) + assert len(org.id) > 0 + assert "openai" in org.external_keys + assert org.external_keys["openai"] == "sk-test" + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_organizations_free_tier_check(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with ( + _create_org(owl, duncan.id, name="Free 0", tier=PlanName.FREE) as o0, + _create_org(owl, duncan.id, name="Free 1", tier=PlanName.FREE) as o1, + _create_org(owl, duncan.id, name="Paid 0", tier=PlanName.PRO) as o2, + ): + assert isinstance(o0, OrganizationRead) + assert isinstance(o0.id, str) + assert len(o0.id) > 0 + assert isinstance(o1, OrganizationRead) + assert isinstance(o1.id, str) + assert len(o1.id) > 0 + assert isinstance(o2, OrganizationRead) + assert isinstance(o2.id, str) + assert len(o2.id) > 0 + assert o0.active is True + assert o1.active is False + assert o2.active is True + with _create_project(owl, o0.id, "Pear"): + pass + with pytest.raises(RuntimeError, match="not activated"): + with _create_project(owl, o1.id, "Pear"): + pass + with _create_project(owl, o2.id, "Pear"): + pass + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_organizations_invalid_key(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with pytest.raises(RuntimeError, match="Unsupported external provider"): + with _create_org(owl, duncan.id, external_keys=dict(invalid="sk-test")): + pass + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_and_list_organizations(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, name="company") as company: + with _create_org(owl, duncan.id, name="Personal"): + # Test fetch + org = owl.admin.backend.get_organization(company.id) + assert isinstance(org, OrganizationRead) + assert org.id == company.id + assert isinstance(org.members, list) + assert isinstance(org.api_keys, list) + assert isinstance(org.projects, list) + assert duncan.id in set(u.user_id for u in org.members) + assert len(org.api_keys) == 0 + assert len(org.projects) == 0 + + with ( + _create_project(owl, company.id, "bear") as p0, + _create_project(owl, company.id) as p1, + ): + org = owl.admin.backend.get_organization(company.id) + assert isinstance(org, OrganizationRead) + assert org.id == company.id + assert isinstance(org.members, list) + assert isinstance(org.api_keys, list) + assert isinstance(org.projects, list) + assert duncan.id in set(u.user_id for u in org.members) + assert len(org.api_keys) == 0 + assert len(org.projects) == 2 + assert p0.id in set(p.id for p in org.projects) + assert p1.id in set(p.id for p in org.projects) + + # Test list + orgs = owl.admin.backend.list_organizations() + assert isinstance(orgs.items, list) + assert all(isinstance(r, OrganizationRead) for r in orgs.items) + assert orgs.total == 2 + assert orgs.offset == 0 + assert orgs.limit == 100 + assert len(orgs.items) == 2 + + orgs = owl.admin.backend.list_organizations(offset=1) + assert isinstance(orgs.items, list) + assert all(isinstance(r, OrganizationRead) for r in orgs.items) + assert orgs.total == 2 + assert orgs.offset == 1 + assert orgs.limit == 100 + assert len(orgs.items) == 1 + + orgs = owl.admin.backend.list_organizations(limit=1) + assert isinstance(orgs.items, list) + assert all(isinstance(r, OrganizationRead) for r in orgs.items) + assert orgs.total == 2 + assert orgs.offset == 0 + assert orgs.limit == 1 + assert len(orgs.items) == 1 + + # Test list with order_by + orgs = owl.admin.backend.list_organizations( + order_by="created_at", order_descending=False + ) + assert isinstance(orgs.items, list) + assert all(isinstance(r, OrganizationRead) for r in orgs.items) + assert orgs.items[0].name == "company" + assert orgs.items[1].name == "Personal" + assert orgs.total == 2 + assert orgs.offset == 0 + assert orgs.limit == 100 + assert len(orgs.items) == 2 + + # Ensure ordering is case-insensitive, otherwise uppercase will come before lowercase + orgs = owl.admin.backend.list_organizations( + order_by="name", order_descending=False + ) + assert isinstance(orgs.items, list) + assert all(isinstance(r, OrganizationRead) for r in orgs.items) + assert orgs.items[0].name == "company" + assert orgs.items[1].name == "Personal" + assert orgs.total == 2 + assert orgs.offset == 0 + assert orgs.limit == 100 + assert len(orgs.items) == 2 + + for order_by in AdminOrderBy: + orgs = owl.admin.backend.list_organizations(order_by=order_by) + org_ids = [org.id for org in orgs.items] + assert len(orgs.items) == 2 + orgs_desc = owl.admin.backend.list_organizations( + order_by=order_by, order_descending=False + ) + org_ids_desc = [org.id for org in orgs_desc.items] + assert len(orgs_desc.items) == 2 + assert ( + org_ids == org_ids_desc[::-1] + ), f"Failed to order by {order_by}: {org_ids} != {org_ids_desc[::-1]}" + + # # Test starting_after + # orgs = owl.admin.backend.list_organizations( + # order_by="created_at", order_descending=False, starting_after=company.id + # ) + # assert isinstance(orgs.items, list) + # assert all(isinstance(r, OrganizationRead) for r in orgs.items) + # assert orgs.items[0].name == "Personal" + # assert orgs.total == 2 + # assert orgs.offset == 0 + # assert orgs.limit == 100 + # assert len(orgs.items) == 1 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_update_organization(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + updated_org = owl.admin.backend.update_organization( + OrganizationUpdate( + id=org.id, + name="Company X", + active=True, + llm_tokens_usage_mtok=100.0, + ) + ) + assert isinstance(updated_org, OrganizationRead) + assert updated_org.id == org.id + assert updated_org.name == "Company X" + assert updated_org.llm_tokens_usage_mtok == 100.0 + updated_org = owl.admin.backend.update_organization( + OrganizationUpdate( + id=org.id, + embedding_tokens_quota_mtok=9.0, + ) + ) + assert isinstance(updated_org, OrganizationRead) + org = owl.admin.backend.get_organization(org.id) + assert isinstance(org, OrganizationRead) + assert updated_org.llm_tokens_usage_mtok == 100.0 + assert updated_org.embedding_tokens_quota_mtok == 9.0 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_delete_organizations(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org, OrganizationRead) + # Assert there is an org + orgs = owl.admin.backend.list_organizations() + assert isinstance(orgs.items, list) + assert orgs.total == 1 + + # Delete the organization + response = owl.admin.backend.delete_organization(org.id) + assert isinstance(response, OkResponse) + + # Assert there is no org + orgs = owl.admin.backend.list_organizations() + assert isinstance(orgs.items, list) + assert orgs.total == 0 + + response = owl.admin.backend.delete_organization(org.id) + assert isinstance(response, OkResponse) + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.backend.delete_organization(org.id, missing_ok=False) + + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.backend.update_organization( + OrganizationUpdate(id=org.id, name="Updated Name") + ) + + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.backend.get_organization(org.id) + + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.organization.create_project( + ProjectCreate(name="New Project", organization_id=org.id) + ) + + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.backend.join_organization( + OrgMemberCreate(user_id=duncan.id, organization_id=org.id) + ) + + with pytest.raises(RuntimeError, match="Organization .+ is not found."): + owl.admin.backend.leave_organization(user_id=duncan.id, organization_id=org.id) + + +def test_organization_update_pydantic_model(): + sig = signature(OrganizationUpdate) + for name, param in sig.parameters.items(): + if name == "id": + continue + assert ( + param.default is None + ), f'Parameter "{name}" has a default value of {param.default} instead of None.' + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_refresh_quota(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, tier=PlanName.FREE) as org: + free_quota = org.llm_tokens_quota_mtok + assert org.llm_tokens_usage_mtok == 0.0 + # Set to another tier + org = owl.admin.backend.update_organization( + OrganizationUpdate( + id=org.id, + tier=PlanName.PRO, + llm_tokens_usage_mtok=0.2, + ) + ) + # Quota should be unchanged before refresh + assert org.llm_tokens_quota_mtok == free_quota + assert org.llm_tokens_usage_mtok == 0.2 + # Quota should increase after refresh, usage should reset + org = owl.admin.backend.refresh_quota(org.id) + assert isinstance(org, OrganizationRead) + pro_quota = org.llm_tokens_quota_mtok + assert pro_quota > free_quota + assert org.llm_tokens_usage_mtok == 0.0 + # Test refresh without resetting usage + owl.admin.backend.update_organization( + OrganizationUpdate( + id=org.id, + tier=PlanName.FREE, + llm_tokens_usage_mtok=0.2, + ) + ) + org = owl.admin.backend.refresh_quota(org.id, False) + assert org.llm_tokens_quota_mtok < pro_quota + assert org.llm_tokens_usage_mtok == 0.2 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_fetch_delete_api_key(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, tier=PlanName.PRO) as org: + # Create API key + api_key = owl.admin.backend.create_api_key(ApiKeyCreate(organization_id=org.id)) + assert isinstance(api_key, ApiKeyRead) + print(f"API key created: {api_key}\n") + + # Fetch API key info + fetched_key = owl.admin.backend.get_api_key(api_key.id) + assert isinstance(fetched_key, ApiKeyRead) + assert fetched_key.id == api_key.id + print(f"API key fetched: {fetched_key}\n") + + # Fetch company using API key + org = owl.admin.backend.get_organization(api_key.id) + assert isinstance(org, OrganizationRead) + print(f"Organization fetched: {org}\n") + + # Delete API key + response = owl.admin.backend.delete_api_key(api_key.id) + assert isinstance(response, OkResponse) + print(f"API key deleted: {api_key.id}\n") + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_fetch_specific_user(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + user = owl.admin.backend.get_user(duncan.id) + assert isinstance(user, UserRead) + print(f"User fetched: {user}\n") + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_join_and_leave_organization(client_cls: Type[JamAI]): + owl = client_cls() + with ( + _create_user(owl, USER_ID_A, email="a@gmail.com") as u0, + _create_user(owl, USER_ID_B, email="b@gmail.com") as u1, + _create_user(owl, USER_ID_C, email="c@gmail.com") as u2, + ): + # --- Join without invite link --- # + with _create_org(owl, u0.id, tier="pro") as pro_org, _create_org(owl, u0.id) as free_org: + assert u1.id not in set(m.user_id for m in pro_org.members) + member = owl.admin.backend.join_organization( + OrgMemberCreate(user_id=u1.id, organization_id=pro_org.id) + ) + assert isinstance(member, OrgMemberRead) + assert member.user_id == u1.id + assert member.organization_id == pro_org.id + assert member.role == "admin" + # Cannot join free org + with pytest.raises(RuntimeError): + owl.admin.backend.join_organization( + OrgMemberCreate(user_id=u1.id, organization_id=free_org.id) + ) + # --- 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) + member = owl.admin.backend.join_organization( + OrgMemberCreate( + user_id=u1.id, + organization_id=pro_org.id, + role="member", + invite_token=invite, + ) + ) + assert isinstance(member, OrgMemberRead) + assert member.user_id == u1.id + assert member.organization_id == pro_org.id + assert member.role == "member" + # --- Join with private 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 token email validation should be case and space insensitive + invite = owl.admin.backend.generate_invite_token(pro_org.id, f" {u1.email.upper()} ") + member = owl.admin.backend.join_organization( + OrgMemberCreate( + user_id=u1.id, + organization_id=pro_org.id, + role="admin", + invite_token=invite, + ) + ) + assert isinstance(member, OrgMemberRead) + assert member.user_id == u1.id + assert member.organization_id == pro_org.id + assert member.role == "admin" + # Other email should fail + with pytest.raises(RuntimeError): + owl.admin.backend.join_organization( + OrgMemberCreate( + user_id=u2.id, + organization_id=pro_org.id, + role="admin", + invite_token=invite, + ) + ) + # --- Leave organization --- # + leave_response = owl.admin.backend.leave_organization(u0.id, pro_org.id) + assert isinstance(leave_response, OkResponse) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_add_event(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + response = owl.admin.backend.add_event( + EventCreate( + id=f"{org.id}_token", + organization_id=org.id, + deltas={ProductType.LLM_TOKENS: -0.5}, + values={}, + ) + ) + assert isinstance(response, OkResponse) + + event = owl.admin.backend.get_event(f"{org.id}_token") + assert isinstance(event, EventRead) + assert event.id == f"{org.id}_token" + assert event.deltas.get(ProductType.LLM_TOKENS) == -0.5 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_event(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + owl.admin.backend.add_event( + EventCreate( + id=f"{org.id}_token", + organization_id=org.id, + deltas={ProductType.LLM_TOKENS: -0.5}, + values={}, + ) + ) + + event = owl.admin.backend.get_event(f"{org.id}_token") + assert isinstance(event, EventRead) + assert event.id == f"{org.id}_token" + assert event.deltas.get(ProductType.LLM_TOKENS) == -0.5 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_mark_event_as_done(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + owl.admin.backend.add_event( + EventCreate( + id=f"{org.id}_token", + organization_id=org.id, + deltas={ProductType.LLM_TOKENS: -0.5}, + values={}, + ) + ) + + response = owl.admin.backend.mark_event_as_done(f"{org.id}_token") + assert isinstance(response, OkResponse) + + event = owl.admin.backend.get_event(f"{org.id}_token") + assert isinstance(event, EventRead) + assert event.id == f"{org.id}_token" + assert event.pending is False + assert event.deltas.get(ProductType.LLM_TOKENS) == -0.5 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_pricing(client_cls: Type[JamAI]): + owl = client_cls() + response = owl.admin.backend.get_pricing() + assert isinstance(response, Price) + assert len(response.plans) > 0 + response = owl.admin.backend.get_model_pricing() + assert isinstance(response, ModelPrice) + assert len(response.llm_models) > 0 + assert len(response.embed_models) > 0 + assert len(response.rerank_models) > 0 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_add_credit(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org, OrganizationRead) + assert isinstance(org.id, str) + assert len(org.id) > 0 + + assert org.credit == 0 + assert org.credit_grant == 0 + assert org.llm_tokens_usage_mtok == 0 + assert org.db_usage_gib == 0 + assert org.file_usage_gib == 0 + assert org.egress_usage_gib == 0 + # Set values + response = owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ + ProductType.CREDIT: 20.0, + ProductType.CREDIT_GRANT: 1, + ProductType.LLM_TOKENS: 70, + ProductType.DB_STORAGE: 2.0, + ProductType.FILE_STORAGE: 3.0, + ProductType.EGRESS: 4.0, + ProductType.EMBEDDING_TOKENS: 5.0, + ProductType.RERANKER_SEARCHES: 6.0, + }, + ) + ) + assert isinstance(response, OkResponse) + org = owl.admin.backend.get_organization(org.id) + assert org.credit == 20.0 + assert org.credit_grant == 1.0 + assert org.llm_tokens_usage_mtok == 70 + assert org.db_usage_gib == 2.0 + assert org.file_usage_gib == 3.0 + assert org.egress_usage_gib == 4.0 + assert org.embedding_tokens_usage_mtok == 5.0 + assert org.reranker_usage_ksearch == 6.0 + for product in ProductType.exclude_credits(): + assert isinstance(org.quotas[product]["quota"], (int, float)) + assert isinstance(org.quotas[product]["usage"], (int, float)) + # Add deltas + response = owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + deltas={ + "credit": 1.0, + ProductType.CREDIT_GRANT: 1.0, + ProductType.LLM_TOKENS: 70, + ProductType.DB_STORAGE: 2.0, + ProductType.FILE_STORAGE: 3.0, + ProductType.EGRESS: 4.0, + ProductType.EMBEDDING_TOKENS: 5.0, + ProductType.RERANKER_SEARCHES: 6.0, + }, + ) + ) + assert isinstance(response, OkResponse) + org = owl.admin.backend.get_organization(org.id) + assert org.credit == 21.0 + assert org.credit_grant == 2.0 + assert org.llm_tokens_usage_mtok == 140 + assert org.db_usage_gib == 4.0 + assert org.file_usage_gib == 6.0 + assert org.egress_usage_gib == 8.0 + assert org.embedding_tokens_usage_mtok == 10.0 + assert org.reranker_usage_ksearch == 12.0 + # Ensure values cannot go to negative + response = owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + deltas={ + "credit": -200.0, + ProductType.CREDIT_GRANT: -200.0, + ProductType.LLM_TOKENS: -200, + ProductType.DB_STORAGE: -200.0, + ProductType.FILE_STORAGE: -200.0, + ProductType.EGRESS: -200.0, + ProductType.EMBEDDING_TOKENS: -200.0, + ProductType.RERANKER_SEARCHES: -200.0, + }, + ) + ) + assert isinstance(response, OkResponse) + org = owl.admin.backend.get_organization(org.id) + assert org.credit == 0 + assert org.credit_grant == 0 + assert org.llm_tokens_usage_mtok == 0 + assert org.db_usage_gib == 0 + assert org.file_usage_gib == 0 + assert org.egress_usage_gib == 0 + assert org.embedding_tokens_usage_mtok == 0.0 + assert org.reranker_usage_ksearch == 0.0 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_set_model_config(client_cls: Type[JamAI]): + owl = client_cls() + # Initial fetch + config = owl.admin.backend.get_model_config() + assert isinstance(config, ModelListConfig) + assert len(config.llm_models) > 1 + assert len(config.embed_models) > 1 + assert len(config.rerank_models) > 1 + llm_model_ids = [m.id for m in config.llm_models] + assert "ellm/new_model" not in llm_model_ids + # Set + new_config = config.model_copy(deep=True) + new_config.llm_models.append( + LLMModelConfig( + id="ellm/new_model", + name="ELLM New Model", + context_length=8000, + deployments=[ + ModelDeploymentConfig( + provider="ellm", + ) + ], + languages=["mul"], + capabilities=["chat"], + owned_by="ellm", + ) + ) + with _set_model_config(owl, new_config) as response: + assert isinstance(response, OkResponse) + # Fetch again + new_config = owl.admin.backend.get_model_config() + assert isinstance(new_config, ModelListConfig) + assert len(new_config.llm_models) == len(config.llm_models) + 1 + assert len(new_config.embed_models) == len(config.embed_models) + assert len(new_config.rerank_models) == len(config.rerank_models) + llm_model_ids = [m.id for m in new_config.llm_models] + assert "ellm/new_model" in llm_model_ids + # Fetch model list + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + jamai = JamAI(project_id=project.id) + models = jamai.model_names(capabilities=["chat"]) + assert isinstance(models, list) + assert "ellm/new_model" in models + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_credit_check_llm(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org, OrganizationRead) + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + # Get model list + jamai = JamAI(project_id=project.id) + models = jamai.model_info(capabilities=["chat"]).data + assert isinstance(models, list) + models = {m.owned_by: m for m in models} + model = models["openai"] + + # --- No credit to use 3rd party models --- # + assert org.credit == 0 + assert len(model.id) > 0 + # Error message should show model ID when called via API + with pytest.raises( + RuntimeError, + match=f"Insufficient LLM token quota or credits for model: {model.id}", + ): + _chat(jamai, model.id) + assert len(model.name) > 0 + assert model.name != model.id + # Error message should show model name when called via browser + name = model.name.replace("(", "\\(").replace(")", "\\)") + with pytest.raises( + RuntimeError, + match=f"Insufficient LLM token quota or credits for model: {name}", + ): + _chat( + JamAI(project_id=project.id, headers={"User-Agent": "Mozilla"}), + model.id, + ) + + @retry( + wait=wait_exponential(multiplier=1, min=1, max=10), + stop=stop_after_attempt(5), + reraise=True, + ) + def _assert_usage_updated(initial_value: int | float = 0): + org_read = owl.admin.backend.get_organization(org.id) + assert isinstance(org_read, OrganizationRead) + assert org_read.llm_tokens_usage_mtok > initial_value + + @retry( + wait=wait_exponential(multiplier=1, min=1, max=10), + stop=stop_after_attempt(5), + reraise=True, + ) + def _assert_chat_fail(_model_id: str): + # No more credit left + try: + _chat(jamai, _model_id) + logger.warning( + f"Org credit grant = {owl.admin.backend.get_organization(org.id).credit_grant}" + ) + except RuntimeError as e: + if ( + f"Insufficient LLM token quota or credits for model: {_model_id}" + not in str(e) + ): + raise ValueError("Error message mismatch") from e + # We actually want this to raise RuntimeError + else: + raise ValueError("Chat attempt did not fail.") + + # --- Test credit --- # + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ProductType.CREDIT: 1e-12}, + ) + ) + _chat(jamai, model.id) + _assert_chat_fail(model.id) + + # --- Test credit grant --- # + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ + ProductType.CREDIT: 0.0, + ProductType.CREDIT_GRANT: 1e-12, + }, + ) + ) + org = owl.admin.backend.get_organization(org.id) + assert org.credit == 0 + assert org.credit_grant == 1e-12 + _chat(jamai, model.id) + _assert_chat_fail(model.id) + + # --- Test ELLM model --- # + # ELLM model ok + ellm_model_id = "ellm/llama-3.1-8B" + config = owl.admin.backend.get_model_config() + config.llm_models.append( + LLMModelConfig( + id=ellm_model_id, + name="ELLM Meta Llama 3.1 (8B)", + deployments=[ + ModelDeploymentConfig( + litellm_id="together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", + provider="together_ai", + ) + ], + context_length=8000, + languages=["mul"], + capabilities=["chat"], + owned_by="ellm", + ) + ) + with _set_model_config(owl, config): + _chat(jamai, ellm_model_id) + _assert_usage_updated() + # Exhaust the quota + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_llm_tokens_{uuid7_str()}", + organization_id=org.id, + values={ + ProductType.CREDIT: 0.0, + ProductType.CREDIT_GRANT: 0.0, + ProductType.LLM_TOKENS: 100000.0, + }, + ) + ) + # No quota to use ELLM model + _assert_chat_fail(ellm_model_id) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_credit_check_embedding(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org, OrganizationRead) + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + # Get model list + jamai = JamAI(project_id=project.id) + models = jamai.model_info(capabilities=["embed"]).data + assert isinstance(models, list) + models = {m.owned_by: m for m in models} + model = models["openai"] + + # --- No credit to use 3rd party models --- # + assert org.credit == 0 + assert len(model.id) > 0 + # Error message should show model ID when called via API + with pytest.raises( + RuntimeError, + match=rf"Insufficient Embedding token quota or credits for model: {model.id}", + ): + _embed(jamai, model.id) + assert len(model.name) > 0 + assert model.name != model.id + # Error message should show model name when called via browser + name = model.name.replace("(", "\\(").replace(")", "\\)") + with pytest.raises( + RuntimeError, + match=f"Insufficient Embedding token quota or credits for model: {name}", + ): + _embed( + JamAI(project_id=project.id, headers={"User-Agent": "Mozilla"}), + model.id, + ) + + @retry( + wait=wait_exponential(multiplier=1, min=1, max=10), + stop=stop_after_attempt(5), + reraise=True, + ) + def _assert_usage_updated(initial_value: int | float = 0): + org_read = owl.admin.backend.get_organization(org.id) + assert isinstance(org_read, OrganizationRead) + assert org_read.embedding_tokens_usage_mtok > initial_value + + @retry( + wait=wait_exponential(multiplier=1, min=1, max=20), stop=stop_after_attempt(10) + ) + def _assert_embed_fail(_model_id: str): + # No more credit left + try: + _embed(jamai, _model_id) + logger.warning( + f"Org credit grant = {owl.admin.backend.get_organization(org.id).credit_grant}" + ) + except RuntimeError as e: + if ( + f"Insufficient Embedding token quota or credits for model: {_model_id}" + not in str(e) + ): + raise ValueError("Error message mismatch") from e + # We actually want this to raise RuntimeError + else: + raise ValueError("Embedding attempt did not fail.") + + # --- Test credit --- # + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ProductType.CREDIT: 1e-12}, + ) + ) + _embed(jamai, model.id) + _assert_embed_fail(model.id) + + # --- Test credit grant --- # + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ + ProductType.CREDIT: 0.0, + ProductType.CREDIT_GRANT: 1e-12, + }, + ) + ) + org = owl.admin.backend.get_organization(org.id) + assert org.credit == 0 + assert org.credit_grant == 1e-12 + _embed(jamai, model.id) + _assert_embed_fail(model.id) + + # --- Test ELLM model --- # + # ELLM model ok + model = models["ellm"] + _embed(jamai, model.id) + _assert_usage_updated() + # Exhaust the quota + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_llm_tokens_{uuid7_str()}", + organization_id=org.id, + values={ + ProductType.CREDIT: 0.0, + ProductType.CREDIT_GRANT: 0.0, + ProductType.EMBEDDING_TOKENS: 100000.0, + }, + ) + ) + # No quota to use ELLM model + _assert_embed_fail(model.id) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_external_api_key(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org, OrganizationRead) + assert isinstance(org.id, str) + assert len(org.id) > 0 + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ProductType.CREDIT: 0.001}, + ) + ) + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + # Get model list + jamai = JamAI(project_id=project.id) + models = jamai.model_info(capabilities=["chat"]).data + assert isinstance(models, list) + models = {m.owned_by: m for m in models} + model = models["openai"] + # Will use ELLM's OpenAI API key + _chat(jamai, model.id) + # Replace with fake key + org = owl.admin.backend.update_organization( + OrganizationUpdate(id=org.id, external_keys=dict(openai="fake-key")) + ) + assert org.external_keys["openai"] == "fake-key" + with pytest.raises(RuntimeError, match="AuthenticationError"): + _chat(jamai, model.id) + # Ensure no cooldown + org = owl.admin.backend.update_organization( + OrganizationUpdate(id=org.id, external_keys=dict()) + ) + _chat(jamai, model.id) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_concurrent_usage(client_cls: Type[JamAI]): + def _work(worker_id: int, mp_dict: dict): + owl = client_cls() + # Fetch model list as external org + with _create_user(owl, f"user_{worker_id}") as user: + with _create_org(owl, user.id, name=f"org_{worker_id}") as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + # Add credit + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ProductType.CREDIT: 20.0}, + ) + ) + with _create_project(owl, org.id, name=f"proj_{worker_id}") as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + # Test model list + jamai = JamAI(project_id=project.id) + models = jamai.model_names(capabilities=["chat"]) + assert isinstance(models, list) + # Test chat + _chat(jamai, "") + # Test gen table + data = dict( + input="Hi", + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + User="Tell me a joke.", + ) + for table_type in TABLE_TYPES: + with _create_gen_table( + jamai, table_type, f"table_{table_type}_{worker_id}" + ) as table: + response = jamai.table.add_table_rows( + table_type, + RowAddRequest(table_id=table.id, data=[data], stream=False), + ) + assert isinstance(response, GenTableRowsChatCompletionChunks) + assert len(response.rows) > 0 + response = jamai.table.add_table_rows( + table_type, + RowAddRequest(table_id=table.id, data=[data], stream=True), + ) + responses = [r for r in response] + assert len(responses) > 0 + assert all( + isinstance(r, GenTableStreamChatCompletionChunk) for r in responses + ) + meta = jamai.table.get_table(table_type, table.id) + mp_dict[str(worker_id)] = meta + + num_workers = 5 + manager = Manager() + return_dict = manager.dict() + workers = [Process(target=_work, args=(i, return_dict)) for i in range(num_workers)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + assert len(return_dict) == num_workers + metas = list(return_dict.values()) + assert all(isinstance(meta, TableMetaResponse) for meta in metas) + assert all(meta.num_rows == 2 for meta in metas) + + +if __name__ == "__main__": + test_pat(JamAI) diff --git a/clients/python/tests/cloud/test_org_admin.py b/clients/python/tests/cloud/test_org_admin.py new file mode 100644 index 0000000..16a8680 --- /dev/null +++ b/clients/python/tests/cloud/test_org_admin.py @@ -0,0 +1,848 @@ +from contextlib import asynccontextmanager, contextmanager +from inspect import signature +from io import BytesIO +from os.path import join +from tempfile import TemporaryDirectory +from time import perf_counter +from typing import Generator, Type + +import pytest +from loguru import logger +from tenacity import retry, stop_after_attempt, wait_exponential + +from jamaibase import JamAI, JamAIAsync +from jamaibase.protocol import ( + ActionTableSchemaCreate, + AdminOrderBy, + ChatTableSchemaCreate, + ColumnSchemaCreate, + EventCreate, + GenTableRowsChatCompletionChunks, + GenTableStreamChatCompletionChunk, + KnowledgeTableSchemaCreate, + LLMGenConfig, + LLMModelConfig, + ModelDeploymentConfig, + ModelListConfig, + OkResponse, + OrganizationCreate, + OrganizationRead, + ProjectCreate, + ProjectRead, + ProjectUpdate, + RowAddRequest, + TableMetaResponse, + TableType, + UserCreate, + UserRead, +) +from jamaibase.utils import run +from owl.configs.manager import PlanName, ProductType +from owl.utils import uuid7_str + +CLIENT_CLS = [JamAI] +USER_ID_A = "duncan" +USER_ID_B = "mama" +USER_ID_C = "sus" +TABLE_TYPES = [TableType.action, TableType.knowledge, TableType.chat] + + +@contextmanager +def _create_user( + owl: JamAI, + user_id: str = USER_ID_A, + **kwargs, +) -> Generator[UserRead, None, None]: + owl.admin.backend.delete_user(user_id) + try: + user = owl.admin.backend.create_user( + UserCreate( + id=user_id, + name=kwargs.pop("name", "Duncan Idaho"), + description=kwargs.pop("description", "A Ginaz Swordmaster from House Atreides."), + email=kwargs.pop("email", "duncan.idaho@gmail.com"), + meta=kwargs.pop("meta", {}), + ) + ) + yield user + finally: + owl.admin.backend.delete_user(user_id) + + +@contextmanager +def _create_org( + owl: JamAI, + user_id: str, + active: bool = True, + **kwargs, +) -> Generator[OrganizationRead, None, None]: + org_id = None + try: + org = owl.admin.backend.create_organization( + OrganizationCreate( + creator_user_id=user_id, + name=kwargs.pop("name", "Company"), + external_keys=kwargs.pop("external_keys", {}), + tier=kwargs.pop("tier", PlanName.FREE), + active=active, + **kwargs, + ) + ) + org_id = org.id + yield org + finally: + if org_id is not None: + owl.admin.backend.delete_organization(org_id) + + +def _delete_project(owl: JamAI, project_id: str | None): + if project_id is not None: + owl.admin.organization.delete_project(project_id) + + +@contextmanager +def _create_project( + owl: JamAI, + organization_id: str, + name: str = "default", +) -> Generator[OrganizationRead, None, None]: + project_id = None + try: + project = owl.admin.organization.create_project( + ProjectCreate( + organization_id=organization_id, + name=name, + ) + ) + project_id = project.id + yield project + finally: + _delete_project(owl, project_id) + + +@asynccontextmanager +async def _set_org_model_config( + jamai: JamAI | JamAIAsync, + org_id: str, + config: ModelListConfig, +): + old_config = await run(jamai.admin.organization.get_org_model_config, org_id) + try: + response = await run(jamai.admin.organization.set_org_model_config, org_id, config) + assert isinstance(response, OkResponse) + yield response + finally: + await run(jamai.admin.organization.set_org_model_config, org_id, old_config) + + +@contextmanager +def _create_gen_table( + jamai: JamAI, + table_type: TableType, + table_id: str, + model_id: str = "", + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + embedding_model: str = "", + delete_first: bool = True, + delete: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=LLMGenConfig( + model=model_id, + prompt="${input}", + max_tokens=3, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=model_id, + system_prompt="You are an assistant.", + max_tokens=3, + ), + ), + ] + if table_type == TableType.action: + table = jamai.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == TableType.knowledge: + table = jamai.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + elif table_type == TableType.chat: + table = jamai.table.create_chat_table( + ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + yield table + finally: + if delete: + jamai.table.delete_table(table_type, table_id) + + +def _add_row( + jamai: JamAI, + table_type: TableType, + table_id: str, + stream: bool = False, + data: dict | None = None, + knowledge_data: dict | None = None, + chat_data: dict | None = None, +): + if data is None: + data = dict(input="nano", output="shimmer") + + if knowledge_data is None: + knowledge_data = dict( + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + ) + if chat_data is None: + chat_data = dict(User="Tell me a joke.", AI="Who's there?") + if table_type == TableType.action: + pass + elif table_type == TableType.knowledge: + data.update(knowledge_data) + elif table_type == TableType.chat: + data.update(chat_data) + else: + raise ValueError(f"Invalid table type: {table_type}") + response = jamai.table.add_table_rows( + table_type, + RowAddRequest(table_id=table_id, data=[data], stream=stream), + ) + if stream: + response = list(response) + assert all(isinstance(r, GenTableStreamChatCompletionChunk) for r in response) + else: + assert isinstance(response, GenTableRowsChatCompletionChunks) + return response + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_set_org_model_config(client_cls: Type[JamAI | JamAIAsync]): + owl = client_cls() + # Get model config + config = await run(owl.admin.backend.get_model_config) + assert isinstance(config, ModelListConfig) + assert isinstance(config.models, list) + assert len(config.models) > 3 + assert isinstance(config.llm_models, list) + assert isinstance(config.embed_models, list) + assert isinstance(config.rerank_models, list) + assert len(config.llm_models) > 1 + assert len(config.embed_models) > 1 + assert len(config.rerank_models) > 1 + public_model_ids = [m.id for m in config.models] + assert "ellm/new_model" not in public_model_ids + # Set organization model config + with _create_user(owl) as duncan: + with ( + _create_org(owl, duncan.id) as org, + _create_org(owl, duncan.id, name="personal", tier=PlanName.PRO) as personal, + ): + assert isinstance(org.id, str) + assert len(org.id) > 0 + assert isinstance(personal.id, str) + assert len(personal.id) > 0 + with _create_project(owl, org.id) as p0, _create_project(owl, personal.id) as p1: + assert isinstance(p0.id, str) + assert len(p0.id) > 0 + assert isinstance(p1.id, str) + assert len(p1.id) > 0 + # Set + jamai = JamAI(project_id=p0.id) + new_model_config = ModelListConfig( + llm_models=[ + LLMModelConfig( + id="ellm/new_model", + name="ELLM hyperbolic Llama3.2-3B", + context_length=8000, + languages=["mul"], + capabilities=["chat"], + owned_by="ellm", + deployments=[ + ModelDeploymentConfig( + litellm_id="openai/meta-llama/Llama-3.2-3B-Instruct", + api_base="https://api.hyperbolic.xyz/v1", + provider="hyperbolic", + ), + ], + ) + ] + ) + async with _set_org_model_config(jamai, org.id, new_model_config): + # Fetch org-level config + models = await run(jamai.admin.organization.get_org_model_config, org.id) + assert isinstance(models, ModelListConfig) + assert isinstance(models.llm_models, list) + assert isinstance(models.embed_models, list) + assert isinstance(models.rerank_models, list) + assert len(models.llm_models) == 1 + assert len(models.embed_models) == 0 + assert len(models.rerank_models) == 0 + # Fetch model list + models = await run(jamai.model_names) + assert isinstance(models, list) + assert set(public_model_ids) - set(models) == set() + assert set(models) - set(public_model_ids) == {"ellm/new_model"} + # text add row with org_model + with _create_gen_table( + jamai, TableType.action, "test-org-model", "ellm/new_model", delete=True + ): + _add_row(jamai, TableType.action, "test-org-model") + # Try fetching from another org + jamai = JamAI(project_id=p1.id) + models = await run(jamai.model_names) + assert isinstance(models, list) + assert set(public_model_ids) - set(models) == set() + assert set(models) - set(public_model_ids) == set() + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_create_project(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id, "my-project") as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + # Duplicate name + with pytest.raises(RuntimeError): + with _create_project(owl, org.id, "my-project"): + pass + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize( + "name", ["a", "0", "冰:æ·‡ æ·‹", "a.b", "_a_", " (a) ", "=a", " " + "a" * 100] +) +def test_create_organization_project_valid_name( + client_cls: Type[JamAI], + name: str, +): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, name=name) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id, name=name) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + assert project.name == name.strip() + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("name", ["=", " ", "()", "a" * 101]) +def test_create_organization_project_invalid_name( + client_cls: Type[JamAI], + name: str, +): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with pytest.raises(RuntimeError): + with _create_project(owl, org.id, name=name): + pass + with pytest.raises(RuntimeError): + with _create_org(owl, duncan.id, name=name): + pass + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_get_and_list_projects(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with ( + _create_org(owl, duncan.id) as org, + _create_org(owl, duncan.id, name="Personal", tier=PlanName.PRO) as personal, + ): + assert isinstance(org.id, str) + assert len(org.id) > 0 + assert org.name == "Company" + assert personal.name == "Personal" + with ( + _create_project(owl, org.id, "bear") as proj_bear, + _create_project(owl, personal.id) as personal_default, + ): + with _create_project(owl, org.id, "Pear") as proj_pear: + with _create_project(owl, org.id, "pearl") as proj_pearl: + assert isinstance(proj_bear.id, str) + assert len(proj_bear.id) > 0 + assert isinstance(proj_pear.id, str) + assert len(proj_pear.id) > 0 + + # Test fetch + project = owl.admin.organization.get_project(proj_bear.id) + assert isinstance(project, ProjectRead) + assert project.id == proj_bear.id + assert project.name == "bear" + assert isinstance(project.organization.members, list) + assert len(project.organization.members) == 1 + + project = owl.admin.organization.get_project(proj_pear.id) + assert isinstance(project, ProjectRead) + assert project.id == proj_pear.id + assert project.name == "Pear" + + project = owl.admin.organization.get_project(proj_pearl.id) + assert isinstance(project, ProjectRead) + assert project.id == proj_pearl.id + assert project.name == "pearl" + + project = owl.admin.organization.get_project(personal_default.id) + assert isinstance(project, ProjectRead) + assert project.id == personal_default.id + assert project.name == "default" + + # Test association + org = owl.admin.backend.get_organization(org.id) + assert isinstance(org, OrganizationRead) + assert all(isinstance(p, ProjectRead) for p in org.projects) + proj_names = [p.name for p in org.projects] + assert "bear" in proj_names + assert "Pear" in proj_names + assert "pearl" in proj_names + + # Test list + projects = owl.admin.organization.list_projects(org.id) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 3 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 3 + + projects = owl.admin.organization.list_projects(personal.id) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 1 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 1 + + projects = owl.admin.organization.list_projects(org.id, offset=1) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 3 + assert projects.offset == 1 + assert projects.limit == 100 + assert len(projects.items) == 2 + + projects = owl.admin.organization.list_projects(org.id, limit=1) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 3 + assert projects.offset == 0 + assert projects.limit == 1 + assert len(projects.items) == 1 + + # Test list with search query + projects = owl.admin.organization.list_projects(org.id, "ear") + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 3 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 3 + + projects = owl.admin.organization.list_projects(org.id, "pe") + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 2 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 2 + + projects = owl.admin.organization.list_projects(org.id, "pe", offset=1) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 2 + assert projects.offset == 1 + assert projects.limit == 100 + assert len(projects.items) == 1 + + projects = owl.admin.organization.list_projects(org.id, "pe", limit=1) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.total == 2 + assert projects.offset == 0 + assert projects.limit == 1 + assert len(projects.items) == 1 + + # Test list with order_by + projects = owl.admin.organization.list_projects(org.id, "pe") + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.items[0].name == "pearl" + assert projects.items[1].name == "Pear" + assert projects.total == 2 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 2 + + projects = owl.admin.organization.list_projects( + org.id, "pe", order_descending=False + ) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert projects.items[0].name == "Pear" + assert projects.items[1].name == "pearl" + assert projects.total == 2 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 2 + + projects = owl.admin.organization.list_projects(org.id, order_by="name") + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert [p.name for p in projects.items] == ["pearl", "Pear", "bear"] + assert projects.total == 3 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 3 + + projects = owl.admin.organization.list_projects( + org.id, order_by="name", order_descending=False + ) + assert isinstance(projects.items, list) + assert all(isinstance(r, ProjectRead) for r in projects.items) + assert [p.name for p in projects.items] == ["bear", "Pear", "pearl"] + assert projects.total == 3 + assert projects.offset == 0 + assert projects.limit == 100 + assert len(projects.items) == 3 + + for order_by in AdminOrderBy: + projects = owl.admin.organization.list_projects( + org.id, order_by=order_by + ) + assert len(projects.items) == 3 + proj_ids = [p.id for p in projects.items] + projects_desc = owl.admin.organization.list_projects( + org.id, order_by=order_by, order_descending=False + ) + assert len(projects_desc.items) == 3 + proj_desc_ids = [p.id for p in projects_desc.items] + assert ( + proj_ids == proj_desc_ids[::-1] + ), f"Failed to order by {order_by}: {proj_ids} != {proj_desc_ids[::-1]}" + + # # Test starting_after + # projects = owl.admin.organization.list_projects( + # org.id, order_by="name", starting_after=proj_pearl.id + # ) + # assert isinstance(projects.items, list) + # assert all(isinstance(r, ProjectRead) for r in projects.items) + # assert [p.name for p in projects.items] == ["Pear", "bear"] + # assert projects.total == 3 + # assert projects.offset == 0 + # assert projects.limit == 100 + # assert len(projects.items) == 2 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_delete_projects(client_cls: Type[JamAI]): + owl = client_cls() + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + response = owl.admin.organization.delete_project(project.id) + assert isinstance(response, OkResponse) + with pytest.raises(RuntimeError, match="Project .+ is not found."): + owl.admin.organization.update_project( + ProjectUpdate(id=project.id, name="Updated Project") + ) + + with pytest.raises(RuntimeError, match="Project .+ is not found."): + owl.admin.organization.get_project(project.id) + + response = owl.admin.organization.delete_project(project.id) + assert isinstance(response, OkResponse) + with pytest.raises(RuntimeError, match="Project .+ is not found."): + owl.admin.organization.delete_project(project.id, missing_ok=False) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_update_project(client_cls: Type[JamAI]): + owl = client_cls() + + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + with _create_project(owl, org.id) as project: + updated_project_request = ProjectUpdate(id=project.id, name="Updated Project") + updated_project_response = owl.admin.organization.update_project( + updated_project_request + ) + assert isinstance(updated_project_response, ProjectRead) + assert updated_project_response.id == project.id + assert updated_project_response.name == "Updated Project" + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_project_updated_at(client_cls: Type[JamAI]): + owl = client_cls() + + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id) as org: + assert isinstance(org.id, str) + assert len(org.id) > 0 + # Add credit + owl.admin.backend.add_event( + EventCreate( + id=f"{org.quota_reset_at}_credit_{uuid7_str()}", + organization_id=org.id, + values={ProductType.CREDIT: 20.0}, + ) + ) + with _create_project(owl, org.id) as project: + assert isinstance(project.id, str) + assert len(project.id) > 0 + old_proj_updated_at = project.updated_at + jamai = JamAI(project_id=project.id) + # Test gen table + with _create_gen_table(jamai, TABLE_TYPES[0], "xx"): + pass + + @retry( + wait=wait_exponential(multiplier=1, min=1, max=10), + stop=stop_after_attempt(5), + reraise=True, + ) + def _assert_bumped_updated_at(): + proj = owl.admin.organization.get_project(project.id) + assert isinstance(proj, ProjectRead) + assert proj.updated_at > old_proj_updated_at + + t0 = perf_counter() + _assert_bumped_updated_at() + logger.info(f"Succeeded after {perf_counter() - t0:,.2f} seconds") + + +def test_project_update_model(): + sig = signature(ProjectUpdate) + for name, param in sig.parameters.items(): + if name == "id": + continue + assert ( + param.default is None + ), f'Parameter "{name}" has a default value of {param.default} instead of None.' + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("empty_project", [True, False], ids=["Empty project", "With data"]) +def test_project_import_export_round_trip(client_cls: Type[JamAI], empty_project: bool): + owl = client_cls() + + with _create_user(owl) as duncan: + with ( + _create_org(owl, duncan.id, name="Personal", tier=PlanName.PRO) as o0, + _create_org(owl, duncan.id, name="Company", tier=PlanName.PRO) as o1, + ): + assert isinstance(o0.id, str) + assert len(o0.id) > 0 + assert isinstance(o1.id, str) + assert len(o1.id) > 0 + assert o0.id != o1.id + # Add credit + owl.admin.backend.add_event( + EventCreate( + id=f"{o0.quota_reset_at}_credit_{uuid7_str()}", + organization_id=o0.id, + values={ProductType.CREDIT: 20.0}, + ) + ) + with _create_project(owl, o0.id) as p0, _create_project(owl, o0.id, "p1") as p1: + assert isinstance(p0.id, str) + assert len(p0.id) > 0 + # Create some tables + jamai = JamAI(project_id=p0.id) + if not empty_project: + for table_type in TABLE_TYPES: + with _create_gen_table(jamai, table_type, table_type, delete=False): + _add_row(jamai, table_type, table_type) + + def _check_tables(_project_id: str): + jamai = JamAI(project_id=_project_id) + if empty_project: + for table_type in TABLE_TYPES: + assert jamai.table.list_tables(table_type).total == 0 + else: + for table_type in TABLE_TYPES: + assert jamai.table.list_tables(table_type).total == 1 + rows = jamai.table.list_table_rows(table_type, table_type) + assert len(rows.items) == 1 + + # --- Export --- # + data = jamai.admin.organization.export_project(p0.id) + + # --- Import as new project --- # + # Test file-like object + with BytesIO(data) as f: + new_p0 = jamai.admin.organization.import_project(f, o0.id) + assert isinstance(new_p0, ProjectRead) + _check_tables(new_p0.id) + # List the projects + proj_ids = set(p.id for p in owl.admin.organization.list_projects(o0.id).items) + assert len(proj_ids) == 3 # Also ensures uniqueness + assert p0.id in proj_ids + assert p1.id in proj_ids + assert new_p0.id in proj_ids + + # --- Import into existing project --- # + # Test file path + with TemporaryDirectory() as tmp_dir: + export_filepath = join(tmp_dir, "project.parquet") + with open(export_filepath, "wb") as f: + f.write(data) + new_p1 = jamai.admin.organization.import_project(export_filepath, o0.id, p1.id) + assert isinstance(new_p1, ProjectRead) + assert new_p1.id == p1.id + _check_tables(new_p1.id) + # List the projects + proj_ids = set(p.id for p in owl.admin.organization.list_projects(o0.id).items) + assert len(proj_ids) == 3 # Also ensures uniqueness + assert p0.id in proj_ids + assert p1.id in proj_ids + assert new_p0.id in proj_ids + + # --- Import again, should fail --- # + if not empty_project: + with BytesIO(data) as f: + with pytest.raises(RuntimeError): + jamai.admin.organization.import_project(f, o0.id, p1.id) + + # --- Import into another organization --- # + with BytesIO(data) as f: + project = JamAI().admin.organization.import_project(f, o1.id) + assert isinstance(project, ProjectRead) + _check_tables(project.id) + # List the projects + proj_ids = set(p.id for p in owl.admin.organization.list_projects(o1.id).items) + assert len(proj_ids) == 1 + assert project.id in proj_ids + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("empty_project", [True, False], ids=["Empty project", "With data"]) +def test_project_import_export_template(client_cls: Type[JamAI], empty_project: bool): + owl = client_cls() + + with _create_user(owl) as duncan: + with _create_org(owl, duncan.id, name="Personal") as o0: + assert isinstance(o0.id, str) + assert len(o0.id) > 0 + # Add credit + owl.admin.backend.add_event( + EventCreate( + id=f"{o0.quota_reset_at}_credit_{uuid7_str()}", + organization_id=o0.id, + values={ProductType.CREDIT: 20.0}, + ) + ) + with ( + _create_project(owl, o0.id) as p0, + _create_project(owl, o0.id, "p1") as p1, + _create_project(owl, o0.id, "p2") as p2, + ): + assert isinstance(p0.id, str) + assert len(p0.id) > 0 + # Create some tables + jamai = JamAI(project_id=p0.id) + if not empty_project: + for table_type in TABLE_TYPES: + with _create_gen_table(jamai, table_type, table_type, delete=False): + _add_row(jamai, table_type, table_type) + + def _check_tables(_project_id: str): + jamai = JamAI(project_id=_project_id) + if empty_project: + for table_type in TABLE_TYPES: + assert jamai.table.list_tables(table_type).total == 0 + else: + for table_type in TABLE_TYPES: + assert jamai.table.list_tables(table_type).total == 1 + rows = jamai.table.list_table_rows(table_type, table_type) + assert len(rows.items) == 1 + + # --- Export template --- # + data = jamai.admin.organization.export_project_as_template( + p0.id, + name="Template 试验", + tags=["sector:finance", "sector:科技"], + description="テンプレート description", + ) + with BytesIO(data) as f: + # Import as new project + new_p0 = jamai.admin.organization.import_project(f, o0.id) + assert isinstance(new_p0, ProjectRead) + _check_tables(new_p0.id) + # Import into existing project + new_p1 = jamai.admin.organization.import_project(f, o0.id, p1.id) + assert isinstance(new_p1, ProjectRead) + assert new_p1.id == p1.id + _check_tables(new_p1.id) + # List the projects + proj_ids = set(p.id for p in owl.admin.organization.list_projects(o0.id).items) + assert len(proj_ids) == 4 # Also ensures uniqueness + assert p0.id in proj_ids + assert p1.id in proj_ids + assert p2.id in proj_ids + assert new_p0.id in proj_ids + + # --- Add template --- # + new_template_id = "test_template" + response = jamai.admin.backend.add_template(f, new_template_id, True) + assert isinstance(response, OkResponse) + # Add again, should fail + with pytest.raises(RuntimeError): + jamai.admin.backend.add_template(f, new_template_id) + # List templates + template_ids = set(t.id for t in jamai.template.list_templates().items) + assert new_template_id in template_ids + # Import as new project + new_p2 = jamai.admin.organization.import_project_from_template( + o0.id, new_template_id + ) + assert isinstance(new_p2, ProjectRead) + _check_tables(new_p2.id) + # Import into existing project + new_p3 = jamai.admin.organization.import_project_from_template( + o0.id, new_template_id, p2.id + ) + assert isinstance(new_p3, ProjectRead) + assert new_p3.id == p2.id + _check_tables(new_p3.id) + # List the projects + proj_ids = set(p.id for p in owl.admin.organization.list_projects(o0.id).items) + assert len(proj_ids) == 5 # Also ensures uniqueness + assert p0.id in proj_ids + assert p1.id in proj_ids + assert p2.id in proj_ids + assert new_p0.id in proj_ids + assert new_p2.id in proj_ids diff --git a/clients/python/tests/files/bmp/cifar10-deer.bmp b/clients/python/tests/files/bmp/cifar10-deer.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c7090760e00b99b277f8ef02690bd4d4d70220d8 GIT binary patch literal 12730 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 E0j2|u^8f$< literal 0 HcmV?d00001 diff --git a/clients/python/tests/csv/company-profile.csv b/clients/python/tests/files/csv/company-profile.csv similarity index 100% rename from clients/python/tests/csv/company-profile.csv rename to clients/python/tests/files/csv/company-profile.csv diff --git a/clients/python/tests/__init__.py b/clients/python/tests/files/csv/empty.csv similarity index 100% rename from clients/python/tests/__init__.py rename to clients/python/tests/files/csv/empty.csv diff --git a/clients/python/tests/csv/weather_observations_long.csv b/clients/python/tests/files/csv/weather_observations_long.csv similarity index 100% rename from clients/python/tests/csv/weather_observations_long.csv rename to clients/python/tests/files/csv/weather_observations_long.csv diff --git a/clients/python/tests/doc/Recommendation Letter.doc b/clients/python/tests/files/doc/Recommendation Letter.doc similarity index 100% rename from clients/python/tests/doc/Recommendation Letter.doc rename to clients/python/tests/files/doc/Recommendation Letter.doc diff --git a/clients/python/tests/docx/Recommendation Letter.docx b/clients/python/tests/files/docx/Recommendation Letter.docx similarity index 100% rename from clients/python/tests/docx/Recommendation Letter.docx rename to clients/python/tests/files/docx/Recommendation Letter.docx diff --git a/clients/python/tests/files/gif/rabbit_cifar10-deer.gif b/clients/python/tests/files/gif/rabbit_cifar10-deer.gif new file mode 100644 index 0000000000000000000000000000000000000000..66039a06aa5fd7be28291e28f8344baec23fe904 GIT binary patch literal 53843 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 U-?R0*q?GHDJ>mA!M99AX1LxD5CjbBd literal 0 HcmV?d00001 diff --git a/clients/python/tests/html/RAG and LLM Integration Guide.html b/clients/python/tests/files/html/RAG and LLM Integration Guide.html similarity index 100% rename from clients/python/tests/html/RAG and LLM Integration Guide.html rename to clients/python/tests/files/html/RAG and LLM Integration Guide.html diff --git a/clients/python/tests/html/multilingual-code-examples.html b/clients/python/tests/files/html/multilingual-code-examples.html similarity index 100% rename from clients/python/tests/html/multilingual-code-examples.html rename to clients/python/tests/files/html/multilingual-code-examples.html diff --git a/clients/python/tests/html/table.html b/clients/python/tests/files/html/table.html similarity index 100% rename from clients/python/tests/html/table.html rename to clients/python/tests/files/html/table.html diff --git a/clients/python/tests/files/jpeg/cifar10-deer.jpg b/clients/python/tests/files/jpeg/cifar10-deer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6a95505712e355e5d6fa33c31b0707d1179365f4 GIT binary patch literal 929 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!|T%Qn&{~BpPoKucW>deFiQMPxF7_bHy9cP(!X4}=0>n(Rw|L&i_p!(-vW8|mmV`1UyAI^R~ za=kd?`v;ry+?m2Q4Ku#JKN~aej!vudY6cGrTi&p}SKg{^@-+F~TD^F($cF7&h9}PN zU+TT2Qt0JtnX>iF4|czMUTEuAn|W%YR$=~>2X;o8=E1LKC2lkFQ0-XcW>l9@dEo1l z@QJxIC5||<$hq&WO}Cq~Yu;kk%Svrq!Ye*i8A_agI_u=_56`Bqs9dQxmFe7x&{VZp(_B-EA-)Lv(z7?`K&aohc?`!noU8VDy pvvmxkj&qA2IP_oxLs7lV)tm2@lqNLj#w1?}S-E0e`fmIGHvtz>YE1wD literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/jpeg/rabbit.jpeg b/clients/python/tests/files/jpeg/rabbit.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..26b0f3f00efdad5a320df7a1dfad1bb39027e2f2 GIT binary patch literal 60109 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 VR=YS@J7>;odw5Rrkj3Ar{{cA2ULybi literal 0 HcmV?d00001 diff --git a/clients/python/tests/json/company-profile.json b/clients/python/tests/files/json/company-profile.json similarity index 100% rename from clients/python/tests/json/company-profile.json rename to clients/python/tests/files/json/company-profile.json diff --git a/clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl b/clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl new file mode 100644 index 0000000..b1e777f --- /dev/null +++ b/clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl @@ -0,0 +1,5 @@ +{"query": "我腹痛,没有其他症状,有什么中è¯å¯ä»¥æŽ¨èå—?è¦æ±‚:1. 请考虑所有症状。2. 请输出推ç†è¿‡ç¨‹ï¼ŒæŽ¨ç†è¿‡ç¨‹å¯èƒ½æ¶‰åŠç—‡çŠ¶ã€è¯å€™ã€æ²»æ³•ã€æ–¹å‰‚/中è¯ç­‰ä¿¡æ¯ã€‚", "response": "首先需è¦ç¡®å®šè…¹ç—›çš„性质(寒热虚实),进而确定è¯å€™ã€‚如果腹痛为寒性,常è§è¯å€™ä¸ºå¯’ç–,其症状为腹痛缓解于温,加é‡äºŽå¯’;腹部感觉冷痛,喜按;肢冷,脉沉紧。治法常用温ç»æ•£å¯’ã€è¡Œæ°”止痛,常用方剂为ç†ä¸­ä¸¸ã€æ¸©ä¸­æ±¤ç­‰ã€‚其中,ç†ä¸­ä¸¸ä¸»è¦æˆåˆ†ä¸ºé»„è¿žã€åŠå¤ã€ç”Ÿå§œç­‰ï¼Œå…·æœ‰æ¸©ä¸­æ•£å¯’ã€è¡Œæ°”止痛的作用。\n\n如果腹痛为实性,常è§è¯å€™ä¸ºå®žç–,其症状为腹痛剧烈,ä¸å—温度影å“;腹部胀满,喜按;舌苔厚腻,脉滑数。治法常用清热化湿ã€è¡Œæ°”消肿,常用方剂为二陈汉è¯ã€æ¶ˆç—žä¸¸ç­‰ã€‚其中,二陈汉è¯ä¸»è¦æˆåˆ†ä¸ºé»„è¿žã€åŠå¤ã€ç”Ÿå§œã€é™ˆçš®ç­‰ï¼Œå…·æœ‰æ¸…热解毒ã€è¡Œæ°”止痛的作用。\n\n如果腹痛为虚性,常è§è¯å€™ä¸ºè™šç–,其症状为腹痛呈间歇性ã€éšç—›æ€§è´¨ï¼Œå¤šè§äºŽè„¾èƒƒæ°”虚ã€è‚¾æ°”虚等。治法常用补益气血ã€å›ºæ‘„止痛,常用方剂为四å›å­æ±¤ã€å…­å›å­ä¸¸ç­‰ã€‚其中,四å›å­æ±¤ä¸»è¦æˆåˆ†ä¸ºäººå‚ã€ç™½æœ¯ã€èŒ¯è‹“ã€ç”˜è‰ç­‰ï¼Œå…·æœ‰è¡¥ç›Šè„¾èƒƒã€å›ºæ‘„止痛的作用。\n\n综上所述,具体选择中è¯æ–¹å‰‚需è¦æ ¹æ®è…¹ç—›çš„性质和相应的è¯å€™æ¥ç¡®å®šï¼Œå»ºè®®å°±åŒ»å¹¶åœ¨åŒ»ç”ŸæŒ‡å¯¼ä¸‹è¿›è¡Œä¸­è¯æ²»ç–—。"} +{"query": "阴疮无其他症状应该如何治疗?请帮我推è中è¯æˆ–者方剂。è¦æ±‚:1. 请考虑所有症状。2. 请输出推ç†è¿‡ç¨‹ã€‚", "response": "很抱歉,阴疮是一个模糊的医学术语,å¯ä»¥æŒ‡é˜´é“ã€å¤–阴ã€ä¼šé˜´ç­‰éƒ¨ä½çš„皮肤病å˜ã€‚由于您没有æ供其他症状,比如阴é“分泌物ã€ç˜™ç—’ã€ç–¼ç—›ç­‰ï¼Œå› æ­¤æˆ‘ä¸èƒ½æ供具体的治疗方案。建议您å‰å¾€åŒ»é™¢å°±è¯Šï¼Œç”±åŒ»ç”Ÿè¿›è¡Œè¯¦ç»†çš„检查和诊断,然åŽè¿›è¡Œæ²»ç–—。如果您需è¦å…¶ä»–帮助,请éšæ—¶å‘Šè¯‰æˆ‘。"} +{"query": "患者出现åŠèº«ä¸é‚症状,没有其他症状。请推è中è¯ã€‚è¦æ±‚:1. 请考虑所有症状。2. 请根æ®è¾“出一步步的推ç†è¿‡ç¨‹ã€‚", "response": "感谢您的æ问。根æ®æ‚¨æ供的信æ¯ï¼Œæ‚£è€…出现了åŠèº«ä¸é‚症状,没有其他症状。这ç§æƒ…况å¯èƒ½æ˜¯ç”±äºŽè„‘血管疾病引起的,因此,我会基于这个症状推è一些中è¯ã€‚\n\n首先,我们需è¦äº†è§£æ‚£è€…症状的具体表现,例如是左侧或å³ä¾§ä¸é‚,上肢或下肢ä¸é‚等等。这些信æ¯å¯ä»¥å¸®åŠ©æˆ‘们确定病å˜çš„ä½ç½®ã€‚\n\n其次,我们需è¦è€ƒè™‘患者的体质和病å²ï¼Œä¾‹å¦‚是å¦å­˜åœ¨é«˜è¡€åŽ‹ã€é«˜è¡€è„‚ã€ç³–尿病等慢性疾病。这些病å²å¯èƒ½å½±å“治疗方案的选择。\n\n在了解完这些信æ¯ä¹‹åŽï¼Œæˆ‘们å¯ä»¥è€ƒè™‘以下中è¯ï¼š\n\n1. 通络活血类中è¯ï¼šå¦‚桃ä»ã€çº¢èŠ±ã€å·èŠŽã€ä¸¹å‚等。这类中è¯å¯ä»¥ä¿ƒè¿›è¡€æ¶²å¾ªçŽ¯ï¼Œèˆ’展血管,从而改善åŠèº«ä¸é‚症状。\n\n2. 祛风化痰类中è¯ï¼šå¦‚天麻ã€é’©è—¤ã€ç™½èŠã€åŠå¤ç­‰ã€‚这类中è¯å¯ä»¥ç¼“解病情,å‡è½»ç—‡çŠ¶ï¼Œç‰¹åˆ«æ˜¯å¯¹äºŽå› é£Žç—°é˜»å¡žå¼•èµ·çš„ä¸é‚症状更为有效。\n\n3. 补肾益气类中è¯ï¼šå¦‚枸æžå­ã€é»„ç²¾ã€æ·«ç¾Šè—¿ã€èŸä¸å­ç­‰ã€‚这类中è¯å¯ä»¥å¢žå¼ºèº«ä½“çš„å…疫力,æ高机体的抗病能力,对于慢性病å²è¾ƒé•¿çš„患者有一定的帮助。\n\n需è¦æ³¨æ„的是,中è¯çš„选择需è¦è€ƒè™‘具体情况,因此建议患者在就医å‰å’¨è¯¢ä¸“业医生的æ„è§ã€‚åŒæ—¶ï¼Œä¸­è¯æ²»ç–—需è¦é•¿æœŸåšæŒï¼Œä¸èƒ½è½»æ˜“åœè¯ï¼Œå¦åˆ™å¯èƒ½ä¼šå¯¼è‡´ç—…情åå¤ã€‚"} +{"query": "我最近ç»å¸¸è…¹ç—›ï¼Œæ²¡æœ‰å…¶ä»–症状。请帮我推è中è¯æˆ–者方剂。è¦æ±‚:1. 请考虑所有症状。2. 请输出推ç†è¿‡ç¨‹ã€‚", "response": "对于您的腹痛,中è¯æ–¹å‰‚治疗å¯ä»¥è€ƒè™‘以下几个方é¢ï¼š\n\n1. 脾胃虚弱型:腹痛轻微,消化ä¸è‰¯ï¼Œé£Ÿæ¬²ä¸æŒ¯ï¼Œå¤§ä¾¿ç¨€è½¯ï¼ŒèˆŒè´¨æ·¡ç™½ï¼Œè‹”薄白,脉虚弱。建议æœç”¨å››å›å­æ±¤æˆ–å…­å›å­æ±¤ç­‰è¡¥è„¾ç›Šæ°”方剂。\n\n2. 湿热éƒæ»žåž‹ï¼šè…¹ç—›é‡ï¼Œä¼´æœ‰è…¹èƒ€ã€æ¶å¿ƒã€å£æ¸´ã€å£è‹¦ã€å¤§ä¾¿ç¨ é»æˆ–者腹泻,舌质红ã€è‹”黄腻,脉滑数。建议æœç”¨æ¸…热解毒ã€ç‡¥æ¹¿é€šä¾¿çš„方剂,如黄芩汤ã€é¾™èƒ†æ³»è‚汤等。\n\n3. è‚胃失和型:腹痛éšç—›ï¼Œä¼´æœ‰èƒ¸é—·ã€å£è‹¦ã€å£å¹²ã€æ˜“怒ã€å¤±çœ ç­‰ï¼ŒèˆŒè´¨çº¢ã€è‹”黄腻,脉弦细。建议æœç”¨æŸ´èƒ¡åŠ èŠè¯æ±¤ã€é¾™èƒ†æ³»è‚汤等调和è‚胃的方剂。\n\n4. 寒湿阻滞型:腹痛胀满,伴有ç•å¯’ã€è…¹æ³»ã€å°ä¾¿ä¸ç•…等,舌质淡白,苔白腻,脉沉迟。建议æœç”¨æ¸©ä¸­æ•£å¯’ã€é€šç»æ­¢ç—›çš„方剂,如ç†ä¸­ä¸¸ã€å°å»ºä¸­æ±¤ç­‰ã€‚\n\n需è¦æ³¨æ„的是,中è¯æ–¹å‰‚治疗需è¦æ ¹æ®ä¸ªäººæƒ…况进行调整,建议您就医åŽåœ¨åŒ»ç”ŸæŒ‡å¯¼ä¸‹æœç”¨ä¸­è¯ã€‚åŒæ—¶ï¼Œä¸­è¯æ²»ç–—需è¦ä¸€å®šæ—¶é—´æ‰èƒ½è§æ•ˆï¼Œå»ºè®®æ‚¨è€å¿ƒæ²»ç–—。"} +{"query": "我多唾有些时间段比较严é‡ï¼Œæ˜¯ä¸æ˜¯è¿˜æœ‰å…¶ä»–çš„è¯å€™å‘¢ï¼Ÿè¯·å¸®æˆ‘推è中è¯æˆ–者方剂。è¦æ±‚:1. 请考虑所有症状。2. 请根æ®è¾“出一步步的推ç†è¿‡ç¨‹ã€‚", "response": "唾液分泌过多是一ç§æ¯”较常è§çš„症状,通常情况下,唾液分泌过多还会伴éšä»¥ä¸‹ç—‡çŠ¶ï¼šå£å¹²èˆŒç‡¥ã€å£è…”溃疡ã€å£è‡­ã€å—“å­å¹²ç—’ã€é£Ÿæ¬²å‡é€€ã€æ¶ˆåŒ–ä¸è‰¯ã€è…¹æ³»ç­‰ã€‚因此,我们需è¦è€ƒè™‘到这些相关症状,æ¥å¯»æ‰¾æœ€é€‚åˆçš„中è¯æˆ–方剂。\n\n中医认为,唾液分泌过多主è¦æ˜¯å› ä¸ºè„¾èƒƒè™šå¼±ï¼Œæ°”è¡€ä¸è¶³ï¼Œæ¹¿çƒ­å†…蕴等原因引起,因此,建议您选择具有å¥è„¾åŒ–湿ã€ç›Šæ°”生血ã€æ¸…热解毒等功效的中è¯æˆ–方剂。\n\n推è以下方剂:\n\n1. ä¿å’Œä¸¸ï¼šä¿å’Œä¸¸ä¸»è¦æˆåˆ†ä¸ºé»„芪ã€ç™½æœ¯ç­‰ï¼Œå…·æœ‰ç›Šæ°”å¥è„¾ã€åŒ–湿止泻的功效,适用于脾胃虚弱ã€é£Ÿæ¬²ä¸æŒ¯ã€è…¹æ³»ç­‰ç—‡çŠ¶ã€‚\n\n2. 二妙丸:二妙丸主è¦æˆåˆ†ä¸ºé»„è¿žã€é»„æŸç­‰ï¼Œå…·æœ‰æ¸…热解毒ã€ç‡¥æ¹¿æ­¢ç—’的功效,适用于å£å¹²èˆŒç‡¥ã€å£è…”溃疡等症状。\n\n3. èŠè¯ç”˜è‰æ±¤ï¼šèŠè¯ç”˜è‰æ±¤ä¸»è¦æˆåˆ†ä¸ºç™½æœ¯ã€èŒ¯è‹“ã€èŠè¯ç­‰ï¼Œå…·æœ‰å¥è„¾ç›Šæ°”ã€æ¸…热解毒的功效,适用于脾胃虚弱ã€æ¹¿çƒ­å†…蕴等症状。\n\n建议您在选择用è¯å‰ï¼Œå…ˆåˆ°åŒ»é™¢è¿›è¡Œæ£€æŸ¥ï¼Œä»¥ç¡®å®šç—…因,并在医生的指导下使用中è¯æˆ–方剂。"} \ No newline at end of file diff --git a/clients/python/tests/jsonl/llm-models.jsonl b/clients/python/tests/files/jsonl/llm-models.jsonl similarity index 100% rename from clients/python/tests/jsonl/llm-models.jsonl rename to clients/python/tests/files/jsonl/llm-models.jsonl diff --git a/clients/python/tests/md/creative-story.md b/clients/python/tests/files/md/creative-story.md similarity index 100% rename from clients/python/tests/md/creative-story.md rename to clients/python/tests/files/md/creative-story.md diff --git a/clients/python/tests/pdf/1970_PSS_ThAT_mechanism.pdf b/clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf similarity index 100% rename from clients/python/tests/pdf/1970_PSS_ThAT_mechanism.pdf rename to clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf diff --git a/clients/python/tests/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf b/clients/python/tests/files/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf similarity index 100% rename from clients/python/tests/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf rename to clients/python/tests/files/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf diff --git a/clients/python/tests/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf b/clients/python/tests/files/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf similarity index 100% rename from clients/python/tests/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf rename to clients/python/tests/files/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf diff --git a/clients/python/tests/pdf/Swire_AR22_e_230406_sample.pdf b/clients/python/tests/files/pdf/Swire_AR22_e_230406_sample.pdf similarity index 100% rename from clients/python/tests/pdf/Swire_AR22_e_230406_sample.pdf rename to clients/python/tests/files/pdf/Swire_AR22_e_230406_sample.pdf diff --git a/clients/python/tests/pdf/System Design Blueprint - The Ultimate Guide.pdf b/clients/python/tests/files/pdf/System Design Blueprint - The Ultimate Guide.pdf similarity index 100% rename from clients/python/tests/pdf/System Design Blueprint - The Ultimate Guide.pdf rename to clients/python/tests/files/pdf/System Design Blueprint - The Ultimate Guide.pdf diff --git a/clients/python/tests/pdf/Vehicle Detail - MyPUSPAKOM.pdf b/clients/python/tests/files/pdf/Vehicle Detail - MyPUSPAKOM.pdf similarity index 100% rename from clients/python/tests/pdf/Vehicle Detail - MyPUSPAKOM.pdf rename to clients/python/tests/files/pdf/Vehicle Detail - MyPUSPAKOM.pdf diff --git a/clients/python/tests/pdf/ag-energy-round-up-2017-02-24.pdf b/clients/python/tests/files/pdf/ag-energy-round-up-2017-02-24.pdf similarity index 100% rename from clients/python/tests/pdf/ag-energy-round-up-2017-02-24.pdf rename to clients/python/tests/files/pdf/ag-energy-round-up-2017-02-24.pdf diff --git a/clients/python/tests/pdf/background-checks.pdf b/clients/python/tests/files/pdf/background-checks.pdf similarity index 100% rename from clients/python/tests/pdf/background-checks.pdf rename to clients/python/tests/files/pdf/background-checks.pdf diff --git a/clients/python/tests/pdf/ca-warn-report.pdf b/clients/python/tests/files/pdf/ca-warn-report.pdf similarity index 100% rename from clients/python/tests/pdf/ca-warn-report.pdf rename to clients/python/tests/files/pdf/ca-warn-report.pdf diff --git a/clients/python/tests/files/pdf/empty.pdf b/clients/python/tests/files/pdf/empty.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3f02108c4844a3217a99446400ea18504c1f9896 GIT binary patch literal 516 zcmZvZ%}T>S6ovQqDefkq3p@Wa36w6Z7DU7l-ANa*nUqM$$Rvf2?8+hNChyks>>dq-n}iDMc!c%CweBD8;$dG!sT)D*0q=&y3-kYr&;b z+GvwWsyO44av`IC8G*fxwfrL9d0RsO0D2tMDx_hJM~x{QZm~USCcHJyNMr0DO@|VH ue7!H5jrGHqJv47N#FE0f9%Io3kejx47(Zv|wChXX4}3y0NRsRO1$+Zj(SIBO literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/pdf/empty_3pages.pdf b/clients/python/tests/files/pdf/empty_3pages.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cf6795c657b69382cd67aeb16da388f650ea2a52 GIT binary patch literal 924 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/png/rabbit.png b/clients/python/tests/files/png/rabbit.png new file mode 100644 index 0000000000000000000000000000000000000000..fb5ee896a8375021fb217b6bed15634dc5462161 GIT binary patch literal 40878 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⪻XxLoi2E;O&voZi8H($%e*M|+bM&wNIrLsGV2Z^pMJ zLc9XB5sq~%-$zmg3?T?t%_3owrYJraKw^oF$fco17?sf*BXe^hn3P^-Qe>xJfg9Sy zWomgYJImmSEMI;LJIsmJnUk%s(X#DZ@YF6kPnFoi^l4kxR($girzR4EOnTq~U|vI; zBg9KaxV4^?+T=!M&mM8v8w3-_riIXrq&dTe)uPa&i_%9s_+o4oR98;x>1|KYehSoI z0=6{)oQ!ze?yoF(ddneCU&^ZEr2Iue?BR7+pdPwoPaQIb1)JJ|N&XkG$c_`hxnqQx z2MM8l@$zu6Le3 z5ozCx#bPg!Yp3!UT)OB0@$Jg5-C77o2Oag!MLwg5yAfIpg(|@{8gWD+un;{fv<=Y= zARs{{JeeN=EFZ37_fv#X%mKvD_nShPYbW>R`h_%UKfHblWVL#_9%+uumnmn0_)!7u zV=>F8P6z0e3b&Axq&dX&hKP!l7=|BSuUX?-ssgRpea zHm)2)D``u?Acb!zo-u4|>MVI@O-b~pV&@My=W-M~L42PNF_UgvaIjDm!-vcX@#dnxLV}I^QBGuE4~~t}&%$$*Y&I z?EMUUgqXUJuK}L+r4FB#M&3R#C5TV=TPUIyF}PFr!-=kn4TS8NR1l0rMWz^XMwBbQ z>gbwBCs8z$Fdt=T}PN>B)rkBRAzMJ?`c1v|Ff|@GT*Xe7f_gx2F zbIN=6%Pp>{YZu)m(gt^%dDNtbL^&n4gA<>f7IO6ni&3q5}o z16Z9B{FNKRVmn{ZsT6JDM*1vOL1pNLre8*NBE}M(*Py~XxYynSD#zdFQFuS-b)LQs z&QDzgQwqj`9dV|uDxGAh$=Q*ff9<7Oa{@c?80jh4Tt`B&j%Esq27q=k!=OZ5)**e` zERMGD319p+oXsdLkKghMb_|2kE93ihfPd7EHD3=EKWHAo2yDe-q5B^#WSnQsBb&<{ z;e&!g5(E)x*~ZsrqFhW)sZg13m8aMry+Omkp>bf+1<~XO2Hzw0_`X@F{B4(A>u_B+ zy?mCp`)S)`rDv`-GqxuNte_#Nq*7wjRt!D2|#@?MHyT2wkk1rDo{JgvU-mzGy2ABqe1N&%mU_z0jW?hbq{Z?0`lK_M1B-)bE zy)lxLPMnzgNots97drShwC&SYPUb})3JN9D@M9l&2b~a}ej}iMHJ>j1v{CLk?~it7 zpf%0wO^fJsc~Sz6q@#p-unrrhYtnf+2Q}65MR7|00mFRu6`-q%A_%h1AaNZx`$>(Oad&!@gwOmB$Vvlz##OULjFQ5Iq6 zN&wWMS3Eymd&nFj*Wd8aKXP13y~NT*3xmGW=hwkVMzc7LWs{s0l{Pv#lZCB;*k%_W zMVEi4y{C`C@T++=7X5zE#tSKjPS!oIygKvoCoMQ2T$Cz1p@roE?oCT`C1lZlz(p1; z#Sjn5x#%o^MB`l28-rB6+AC0`fc;rxhBv}*qd=`fuXG_fS)f0rAGh-#^w0ug(;|BJ z6|IG+i|$83StqziqheC5YDhv+IqY6IX5h)=uINFiksN@%7Cd|-FoUe3Je>p33A8-@ zLXnE`6Z+G~$6rO5{xNMXf6B55;PZV{d}f)zfu$EfkZ=n%9g%He8HTN=Bdyy@sz?k^ zk1?N3ltCYC_d{|{u0YR==%g=~*WE9DIsaLN$%>nF!kz&A&H&Gy6tl`qm5Ht)&p<~C zTBFlxHIQYpq^}swY4sG<7)Nsy?yW;@ZdhfI0F@!Oo5TRcPax;N&^Nr@6^^)l{N39$ znO!b$vk}0{#hT{ayI&0me^eSBymsyzWj4#!vSB$TD`#*}P3owI-b-@^$TdjN`MUPT zGI54y6JWWpCAd@F-}@iNw*xvNF9Q(-#+a!|c`8oUPe*>^|);$4y-Hw_DSk^18 zO=4#r;I6CnWfjXPXuW5wj?CI8YK&i=r5f!Zy)IMiuIh8QDqQXQ2f&V)OL^PHE9$v3 z!S+9N(Y71>Icb(YT9Vx{iBgyMgd;z2TKtB3QP=_^){z<1MzFw24#ct9E0$&$#&;A^n|jR$$O;$TGp)ESV+c;t(}Ha`CG0TKviW_ z!4wrY0BMCc)|A?n5O_BF!37$YiI^`m>VBEGo3q=?O5`BZA5Nn`%o9zWp4QQvj~+~k zZ*bJs*Rwcn4y=!YMAn$r5?Dpyz{4C>4YMdWpl;X66KQpbm3%6lT9Cr>K-;{Tss;w$ zHPb%-B`o)QR{zU#I{#&Kr(nT*7~eU~ay~lIiKZazKl+clYuj$z)}aU?QlgG8vc}e0 z^Z!43M1U87q->`Ti6?E-W|B!WhH&55n>dAm=F!07VNeNE9+_DcG^o%Vs9CG3{8#~8 zEBuuB#?Xn$h|g!x2U{l+ES|Lr_Y4N?ogkQx+b`Gu2@hnB|0;c|GJbgc6e;_<-Sb|ozh2T2j>1y z>XcvqVh#?_O0w{EkxUE*ia)2$nKGEZM(eHj` z8;fm{#qZ9qaohv({Z8y0k=&)cjp5?-@edo=4F6+J=G&N;3ew0XpZ^EiJ8ScCzkv*; zbO%=}VDJF7ieBKS=A{<$gX-xIpdIUzVyGm$dcjo1OEfyx%--|AnRToSO)A7O| z(A8k)BgjsE1hmFhbwdnTsFlR@(iM!zli@V&^h#)#LD&%H4p@yCIVwjT!*N(dF_k4O z!5vOyJ~t7+2+`qKc-f?h$;Q{wWs>OV!GNL25+U>8A{Z(tK#J{I?^|`!MyOc2>Y!5+ zJ?P7^{3rk%@HQYZo`n+y%L4I;mU)!smGnAz*gWuF0bgOg-Nz>4FR;%h9rTG|SuYO~ zu@PQ;N!>1)cMLFEYttPPC=R>Yo(*XMLm#~0C7=>yN&ZcMk%k2x6=bHnB7BLI2OyWc zEacH>V?#4kKJSvb^_r0F`}$in;Io6GcPu`RZV z;9r}#>Ub*F4hu!K7V8yqOacuahfNw_fQTz!zH zm&S!1<;hKH&%9vg$-;2I&*O!x8Z~mVP+crH4T`?Imc(hg$gFf9b8MqWB~S)}EyuB#7*oQdl&{F5Hc77nk-K<(g-AClE&qUD%@x!K{m$_HZo!J^_8)&U%{uymq#NeWAyH9a=4 zS0~mH^(-nSQ3zzTe*Y;#RXi~Rp->Q>0yk+nT<$a?2(oXEt( zUTZmXA{RcH@JEl>yGS&fw5E!_nNm7~_OcYS*it%e!#+~rXrA1D0`j6dz)t-B&Y>UH zExT zP6rG{A1jYK3goA~xqf!pP|YeaiaqbaX79hI#}<<7ZeSLbWxwSC2J?L>uz_ovcYNx> zPzil~R!984frE(yH_to``|EWh7d~4~ZX}{)6E(MFuk6WSwOsH;EX{!A>;d>M6b^Qb zx(&PW_6WRZ-DQ~Uq!vQn6iR~X+Q_}Sj4aQpGAxwq=g&#R=XVWEM=b$#MsTFjjLC(Q zbs}0`9Sk>5`ayw$dDATYT(rcmju><`CPY15ud*mqDf_L9En?e zoY{pJ$Gp@F^ZnsJP$hM}KI7kGHS90Mi=U{_IU@QwWr4+>yxK75{E*v2bP!6K3c`lciytjk5cwJ}?>beKw6^YxiqpzKoxbKH67l){J~*p{8#eJU zZd_&!p^9dX|JVv^wk#<0hJE7blF?cYHHRoqlX|OsC?K1#7V@*VYMaZ*I2TtJYUPMe zyIoMRScIZFCJPHocgBxM#Aoo|i_r|UqZ;)@G5XG^9m67$w0zO8kyZ|ti+sQr8sN%x zQQ>yjF5!H}@D?g8i1udvvGF=+;4OXVmyDi_s4?X6mV{x{Stz@4cE=C!L+mrAdFzcy+h*|yociP z5_B4QYmiPJEyzWpyed3|oj~2EmuM~O!CX6wf9XK|%Zd2R*M z0aGt^I|34*i(^HDtrnogq^{Z55 zB~{75!gE1TmHgsp`uy|&{4fRE%uVVxj?n|7A1rXA)c9w)Fogq;b@1f;j~32B{he}r z0Ii0>c-;T&7M|9D#^iHSw#wqus?ik_s%}*Uo%R+xRt8Y{S7@A99vlPLZzkfi4$=NS z6K4xM&oO!nF?!FvX;>-A>?9UeJ41hN>8cz5cmQR`VA^@MR1*e`?ud(zo?{Bj2=nS`aF9I(uwL{P<50-2Fk%`$Go~|=D9_9koJ%5$R0YbkNC!D z8`>R&dKsN!ZBRGHsMbVBi9wc6!Y}2lLx#=Czf5p#sTe#?W6R1&5i{PY!= z8XuNACE{hV2^}rupDQI;R8%`oYp}!uIvyDW05jE6y8|ytVttRxgtCcx&RB87zk1W_ z>1Nod-L+@R+JkF{g^rWEuwVUg^M0L@f&H@$Quboy!IXq~)^TM62hX)Erws`+C1H~C ze5@9gbm~eBJZi0K9a>AVgyKDmk7N#IvAf zS|k9{CmCl|O&wt3Iu)HNqe==E?-8I^V?ozF;V1`J)%0-wN#jxmzTIwcv`8{d8dd}8 z+$e=4JjD8$8>OT+8b0={fCpNH5alJA>FS3x6&Va|-_7{dW5UZy`C1qJw9`Buc_JGVtAcThS^Vb8en$bi0laM&9u%wFh2klUX0yJwG@+G zCT(yepww<)Zhhz^*V_H}2Rf>W4k}RVuM5$g<60T2&0i-VtJW9wHmI$xrSUS)lKooE+pQy%t&?C^fR{2kX}YZ#58r{>mquA*w1O}iA$9irBsA}M9ri`_vu-z2C z2+%nZ{{(89M#!h#eXqAG^yvJHiPQc_gz2<13=X+hSv2YxZ8mUdU1<+*hrfR>fB&xQ zJk7-48LYRbQp;B@;~2I=Y=Hmsb|u?y$+1LA8{y~z^7J%sIW2HTZ_4RuK|R%)25?>G)~~^#_6lpQ zq!*w5hl)5~sdKrH4CWyk$j~=XR6&LCqOt&0)@h#s&P7hzggE^E{$79Mi~G5Lf3)!Y1Y4F77dI_G9YJtYz(FYjPmZv?Eh_%k5|}m|FhC0MJy$UiKsX za~N;8=l_eq_sjiaAL$Q7Th$9mr^KAnFjC?R0ls0x%}0|0#F_1g)9(mz^duk3CJ=g9 z<9^c)b8Xbh$$gQ^pRqo=jll15dBU-SV`uks4JVLnD9cu4jo!|)&*U_AGDmv@;@Kx) ze`FH>c_NMk?$fi=XT4*9P8brwrK}w(3HQmEm99I4vJVXrN!hkOXe zF6|$TxfcH%5s$lV`Cv7+Ny1hSwoMZi6H~I1<(b$~qpQ`hRPsr_;a5j!PzZ;wzix6I zF+SgEFIt3SA+HFF*}UO9O$?(LO71r_-|gZtM2WM9Y1ykBS+mLbrc*I?w|+Q8%gsKX z!o}h;J8XaJx!uMxV#s|1=M3Yc2`H~{=$?WcAox}%V+rHwTJ88_``bi}1ioKlpQCr` zx)nD14WRlRR7d;mE!9alFpO(~+bE#WU~wp$Uh%bRKF1}4-EKeXs{IHn-qQO0eJFFG zt++W_-wx^9s|RGZYuPYlQ;NGI)i8IaB6KZrCBDbLzo%!{D1Nhc29l0B zn|YnINkfKcUyUIcF+EK1=CvS<$r0u*Hk;$}(C#+;+{PSz9kF)GE+w}8nsyAQhr6^( zWieRDW1etz=$7QS6K(t5^lI(DPQ)+HJMXb!(n*JB-lkw1CevA)+)X7IYz~B8 zA#k}Rj-10>Xgnt3%U?Aq|9w6B^qc@DdnvSE6{vs?bnu@6j^AdN06wJE1~=9@(+=(- zokEY)I^X~F?#g?w;ok-2HD?`AHpOYj>#OIva;(oSygTJn|zp9jLuzZ zRpD~t&lr{W@%_79J=Jr&W-V7QGbxj!*TL31XU`Q(ZEP^uKb?asqVgPdNA8Y81@k9O zj<4bIi0DR)9Z$2e1f~Luy`5up>~ti6J0#I8SRnn}|~ld>H{AFg_!LLnBB79RZ7vOaMEN*4fi{mkOnj zHorFbW|u|<5#{U8&mpNS=N7kDmJY_4!P1%zSYBp1__ZcKPu0B8tbG}-R8Qw^`QgI~ z>ogL0d`5-!Fmo`_kSxnz`iubQR#u0B*o7_~_1JZ3a2V(I@f?MSFI^D#ewn;ZFjbbl zyh&lr{7ksLWo~VVDeqoIx8_Qi{(J;(Mxtp^YUU zHgFN08Js;Go9k_#_E*w+N&3NtO^nRo_&TWtvGl}QYhPy4HCq2(vr{gg%tPs4ZEA2S z<>c%6MzM13@^nC__urqYfd75P3|tr-Ta{ocE8uqt8#9QEFakU>cz-)zH`wm#y8YpH zh%5ggE$T{HsrQ3qqUH8Cuhf*CU#+j>-s@Yb(Sm4XksIWun;<`yh+~ff z@qOf0^u%CM4a=T})L)gs=VYNjj5_KTosVNoKHmJk$V4eI_=+L=7%QRMA)Xt)#|ESeik;R0%U+#| zpq6c=+tGR5fpjd)dU17&jKS)auEqpx;KlBA+cLu!G{QJa|9-ay{&zwGkHpPI zaGVEI{luzV!C3YS-5zP2bVv+7-_EyDFYnUaPo8*X?F!Iy5oQgj%H?XDY2)XL*k_=8 zMPHGzU?341R1=F_&#RxtpXFPLIMztVq13>cza<9?zXnUoAovSZM7!-C?{GNc{eHWU zjx^FDSUDCsl}(wWOmNyx-;{S=t;|(65nP{Vazi zjIbVL)~Di%ng?kLZUuV_mv$5iPKFKVbQN4Ulp?jBA7+`m0W(kB0eF7`(elxa4C`!gLM#=8CXiK zdKAmHGWfa=uCb}-#Qc8et|pOMFC^kbvOX&4M`>a8rA(qhnZMAwN*;Gtb1(u7Mx)4?8?;tq zLOm!JC_KAx@C?MT05QmV$Qc7bR{u&34Ko<4NbRA2DMo+g9%WzKSed~T)#)@!k%3t* zhAOhQo6O+z`MQH8(c&#cyi-eD))2^Rwai|Br;3Fc9S!1Mdtr12siCyAGQf4S2>YKG zYTzR_DG7rgDF+ix94NliFI7R&^D3QB0H22Q?XZJcWvWQq8shlYIAEz;>}STZ##n$S zSglqUp?&DsYPXcivb84FePX7S^g4pHJ9DEm9Vi|9B$UOaxIBP*%TCo z>$^P+Yf7ZFt1=7(-&_P|k)<;*m03n?Cf7H(?wHKh8DLDZv*ab{EAIy!Q8T@+-54qEz5iTHL}!Qe#TV#cD4$};eu5$!>p-Y^jvb1(v& z7;IR+pPRz@cRq#F4y?s%3(1N!$6c5s?O4tkt-V%@4J_Gtkl2q^%5hZ_I>9^SE>=y! zX__=uJzT+)>}+){Qzaaa1&S*D`_?&CcRUU8C!fsuooW%QSI7)VBwBEG#;&N6*Jj&Q zfoI>KWKR`23yJuYtnOop6zNN5{Y7fIdN?6dd#pMW+^w}!++{!p|NHvBcA_d#OV6)_ z<=36b&PbP(ZZIRWtf9U9WoKT3>a1>-@G>ehJ})KWLg2p7`zm)PwHzz2!C8_osEVl` z#p2?5BC!t30E4q6%Bz-%84wrCBQUBMDH3!#E)5(|yPqKPH8eExs;9ualYtAaz& zCJ~>q5vwoRx37+lp)goZoeeo}`LyzeaJXO&E&$7FkuvH{h<&T0+-OchYpQ1kWl-Hk zPF1iR8t$1fkpkCHR(k4_x=F_w0u!tIom5JR%okU`xN9}Tr{Q5>r+7>Nr%uZM#o*a_ z02oEEXhw!PO)Wy1Qw2}`?}&u^0>**A5Q&)L?i-013EU5*e}&kxV(C&D?4-3*MT-!O zA)-q?(+OZhq<&s&$r&H;*ldZZ`W@kpqgC^Q`K?nY2A>PPj<2YH^CV9k9XXkk!(1Xp z0#Bj&fep)nIx9&XtnvvTF9qY+otUY5#$dgm^HRaIj> zLPb`KqPPDR#%$OFWRHt^$ye}{iYVf3ohl&hgLLp_WM*9k0AZy7wz6A!Y$oEQuu=jZ zDtmJceCgv^m9iaL{x`%uX~$*rhU9nj6^sW|anBIRXSR5fti|S2Hlz#o`Mk zB$3Nzg|3EPRaimJ3&p{6yyqWp{_J|)raTrG$D{PJNRuO-bVm&u)A#>hYuBROHVy+( z)R-idTpfFo{r`Vjk%UA%{m4|!G-=$K#9l7}EEdqY3r%JNcCS)r@QvFUe+Iy)1X-}B z<3Bp5Q3%gyir-v;3|z_()Q6)sLRZ~>EBI!e9HR&aUbyj6<94+KE-MG$mGNc2JL_5Z z64vD~#>lKvv|RzzGLwQ)ThT2~Q9bsG zm+DQx8C$)U8~qA)x!?Dv7;fop;H_#{UB;>%|8h*M9D2w9aJ-*vRY!r)AqwC#Iza!K z*F`ee%CWY&_Fh#SRMR%mh2XR`Em{AN8u#nJH91YdauwboZQRJw5`$r|*|U#&6~79^ z72s0wWe#)rUAlzD`4*T1S|*01D{q-0M{g%h zw~HwfBZ(+nL}G-BfYf2~8+hQ(Myv)}mq4JxZ;B$l2P-8F{Yb1DOi|1|kLYOI!bxa= zU-P(i&d%XwnU-~{b?|WP+*??#WwB{C*eZ{7b4$^gf1y5Uv4{H$LRxFeGfx+?b`4;V zfLRNW>+1>Aj2W`lDir8MSYz!1#cZ>h#2=j=qiS8v_}GRf|k9SQFbakpfn}#9&JC z#qb^EWI{wr(H-^D*V@LtOG9`+rI&ds>&ED^;IX#r4x}x)3SJ5m0~8ad72t)%=+DF_ zYF>Q=w*$_&S@k@1uoEz~MHRTcO{P0{#C*h=ZYlP$t50uOc~8faDfBC%5PZ z{5}6@z%s(qK$z)!DAPdpG&)`>3YuCj2G@_Ho%D%9$uicJ^F=*m2|qjXh`#Y?h~_SW zDg%^z_u0GdtdWu?{L{EA8Em_Kq!la5`mjW{5YB0g@Hus0E56rhndcgaH>Phn2;3Mv zZiPfi&ERd7@<=HxWwptMw5|Xnc6%GEOt5Sc6==&!*3be5T5-{T>%s8ITPL#&r8cGx zHE>ua^1)4v?);kG)OioYSzm}{o(zW-|l2d{vGoTbCg8rIL{5TgkI zAgCR+h>vKyv?4h}T?z#QKu3E)A&gd|!XTr6q4@c3=Ikm-(nBdOZw|SN@$G#x4%4>N zcBgRtGgX9Zs&(6#y!UNuM>deY<>bS*r7_U*^Wk<<2iV9QVO6Oa4B&il!4@K|KaRey z9-0UAZkmI^uNJgVDNNP^OXxhtNJ6*JsWPHtE&AKL((?=Pzo}BUWzzkc8p16UcYF&o z*bP5Lu55E(;qwWt%;3{jnQ6BoM(VE9_*yS#)p1o13Z!zsrz+q-f!4djNlxy?$F3a* zp`aJ&7lpob?N97d8p3l$_}V$VR)j0MP3I_Q3=YpHbqJ{_EWFX3!9OIx59y*pr<}`k z0000?CzPJo!On4JvsXN(jX}a2(S~y| zj;pInmmsW9ThLq|E0wEgZU|ui_tM827(gHpmz;P-a~&7O@C2Z1c+7yYU;-?}gcHjz zoZl=5@z#-QmJ-Z@g#b`ESI{BC9Ao!Cm;7FxqC;dgY$o}Cn&fp{fR2hlN9@1tdksb~ zhj>AUP^ojF5P2PlygtSVHWM%SK94e zuKUci*%$1np0*+&1zSWIhbdrD_Cs~bkc$R%vDStuKy zeRy(?~+B!~=KP&6ctP0?-bct0we0*~b zj5yTSaKd`u>6_~%_r-I`h{D5tYeK!>ff9#}8ElE+y8-zLSW-*RmH%v>VmQV?`lbOS zCn;TLk?lr$xj`^418!A54q$`ib;+^!)81C!m-p4`Tr2q`^G;RVQ}sfnJi^SbbuP+u-qvsA16?AXZms2k z_F`AS_sh1gsbkXg@N7_hL1*r%I>ru8Gnoe5l1cMEkA;}BDOZ7xuCsL1x?)?&X0gK6 z%TtAODR9r3fQ{l!6?4hS&VU|dcHzCR87Ay1f0d&FFN&c4{T0h)IRRf_s6SM@C7l1! zkKFo{#tj}r5-_TDI-NpLeFX#-x< z7$nfZT>Rr+%}Am*PsUVO7DrJ zG-AFhzvpE-=`xw67?12b{}3ypx9@2SQVsuvH|tVw`wfW#>4VcpY;U^X%}#isYh-ED zc+)*Lba-IBRSexyxukCC^1oC?S)-%V|57=kjn`_3J#`Un|4+%|KLY_;n{TIwt39g4qR*gKHCZ)&s;ACL95E7cED_~-+N1D0G-iIUPDHEge)*Cm($*UK7s2lr-26QKgEtD;93rY86q} zE%pD_b#@U&TE!H`D$Ac{D$l3(v^;m;C^cjr>}hz!uu>Rzu5p^M{hOHF*)1kAZr3#* z)$s$s!2lk~N}Zc(Z+i52t9$HGk7we3W-iX4hk;Jo1uX_z1}qyLQE#&Hif@<5}R=d6728@Yg< zluWYa!isrQ#FvdLAHM+PfY2#_dpE}ulc_p?N!M7Gf>(DzF7ofhlp{yGeRhhapfe6F zvJL-IC62c%Xb5zw@c0fGG5ya*>_YV}h}fdM3*s66bosE0rkg1%F~Wm&XnRD<)phQx zBTXe}w0pwZ2Ism9dQ>Yjm@ zp~o+kEBG-7*thH$3&i?9AguuG1`DZ85U+kg&65vzuR}z%h*VA@6@(vk_!in4)h0H! z(5`-u51%RQ$hGN^Xd7Pzh|i^l>7xC%MR{Hdm!3SHc!ayoAKr@&-US5@sr*BI-MNR3 zNN7esB?&j@gIcmQC?~nPt)mw5k+L*iJ2Jzo~Y`5=)J=}N;9SQDP zW?=p*+8-wGg52Vn{u9vVC7DmXjVid{V$=0$9|mitPWI`zt;UqeE+})^(|s3Y@aOMZ zgDpRcT_IMd>{d|OkAF&?GEJc|sa@F0m&o79G4YbH67p)$1wu zJ!cTFo32IEU7qui)~#dzc<-UxBPjq=;K8Pw=;iU`|rfUJeBrgacaPK7-@)1D4aCW?)jy z5*SaUimWB+Dm+{noWSz6vW^d+6A?ce>eJPraWX=WHx#>)wCd0 ztf6Bn_C&AsR)Tq)tESg96uzTValStFIf}lWIivKWOTEeMU$pCZ# zzjV<>Sg}7w_sqv;3ykZ}7Xt=2Plb|%wDg@BK|z_HCYi;i{8hUkcikV1$my|9-~#~R z2@rUIm=xXxJqykU*a-0qUSJr7{}bSn6~&Z zx(hlhhGy(kQ;8~*UW|qJo@ZOP2X{eDi>06NQ9X3t@muvW8k0T6(zMOx|(e0KIr|t6o(u+7%(0Xyq>K5Mt zi5L7@zk8`AtzV!L8yh7}$E`gAH4o6ibu~|?jzo^zM~9+xsnoj*5&52Z6TEZ*^Qaku ziZ-kc=D9EEbz!Ubz5MJV+aeWCO_N#iW;cSnhC6{X#tZ@kAnglR_tyVy9T7Rofw#t^b9b94eXLvL>QH9Sx_Hl>!#vm@S|y{^lbg=d}>{; z#oOb*#EJRvq-R0HYD9z2gdum)4J{HX682`?wK(jfy0wz*_vdrWdDVs73&`xG>$kpn z-2SUP6d>%8Z+*JD`QdtDGIRUAX77($sfvf)+?y(a27$%gzR!_^!W1k3yicB*rQvKfPR_GoM@Cu4<<3un5JR z-=Z@|YP=07kfY5sg;P>wEXl*?(n{;e&d$fV1uh@n&J)7UiKRq=Hu0HVq@xUNff?C$Yc{R0iaLcSxN$k`grZeA#lA2IJ#C1GqW%aSM_FoZ3tPf5CQ<5dJH@uE616 zcM2y!FaihWai(#rV-QkSxYj6C;=Q>r-)$?Cic8)d;QrFvsyi}rtJ*Zm+mWSQR{t)j z_Eu)SG_^QC+~*GLgYz`(kVU9+!JB(n6nWc1M|ljDC=#}jnSc8;wa~_7EHXRZLp}s? z|5o+16khXaov*2FkZ+z?kAJq#pS4) zn_ux>eM8rYDLr4I*p(M~j|0f}(zC{Y94@ZZydw0PCnIrDRPd@+@ulbp?gw8;`AR^61&Q(Nk5(c$(#{A0vujpZ=s`1Mm$L!-qAb zc-N6-LxNH*;PyM;HiL--^RDL~UoSDxfpLcTPN@GlW2hUMOeC)cWu!Ap4znoCtETY- z#2O2%{WO@3c#B3Z?-+TaOlLJHiH-HUAj8x-mcmq#8vimd#?j?F=7lo+D&1Wg;K|c3k<+UY|>H`Y;+UNR#Cr@%?2gZ$|vWjJ6 zH(YOK^PkOEoYgnZFI{g8{x-+*ZPhKG2*}+$!m63?-D+Cy0spBbmTwJ~Jo)?c!T40! zO}w4HoTyYF7}5K(+22P$ytd{~F!EtaDuYm>(*uh$-UT(>8>%RvBr60=*WVjbKX#<_ z&S4GdZxfBLPH;sVU>CP-Uw3e{<|86mMhw25Pipx+{U7f>-^7Na$uWibX{E33`<0T@ zO6TtTl|A{B_)&tZ#_W1&N9_DX5%I+;WR;w5Qks*0mffeh0M{1Xz~Uh7$0e)+@Ni+~ z(TT)ekhuWGvO8CDDuzXzH8Y(_&3{!FFW`j;w?AT+Akp@6y>M#o1N1Vj`0BGtn%B;A zt&yn`!?FpFU63V@ePDFwC89r(pgI2CT1BkRHfua81!uch+12pWJF+PSr;}ahIiU=k z0(y`Q{FI&!kbJAqqidawl_628IJ#Des_-o4%aCU#L&t>2Yu*O&GPCB#XX;L|$k{`r zka4;-IMCm|JNOQkRF*sGb9UVUS#_i7fo5KRv!j?XxO?ZrTG6ioN7r2XbdtRRVSnm> zmE~qteZRw3Ay%$$t=f2m%Ml-XWwVJGORR%sB}a?Q$fc)Z&%AOHAH7d>f8mW8i#uCa zR2FLZpl(@oDrwY!IcrwQZ8x`G+<%LcbP*57o)rZEulMHj?E$U;v_BDlt118r0CjM3 zRuSycc&|lKfaFwYOq+FNPQt{el@J53hxe2*=4Z##bJ8T$d_v&w;_Fle>x<-p!?)tZ zfREAT8ka|7)%ub{1&v|WSroG$@|J(D4+WGNl;0eV@6-%^AD?;CIO@+fpILLVlC++U z54;)juuE2M5w*SWyFRhNM0frkoPRLKcMfGqTl0@ADVJC+F;+PDP_L)I4F9+7NuD#= zDrayhcC}Pttw3yaKo!zsmv4BYCip~9W!ieX<+#^T;mqoP?-|(nE^l=yDhZ7C!%v88aVva=xn=HQI@IRijmi3quWca@BguleUs&$SyXbC%ZX|1@UTRHvub1LdE#kH zr38nxb_GlIJd_xHV4vPB{|>qLcGQ*^$$G8f*)AyH3w5fDJY2z|y&NaptF4XB5b#{b z?~{5L)W(iI=3)o7Mc`V!hUto#`^@aT2AW`%{UO9D;@3aAferk5hUcAKe?_fl^MAS& zn|7La`5p#j#qAvvEYp81;DnjA2v0xZgc|w#uLviw0bD&@I3ig3b7M-qgZx^_+d`9P z04!)oi%JiMBr7z187R-!e(rlY;kxfyVMM7W`4qz{cxdiN)jl8U9c%&4*K3aM z4@)jyXHj)eXM5MN5i`+^tdb!hqw~Wq`GZr?%UH)n6|rcUT~N@V*NnK9H$9m}FQ$5* zYr5KquF_|g`{jz{x!h@S$nXBg6>n6<8EZKCfdF7b6Z4wZkoLNMml(8G#OhO?ZVI$C7xe^R4lFBF z5lAN7y1OZmNie*CZ<<~|mnW}Vw}cUy_A)mzl<}Be)IPY+w&_m}#K~$=x|z_ZpC0R| zfqXWooBmRy1!?iAUgYOiw}O?U-YzIS-%57SBI9XQFkL!q&YPLHlaYvc5;WVBe>qi} zw3(3@uhe+Y2Sp*6$M92Iee_9PNp!|gUfs)ix`uQo`dd%Fk({Td=~SS#*7?YuPV`yZ z$YB4T<>)!-<&CA1o^9`)u-kFKaX~|DRWNo@C3P1xQa2~9=gV~T-+3jrDkQByJoKF< zB;VA45_LfHVU2w(QKDO!UsECDb$IKBkE!$DUlYuTLzl-r>0O$~#-Fbj&eo@TZGRgs zF3S$6V+K(4rk@k|)fyybwaT{d& z`GmE#?3#3K-Hs=LffgvI%qg$-lnn-vzBHW-JhM+_Ru!Rp22hq^XXvIvKd#UPguciB zm?<_fO`gVECuCkGA`Yl74_Y3ewccAd@tE+BR)v`;jmB9fr+T~bHR%rUgHf#D5gAk)1!3L;nLeE_tH!41V&-tX`e`(xlu852x2X&2tm@Pz#~zB613CqTnkAS-(=PT*_~TxCbkH`% z7wHeznaCer=;1UQ0vhqaE6|A1FH?utnYJH}eILlGR;Vkdm>UE>L0=EG^3-&s4|<(X z1BmEMIDJ3`ybSJ+4`8kAkgo*)>3H7P$QV)!RZm(d>HOA)LdvPqOEk4shfYFe&!`VM zgt7kg^yl0+ofO$NyLvr>Da}f2aP*i6np~WJ-En+rc465B9;4z-^T(?`)V`Nnj=vk( zXI)64m|F1>bnOvQy8LIQkq};2eZYr*PgD}Rnrihz?X~z~o7IHsvZ*joz@U>w0+ab( z4Ms2x*J54#d8fd^chZ-qyy*jVIZP5bUHyuE<1DSn5vzd%C_}+m52)%6d3?l`-b%7Q zlLWmKM4=D0<-cnOUuXHAyZ$SPGEI=5Gj5F?8O{wj?mg$bUeF*p+eV?7FP+ZGnbOcc zg??tyt%sL+{G!dx&DsUqV#H8YSY%vV*kJ3?z9RZoY5Un>csH9VIz!ryh|z`r|eO5Q~dKN4RgQmX;L3c>UGx zJMrbK<+(O#g`@@c3nVw~N6Os2)#XFm117vyH;)xTkS!iLBoaE1@JN}ffGvHhKtT9GjMAK z)hob5p#E4)^e40dmd6;kunf~W=b^rdlaU?>^xs!YzXX8Ch(+Zz}rwy zu%BD8m;bnqUw<3!;i1FG(vljqX!*tV%t%S)%$A(}x%*=<2QdfvT$^=feI6oDie<{^ zne4YpxW&rEpLq);THC!m3@F~{e!Q2-R1Ksq-fNOdxfxl@oSLc;^iZ=FhWE|C6vEVqJpRJF$l z-q-i@)inUItT;}b6lrae_I*3Y$W`5JR?W@)q!1khKOe5`nNC1nI}>Y*^Flb@Zl6>O z98gcufFhjZFXAXht8;5iw{uGh8;^khhcYDQh#EyAreKu^cB~2x2FG0R?>pNO7yq`# zZibzOzieZC{h+r>6z#aPfl?9?-a+c+6&>k4QFf!?4((Hc-n+=re9H^o*t$~}I~Jua zU(FU~Cy)w@!>K|QFTXV3csTa+%dZ*iV=-ZM(G0N{wBlc@pDo{zY{ZZABynbj>i=5( ziI+IAF7){8R8EGYW=_de$HpBlm22+aW)qoJS$-thC}>ZO(g;^n0M+a!`ndC)r?iyk zP!*$gRkKF`P@LTZHH)IMFR%Zp}~g7+&?C$X={%gpt{u6Q6uxE6pn zhLit#HhKwWI{D5%E1=sxo02v^VA-Or_u*>URmfo>?^rG3Qcv%|1nUozU$u^hY;ovI zvaphV-l6(2*N~TTS6p5##ia^QxV~Hv!r@LCO)m{G)jiUQlSTOO5wA@&-hHfT2#Oe~ z^(aQwc*+SfUHf;sGLaYPshWdj4L~sgmp5+q>AIN8Xmr$|9Pgx|{Ue+S76QZwM~%gr z;RLzt)u66MlA4cs&waV$&Z%`VKDB8_jz%|h`_xvJHC{aqCch#vF}cbS0Ct`P{bCf$ z=~&c5&^r10^$s(WF@wbd2~|cbYb#INemRq|kbhSVcnI-vI)mQ4f8+;Uy>qi0;zyko zSx&R8FRhbO1O7Wml=VNuhOpE(Y|tIAEB2NaZND^RHJ~#SL>pmL_gwP4=uKgcVaE-? z$akBWEzs9fd36J?$IddBSIYlk4D{*D?}DEBZQ|)qKg5!{drW`hKR*3(Du6~C*h2ch z;iWZbKws~I$}KKehj+O25ztHqv?HfURubB0r7O|2y7y&PoF;O%)FGg z3#tIpwBi+)-wKRXpXgr&`?%DPX9m{sM~_;RF-NZf4rQc7-ZnU$FCewjr#~j*c+20k zwGwk&2V9`R6uvqbYTW2SUN$}MF)Oa|N1!g^>1NUXtiS;D7T^aF`Jy4@dGzdHj^hB3 z?&D-x3ta6K)(HFMh^C3p0m108-0pv2Q9s4a*_i@w^{%O^TE`vmtMxf+CWs z&xtRAs!cQx#W=u!Ho|c#^?cJKO*_Xati9^2@Md9p?M7ilDmpwwa%$*c7wst%c`|i@ zyghI*XcCHe)kEFx56&H5FPvl&5pNfmXlwtys1=x_L#IVqw0i zDoiorc_BZHAOH5rgb-;Hs@^eDk*__0+C&mW7&4xWQQ?x<%&OXDLTt1_x=G9AW~Dq_ zuJHbBM>l$`+33^T=xCX1z`;W3+=S3<88AD>{kokB@Fsi4(NrBT`hDnfvAufTKhccE zXsXXHNL`n?4!G=G2C$lXIDpM#P696hjm5TEwATo{pjMAKl6TNc968u_)c-QY>cUM? zPy6BgGh83hh>pQB@e>-w*CeaTNzaBgvYr_0$~@5Ny(U>Q-u)N%SR5%VC!Vmxg_pPG zd%}ajENCtGkEBS$ddL7BcP>)VerR{k&7)ARSkNr+Dt-BfF^_|l~=pS6Ic8Y4ZC zCmBNrqp>l>F5}hL1Th|hg3D$>HJ2-;w_nli!}=fB$4>&+idPUhAJnkfhECQzvNu2= z6tc47l}E?O6wdN!?K}lYCiYJdg2@-&Q>g|^ej7N&*IvkIwPN+9Y?H;vR5yqBT{RUt zR6Ir{6KPBuizJPe+FoNA480L=9K>ad^_SY~Sxv79X^acKTM^v60ACS$8+acv^jYr^0s?$Dr{eYvMeG$rB6m;RF)!LB*O;*$dQ2($gmp8~f>Zk|qeujDLz z!Yi?$R9*Y{QdaS)P+oeZ%~9%^;!`hUX`+O=Tt6mqIw5j=9alIB6zAoa$m7GALe=g- z_#BMYIu$ukhd275Lm(oO|Mr%94d*HU1C(hWTFKkJ_ao7-*Yj3$qw6S^55u8XR>)H& z#`{nY=)r13{@OGFFy6{#zaNWibpoqvJA=EO($n50O0ACoRdII?Ua@ijv8tN=P9j1Lg z0>1@Q79#R_u1*Z7RQVWv_H?1~i;X37V$b{CS34e+FqAm@;;+edIEu#cGwo>!r@R#> z<}qg+I!PU`>;)96JGnBJh9?RSCKdjWHGSiYyLc_%Y8)G*b^J-k_F$-z=N(k_FBJgq zXJ&wxSk+BNs7$KGvw5BP5B~+=C;#hz2H=0{|MZ6dIGZ;I@YUqt z-otfdfEP6Zd_S89fZKBLfX)Jgt^xP5KfHTi;9bvgkQRvl0N+7AUjBo82M-_S7dU=K z==iZ?$1k2bee#SPME;5#MD~)xbpuTWWgV4EvNx=6>KK{8;IONjhzAI>`v&GP)7|eN zFfcqf$KL1P0RWnlYajQ19$tVs(4poCTR&W!`?$Ed_w55(gMoI?zN6g7&dT51f86{5 z&pB_wt0A%3yb`x6z6ink{yDGU@FesA--(mLBBB>0FG@kA6_u1#RIgpvx~;9FtEX=O zx3ILbzGH)Mbb9#6*~Jy<^VHYR|5-rb%U5CHuiw1I#Kk8hCMCa5!RF-VOX$^`t5sjOKaPY_Wpsvq2ZCAqhr5kX6NP?NPozSE334%^^MIf`u6T9D2!qb u%;yE@+j=2;UYuQ>Mr_FOFMI*|kLHa2y3VdYq9U5bhMY35`2cVR^#1^$Um<(| literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/tiff/rabbit.tiff b/clients/python/tests/files/tiff/rabbit.tiff new file mode 100644 index 0000000000000000000000000000000000000000..d66358b71815b7081ef74a2222d16a79a6a6d730 GIT binary patch literal 47244 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/tsv/weather_observations.tsv b/clients/python/tests/files/tsv/weather_observations.tsv similarity index 100% rename from clients/python/tests/tsv/weather_observations.tsv rename to clients/python/tests/files/tsv/weather_observations.tsv diff --git a/clients/python/tests/txt/creative-story.txt b/clients/python/tests/files/txt/creative-story.txt similarity index 100% rename from clients/python/tests/txt/creative-story.txt rename to clients/python/tests/files/txt/creative-story.txt diff --git a/clients/python/tests/files/txt/empty.txt b/clients/python/tests/files/txt/empty.txt new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/clients/python/tests/files/txt/empty.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/python/tests/txt/weather.txt b/clients/python/tests/files/txt/weather.txt similarity index 100% rename from clients/python/tests/txt/weather.txt rename to clients/python/tests/files/txt/weather.txt diff --git a/clients/python/tests/files/webp/rabbit_cifar10-deer.webp b/clients/python/tests/files/webp/rabbit_cifar10-deer.webp new file mode 100644 index 0000000000000000000000000000000000000000..af5939fb40c76d3e07089959753d98087b108ca5 GIT binary patch literal 8318 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/xls/Claims Form.xls b/clients/python/tests/files/xls/Claims Form.xls similarity index 100% rename from clients/python/tests/xls/Claims Form.xls rename to clients/python/tests/files/xls/Claims Form.xls diff --git a/clients/python/tests/xlsx/Claims Form.xlsx b/clients/python/tests/files/xlsx/Claims Form.xlsx similarity index 100% rename from clients/python/tests/xlsx/Claims Form.xlsx rename to clients/python/tests/files/xlsx/Claims Form.xlsx diff --git a/clients/python/tests/xml/weather-forecast-service.xml b/clients/python/tests/files/xml/weather-forecast-service.xml similarity index 100% rename from clients/python/tests/xml/weather-forecast-service.xml rename to clients/python/tests/files/xml/weather-forecast-service.xml diff --git a/clients/python/tests/oss/gen_table/test_export_ops.py b/clients/python/tests/oss/gen_table/test_export_ops.py new file mode 100644 index 0000000..e0c20b7 --- /dev/null +++ b/clients/python/tests/oss/gen_table/test_export_ops.py @@ -0,0 +1,1164 @@ +from collections import defaultdict +from contextlib import contextmanager +from os.path import join +from tempfile import TemporaryDirectory +from time import sleep +from typing import Type + +import httpx +import numpy as np +import pandas as pd +import pytest +from flaky import flaky + +from jamaibase import JamAI +from jamaibase import protocol as p +from jamaibase.utils.io import csv_to_df, df_to_csv + +CLIENT_CLS = [JamAI] +TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] + +TABLE_ID_A = "table_a" +TABLE_ID_B = "table_b" +TABLE_ID_C = "table_c" +TABLE_ID_X = "table_x" +TEXT = '"Arrival" is a 2016 American science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' +TEXT_CN = ( + '"Arrival" 《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年美国科幻剧情片,由丹尼斯·维伦纽瓦执导,埃里克·海瑟尔改编。' +) +TEXT_JP = '"Arrival" 「Arrivalã€ã¯ã€ãƒ‰ã‚¥ãƒ‹ãƒ»ãƒ´ã‚£ãƒ«ãƒŒãƒ¼ãƒ´ãŒç›£ç£ã—ã€ã‚¨ãƒªãƒƒã‚¯ãƒ»ãƒã‚¤ã‚»ãƒ©ãƒ¼ãŒè„šè‰²ã—ãŸ2016å¹´ã®ã‚¢ãƒ¡ãƒªã‚«ã®SFドラマ映画ã§ã™ã€‚' + + +@pytest.fixture(scope="module", autouse=True) +def setup(): + client = JamAI() + _delete_tables(client) + yield + _delete_tables(client) + + +def _delete_tables(jamai: JamAI): + batch_size = 100 + for table_type in TABLE_TYPES: + offset, total = 0, 1 + while offset < total: + tables = jamai.table.list_tables(table_type, offset=offset, limit=batch_size) + assert isinstance(tables.items, list) + for table in tables.items: + jamai.table.delete_table(table_type, table.id) + total = tables.total + offset += batch_size + + +def _get_chat_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="openai/gpt-4o-mini", capabilities=["chat"]) + return models[0] + + +def _rerun_on_fs_error_with_delay(err, *args): + if "LanceError(IO): Generic LocalFileSystem error" in str(err): + sleep(1) + return True + return False + + +@contextmanager +def _create_table( + jamai: JamAI, + table_type: p.TableType, + table_id: str = TABLE_ID_A, + cols: list[p.ColumnSchemaCreate] | None = None, + chat_cols: list[p.ColumnSchemaCreate] | None = None, + embedding_model: str | None = None, + delete_first: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + cols = [ + p.ColumnSchemaCreate(id="good", dtype="bool"), + 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="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + # Interpolate string and non-string input columns + prompt="Summarise this in ${words} words:\n\n${inputs}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + p.ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + system_prompt="You are a concise assistant.", + # Interpolate file input column + prompt="${photo} \n\nWhat's in the image?", + temperature=0.001, + top_p=0.001, + max_tokens=300, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + + if table_type == p.TableType.action: + table = jamai.table.create_action_table( + p.ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + if embedding_model is None: + embedding_model = "" + table = jamai.table.create_knowledge_table( + p.KnowledgeTableSchemaCreate( + id=table_id, cols=cols, embedding_model=embedding_model + ) + ) + elif table_type == p.TableType.chat: + table = jamai.table.create_chat_table( + p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + jamai.table.delete_table(table_type, table_id) + + +def _add_row( + jamai: JamAI, + table_type: p.TableType, + stream: bool, + table_name: str = TABLE_ID_A, + data: dict | None = None, + knowledge_data: dict | None = None, + chat_data: dict | None = None, +): + if data is None: + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + data = dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo=upload_response.uri, + ) + + if knowledge_data is None: + knowledge_data = dict( + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + ) + if chat_data is None: + chat_data = dict(User="Tell me a joke.") + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + data.update(knowledge_data) + elif table_type == p.TableType.chat: + data.update(chat_data) + else: + raise ValueError(f"Invalid table type: {table_type}") + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + return response + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert len(response.rows) == 1 + return response.rows[0] + + +@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"]) +@pytest.mark.parametrize("delimiter", [","], ids=["comma_delimiter"]) +def test_import_data_complete( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, + delimiter: str, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + # Complete CSV + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_complete.csv") + chat_data = {"User": ".", "AI": ".", "Extra Data": TEXT} + data = [ + { + "good": True, + "words": 5, + "stars": 0.0, + "inputs": "", + "summary": "", + "captioning": "", + **chat_data, + }, + { + "good": False, + "words": 5, + "stars": 1.0, + "inputs": TEXT, + "summary": "", + "captioning": "", + **chat_data, + }, + { + "good": True, + "words": 5, + "stars": 2.0, + "inputs": TEXT_CN, + "summary": "", + "captioning": "", + **chat_data, + }, + { + "good": False, + "words": 5, + "stars": 3.0, + "inputs": TEXT_JP, + "summary": "", + "captioning": "", + **chat_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "good": "bool", + "words": "int32", + "stars": "float32", + "inputs": "string", + "summary": "string", + "captioning": "string", + } + ) + df_to_csv(df, file_path, delimiter) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + delimiter=delimiter, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 4 + for row, d in zip(rows.items[::-1], data, strict=True): + if table_type == p.TableType.knowledge: + assert isinstance(row["Text Embed"]["value"], list) + assert len(row["Text Embed"]["value"]) > 0 + assert isinstance(row["Title Embed"]["value"], list) + assert len(row["Title Embed"]["value"]) > 0 + for k, v in d.items(): + if k not in row and k in chat_data: + continue + if v == "": + assert ( + row[k]["value"] is None + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + else: + assert ( + row[k]["value"] == v + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + + +@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_import_data_cast_to_string( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + gen_cfg = p.LLMGenConfig() + cols = [ + p.ColumnSchemaCreate(id="bool", dtype="str"), + p.ColumnSchemaCreate(id="int", dtype="str"), + p.ColumnSchemaCreate(id="float", dtype="str"), + p.ColumnSchemaCreate(id="str", dtype="str"), + # p.ColumnSchemaCreate(id="bool_out", dtype="bool", gen_config=gen_cfg), + # p.ColumnSchemaCreate(id="int_out", dtype="int", gen_config=gen_cfg), + # p.ColumnSchemaCreate(id="float_out", dtype="float", gen_config=gen_cfg), + p.ColumnSchemaCreate(id="str_out", dtype="str", gen_config=gen_cfg), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + # Complete CSV + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_cast_to_string.csv") + chat_data = {"User": ".", "AI": ".", "Extra Data": TEXT} + data = [ + { + "bool": True, + # "bool_out": False, + "int": 5, + # "int_out": -5, + "float": 5.1, + # "float_out": -5.1, + "str": "True", + "str_out": "False", + **chat_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "bool": "bool", + # "bool_out": "bool", + "int": "int32", + # "int_out": "int32", + "float": "float64", + # "float_out": "float64", + "str": "string", + "str_out": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + for row, d in zip(rows.items[::-1], data, strict=True): + if table_type == p.TableType.knowledge: + assert isinstance(row["Text Embed"]["value"], list) + assert len(row["Text Embed"]["value"]) > 0 + assert isinstance(row["Title Embed"]["value"], list) + assert len(row["Title Embed"]["value"]) > 0 + for k, v in d.items(): + if k not in row and k in chat_data: + continue + assert row[k]["value"] == str( + v + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + + +@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_import_data_cast_from_string( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + gen_cfg = p.LLMGenConfig() + cols = [ + p.ColumnSchemaCreate(id="bool", dtype="bool"), + p.ColumnSchemaCreate(id="int", dtype="int"), + p.ColumnSchemaCreate(id="float", dtype="float"), + p.ColumnSchemaCreate(id="str", dtype="str"), + # p.ColumnSchemaCreate(id="bool_out", dtype="bool", gen_config=gen_cfg), + # p.ColumnSchemaCreate(id="int_out", dtype="int", gen_config=gen_cfg), + # p.ColumnSchemaCreate(id="float_out", dtype="float", gen_config=gen_cfg), + p.ColumnSchemaCreate(id="str_out", dtype="str", gen_config=gen_cfg), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + # Complete CSV + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_cast_to_string.csv") + chat_data = {"User": ".", "AI": ".", "Extra Data": TEXT} + data = [ + { + "bool": "True", + # "bool_out": "False", + "int": "5", + # "int_out": "-5", + "float": "5.1", + # "float_out": "-5.1", + "str": "True", + "str_out": "False", + **chat_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "bool": "string", + # "bool_out": "string", + "int": "string", + # "int_out": "string", + "float": "string", + # "float_out": "string", + "str": "string", + "str_out": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + for row, d in zip(rows.items[::-1], data, strict=True): + if table_type == p.TableType.knowledge: + assert isinstance(row["Text Embed"]["value"], list) + assert len(row["Text Embed"]["value"]) > 0 + assert isinstance(row["Title Embed"]["value"], list) + assert len(row["Title Embed"]["value"]) > 0 + for k, v in d.items(): + if k not in row and k in chat_data: + continue + assert ( + str(row[k]["value"]) == v + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + + +@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_import_data_cast_dtype( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + # Complete CSV + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_cast_dtype.csv") + chat_data = {"User": ".", "AI": ".", "Extra Data": TEXT} + gt_data = [ + { + "good": True, + "words": 5, + "stars": 0.0, + "inputs": "50", + "summary": "", + "captioning": "", + **chat_data, + } + ] + data = [ + { + "good": "True", + "words": "5.0", + "stars": 0, + "inputs": 50, + "summary": "", + "captioning": "", + **chat_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "good": "string", + "words": "string", + "stars": "int32", + "inputs": "int32", + "summary": "string", + "captioning": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == len(data) + for row, d in zip(rows.items[::-1], gt_data, strict=True): + if table_type == p.TableType.knowledge: + assert isinstance(row["Text Embed"]["value"], list) + assert len(row["Text Embed"]["value"]) > 0 + assert isinstance(row["Title Embed"]["value"], list) + assert len(row["Title Embed"]["value"]) > 0 + for k, v in d.items(): + if k not in row and k in chat_data: + continue + if v == "": + assert ( + row[k]["value"] is None + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + else: + assert ( + row[k]["value"] == v + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + + +@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_import_data_incomplete( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + # CSV without input column + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_complete.csv") + cols = ["good", "words", "stars", "inputs", "summary"] + chat_data = {"User": ".", "AI": "."} + data = [ + { + "good": True, + "stars": 0.0, + "inputs": TEXT, + "summary": TEXT, + "captioning": "", + **chat_data, + }, + { + "good": False, + "stars": 1.0, + "inputs": TEXT, + "summary": TEXT, + "captioning": "", + **chat_data, + }, + { + "good": True, + "stars": 2.0, + "inputs": TEXT_CN, + "summary": TEXT, + "captioning": "", + **chat_data, + }, + { + "good": False, + "stars": 3.0, + "inputs": TEXT_JP, + "summary": TEXT, + "captioning": "", + **chat_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "good": "bool", + "stars": "float32", + "inputs": "string", + "summary": "string", + "captioning": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 4 + for row, d in zip(rows.items[::-1], data, strict=True): + for k in cols: + if k not in d: + assert ( + row[k]["value"] is None + ), f"Imported data should be None: col=`{k}` val={row[k]}" + else: + assert ( + row[k]["value"] == d[k] + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{d[k]}`" + + +@flaky(max_runs=3, min_passes=1) +@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_import_data_with_generation( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + # CSV without output column + with TemporaryDirectory() as tmp_dir: + chat_data = {"User": ".", "AI": "."} + data = [ + { + "good": False, + "words": 5, + "stars": 1.0, + "inputs": TEXT, + **chat_data, + }, + { + "good": False, + "words": 5, + "stars": 3.0, + "inputs": TEXT_JP, + **chat_data, + }, + ] + file_path = join(tmp_dir, "test_import_data_with_generation.csv") + df = pd.DataFrame.from_dict(data).astype( + { + "good": "bool", + "words": "int32", + "stars": "float32", + "inputs": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) > 0 + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(r.object == "gen_table.completion.chunk" for r in responses) + assert all(r.output_column_name in ("summary", "captioning") for r in responses) + summaries = defaultdict(list) + for r in responses: + if r.output_column_name != "summary": + continue + summaries[r.row_id].append(r.text) + summaries = {k: "".join(v) for k, v in summaries.items()} + assert len(summaries) == 2 + assert all(len(v) > 0 for v in summaries.values()) + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all( + isinstance(r.usage, p.CompletionUsage) + for r in responses + if r.output_column_name in ("summary", "captioning") + ) + assert all( + isinstance(r.prompt_tokens, int) + for r in responses + if r.output_column_name in ("summary", "captioning") + ) + assert all( + isinstance(r.completion_tokens, int) + for r in responses + if r.output_column_name in ("summary", "captioning") + ) + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + for row in response.rows: + for output_column_name in ("summary", "captioning"): + assert len(row.columns[output_column_name].text) > 0 + assert isinstance(row.columns[output_column_name].usage, p.CompletionUsage) + assert isinstance(row.columns[output_column_name].prompt_tokens, int) + assert isinstance(row.columns[output_column_name].completion_tokens, int) + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + for row, d in zip(rows.items[::-1], data, strict=True): + for k, v in d.items(): + if k not in row and k in chat_data: + continue + if v == "": + assert ( + row[k]["value"] is None + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + else: + assert ( + row[k]["value"] == v + ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + + +@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_import_data_empty( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + with TemporaryDirectory() as tmp_dir: + # Empty + file_path = join(tmp_dir, "empty.csv") + df_to_csv(pd.DataFrame(columns=[]), file_path) + with pytest.raises(RuntimeError, match="No columns to parse"): + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, table_id=table.id, stream=stream + ), + ) + if stream: + response = list(response) + # No rows + file_path = join(tmp_dir, "empty.csv") + df_to_csv( + pd.DataFrame(columns=["good", "words", "stars", "inputs", "summary"]), file_path + ) + with pytest.raises(RuntimeError, match="empty"): + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, table_id=table.id, stream=stream + ), + ) + if stream: + response = list(response) + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 0 + + +@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("stream", [True, False], ids=["stream", "non-stream"]) +def test_import_data_with_vector( + client_cls: Type[JamAI], + stream: bool, +): + table_type = p.TableType.knowledge + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + # Add a row first to figure out the vector length + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + { + "good": True, + "words": 5, + "stars": 0.0, + "inputs": "", + "summary": "", + } + ], + stream=False, + ), + ) + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + vector_len = len(rows.items[0]["Text Embed"]["value"]) + + # CSV with vector data + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_data_with_vector.csv") + vector_data = { + "Text Embed": np.random.rand(vector_len).tolist(), + "Title Embed": np.random.rand(vector_len).tolist(), + "Extra Data": TEXT, + } + data = [ + { + "good": True, + "words": 5, + "stars": 0.0, + "inputs": "", + "summary": "", + "captioning": "", + **vector_data, + }, + { + "good": False, + "words": 5, + "stars": 1.0, + "inputs": TEXT, + "summary": "", + "captioning": "", + **vector_data, + }, + { + "good": True, + "words": 5, + "stars": 2.0, + "inputs": TEXT_CN, + "summary": "", + "captioning": "", + **vector_data, + }, + { + "good": False, + "words": 5, + "stars": 3.0, + "inputs": TEXT_JP, + "summary": "", + "captioning": "", + **vector_data, + }, + ] + df = pd.DataFrame.from_dict(data).astype( + { + "good": "bool", + "words": "int32", + "stars": "float32", + "inputs": "string", + "summary": "string", + "captioning": "string", + } + ) + df_to_csv(df, file_path) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + ), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 5, len(rows.items) + for row in rows.items[::-1]: + assert isinstance(row["Text Embed"]["value"], list) + assert len(row["Text Embed"]["value"]) > 0 + assert isinstance(row["Title Embed"]["value"], list) + assert len(row["Title Embed"]["value"]) > 0 + + +@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("delimiter", [","], ids=["comma_delimiter"]) +def test_export_data( + client_cls: Type[JamAI], + table_type: p.TableType, + delimiter: str, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + data = [ + {"good": True, "words": 5, "stars": 0.0, "inputs": TEXT, "summary": TEXT}, + {"good": False, "words": 5, "stars": 1.0, "inputs": TEXT, "summary": TEXT}, + {"good": True, "words": 5, "stars": 2.0, "inputs": TEXT_CN, "summary": TEXT}, + {"good": False, "words": 5, "stars": 3.0, "inputs": TEXT_JP, "summary": TEXT}, + ] + for d in data: + _add_row(jamai, table_type, False, data=d) + rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) + assert isinstance(rows.items, list) + assert len(rows.items) == 4 + # All columns + csv_data = jamai.export_table_data(table_type, table.id, delimiter=delimiter) + csv_df = csv_to_df(csv_data.decode("utf-8"), sep=delimiter) + exported_rows = csv_df.to_dict(orient="records") + assert len(exported_rows) == 4 + for row, d in zip(exported_rows, data, strict=True): + for k, v in d.items(): + assert row[k] == v, f"Exported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + # Subset of columns + columns = ["good", "words"] + csv_data = jamai.export_table_data(table_type, table.id, columns, delimiter) + csv_df = csv_to_df(csv_data.decode("utf-8"), sep=delimiter) + exported_rows = csv_df.to_dict(orient="records") + assert len(exported_rows) == 4 + for row, d in zip(exported_rows, data, strict=True): + for k, v in d.items(): + if k in columns: + assert ( + row[k] == v + ), f"Exported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + else: + assert k not in row + + +@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"]) +@pytest.mark.parametrize("delimiter", [",", "\t"], ids=["comma_delimiter", "tab_delimiter"]) +def test_import_export_data_round_trip( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, + delimiter: str, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + with TemporaryDirectory() as tmp_dir: + data = [ + { + "good": True, + "words": 5, + "stars": 0.0, + "inputs": TEXT, + "summary": TEXT, + "captioning": "", + }, + { + "good": False, + "words": 5, + "stars": 1.0, + "inputs": TEXT, + "summary": TEXT, + "captioning": "", + }, + { + "good": True, + "words": 5, + "stars": 2.0, + "inputs": TEXT_CN, + "summary": TEXT, + "captioning": "", + }, + { + "good": False, + "words": 5, + "stars": 3.0, + "inputs": TEXT_JP, + "summary": TEXT, + "captioning": "", + }, + ] + file_path = join(tmp_dir, "test_import_export_round_trip.csv") + df = pd.DataFrame.from_dict(data).astype( + { + "good": "bool", + "words": "int32", + "stars": "float32", + "inputs": "string", + "summary": "string", + "captioning": "string", + } + ) + df_to_csv(df, file_path, delimiter) + response = jamai.import_table_data( + table_type, + p.TableDataImportRequest( + file_path=file_path, + table_id=table.id, + stream=stream, + delimiter=delimiter, + ), + ) + if stream: + responses = [r for r in response] + if table_type == p.TableType.chat: + assert len(responses) > 0 + else: + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + + csv_data = jamai.export_table_data(table_type, table.id, delimiter=delimiter) + csv_df = csv_to_df(csv_data.decode("utf-8"), sep=delimiter) + exported_df = csv_df[df.columns.tolist()] + assert df.eq(exported_df).all(axis=None) + + +@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) +def test_import_export_round_trip( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + _add_row(jamai, table_type, False) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=1 / 3, inputs=TEXT_CN, summary=""), + ) + _add_row( + jamai, + table_type, + False, + data=dict(good=False, words=5, stars=-5 / 3, inputs=TEXT_JP, summary=""), + ) + rows = jamai.table.list_table_rows(table_type, table.id) + assert len(rows.items) == 3 + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_export_round_trip.parquet") + # Export + with open(file_path, "wb") as f: + f.write(jamai.table.export_table(table_type, table.id)) + # Import + table_id_dst = f"{table.id}_import" + try: + imported_table = jamai.table.import_table( + table_type, + p.TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + ), + ) + assert isinstance(imported_table, p.TableMetaResponse) + assert imported_table.id == table_id_dst + imported_rows = jamai.table.list_table_rows(table_type, imported_table.id) + assert len(imported_rows.items) == len(rows.items) + assert imported_rows.items[0]["photo"]["value"] is None + assert imported_rows.items[1]["photo"]["value"] is None + assert isinstance(imported_rows.items[2]["photo"]["value"], str) + raw_urls = jamai.file.get_raw_urls( + [rows.items[2]["photo"]["value"], imported_rows.items[2]["photo"]["value"]] + ) + raw_files = [ + httpx.get(url, headers={"X-PROJECT-ID": "default"}).content + for url in raw_urls.urls + ] + assert ( + raw_urls.urls[0] != raw_urls.urls[1] + ) # URL is different but file should match + assert raw_files[0] == raw_files[1] + rows.items[2]["photo"]["value"] = raw_files[0] + imported_rows.items[2]["photo"]["value"] = raw_files[1] + assert imported_rows.items == rows.items + finally: + jamai.table.delete_table(table_type, table_id_dst) + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_import_export_wrong_table_type( + client_cls: Type[JamAI], +): + jamai = client_cls() + with _create_table(jamai, "action") as table: + assert isinstance(table, p.TableMetaResponse) + _add_row(jamai, "action", False) + _add_row( + jamai, + "action", + False, + data=dict(good=True, words=5, stars=5 / 3, inputs=TEXT, summary=""), + ) + rows = jamai.table.list_table_rows("action", table.id) + assert len(rows.items) == 2 + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_import_export_round_trip.parquet") + # Export + with open(file_path, "wb") as f: + f.write(jamai.export_table("action", table.id)) + table_id_dst = f"{table.id}_import" + # Import as knowledge + with pytest.raises(RuntimeError): + jamai.import_table( + "knowledge", + p.TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + ), + ) + # Import as chat + with pytest.raises(RuntimeError): + jamai.import_table( + "chat", + p.TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + ), + ) + + +if __name__ == "__main__": + test_import_export_round_trip(JamAI, p.TableType.action) diff --git a/clients/python/tests/oss/gen_table/test_row_ops.py b/clients/python/tests/oss/gen_table/test_row_ops.py new file mode 100644 index 0000000..c8f05b8 --- /dev/null +++ b/clients/python/tests/oss/gen_table/test_row_ops.py @@ -0,0 +1,2362 @@ +import re +from contextlib import contextmanager +from decimal import Decimal +from os.path import basename, join +from tempfile import TemporaryDirectory +from time import sleep +from typing import Any, Generator, Type + +import httpx +import pandas as pd +import pytest +from flaky import flaky +from pydantic import ValidationError + +from jamaibase import JamAI +from jamaibase import protocol as p +from jamaibase.exceptions import ResourceNotFoundError +from jamaibase.protocol import IMAGE_FILE_EXTENSIONS +from jamaibase.utils.io import df_to_csv + +CLIENT_CLS = [JamAI] +TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] + +TABLE_ID_A = "table_a" +TABLE_ID_B = "table_b" +TABLE_ID_C = "table_c" +TABLE_ID_X = "table_x" +TEXT = '"Arrival" is a 2016 American science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' +TEXT_CN = ( + '"Arrival" 《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年美国科幻剧情片,由丹尼斯·维伦纽瓦执导,埃里克·海瑟尔改编。' +) +TEXT_JP = '"Arrival" 「Arrivalã€ã¯ã€ãƒ‰ã‚¥ãƒ‹ãƒ»ãƒ´ã‚£ãƒ«ãƒŒãƒ¼ãƒ´ãŒç›£ç£ã—ã€ã‚¨ãƒªãƒƒã‚¯ãƒ»ãƒã‚¤ã‚»ãƒ©ãƒ¼ãŒè„šè‰²ã—ãŸ2016å¹´ã®ã‚¢ãƒ¡ãƒªã‚«ã®SFドラマ映画ã§ã™ã€‚' + +EMBED_WHITE_LIST_EXT = [ + "application/pdf", # pdf + "text/markdown", # md + "text/plain", # txt + "text/html", # html + "text/xml", # xml + "application/xml", # xml + "application/json", # json + "application/jsonl", # jsonl + "application/x-ndjson", # alternative for jsonl + "application/json-lines", # another alternative for jsonl + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx + "application/msword", # doc + "application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx + "application/vnd.ms-powerpoint", # ppt + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx + "application/vnd.ms-excel", # xls + "text/tab-separated-values", # tsv + "text/csv", # csv +] + + +@pytest.fixture(scope="module", autouse=True) +def setup(): + client = JamAI() + _delete_tables(client) + yield + _delete_tables(client) + + +def _delete_tables(jamai: JamAI): + batch_size = 100 + for table_type in TABLE_TYPES: + offset, total = 0, 1 + while offset < total: + tables = jamai.table.list_tables(table_type, offset=offset, limit=batch_size) + assert isinstance(tables.items, list) + for table in tables.items: + jamai.table.delete_table(table_type, table.id) + total = tables.total + offset += batch_size + + +def _get_chat_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="openai/gpt-4o-mini", capabilities=["chat"]) + 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"] + ) + image_models = jamai.model_names(prefer="", capabilities=["image"]) + return list(set(chat_models) - set(image_models))[0] + + +def _get_reranking_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="cohere/rerank-english-v3.0", capabilities=["rerank"]) + return models[0] + + +def _rerun_on_fs_error_with_delay(err, *args): + if "LanceError(IO): Generic LocalFileSystem error" in str(err): + sleep(1) + return True + return False + + +@contextmanager +def _create_table( + jamai: JamAI, + table_type: p.TableType, + table_id: str = TABLE_ID_A, + cols: list[p.ColumnSchemaCreate] | None = None, + chat_cols: list[p.ColumnSchemaCreate] | None = None, + embedding_model: str | None = None, + delete_first: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + cols = [ + p.ColumnSchemaCreate(id="good", dtype="bool"), + 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="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + # Interpolate string and non-string input columns + prompt="Summarise this in ${words} words:\n\n${inputs}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + p.ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + system_prompt="You are a concise assistant.", + # Interpolate file input column + prompt="${photo} \n\nWhat's in the image?", + temperature=0.001, + top_p=0.001, + max_tokens=300, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + + if table_type == p.TableType.action: + table = jamai.table.create_action_table( + p.ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + if embedding_model is None: + embedding_model = "" + table = jamai.table.create_knowledge_table( + p.KnowledgeTableSchemaCreate( + id=table_id, cols=cols, embedding_model=embedding_model + ) + ) + elif table_type == p.TableType.chat: + table = jamai.table.create_chat_table( + p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + jamai.table.delete_table(table_type, table_id) + + +def _add_row( + jamai: JamAI, + table_type: p.TableType, + stream: bool, + table_name: str = TABLE_ID_A, + data: dict | None = None, + knowledge_data: dict | None = None, + chat_data: dict | None = None, +): + if data is None: + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + data = dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo=upload_response.uri, + ) + + if knowledge_data is None: + knowledge_data = dict( + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + ) + if chat_data is None: + chat_data = dict(User="Tell me a joke.") + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + data.update(knowledge_data) + elif table_type == p.TableType.chat: + data.update(chat_data) + else: + raise ValueError(f"Invalid table type: {table_type}") + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + return response + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert len(response.rows) == 1 + return response.rows[0] + + +def _assert_is_vector(x: Any): + assert isinstance(x, list), f"Not a list: {x}" + assert len(x) > 0, f"Not a non-empty list: {x}" + assert all(isinstance(v, float) for v in x), f"Not a list of floats: {x}" + + +def _collect_text( + responses: p.GenTableRowsChatCompletionChunks + | Generator[p.GenTableStreamChatCompletionChunk, None, None], + col: str, +): + if isinstance(responses, p.GenTableRowsChatCompletionChunks): + return "".join(r.columns[col].text for r in responses.rows) + return "".join(r.text for r in responses if r.output_column_name == col) + + +def _get_exponent(x: float) -> int: + return Decimal(str(x)).as_tuple().exponent + + +@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("stream", [True, False], ids=["stream", "non-stream"]) +def test_knowledge_table_embedding( + client_cls: Type[JamAI], + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: + assert isinstance(table, p.TableMetaResponse) + # Don't include embeddings + data = [ + dict( + Title="Six-spot burnet", + Text="The six-spot burnet is a day-flying moth of the family Zygaenidae.", + ), + # Test missing Title + dict( + Text=( + "In machine learning, a neural network is a model inspired by " + "biological neural networks in animal brains." + ), + ), + # Test missing Text + dict( + Title=( + "A supercomputer is a type of computer with a high level of performance " + "as compared to a general-purpose computer." + ), + ), + ] + response = jamai.table.add_table_rows( + "knowledge", + p.RowAddRequest(table_id=table.id, data=data, stream=stream), + ) + if stream: + responses = [r for r in response] + assert len(responses) == 0 # We currently dont return anything if LLM is not called + else: + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + # Check embeddings + rows = jamai.table.list_table_rows("knowledge", table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 3 + row = rows.items[2] + assert row["Title"]["value"] == data[0]["Title"], row + assert row["Text"]["value"] == data[0]["Text"], row + _assert_is_vector(row["Title Embed"]["value"]) + _assert_is_vector(row["Text Embed"]["value"]) + row = rows.items[1] + assert row["Title"]["value"] is None, row + assert row["Text"]["value"] == data[1]["Text"], row + _assert_is_vector(row["Text Embed"]["value"]) + row = rows.items[0] + assert row["Title"]["value"] == data[2]["Title"], row + assert row["Text"]["value"] is None, row + _assert_is_vector(row["Title Embed"]["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("stream", [True, False], ids=["stream", "non-stream"]) +def test_knowledge_table_no_embed_input( + client_cls: Type[JamAI], + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, "knowledge", cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Purposely leave out Title and Text + data = dict(words=5) + response = jamai.table.add_table_rows( + "knowledge", + p.RowAddRequest(table_id=table.id, data=[data], stream=stream), + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + summary = "".join(r.text for r in responses if r.output_column_name == "summary") + assert len(summary) > 0 + else: + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + + +@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("stream", [True, False], ids=["stream", "non-stream"]) +def test_full_text_search( + client_cls: Type[JamAI], + stream: bool, +): + jamai = client_cls() + cols = [p.ColumnSchemaCreate(id="text", dtype="str")] + with _create_table(jamai, "action", cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Add data + texts = [ + '"Dune: Part Two" 2024 is Denis\'s science-fiction film.', + '"Dune: Part Two" 2024 is Denis\'s film.', + '"Arrival" 《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年美国科幻剧情片,由丹尼斯·维伦纽瓦执导。', + '"Arrival" 『デューン: パート 2ã€2024 ã¯ãƒ‡ãƒ‹ã‚¹ã®æ˜ ç”»ã§ã™ã€‚', + ] + response = jamai.table.add_table_rows( + "action", + p.RowAddRequest(table_id=table.id, data=[{"text": t} for t in texts], stream=stream), + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + + # Search + def _search(query: str): + return jamai.table.hybrid_search( + "action", p.SearchRequest(table_id=table.id, query=query) + ) + + assert len(_search("AND")) == 0 # SQL-like statements should still work + assert len(_search("《")) == 0 # Not supported + assert len(_search("scien*")) == 0 # Not supported + assert len(_search("film")) == 2 + assert len(_search("science -fiction")) == 1 + assert len(_search("science-fiction")) == 1 + assert len(_search("science -fiction\n2016")) == 2 + assert len(_search("美国")) == 0 # Not supported + + +@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_rag( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + # Create Knowledge Table and add some rows + with _create_table(jamai, "knowledge", cols=[]) as ktable: + assert isinstance(ktable, p.TableMetaResponse) + response = jamai.table.add_table_rows( + p.TableType.knowledge, + p.RowAddRequest( + table_id=ktable.id, + data=[ + dict( + Title="Six-spot burnet", + Text="The six-spot burnet is a day-flying moth of the family Zygaenidae.", + ), + # Test missing Title + dict( + Text="In machine learning, a neural network is a model inspired by biological neural networks in animal brains.", + ), + # Test missing Text + dict( + Title="A supercomputer is a type of computer with a high level of performance as compared to a general-purpose computer.", + ), + ], + stream=False, + ), + ) + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + rows = jamai.table.list_table_rows(p.TableType.knowledge, ktable.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 3 + + # Create the other table + cols = [ + p.ColumnSchemaCreate(id="question", dtype="str"), + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="rag", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt="${question}? Summarise in ${words} words", + temperature=0.001, + top_p=0.001, + max_tokens=10, + rag_params=p.RAGParams( + table_id=ktable.id, + reranking_model=_get_reranking_model(jamai), + search_query="", # Generate using LM + rerank=True, + ), + ), + ), + ] + with _create_table(jamai, table_type, TABLE_ID_B, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Perform RAG + data = dict(question="What is a burnet?", words=5) + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table.id, data=[data], stream=stream), + ) + if stream: + responses = [r for r in response if r.output_column_name == "rag"] + assert len(responses) > 0 + assert isinstance(responses[0], p.GenTableStreamReferences) + responses = responses[1:] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + rag = "".join(r.text for r in responses) + assert len(rag) > 0 + else: + assert len(response.rows) > 0 + for row in response.rows: + assert isinstance(row, p.GenTableChatCompletionChunks) + assert len(row.columns) > 0 + if table_type == p.TableType.chat: + assert "AI" in row.columns + assert "rag" in row.columns + assert isinstance(row.columns["rag"], p.ChatCompletionChunk) + assert isinstance(row.columns["rag"].references, p.References) + assert len(row.columns["rag"].text) > 0 + + +@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_rag_with_file_input( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + # Create Knowledge Table and add some rows + with _create_table(jamai, "knowledge", cols=[]) as ktable: + assert isinstance(ktable, p.TableMetaResponse) + response = jamai.table.add_table_rows( + p.TableType.knowledge, + p.RowAddRequest( + table_id=ktable.id, + data=[ + dict( + Title="Coffee Lover", + Text="I called my rabbit as Latte.", + ), + dict( + Title="Coffee Lover", + Text="We ordered two cups of cappuccino in rabbit cafe, named Bunny World.", + ), + ], + stream=False, + ), + ) + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + rows = jamai.table.list_table_rows(p.TableType.knowledge, ktable.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + + # Create the other table + cols = [ + p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate(id="question", dtype="str"), + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="rag", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt="${photo} What's the animal? ${question} Summarise in ${words} words", + temperature=0.001, + top_p=0.001, + max_tokens=10, + rag_params=p.RAGParams( + table_id=ktable.id, + reranking_model=_get_reranking_model(jamai), + search_query="", # Generate using LM + rerank=True, + ), + ), + ), + ] + with _create_table(jamai, table_type, TABLE_ID_B, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + # Perform RAG + data = dict(photo=upload_response.uri, question="Get it's name.", words=5) + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table.id, data=[data], stream=stream), + ) + if stream: + responses = [r for r in response if r.output_column_name == "rag"] + assert len(responses) > 0 + assert isinstance(responses[0], p.GenTableStreamReferences) + responses = responses[1:] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + rag = "".join(r.text for r in responses) + assert len(rag) > 0 + else: + assert len(response.rows) > 0 + for row in response.rows: + assert isinstance(row, p.GenTableChatCompletionChunks) + assert len(row.columns) > 0 + if table_type == p.TableType.chat: + assert "AI" in row.columns + assert "rag" in row.columns + assert isinstance(row.columns["rag"], p.ChatCompletionChunk) + assert isinstance(row.columns["rag"].references, p.References) + assert len(row.columns["rag"].text) > 0 + + rows = jamai.table.list_table_rows(table_type, TABLE_ID_B) + 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 "Latte" in row["rag"]["value"] and "Bunny World" not in row["rag"]["value"] + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +def test_conversation_starter( + client_cls: Type[JamAI], + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You help remember facts.", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are an assistant", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + with _create_table(jamai, "chat", cols=[], chat_cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Add the starter + response = jamai.table.add_table_rows( + "chat", + p.RowAddRequest(table_id=table.id, data=[dict(AI="Jim has 5 apples.")], stream=stream), + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + # Chat with it + response = jamai.table.add_table_rows( + "chat", + p.RowAddRequest( + table_id=table.id, + data=[dict(User="How many apples does Jim have?")], + stream=stream, + ), + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + answer = "".join(r.text for r in responses if r.output_column_name == "AI") + assert "5" in answer or "five" in answer.lower() + summary = "".join(r.text for r in responses if r.output_column_name == "summary") + assert len(summary) > 0 + else: + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + + +@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_add_row( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + response = _add_row(jamai, table_type, stream) + 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 ("summary", "captioning", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "captioning") 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 ("summary", "captioning"): + 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["good"]["value"] is True, row["good"] + 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"] + for animal in ["deer", "rabbit"]: + if animal in row["photo"]["value"].split("_")[0]: + assert animal in row["captioning"]["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]) +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="file"), + p.ColumnSchemaCreate(id="photo2", dtype="file"), + 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_file_type_output_column( +# client_cls: Type[JamAI], +# table_type: p.TableType, +# stream: bool, +# ): +# jamai = client_cls() +# cols = [ +# p.ColumnSchemaCreate(id="photo", dtype="file"), +# p.ColumnSchemaCreate(id="question", dtype="str"), +# p.ColumnSchemaCreate( +# id="captioning", +# dtype="file", +# gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), +# ), +# p.ColumnSchemaCreate( +# id="answer", +# dtype="file", +# gen_config=p.LLMGenConfig( +# model="", +# prompt="${photo} ${question}?", +# ), +# ), +# p.ColumnSchemaCreate( +# id="compare", +# dtype="file", +# gen_config=p.LLMGenConfig( +# model="", +# prompt="Compare ${captioning} and ${answer}.", +# ), +# ), +# ] +# with _create_table(jamai, table_type, cols=cols) as table: +# assert isinstance(table, p.TableMetaResponse) +@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) +def test_add_row_output_column_referred_image_input_with_chat_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="photo", dtype="file"), + p.ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + # Add output column that referred to image file, but using chat model + # (Notes: chat model can be set due to default prompt was added afterward) + chat_only_model = _get_chat_only_model(jamai) + cols = [ + p.ColumnSchemaCreate( + id="captioning2", + dtype="str", + gen_config=p.LLMGenConfig(model=chat_only_model), + ), + ] + with pytest.raises(RuntimeError): + if table_type == p.TableType.action: + jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False]) +def test_add_row_sequential_completion_with_error( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt="Summarise ${input}.", + ), + ), + p.ColumnSchemaCreate( + id="rephrase", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + prompt="Rephrase ${summary}", + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + response = _add_row( + jamai, + table_type, + stream, + TABLE_ID_A, + data=dict(input="a" * 10000000), + ) + 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 ("summary", "rephrase", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "rephrase") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + for output_column_name in ("summary", "rephrase"): + assert len(response.columns[output_column_name].text) > 0 + + 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["summary"]["value"].startswith("[ERROR] ") + second_output = (row["rephrase"]["value"]).upper() + if stream: + assert second_output.startswith("[ERROR] ") + else: + assert "WARNING" in second_output or "ERROR" in second_output + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +@pytest.mark.parametrize( + "img_filename", + [ + "clients/python/tests/files/jpeg/cifar10-deer.jpg", + "clients/python/tests/files/jpeg/rabbit.jpeg", + "clients/python/tests/files/png/rabbit.png", + "clients/python/tests/files/webp/rabbit_cifar10-deer.webp", + "clients/python/tests/files/gif/rabbit_cifar10-deer.gif", + ], + ids=lambda x: basename(x), +) +def test_add_row_image_file_type_with_generation( + client_cls: Type[JamAI], table_type: p.TableType, stream: bool, img_filename: str +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + upload_response = jamai.file.upload_file(img_filename) + response = _add_row( + jamai, + table_type, + stream, + data=dict( + photo=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 ("summary", "captioning", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + assert len(response.columns["captioning"].text) > 0 + 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"] + result_caption = row["captioning"]["value"] + for animal in ["deer", "rabbit"]: + if animal in img_filename.split("_")[0]: + if "cifar10" in img_filename: + assert ( + animal in result_caption + or "see" in result_caption + or "can't" in result_caption + ) + else: + assert animal in result_caption + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +@pytest.mark.parametrize( + "img_filename", + [ + "s3://image-bucket/bmp/cifar10-deer.bmp", + "s3://image-bucket/tiff/cifar10-deer.tiff", + "file://image-bucket/tiff/rabbit.tiff", + ], +) +def test_add_row_image_file_column_invalid_extension( + client_cls: Type[JamAI], table_type: p.TableType, stream: bool, img_filename: str +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + with pytest.raises( + ValidationError, + match=( + "Unsupported file type. Make sure the file belongs to " + "one of the following formats: \n" + f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS}" + ) + .replace("[", "\\[") + .replace("]", "\\]"), + ): + _add_row( + jamai, + table_type, + stream, + data=dict(photo=img_filename), + ) + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_add_row_validate_one_image_per_completion( + client_cls: Type[JamAI], table_type: p.TableType, stream: bool = True +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + captioning=p.LLMGenConfig( + system_prompt="You are a concise assistant.", + prompt="${photo} ${photo}\n\nWhat's in the image?", + ), + ), + ), + ) + + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + response = _add_row( + jamai, + table_type, + stream, + data=dict( + photo=upload_response.uri, + ), + ) + 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 ("summary", "captioning", "AI") for r in responses) + else: + assert all(r.output_column_name in ("summary", "captioning") 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) + assert isinstance(rows.items, list) + assert len(rows.items) == 1 + row = rows.items[0] + assert row["photo"]["value"] == row["photo"]["value"], row["photo"]["value"] + assert row["captioning"]["value"] == "[ERROR] Only one image is supported per completion." + + +@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_add_row_wrong_dtype( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + response = _add_row(jamai, table_type, stream) + 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 ("summary", "captioning", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + assert len(response.columns["summary"].text) > 0 + + # Test adding data with wrong dtype + response = _add_row( + jamai, + table_type, + stream, + TABLE_ID_A, + data=dict(good="dummy1", words="dummy2", stars="dummy3", inputs=TEXT), + ) + if stream: + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + row = rows.items[0] + assert row["good"]["value"] is None, row["good"] + assert row["good"]["original"] == "dummy1", row["good"] + assert row["words"]["value"] is None, row["words"] + assert row["words"]["original"] == "dummy2", row["words"] + assert row["stars"]["value"] is None, row["stars"] + assert row["stars"]["original"] == "dummy3", row["stars"] + + +@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_add_row_missing_columns( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + response = _add_row(jamai, table_type, stream) + 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 ("summary", "captioning", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + assert len(response.columns["summary"].text) > 0 + + # Test adding data with missing column + response = _add_row( + jamai, + table_type, + stream, + TABLE_ID_A, + data=dict(good="dummy1", inputs=TEXT), + ) + if stream: + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response, p.GenTableChatCompletionChunks) + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + row = rows.items[0] + assert row["good"]["value"] is None, row["good"] + assert row["good"]["original"] == "dummy1", row["good"] + assert row["words"]["value"] is None, row["words"] + assert "original" not in row["words"], row["words"] + assert row["stars"]["value"] is None, row["stars"] + assert "original" not in row["stars"], row["stars"] + + +@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_add_rows_all_input( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="0", dtype="int"), + p.ColumnSchemaCreate(id="1", dtype="float"), + p.ColumnSchemaCreate(id="2", dtype="bool"), + p.ColumnSchemaCreate(id="3", dtype="str"), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + {"0": 1, "1": 2.0, "2": False, "3": "days"}, + {"0": 0, "1": 1.0, "2": True, "3": "of"}, + ], + stream=stream, + ), + ) + if stream: + responses = [r for r in response if r.output_column_name != "AI"] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert len(response.rows) == 2 + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + + +@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) +def test_update_row( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + row = _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy"), + ) + assert isinstance(row, p.GenTableChatCompletionChunks) + 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] + original_ts = row["Updated at"] + assert row["good"]["value"] is True, row["good"] + assert row["words"]["value"] == 5, row["words"] + assert row["stars"]["value"] == 9.9, row["stars"] + # Regular update + response = jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=row["ID"], + data=dict(good=False, stars=1.0), + ), + ) + assert isinstance(response, p.OkResponse) + 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["good"]["value"] is False, row["good"] + assert row["words"]["value"] == 5, row["words"] + assert row["stars"]["value"] == 1.0, row["stars"] + assert row["Updated at"] > original_ts + + # Test updating data with wrong dtype + response = jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=row["ID"], + data=dict(good="dummy", words="dummy", stars="dummy"), + ), + ) + assert isinstance(response, p.OkResponse) + 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["good"]["value"] is None, row["good"] + assert row["words"]["value"] is None, row["words"] + assert row["stars"]["value"] is None, row["stars"] + + +@flaky(max_runs=3, min_passes=1) +@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_rows( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + 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") + response = _add_row( + jamai, + table_type, + False, + data=dict( + good=True, + words=10, + stars=9.9, + inputs=TEXT, + photo=upload_response.uri, + ), + ) + assert isinstance(response, p.GenTableChatCompletionChunks) + 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] + _id = row["ID"] + original_ts = row["Updated at"] + assert "arrival" in row["summary"]["value"].lower() + # Regen + jamai.table.update_table_row( + table_type, + p.RowUpdateRequest( + table_id=TABLE_ID_A, + row_id=_id, + data=dict( + inputs="Dune: Part Two is a 2024 American epic science fiction film directed and produced by Denis Villeneuve" + ), + ), + ) + response = jamai.table.regen_table_rows( + table_type, p.RowRegenRequest(table_id=TABLE_ID_A, row_ids=[_id], stream=stream) + ) + 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 ("summary", "captioning", "AI") for r in responses + ) + else: + assert all(r.output_column_name in ("summary", "captioning") for r in responses) + assert len("".join(r.text for r in responses)) > 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.rows[0].object == "gen_table.completion.chunks" + assert len(response.rows[0].columns["summary"].text) > 0 + 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["good"]["value"] is True + assert row["words"]["value"] == 10 + assert row["stars"]["value"] == 9.9 + assert row["photo"]["value"] == upload_response.uri + assert row["Updated at"] > original_ts + assert "dune" in row["summary"]["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], ids=["stream", "non-stream"]) +def test_regen_rows_all_input( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="0", dtype="int"), + p.ColumnSchemaCreate(id="1", dtype="float"), + p.ColumnSchemaCreate(id="2", dtype="bool"), + p.ColumnSchemaCreate(id="3", dtype="str"), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + {"0": 1, "1": 2.0, "2": False, "3": "days"}, + {"0": 0, "1": 1.0, "2": True, "3": "of"}, + ], + stream=False, + ), + ) + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert len(response.rows) == 2 + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + # Regen + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=table.id, row_ids=[r["ID"] for r in rows.items], stream=stream + ), + ) + if stream: + responses = [r for r in response if r.output_column_name != "AI"] + assert len(responses) == 0 + else: + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + + +@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) +def test_delete_rows( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") + _add_row(jamai, table_type, False, data=data) + _add_row(jamai, table_type, False, data=data) + _add_row(jamai, table_type, False, data=data) + _add_row(jamai, table_type, False, data=data) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=7.9, inputs=TEXT_CN), + ) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=7.9, inputs=TEXT_JP), + ) + ori_rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(ori_rows.items, list) + assert len(ori_rows.items) == 6 + delete_id = ori_rows.items[0]["ID"] + + # Delete one row + response = jamai.table.delete_table_row(table_type, TABLE_ID_A, delete_id) + assert isinstance(response, p.OkResponse) + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 5 + row_ids = set(r["ID"] for r in rows.items) + assert delete_id not in row_ids + # Delete multiple rows + delete_ids = [r["ID"] for r in ori_rows.items[1:4]] + response = jamai.table.delete_table_rows( + table_type, + p.RowDeleteRequest( + table_id=TABLE_ID_A, + row_ids=delete_ids, + ), + ) + assert isinstance(response, p.OkResponse) + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + row_ids = set(r["ID"] for r in rows.items) + assert len(set(row_ids) & set(delete_ids)) == 0 + + +@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) +def test_get_and_list_rows( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + _add_row(jamai, table_type, False) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=5 / 3, inputs=TEXT, summary=""), + ) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=1 / 3, inputs=TEXT_CN, summary=""), + ) + _add_row( + jamai, + table_type, + False, + data=dict(good=False, words=5, stars=-5 / 3, inputs=TEXT_JP, summary=""), + ) + _add_row( + jamai, + table_type, + False, + data=dict(good=False, words=5, stars=-1 / 3, inputs=TEXT, summary=""), + ) + # Regular case + expected_cols = { + "ID", + "Updated at", + "good", + "words", + "stars", + "inputs", + "photo", + "summary", + "captioning", + } + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) + assert isinstance(rows.items, list) + assert all(isinstance(r, dict) for r in rows.items) + assert rows.total == 5 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) == 5 + stars = [r["stars"]["value"] for r in rows.items] + assert stars[0] == -1 / 3 + assert stars[-1] == 7.9 + assert all(set(r.keys()) == expected_cols for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + # Test get row + _id = rows.items[0]["ID"] + row = jamai.table.get_table_row(table_type, TABLE_ID_A, _id) + assert row["ID"] == _id + assert set(row.keys()) == expected_cols + row = jamai.table.get_table_row(table_type, TABLE_ID_A, _id, columns=["good"]) + assert row["ID"] == _id + assert set(row.keys()) == {"ID", "Updated at", "good"} + + # Test various offset and limit + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=0, limit=3) + assert isinstance(rows.items, list) + assert rows.total == 5 + assert rows.offset == 0 + assert rows.limit == 3 + assert len(rows.items) == 3 + stars = [r["stars"]["value"] for r in rows.items] + assert stars[0] == -1 / 3 + assert stars[-1] == 1 / 3 + + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=1, limit=3) + assert isinstance(rows.items, list) + assert rows.total == 5 + assert rows.offset == 1 + assert rows.limit == 3 + assert len(rows.items) == 3 + stars = [r["stars"]["value"] for r in rows.items] + assert stars[0] == -5 / 3 + assert stars[-1] == 5 / 3 + + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=4, limit=3) + assert isinstance(rows.items, list) + assert rows.total == 5 + assert rows.offset == 4 + assert rows.limit == 3 + assert len(rows.items) == 1 + stars = [r["stars"]["value"] for r in rows.items] + assert stars[0] == 7.9 + + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=6, limit=3) + assert isinstance(rows.items, list) + assert rows.total == 5 + assert rows.offset == 6 + assert rows.limit == 3 + assert len(rows.items) == 0 + + # Test specifying columns + rows = jamai.table.list_table_rows( + table_type, TABLE_ID_A, offset=1, limit=3, columns=["stars", "good"] + ) + assert isinstance(rows.items, list) + assert rows.total == 5 + assert rows.offset == 1 + assert rows.limit == 3 + assert len(rows.items) == 3 + assert all(set(r.keys()) == {"ID", "Updated at", "good", "stars"} for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + + # Invalid offset and limit + with pytest.raises(RuntimeError): + jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=0, limit=0) + with pytest.raises(RuntimeError): + jamai.table.list_table_rows(table_type, TABLE_ID_A, offset=-1, limit=1) + + # Test search query + rows = jamai.table.list_table_rows( + table_type, + TABLE_ID_A, + offset=0, + limit=3, + search_query="dummy", + columns=["summary"], + ) + assert isinstance(rows.items, list) + assert rows.total == 2 + assert rows.offset == 0 + assert rows.limit == 3 + assert len(rows.items) == 2 + assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + rows = jamai.table.list_table_rows( + table_type, + TABLE_ID_A, + offset=1, + limit=3, + search_query="dummy", + columns=["summary"], + ) + assert isinstance(rows.items, list) + assert rows.total == 2 + assert rows.offset == 1 + assert rows.limit == 3 + assert len(rows.items) == 1 + assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + rows = jamai.table.list_table_rows( + table_type, + TABLE_ID_A, + offset=0, + limit=100, + search_query="yummy", + columns=["summary"], + ) + assert isinstance(rows.items, list) + assert rows.total == 1 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) == 1 + assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + + # --- Test precision --- # + # At least 10 decimals or above + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, limit=4) + assert isinstance(rows.items, list) + for row in rows.items: + for cell in row.values(): + if not isinstance(cell, dict): + continue + cell = cell["value"] + if isinstance(cell, float): + exponent = _get_exponent(cell) + assert exponent <= -10, exponent + elif isinstance(cell, list): + exponents = [_get_exponent(vv) for vv in cell] + assert all(e <= -10 for e in exponents), exponents + else: + continue + # 5 decimals or below + rows = jamai.table.list_table_rows( + table_type, TABLE_ID_A, limit=4, float_decimals=5, vec_decimals=5 + ) + assert isinstance(rows.items, list) + for row in rows.items: + for cell in row.values(): + if not isinstance(cell, dict): + continue + cell = cell["value"] + if isinstance(cell, float): + exponent = _get_exponent(cell) + assert exponent >= -5, exponent + elif isinstance(cell, list): + exponents = [_get_exponent(vv) for vv in cell] + assert all(e >= -5 for e in exponents), exponents + else: + continue + # 1 decimal or below + rows = jamai.table.list_table_rows( + table_type, TABLE_ID_A, limit=4, float_decimals=1, vec_decimals=1 + ) + assert isinstance(rows.items, list) + for row in rows.items: + for cell in row.values(): + if not isinstance(cell, dict): + continue + cell = cell["value"] + if isinstance(cell, float): + exponent = _get_exponent(cell) + assert exponent >= -1, exponent + elif isinstance(cell, list): + exponents = [_get_exponent(vv) for vv in cell] + assert all(e >= -1 for e in exponents), exponents + else: + continue + + # --- Vector column exclusion --- # + # 5 decimals + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, limit=4, vec_decimals=5) + if table_type == "knowledge": + for row in rows.items: + vec_cols = [ + cell + for cell in row.values() + if isinstance(cell, dict) and isinstance(cell["value"], list) + ] + assert len(vec_cols) > 0 + # No vector columns + rows = jamai.table.list_table_rows(table_type, TABLE_ID_A, limit=4, vec_decimals=-1) + for row in rows.items: + vec_cols = [ + cell + for cell in row.values() + if isinstance(cell, dict) and isinstance(cell["value"], list) + ] + assert len(vec_cols) == 0 + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_column_interpolate( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + + cols = [ + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt='Say "Jan has 5 apples.".', + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + p.ColumnSchemaCreate(id="input0", dtype="int"), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt=( + "1. ${output0}\n2. Jan has ${input0} apples.\n\n" + "Do the statements agree with each other? Reply Yes or No." + ), + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + def _add_row_wrapped(stream, data): + return _add_row( + jamai, + table_type=table_type, + stream=stream, + table_name=table.id, + data=data, + knowledge_data=None, + chat_data=dict(User='Say "Jan has 5 apples.".'), + ) + + # Streaming + response = list(_add_row_wrapped(True, dict(input0=5))) + output0 = _collect_text(response, "output0") + ai = _collect_text(response, "AI") + answer = _collect_text(response, "output1") + assert "yes" in answer.lower(), f'output0="{output0}" ai="{ai}" answer="{answer}"' + response = list(_add_row_wrapped(True, dict(input0=6))) + output0 = _collect_text(response, "output0") + ai = _collect_text(response, "AI") + answer = _collect_text(response, "output1") + assert "no" in answer.lower(), f'output0="{output0}" ai="{ai}" answer="{answer}"' + # Non-streaming + response = _add_row_wrapped(False, dict(input0=5)) + answer = response.columns["output1"].text + assert "yes" in answer.lower(), f'columns={response.columns} answer="{answer}"' + response = _add_row_wrapped(False, dict(input0=6)) + answer = response.columns["output1"].text + assert "no" in answer.lower(), f'columns={response.columns} answer="{answer}"' + + +@flaky(max_runs=3, min_passes=1) +@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_chat_history_and_sequential_add( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=p.LLMGenConfig( + system_prompt="You are a calculator.", + prompt="${input}", + multi_turn=True, + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Initialise chat thread and set output format + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + dict(input="x = 0", output="0"), + dict(input="Add 1", output="1"), + dict(input="Add 1", output="2"), + dict(input="Add 1", output="3"), + dict(input="Add 1", output="4"), + ], + stream=False, + ), + ) + # Test adding one row + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[dict(input="Add 1")], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "5" in output, output + # Test adding multiple rows + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + dict(input="Add 1"), + dict(input="Add 2"), + dict(input="Add 1"), + ], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "6" in output, output + assert "8" in output, output + assert "9" in output, output + + +@flaky(max_runs=3, min_passes=1) +@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_chat_history_and_sequential_regen( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=p.LLMGenConfig( + system_prompt="You are a calculator.", + prompt="${input}", + multi_turn=True, + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Initialise chat thread and set output format + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + dict(input="x = 0", output="0"), + dict(input="Add 1", output="1"), + dict(input="Add 1", output="2"), + dict(input="Add 2", output="9"), # Wrong answer on purpose + dict(input="Add 1", output="9"), # Wrong answer on purpose + dict(input="Add 3", output="9"), # Wrong answer on purpose + ], + stream=False, + ), + ) + row_ids = sorted([r.row_id for r in response.rows]) + # Test regen one row + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=table.id, + row_ids=row_ids[3:4], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "4" in output, output + # Test regen multiple rows + # Also test if regen proceeds in correct order from earliest row to latest + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=table.id, + row_ids=row_ids[3:][::-1], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "4" in output, output + assert "5" in output, output + assert "8" in output, output + + +@flaky(max_runs=3, min_passes=1) +@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_convert_into_multi_turn( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=p.LLMGenConfig( + system_prompt="You are a calculator.", + prompt="${input}", + multi_turn=False, + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Initialise chat thread and set output format + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[ + dict(input="x = 0", output="0"), + dict(input="x += 1", output="1"), + dict(input="x += 1", output="2"), + dict(input="x += 1", output="3"), + ], + stream=False, + ), + ) + # Test adding one row as single-turn + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=table.id, + data=[dict(input="x += 1")], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "4" not in output, output + # Convert into multi-turn + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output=p.LLMGenConfig( + system_prompt="You are a calculator.", + prompt="${input}", + multi_turn=True, + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Regen + rows = jamai.table.list_table_rows(table_type, table.id) + response = jamai.table.regen_table_rows( + table_type, + p.RowRegenRequest( + table_id=table.id, + row_ids=[rows.items[0]["ID"]], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "4" in output, output + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_get_conversation_thread( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=p.LLMGenConfig( + system_prompt="You are a calculator.", + prompt="${input}", + multi_turn=True, + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Initialise chat thread and set output format + data = [ + dict(input="x = 0", output="0"), + dict(input="Add 1", output="1"), + dict(input="Add 2", output="3"), + dict(input="Add 3", output="6"), + ] + response = jamai.table.add_table_rows( + table_type, p.RowAddRequest(table_id=table.id, data=data, stream=False) + ) + row_ids = sorted([r.row_id for r in response.rows]) + + def _check_thread(_chat): + assert isinstance(_chat, p.ChatThread) + for i, message in enumerate(_chat.thread): + assert isinstance(message.content, str) + assert len(message.content) > 0 + if i == 0: + assert message.role == p.ChatRole.SYSTEM + elif i % 2 == 1: + assert message.role == p.ChatRole.USER + assert message.content == data[(i - 1) // 2]["input"] + else: + assert message.role == p.ChatRole.ASSISTANT + assert message.content == data[(i // 2) - 1]["output"] + + # --- Fetch complete thread --- # + chat = jamai.table.get_conversation_thread(table_type, table.id, "output") + _check_thread(chat) + assert len(chat.thread) == 9 + assert chat.thread[-1].content == "6" + # --- Row ID filtering --- # + # Filter (include = True) + chat = jamai.table.get_conversation_thread( + table_type, table.id, "output", row_id=row_ids[2] + ) + _check_thread(chat) + assert len(chat.thread) == 7 + assert chat.thread[-1].content == "3" + # Filter (include = False) + chat = jamai.table.get_conversation_thread( + table_type, table.id, "output", row_id=row_ids[2], include=False + ) + _check_thread(chat) + assert len(chat.thread) == 5 + assert chat.thread[-1].content == "1" + # --- Invalid column --- # + with pytest.raises( + ResourceNotFoundError, + match="Column .*input.* is not found. Available chat columns:.*output.*", + ): + jamai.table.get_conversation_thread(table_type, table.id, "input") + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_hybrid_search( + client_cls: Type[JamAI], +): + jamai = client_cls() + table_type = p.TableType.knowledge + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") + rows = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=TABLE_ID_A, + data=[dict(Title="Resume 2012", Text="Hi there, I am a farmer.", **data)], + stream=False, + ), + ) + assert isinstance(rows, p.GenTableRowsChatCompletionChunks) + rows = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=TABLE_ID_A, + data=[dict(Title="Resume 2013", Text="Hi there, I am a carpenter.", **data)], + stream=False, + ), + ) + assert isinstance(rows, p.GenTableRowsChatCompletionChunks) + rows = jamai.table.add_table_rows( + table_type, + p.RowAddRequest( + table_id=TABLE_ID_A, + data=[ + dict( + Title="Byte Pair Encoding", + Text="BPE is a subword tokenization method.", + **data, + ) + ], + stream=False, + ), + ) + assert isinstance(rows, p.GenTableRowsChatCompletionChunks) + sleep(1) # Optional, give it some time to index + # Rely on embedding + rows = jamai.table.hybrid_search( + table_type, + p.SearchRequest( + table_id=TABLE_ID_A, + query="language", + reranking_model=_get_reranking_model(jamai), + limit=2, + ), + ) + assert len(rows) == 2 + assert "BPE" in rows[0]["Text"]["value"], rows + # Rely on FTS + rows = jamai.table.hybrid_search( + table_type, + p.SearchRequest( + table_id=TABLE_ID_A, + query="candidate 2013", + reranking_model=_get_reranking_model(jamai), + limit=2, + ), + ) + assert len(rows) == 2 + assert "2013" in rows[0]["Title"]["value"], rows + # hybrid_search without reranker (RRF only) + rows = jamai.table.hybrid_search( + table_type, + p.SearchRequest( + table_id=TABLE_ID_A, + query="language", + reranking_model=None, + limit=2, + ), + ) + assert len(rows) == 2 + assert "BPE" in rows[0]["Text"]["value"], rows + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize( + "file_path", + [ + "clients/python/tests/files/pdf/salary 总结.pdf", + "clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf", + "clients/python/tests/files/pdf_scan/1978_APL_FP_detrapping.PDF", + "clients/python/tests/files/pdf_mixed/digital_scan_combined.pdf", + "clients/python/tests/files/md/creative-story.md", + "clients/python/tests/files/txt/creative-story.txt", + "clients/python/tests/files/html/RAG and LLM Integration Guide.html", + "clients/python/tests/files/html/multilingual-code-examples.html", + "clients/python/tests/files/html/table.html", + "clients/python/tests/files/xml/weather-forecast-service.xml", + "clients/python/tests/files/json/company-profile.json", + "clients/python/tests/files/jsonl/llm-models.jsonl", + "clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl", + "clients/python/tests/files/docx/Recommendation Letter.docx", + "clients/python/tests/files/doc/Recommendation Letter.doc", + "clients/python/tests/files/pptx/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).pptx", + "clients/python/tests/files/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt", + "clients/python/tests/files/xlsx/Claims Form.xlsx", + "clients/python/tests/files/xls/Claims Form.xls", + "clients/python/tests/files/tsv/weather_observations.tsv", + "clients/python/tests/files/csv/company-profile.csv", + "clients/python/tests/files/csv/weather_observations_long.csv", + ], + ids=lambda x: basename(x), +) +def test_upload_file( + client_cls: Type[JamAI], + file_path: str, +): + jamai = client_cls() + table_type = p.TableType.knowledge + with _create_table(jamai, table_type, cols=[]) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + response = jamai.table.embed_file(file_path, table.id) + assert isinstance(response, p.OkResponse) + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert all(isinstance(r, dict) for r in rows.items) + assert rows.total > 0 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) > 0 + assert all(isinstance(r["Title"]["value"], str) for r in rows.items) + 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) + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize( + "file_path", + [ + "clients/python/tests/files/pdf/empty.pdf", + "clients/python/tests/files/pdf/empty_3pages.pdf", + "clients/python/tests/files/txt/empty.txt", + "clients/python/tests/files/csv/empty.csv", + ], + ids=lambda x: basename(x), +) +def test_upload_empty_file( + client_cls: Type[JamAI], + file_path: str, +): + jamai = client_cls() + table_type = p.TableType.knowledge + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + + pattern = re.compile("There is no text or content to embed") + with pytest.raises(RuntimeError, match=pattern): + response = jamai.table.embed_file(file_path, table.id) + assert isinstance(response, p.OkResponse) + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize( + "file_path", + [ + "clients/python/tests/files/jpeg/rabbit.jpeg", + "clients/python/pyproject.toml", + ], + ids=lambda x: basename(x), +) +def test_upload_file_invalid_file_type( + client_cls: Type[JamAI], + file_path: str, +): + jamai = client_cls() + table_type = p.TableType.knowledge + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + with pytest.raises(RuntimeError, match=r"File type .+ is unsupported"): + jamai.table.embed_file(file_path, table.id) + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_upload_file_options(client_cls: Type[JamAI]): + jamai = client_cls() + + response = jamai.table.embed_file_options() + + assert isinstance(response, httpx.Response) + assert response.status_code == 200 + + assert "Allow" in response.headers + assert "POST" in response.headers["Allow"] + assert "OPTIONS" in response.headers["Allow"] + + assert "Accept" in response.headers + for content_type in EMBED_WHITE_LIST_EXT: + assert content_type in response.headers["Accept"] + + assert "Access-Control-Allow-Methods" in response.headers + assert "POST" in response.headers["Access-Control-Allow-Methods"] + assert "OPTIONS" in response.headers["Access-Control-Allow-Methods"] + + assert "Access-Control-Allow-Headers" in response.headers + assert "Content-Type" in response.headers["Access-Control-Allow-Headers"] + + # Ensure the response body is empty + assert response.content == b"" + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_upload_long_file( + client_cls: Type[JamAI], +): + jamai = client_cls() + with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: + assert isinstance(table, p.TableMetaResponse) + with TemporaryDirectory() as tmp_dir: + # Create a long CSV + data = [ + {"bool": True, "float": 0.0, "int": 0, "str": ""}, + {"bool": False, "float": -1.0, "int": -2, "str": "testing"}, + {"bool": None, "float": None, "int": None, "str": None}, + ] + file_path = join(tmp_dir, "long.csv") + df_to_csv(pd.DataFrame.from_dict(data * 100), file_path) + # Embed the CSV + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + response = jamai.table.embed_file(file_path, table.id) + assert isinstance(response, p.OkResponse) + rows = jamai.table.list_table_rows("knowledge", table.id) + assert isinstance(rows.items, list) + assert all(isinstance(r, dict) for r in rows.items) + assert rows.total == 300 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) == 100 + assert all(isinstance(r["Title"]["value"], str) for r in rows.items) + 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) + + +if __name__ == "__main__": + test_get_conversation_thread(JamAI, p.TableType.action) diff --git a/clients/python/tests/oss/gen_table/test_table_ops.py b/clients/python/tests/oss/gen_table/test_table_ops.py new file mode 100644 index 0000000..6b3116e --- /dev/null +++ b/clients/python/tests/oss/gen_table/test_table_ops.py @@ -0,0 +1,2524 @@ +import re +from contextlib import contextmanager +from time import sleep +from typing import Generator, Type + +import pytest +from flaky import flaky +from pydantic import ValidationError + +from jamaibase import JamAI +from jamaibase import protocol as p +from jamaibase.exceptions import ResourceNotFoundError + +CLIENT_CLS = [JamAI] +TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] +REGULAR_COLUMN_DTYPES: list[str] = ["int", "float", "bool", "str"] +SAMPLE_DATA = { + "int": -1, + "float": -0.9, + "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"] + +TABLE_ID_A = "table_a" +TABLE_ID_B = "table_b" +TABLE_ID_C = "table_c" +TABLE_ID_X = "table_x" +TEXT = '"Arrival" is a 2016 American science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' +TEXT_CN = ( + '"Arrival" 《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年美国科幻剧情片,由丹尼斯·维伦纽瓦执导,埃里克·海瑟尔改编。' +) +TEXT_JP = '"Arrival" 「Arrivalã€ã¯ã€ãƒ‰ã‚¥ãƒ‹ãƒ»ãƒ´ã‚£ãƒ«ãƒŒãƒ¼ãƒ´ãŒç›£ç£ã—ã€ã‚¨ãƒªãƒƒã‚¯ãƒ»ãƒã‚¤ã‚»ãƒ©ãƒ¼ãŒè„šè‰²ã—ãŸ2016å¹´ã®ã‚¢ãƒ¡ãƒªã‚«ã®SFドラマ映画ã§ã™ã€‚' + + +@pytest.fixture(scope="module", autouse=True) +def setup(): + client = JamAI() + _delete_tables(client) + yield + _delete_tables(client) + + +def _delete_tables(jamai: JamAI): + batch_size = 100 + for table_type in TABLE_TYPES: + offset, total = 0, 1 + while offset < total: + tables = jamai.table.list_tables(table_type, offset=offset, limit=batch_size) + assert isinstance(tables.items, list) + for table in tables.items: + jamai.table.delete_table(table_type, table.id) + total = tables.total + offset += batch_size + + +def _get_chat_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="openai/gpt-4o-mini", capabilities=["chat"]) + return models[0] + + +def _get_image_models(jamai: JamAI) -> str: + models = jamai.model_names(prefer="openai/gpt-4o-mini", capabilities=["image"]) + return models + + +def _get_chat_only_model(jamai: JamAI) -> str: + chat_models = jamai.model_names(capabilities=["chat"]) + image_models = _get_image_models(jamai) + chat_only_models = [model for model in chat_models if model not in image_models] + return chat_only_models[0] + + +def _get_reranking_model(jamai: JamAI) -> str: + models = jamai.model_names(prefer="cohere/rerank-english-v3.0", capabilities=["rerank"]) + return models[0] + + +def _rerun_on_fs_error_with_delay(err, *args): + if "LanceError(IO): Generic LocalFileSystem error" in str(err): + sleep(1) + return True + return False + + +@contextmanager +def _create_table( + jamai: JamAI, + table_type: p.TableType, + table_id: str = TABLE_ID_A, + cols: list[p.ColumnSchemaCreate] | None = None, + chat_cols: list[p.ColumnSchemaCreate] | None = None, + embedding_model: str | None = None, + delete_first: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + cols = [ + p.ColumnSchemaCreate(id="good", dtype="bool"), + 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="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + # Interpolate string and non-string input columns + prompt="Summarise this in ${words} words:\n\n${inputs}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + p.ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + system_prompt="You are a concise assistant.", + # Interpolate file input column + prompt="${photo} \n\nWhat's in the image?", + temperature=0.001, + top_p=0.001, + max_tokens=300, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + + if table_type == p.TableType.action: + table = jamai.table.create_action_table( + p.ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + if embedding_model is None: + embedding_model = "" + table = jamai.table.create_knowledge_table( + p.KnowledgeTableSchemaCreate( + id=table_id, cols=cols, embedding_model=embedding_model + ) + ) + elif table_type == p.TableType.chat: + table = jamai.table.create_chat_table( + p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + jamai.table.delete_table(table_type, table_id) + + +@contextmanager +def _create_table_v2( + jamai: JamAI, + table_type: p.TableType, + table_id: str = TABLE_ID_A, + cols: list[p.ColumnSchemaCreate] | None = None, + chat_cols: list[p.ColumnSchemaCreate] | None = None, + llm_model: str = "", + embedding_model: str = "", + system_prompt: str = "", + prompt: str = "", + delete_first: bool = True, +) -> Generator[p.TableMetaResponse, None, None]: + try: + if delete_first: + jamai.table.delete_table(table_type, table_id) + if cols is None: + _input_cols = [ + p.ColumnSchemaCreate(id=f"in_{dtype}", dtype=dtype) + for dtype in REGULAR_COLUMN_DTYPES + ] + _output_cols = [ + p.ColumnSchemaCreate( + id=f"out_{dtype}", + dtype=dtype, + gen_config=p.LLMGenConfig( + model=llm_model, + system_prompt=system_prompt, + prompt=" ".join(f"${{{col.id}}}" for col in _input_cols) + prompt, + max_tokens=10, + ), + ) + for dtype in ["str"] + ] + cols = _input_cols + _output_cols + if chat_cols is None: + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=llm_model, + system_prompt=system_prompt, + max_tokens=10, + ), + ), + ] + + expected_cols = {"ID", "Updated at"} + expected_cols |= {c.id for c in cols} + if table_type == p.TableType.action: + table = jamai.table.create_action_table( + p.ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + table = jamai.table.create_knowledge_table( + p.KnowledgeTableSchemaCreate( + id=table_id, cols=cols, embedding_model=embedding_model + ) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + table = jamai.table.create_chat_table( + p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + ) + expected_cols |= {c.id for c in chat_cols} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + col_ids = set(c.id for c in table.cols) + assert col_ids == expected_cols + yield table + finally: + jamai.table.delete_table(table_type, table_id) + + +def _add_row( + jamai: JamAI, + table_type: p.TableType, + stream: bool, + table_name: str = TABLE_ID_A, + data: dict | None = None, + knowledge_data: dict | None = None, + chat_data: dict | None = None, +): + if data is None: + data = dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo="s3://bucket-images/rabbit.jpeg", + ) + + if knowledge_data is None: + knowledge_data = dict( + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + ) + if chat_data is None: + chat_data = dict(User="Tell me a joke.") + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + data.update(knowledge_data) + elif table_type == p.TableType.chat: + data.update(chat_data) + else: + raise ValueError(f"Invalid table type: {table_type}") + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + return response + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert len(response.rows) == 1 + return response.rows[0] + + +def _add_row_v2( + jamai: JamAI, + table_type: p.TableType, + stream: bool, + table_name: str = TABLE_ID_A, + data: dict | None = None, + knowledge_data: dict | None = None, + chat_data: dict | None = None, + include_output_data: bool = False, +) -> p.GenTableRowsChatCompletionChunks: + if data is None: + data = {f"in_{dtype}": SAMPLE_DATA[dtype] for dtype in REGULAR_COLUMN_DTYPES} + if include_output_data: + data.update({f"out_{dtype}": SAMPLE_DATA[dtype] for dtype in ["str"]}) + + if knowledge_data is None: + knowledge_data = dict( + Title="Dune: Part Two.", + Text='"Dune: Part Two" is a 2024 American epic science fiction film.', + ) + if include_output_data: + knowledge_data.update({"Title Embed": None, "Text Embed": None}) + if chat_data is None: + chat_data = dict(User="Tell me a joke.") + if include_output_data: + chat_data.update({"AI": "Nah"}) + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + data.update(knowledge_data) + elif table_type == p.TableType.chat: + data.update(chat_data) + else: + raise ValueError(f"Invalid table type: {table_type}") + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + chunks = [r for r in response] + assert all(isinstance(c, p.GenTableStreamChatCompletionChunk) for c in chunks) + assert all(c.object == "gen_table.completion.chunk" for c in chunks) + assert len(set(c.row_id for c in chunks)) == 1 + columns = {c.output_column_name: c for c in chunks} + return p.GenTableRowsChatCompletionChunks( + rows=[p.GenTableChatCompletionChunks(columns=columns, row_id=chunks[0].row_id)] + ) + assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert response.object == "gen_table.completion.rows" + assert len(response.rows) == 1 + return response + + +@contextmanager +def _rename_table( + jamai: JamAI, + table_type: p.TableType, + table_id_src: str, + table_id_dst: str, + delete_first: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id_dst) + table = jamai.table.rename_table(table_type, table_id_src, table_id_dst) + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + jamai.table.delete_table(table_type, table_id_dst) + + +@contextmanager +def _duplicate_table( + jamai: JamAI, + table_type: p.TableType, + table_id_src: str, + table_id_dst: str, + include_data: bool = True, + deploy: bool = False, + delete_first: bool = True, +): + try: + if delete_first: + jamai.table.delete_table(table_type, table_id_dst) + table = jamai.table.duplicate_table( + table_type, + table_id_src, + table_id_dst, + include_data=include_data, + create_as_child=deploy, + ) + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + jamai.table.delete_table(table_type, table_id_dst) + + +@contextmanager +def _create_child_table( + jamai: JamAI, + table_type: p.TableType, + table_id_src: str, + table_id_dst: str | None, + delete_first: bool = True, +): + try: + if delete_first and isinstance(table_id_dst, str): + jamai.table.delete_table(table_type, table_id_dst) + table = jamai.table.duplicate_table( + table_type, table_id_src, table_id_dst, create_as_child=True + ) + table_id_dst = table.id + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + if isinstance(table_id_dst, str): + jamai.table.delete_table(table_type, table_id_dst) + + +def _collect_text( + responses: p.GenTableRowsChatCompletionChunks + | Generator[p.GenTableStreamChatCompletionChunk, None, None], + col: str, +): + if isinstance(responses, p.GenTableRowsChatCompletionChunks): + return "".join(r.columns[col].text for r in responses.rows) + return "".join(r.text for r in responses if r.output_column_name == col) + + +@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) +def test_create_delete_table( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table_v2(jamai, table_type) as table_a: + with _create_table_v2(jamai, table_type, TABLE_ID_B) as table_b: + assert isinstance(table_a, p.TableMetaResponse) + assert table_a.id == TABLE_ID_A + assert table_b.id == TABLE_ID_B + assert isinstance(table_a.cols, list) + assert all(isinstance(c, p.ColumnSchema) for c in table_a.cols) + table = jamai.table.get_table(table_type, TABLE_ID_B) + assert isinstance(table, p.TableMetaResponse) + # After deleting table B + with pytest.raises(ResourceNotFoundError, match="is not found."): + jamai.table.get_table(table_type, TABLE_ID_B) + + +@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( + "table_id", ["a", "0", "a.b", "a-b", "a_b", "a-_b", "a-_0b", "a.-_0b", "0_0"] +) +def test_create_table_valid_table_id( + client_cls: Type[JamAI], + table_type: p.TableType, + table_id: str, +): + jamai = client_cls() + with _create_table(jamai, table_type, table_id) as table: + assert isinstance(table, p.TableMetaResponse) + assert table.id == table_id + + +@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) +def test_create_table_valid_column_id( + client_cls: Type[JamAI], + table_type: p.TableType, +): + table_id = TABLE_ID_A + col_ids = ["a", "0", "a b", "a-b", "a_b", "a-_b", "a-_0b", "a -_0b", "0_0"] + jamai = client_cls() + + # --- Test input column --- # + cols = [p.ColumnSchemaCreate(id=_id, dtype="str") for _id in col_ids] + with _create_table(jamai, table_type, table_id, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + assert len(set(col_ids) - {c.id for c in table.cols}) == 0 + + # --- Test output column --- # + cols = [ + p.ColumnSchemaCreate( + id=_id, + dtype="str", + gen_config=p.LLMGenConfig(), + ) + for _id in col_ids + ] + with _create_table(jamai, table_type, table_id, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + assert len(set(col_ids) - {c.id for c in table.cols}) == 0 + + +@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( + "column_id", ["a_", "_a", "_aa", "aa_", "_a_", "-a", "a-", ".a", "a.", "a.b", "a?b", "a" * 101] +) +def test_create_table_invalid_table_id( + client_cls: Type[JamAI], + table_type: p.TableType, + column_id: str, +): + table_id = TABLE_ID_A + jamai = client_cls() + + # --- Test input column --- # + cols = [ + p.ColumnSchemaCreate(id=column_id, dtype="str"), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + # --- Test output column --- # + cols = [ + p.ColumnSchemaCreate( + id=column_id, + dtype="str", + gen_config=p.LLMGenConfig(), + ), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + +@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( + "column_id", ["a_", "_a", "_aa", "aa_", "_a_", "-a", "a-", ".a", "a.", "a.b", "a?b", "a" * 101] +) +def test_create_table_invalid_column_id( + client_cls: Type[JamAI], + table_type: p.TableType, + column_id: str, +): + table_id = TABLE_ID_A + jamai = client_cls() + + # --- Test input column --- # + cols = [ + p.ColumnSchemaCreate(id=column_id, dtype="str"), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + # --- Test output column --- # + cols = [ + p.ColumnSchemaCreate( + id=column_id, + dtype="str", + gen_config=p.LLMGenConfig(), + ), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + +@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) +def test_create_table_invalid_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + table_id = TABLE_ID_A + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(model="INVALID"), + ), + ] + with pytest.raises(ResourceNotFoundError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + +@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) +def test_create_table_invalid_column_ref( + client_cls: Type[JamAI], + table_type: p.TableType, +): + table_id = TABLE_ID_A + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(prompt="Summarise ${input2}"), + ), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, table_id, cols=cols): + pass + + +@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) +def test_create_table_invalid_rag( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + + # Create the knowledge table first + with _create_table(jamai, "knowledge", TABLE_ID_B, cols=[]) as ktable: + # --- Valid knowledge table ID --- # + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id), + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # --- Invalid knowledge table ID --- # + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + rag_params=p.RAGParams(table_id="INVALID"), + ), + ), + ] + with pytest.raises(ResourceNotFoundError): + with _create_table(jamai, table_type, cols=cols): + pass + + # --- Valid reranker --- # + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + rag_params=p.RAGParams( + table_id=ktable.id, reranking_model=_get_reranking_model(jamai) + ), + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + + # --- Invalid reranker --- # + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id, reranking_model="INVALID"), + ), + ), + ] + with pytest.raises(ResourceNotFoundError): + with _create_table(jamai, table_type, cols=cols): + pass + + +@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) +def test_default_llm_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert isinstance(cols["output0"].gen_config.model, str) + assert len(cols["output0"].gen_config.model) > 0 + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert len(cols["AI"].gen_config.model) > 0 + + # --- Update gen config --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=None, + output1=p.LLMGenConfig(), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config is None + assert isinstance(cols["output1"].gen_config, p.GenConfig) + assert isinstance(cols["output1"].gen_config.model, str) + assert len(cols["output1"].gen_config.model) > 0 + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert len(cols["AI"].gen_config.model) > 0 + + # --- Add column --- # + cols = [ + p.ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=None, + ), + p.ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + ] + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + # Check gen configs + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config is None + assert isinstance(cols["output1"].gen_config, p.GenConfig) + assert isinstance(cols["output1"].gen_config.model, str) + assert len(cols["output1"].gen_config.model) > 0 + assert cols["output2"].gen_config is None + assert isinstance(cols["output3"].gen_config, p.GenConfig) + assert isinstance(cols["output3"].gen_config.model, str) + assert len(cols["output3"].gen_config.model) > 0 + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert len(cols["AI"].gen_config.model) > 0 + + +@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) +def test_default_image_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + available_image_models = _get_image_models(jamai) + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(prompt="${input0}"), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert isinstance(cols["output0"].gen_config.model, str) + assert cols["output0"].gen_config.model in available_image_models + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert cols["AI"].gen_config.model in available_image_models + + # --- Update gen config --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=None, + output1=p.LLMGenConfig(prompt="${input0}"), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config is None + assert isinstance(cols["output1"].gen_config, p.GenConfig) + assert isinstance(cols["output1"].gen_config.model, str) + assert cols["output1"].gen_config.model in available_image_models + + # --- Add column --- # + cols = [ + p.ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=p.LLMGenConfig(prompt="${input0}"), + ), + p.ColumnSchemaCreate(id="file_input1", dtype="file"), + p.ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=p.LLMGenConfig(prompt="${file_input1}"), + ), + ] + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + # Add a column with default prompt + cols = [ + p.ColumnSchemaCreate( + id="output4", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + ] + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + # Check gen configs + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config is None + for output_column_name in ["output1", "output2", "output3", "output4"]: + assert isinstance(cols[output_column_name].gen_config, p.GenConfig) + model = cols[output_column_name].gen_config.model + assert isinstance(model, str) + assert ( + model in available_image_models + ), f'Column {output_column_name} has invalid default model "{model}". Valid: {available_image_models}' + + +@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) +def test_invalid_image_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + available_image_models = _get_image_models(jamai) + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), + ), + ] + with pytest.raises(RuntimeError): + with _create_table(jamai, table_type, cols=cols) as table: + pass + + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="file"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(prompt="${input0}"), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert isinstance(cols["output0"].gen_config.model, str) + assert cols["output0"].gen_config.model in available_image_models + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert cols["AI"].gen_config.model in available_image_models + + # --- Update gen config --- # + with pytest.raises(RuntimeError): + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=p.LLMGenConfig( + model=_get_chat_only_model(jamai), + prompt="${input0}", + ), + ), + ), + ) + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=p.LLMGenConfig(prompt="${input0}"), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert isinstance(cols["output0"].gen_config.model, str) + assert cols["output0"].gen_config.model in available_image_models + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + assert isinstance(cols["AI"].gen_config.model, str) + assert cols["AI"].gen_config.model in available_image_models + + # --- Add column --- # + cols = [ + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=p.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), + ) + ] + with pytest.raises(RuntimeError): + if table_type == p.TableType.action: + table = jamai.table.add_action_columns( + p.AddActionColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_default_embedding_model( + client_cls: Type[JamAI], +): + jamai = client_cls() + with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: + assert isinstance(table, p.TableMetaResponse) + for col in table.cols: + if col.vlen == 0: + continue + assert len(col.gen_config.embedding_model) > 0 + + +@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) +def test_default_reranker( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + # Create the knowledge table first + with _create_table(jamai, "knowledge", TABLE_ID_B, cols=[]) as ktable: + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id, reranking_model=""), + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + cols = {c.id: c for c in table.cols} + reranking_model = cols["output0"].gen_config.rag_params.reranking_model + assert isinstance(reranking_model, str) + assert len(reranking_model) > 0 + + +@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( + "messages", + [ + [p.ChatEntry.system(""), p.ChatEntry.user("")], + [p.ChatEntry.user("")], + ], + ids=["system + user", "user only"], +) +def test_default_prompts( + client_cls: Type[JamAI], + table_type: p.TableType, + messages: list[p.ChatEntry], +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate(id="input1", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.ChatRequest(messages=messages), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=p.ChatRequest(messages=messages), + ), + p.ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=p.LLMGenConfig( + system_prompt="You are an assistant.", + prompt="Summarise ${input0}.", + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # ["output0", "output1"] should have default prompts + input_cols = {"input0", "input1"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + input_cols |= {"Title", "Text", "File ID"} + else: + input_cols |= {"User"} + cols = {c.id: c for c in table.cols} + for col_id in ["output0", "output1"]: + assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + user_prompt = cols[col_id].gen_config.prompt + referenced_cols = set(re.findall(p.GEN_CONFIG_VAR_PATTERN, user_prompt)) + assert ( + input_cols == referenced_cols + ), f"Expected input cols = {input_cols}, referenced cols = {referenced_cols}" + # ["output2"] should have provided prompts + input_cols = {"input0"} + cols = {c.id: c for c in table.cols} + for col_id in ["output2"]: + assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + user_prompt = cols[col_id].gen_config.prompt + referenced_cols = set(re.findall(p.GEN_CONFIG_VAR_PATTERN, user_prompt)) + assert ( + input_cols == referenced_cols + ), f"Expected input cols = {input_cols}, referenced cols = {referenced_cols}" + + # --- Add column --- # + cols = [ + p.ColumnSchemaCreate( + id="input2", + dtype="int", + ), + p.ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + ] + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + # ["output0", "output1"] should have default prompts + input_cols = {"input0", "input1"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + input_cols |= {"Title", "Text", "File ID"} + else: + input_cols |= {"User"} + cols = {c.id: c for c in table.cols} + for col_id in ["output0", "output1"]: + assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + user_prompt = cols[col_id].gen_config.prompt + referenced_cols = set(re.findall(p.GEN_CONFIG_VAR_PATTERN, user_prompt)) + assert ( + input_cols == referenced_cols + ), f"Expected input cols = {input_cols}, referenced cols = {referenced_cols}" + # ["output3"] should have default prompts + input_cols = {"input0", "input1", "input2"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + input_cols |= {"Title", "Text", "File ID"} + else: + input_cols |= {"User"} + for col_id in ["output3"]: + assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + user_prompt = cols[col_id].gen_config.prompt + referenced_cols = set(re.findall(p.GEN_CONFIG_VAR_PATTERN, user_prompt)) + assert ( + input_cols == referenced_cols + ), f"Expected input cols = {input_cols}, referenced cols = {referenced_cols}" + # ["output2"] should have provided prompts + input_cols = {"input0"} + for col_id in ["output2"]: + assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + user_prompt = cols[col_id].gen_config.prompt + referenced_cols = set(re.findall(p.GEN_CONFIG_VAR_PATTERN, user_prompt)) + assert ( + input_cols == referenced_cols + ), f"Expected input cols = {input_cols}, referenced cols = {referenced_cols}" + + +@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) +def test_add_drop_columns( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table_v2(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + _add_row_v2( + jamai, + table_type, + stream=False, + include_output_data=False, + ) + + # --- COLUMN ADD --- # + _input_cols = [ + p.ColumnSchemaCreate(id=f"add_in_{dtype}", dtype=dtype) + for dtype in REGULAR_COLUMN_DTYPES + ] + _output_cols = [ + p.ColumnSchemaCreate( + id=f"add_out_{dtype}", + dtype=dtype, + gen_config=p.LLMGenConfig( + model="", + system_prompt="", + prompt=" ".join(f"${{{col.id}}}" for col in _input_cols), + max_tokens=10, + ), + ) + for dtype in ["str"] + ] + cols = _input_cols + _output_cols + expected_cols = {"ID", "Updated at"} + expected_cols |= {f"in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES} + expected_cols |= {f"out_{dtype}" for dtype in ["str"]} + expected_cols |= {f"add_in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES} + expected_cols |= {f"add_out_{dtype}" for dtype in ["str"]} + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + # Existing row of new columns should contain None + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 1 + row = rows.items[0] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col["value"] is None + # Test adding a new row + data = {} + for dtype in REGULAR_COLUMN_DTYPES: + data[f"in_{dtype}"] = SAMPLE_DATA[dtype] + data[f"out_{dtype}"] = SAMPLE_DATA[dtype] + data[f"add_in_{dtype}"] = SAMPLE_DATA[dtype] + data[f"add_out_{dtype}"] = SAMPLE_DATA[dtype] + _add_row_v2(jamai, table_type, False, data=data) + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 2 + row = rows.items[0] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col["value"] is not None + + # --- COLUMN DROP --- # + table = jamai.table.drop_columns( + table_type, + p.ColumnDropRequest( + table_id=table.id, + column_names=[f"in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES] + + [f"out_{dtype}" for dtype in ["str"]], + ), + ) + expected_cols = {"ID", "Updated at"} + expected_cols |= {f"add_in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES} + expected_cols |= {f"add_out_{dtype}" for dtype in ["str"]} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + assert all(set(r.keys()) == expected_cols for r in rows.items) + # Test adding a new row + _add_row_v2(jamai, table_type, False, data=data) + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 3 + assert all(set(r.keys()) == expected_cols for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + + +@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) +def test_add_drop_file_column( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table_v2(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + _add_row_v2( + jamai, + table_type, + stream=False, + include_output_data=False, + ) + + # --- COLUMN ADD --- # + cols = [ + p.ColumnSchemaCreate(id="add_in_file", dtype="file"), + p.ColumnSchemaCreate( + id="add_out_str", + dtype="str", + gen_config=p.LLMGenConfig( + model="", + system_prompt="", + prompt="Describe image ${add_in_file}", + max_tokens=10, + ), + ), + ] + expected_cols = {"ID", "Updated at", "add_in_file", "add_out_str"} + expected_cols |= {f"in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES} + expected_cols |= {f"out_{dtype}" for dtype in ["str"]} + if table_type == p.TableType.action: + table = jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + table = jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + # Existing row of new columns should contain None + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 1 + row = rows.items[0] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col["value"] is None + # Test adding a new row + upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") + data = {"add_in_file": upload_response.uri} + for dtype in REGULAR_COLUMN_DTYPES: + data[f"in_{dtype}"] = SAMPLE_DATA[dtype] + response = _add_row_v2(jamai, table_type, False, data=data) + assert len(response.rows[0].columns["add_out_str"].text) > 0 + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 2 + row = rows.items[0] + for col_id, col in row.items(): + if not col_id.startswith("add_in_"): + continue + assert col["value"] is not None + + # Block file output column + with pytest.raises(RuntimeError): + cols = [ + p.ColumnSchemaCreate( + id="add_out_file", + dtype="file", + gen_config=p.LLMGenConfig( + model="", + system_prompt="", + prompt="Describe image ${add_in_file}", + max_tokens=10, + ), + ), + ] + if table_type == p.TableType.action: + jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == p.TableType.knowledge: + jamai.table.add_knowledge_columns( + p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == p.TableType.chat: + jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + + # --- COLUMN DROP --- # + table = jamai.table.drop_columns( + table_type, + p.ColumnDropRequest( + table_id=table.id, + column_names=[f"in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES] + + [f"out_{dtype}" for dtype in ["str"]], + ), + ) + expected_cols = {"ID", "Updated at", "add_in_file", "add_out_str"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 2 + assert all(set(r.keys()) == expected_cols for r in rows.items) + # Test adding a new row + _add_row_v2(jamai, table_type, False, data={"add_in_file": upload_response.uri}) + rows = jamai.table.list_table_rows(table_type, table.id) + assert isinstance(rows.items, list) + assert len(rows.items) == 3 + assert all(set(r.keys()) == expected_cols for r in rows.items), [ + list(r.keys()) for r in rows.items + ] + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_kt_drop_invalid_columns(client_cls: Type[JamAI]): + table_type = "knowledge" + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + for col in KT_FIXED_COLUMN_IDS: + with pytest.raises(RuntimeError): + jamai.table.drop_columns( + table_type, + p.ColumnDropRequest(table_id=table.id, column_names=[col]), + ) + + +@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) +def test_rename_columns( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="x", dtype="str"), + p.ColumnSchemaCreate( + id="y", + dtype="str", + gen_config=p.LLMGenConfig(prompt=r"Summarise ${x}, \${x}"), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + # Test rename on empty table + table = jamai.rename_columns( + table_type, + p.ColumnRenameRequest(table_id=table.id, column_map=dict(y="z")), + ) + assert isinstance(table, p.TableMetaResponse) + expected_cols = {"ID", "Updated at", "x", "z"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + cols = set(c.id for c in table.cols) + assert cols == expected_cols + + table = jamai.table.get_table(table_type, table.id) + assert isinstance(table, p.TableMetaResponse) + cols = set(c.id for c in table.cols) + assert cols == expected_cols + # Test adding data with new column names + _add_row(jamai, table_type, False, data=dict(x="True", z="")) + # Test rename table with data + # Test also auto gen config reference update + table = jamai.rename_columns( + table_type, + p.ColumnRenameRequest(table_id=table.id, column_map=dict(x="a")), + ) + assert isinstance(table, p.TableMetaResponse) + expected_cols = {"ID", "Updated at", "a", "z"} + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} + elif table_type == p.TableType.chat: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + cols = set(c.id for c in table.cols) + assert cols == expected_cols + table = jamai.table.get_table(table_type, table.id) + assert isinstance(table, p.TableMetaResponse) + cols = set(c.id for c in table.cols) + assert cols == expected_cols + # Test auto gen config reference update + cols = {c.id: c for c in table.cols} + prompt = cols["z"].gen_config.prompt + assert "${a}" in prompt + assert "\\${x}" in prompt # Escaped reference syntax + + # Repeated new column names + with pytest.raises(RuntimeError): + jamai.rename_columns( + table_type, + p.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="b")), + ) + + # Overlapping new and old column names + with pytest.raises(RuntimeError): + jamai.rename_columns( + table_type, + p.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="a")), + ) + + +@flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_kt_rename_invalid_columns(client_cls: Type[JamAI]): + table_type = "knowledge" + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + for col in KT_FIXED_COLUMN_IDS: + with pytest.raises(RuntimeError): + jamai.rename_columns( + table_type, + p.ColumnRenameRequest(table_id=table.id, column_map={col: col}), + ) + + +@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) +def test_reorder_columns( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + table = jamai.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, p.TableMetaResponse) + + column_names = [ + "inputs", + "good", + "words", + "stars", + "photo", + "summary", + "captioning", + ] + expected_order = [ + "ID", + "Updated at", + "good", + "words", + "stars", + "inputs", + "photo", + "summary", + "captioning", + ] + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + expected_order = ( + expected_order[:2] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + + expected_order[2:] + ) + elif table_type == p.TableType.chat: + column_names += ["User", "AI"] + expected_order = expected_order[:2] + ["User", "AI"] + expected_order[2:] + else: + raise ValueError(f"Invalid table type: {table_type}") + cols = [c.id for c in table.cols] + assert cols == expected_order, cols + # Test reorder empty table + table = jamai.reorder_columns( + table_type, + p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), + ) + expected_order = [ + "ID", + "Updated at", + "inputs", + "good", + "words", + "stars", + "photo", + "summary", + "captioning", + ] + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + elif table_type == p.TableType.chat: + expected_order += ["User", "AI"] + else: + raise ValueError(f"Invalid table type: {table_type}") + cols = [c.id for c in table.cols] + assert cols == expected_order, cols + table = jamai.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, p.TableMetaResponse) + cols = [c.id for c in table.cols] + assert cols == expected_order, cols + # Test add row + response = _add_row( + jamai, + table_type, + True, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT), + ) + summary = _collect_text(list(response), "summary") + assert len(summary) > 0 + + +@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) +def test_reorder_columns_invalid( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + table = jamai.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, p.TableMetaResponse) + + column_names = [ + "inputs", + "good", + "words", + "stars", + "photo", + "summary", + "captioning", + ] + expected_order = [ + "ID", + "Updated at", + "good", + "words", + "stars", + "inputs", + "photo", + "summary", + "captioning", + ] + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + expected_order = ( + expected_order[:2] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + + expected_order[2:] + ) + elif table_type == p.TableType.chat: + column_names += ["User", "AI"] + expected_order = expected_order[:2] + ["User", "AI"] + expected_order[2:] + else: + raise ValueError(f"Invalid table type: {table_type}") + cols = [c.id for c in table.cols] + assert cols == expected_order, cols + + # --- Test validation by putting "summary" on the left of "words" --- # + column_names = [ + "inputs", + "good", + "stars", + "summary", + "words", + "photo", + "captioning", + ] + if table_type == p.TableType.action: + pass + elif table_type == p.TableType.knowledge: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + elif table_type == p.TableType.chat: + column_names += ["User", "AI"] + else: + raise ValueError(f"Invalid table type: {table_type}") + with pytest.raises(RuntimeError, match="referenced an invalid source column"): + jamai.reorder_columns( + table_type, + p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), + ) + + +@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) +def test_update_gen_config( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.LLMGenConfig) + assert isinstance(cols["output0"].gen_config.system_prompt, str) + assert isinstance(cols["output0"].gen_config.prompt, str) + assert len(cols["output0"].gen_config.system_prompt) > 0 + assert len(cols["output0"].gen_config.prompt) > 0 + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Switch gen config --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=None, + output1=p.LLMGenConfig(), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config is None + assert isinstance(cols["output1"].gen_config, p.LLMGenConfig) + assert isinstance(cols["output1"].gen_config.system_prompt, str) + assert isinstance(cols["output1"].gen_config.prompt, str) + assert len(cols["output1"].gen_config.system_prompt) > 0 + assert len(cols["output1"].gen_config.prompt) > 0 + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Update gen config --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig(), + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert isinstance(cols["output1"].gen_config, p.GenConfig) + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Update gen config --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output1=None, + ), + ), + ) + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Chat AI column must always have gen config --- # + if table_type == p.TableType.chat: + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(AI=None), + ), + ) + assert isinstance(table, p.TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert cols["AI"].gen_config is not None + + # --- Chat AI column multi-turn must always be True --- # + if table_type == p.TableType.chat: + chat_cfg = {c.id: c for c in table.cols}["AI"].gen_config + chat_cfg.multi_turn = False + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(AI=chat_cfg), + ), + ) + assert isinstance(table, p.TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert cols["AI"].gen_config.multi_turn is True + + +@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) +def test_update_gen_config_invalid_model( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Update gen config --- # + with pytest.raises(ResourceNotFoundError): + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig(model="INVALID"), + ), + ), + ) + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig(model=_get_chat_model(jamai)), + ), + ), + ) + + +@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) +def test_update_gen_config_invalid_column_ref( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.LLMGenConfig) + assert isinstance(cols["output0"].gen_config.system_prompt, str) + assert isinstance(cols["output0"].gen_config.prompt, str) + assert len(cols["output0"].gen_config.system_prompt) > 0 + assert len(cols["output0"].gen_config.prompt) > 0 + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Update gen config --- # + with pytest.raises(RuntimeError): + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig(prompt="Summarise ${input2}"), + ), + ), + ) + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig(prompt="Summarise ${input0}"), + ), + ), + ) + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.LLMGenConfig) + assert isinstance(cols["output0"].gen_config.system_prompt, str) + assert isinstance(cols["output0"].gen_config.prompt, str) + assert len(cols["output0"].gen_config.system_prompt) > 0 + assert len(cols["output0"].gen_config.prompt) > 0 + + +@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) +def test_update_gen_config_invalid_rag( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="input0", dtype="str"), + p.ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=p.LLMGenConfig(), + ), + p.ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(jamai, "knowledge", cols=[]) as ktable: + assert isinstance(ktable, p.TableMetaResponse) + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, p.TableMetaResponse) + # Check gen configs + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config, p.GenConfig) + assert cols["output1"].gen_config is None + if table_type == p.TableType.chat: + assert isinstance(cols["AI"].gen_config, p.GenConfig) + + # --- Invalid knowledge table ID --- # + with pytest.raises(ResourceNotFoundError): + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig( + rag_params=p.RAGParams(table_id="INVALID"), + ), + ), + ), + ) + # --- Valid knowledge table ID --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id), + ), + ), + ), + ) + + # --- Invalid reranker --- # + with pytest.raises(ResourceNotFoundError): + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig( + rag_params=p.RAGParams( + table_id=ktable.id, reranking_model="INVALID" + ), + ), + ), + ), + ) + # --- Valid reranker --- # + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id, reranking_model=None), + ), + ), + ), + ) + cols = {c.id: c for c in table.cols} + assert cols["output0"].gen_config.rag_params.reranking_model is None + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output0=p.LLMGenConfig( + rag_params=p.RAGParams(table_id=ktable.id, reranking_model=""), + ), + ), + ), + ) + cols = {c.id: c for c in table.cols} + assert isinstance(cols["output0"].gen_config.rag_params.reranking_model, str) + assert len(cols["output0"].gen_config.rag_params.reranking_model) > 0 + + +@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_null_gen_config( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + table = jamai.table.update_gen_config( + table_type, + p.GenConfigUpdateRequest(table_id=table.id, column_map=dict(summary=None)), + ) + response = _add_row( + jamai, table_type, stream, data=dict(good=True, words=5, stars=9.9, inputs=TEXT) + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + else: + assert isinstance(response, 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] + assert row["summary"]["value"] is None + + +@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) +def test_invalid_referenced_column( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + # --- Non-existent column --- # + cols = [ + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt="Summarise ${inputs}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with pytest.raises(RuntimeError, match="invalid source column"): + with _create_table(jamai, table_type, cols=cols): + pass + + # --- Vector column --- # + cols = [ + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + prompt="Summarise ${Text Embed}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ).model_dump(), + ), + ] + with pytest.raises(RuntimeError, match="invalid source column"): + with _create_table(jamai, table_type, cols=cols): + pass + + +@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_gen_config_empty_prompts( + client_cls: Type[JamAI], + table_type: p.TableType, + stream: bool, +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + with _create_table(jamai, table_type, cols=cols, chat_cols=chat_cols) as table: + assert isinstance(table, p.TableMetaResponse) + data = dict(words=5) + if table_type == p.TableType.knowledge: + data["Title"] = "Dune: Part Two." + data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." + response = jamai.table.add_table_rows( + table_type, + p.RowAddRequest(table_id=table.id, data=[data], stream=stream), + ) + if stream: + # Must wait until stream ends + responses = [r for r in response] + assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + summary = "".join(r.text for r in responses if r.output_column_name == "summary") + assert len(summary) > 0 + if table_type == p.TableType.chat: + ai = "".join(r.text for r in responses if r.output_column_name == "AI") + assert len(ai) > 0 + else: + assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_gen_config_no_message( + client_cls: Type[JamAI], +): + jamai = client_cls() + with pytest.raises(ValidationError, match="at least 1 item"): + _ = [ + p.ColumnSchemaCreate(id="words", dtype="int"), + p.ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=p.ChatRequest( + model=_get_chat_model(jamai), + messages=[], + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + + +@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) +def test_get_and_list_tables( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + _delete_tables(jamai) + with ( + _create_table(jamai, table_type) as table, + _create_table(jamai, table_type, TABLE_ID_B), + _create_table(jamai, table_type, TABLE_ID_C), + _create_table(jamai, table_type, TABLE_ID_X), + ): + assert isinstance(table, p.TableMetaResponse) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + # Regular case + table = jamai.table.get_table(table_type, TABLE_ID_B) + assert isinstance(table, p.TableMetaResponse) + assert table.id == TABLE_ID_B + + tables = jamai.table.list_tables(table_type) + assert isinstance(tables.items, list) + assert tables.total == 4 + assert tables.offset == 0 + assert tables.limit == 100 + assert len(tables.items) == 4 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + + # Test various offset and limit + tables = jamai.table.list_tables(table_type, offset=3, limit=2) + assert isinstance(tables.items, list) + assert tables.total == 4 + assert tables.offset == 3 + assert tables.limit == 2 + assert len(tables.items) == 1 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + + tables = jamai.table.list_tables(table_type, offset=4, limit=2) + assert isinstance(tables.items, list) + assert tables.total == 4 + assert tables.offset == 4 + assert tables.limit == 2 + assert len(tables.items) == 0 + + tables = jamai.table.list_tables(table_type, offset=5, limit=2) + assert isinstance(tables.items, list) + assert tables.total == 4 + assert tables.offset == 5 + assert tables.limit == 2 + assert len(tables.items) == 0 + + +@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) +def test_table_search_and_parent_id( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + _delete_tables(jamai) + with ( + _create_table(jamai, table_type, "beast") as table, + _create_table(jamai, table_type, "feast"), + _create_table(jamai, table_type, "bear"), + _create_table(jamai, table_type, "fear"), + ): + assert isinstance(table, p.TableMetaResponse) + with ( + _create_child_table(jamai, table_type, "beast", "least"), + _create_child_table(jamai, table_type, "beast", "lease"), + _create_child_table(jamai, table_type, "beast", "yeast"), + ): + # Regular list + tables = jamai.table.list_tables(table_type, limit=3) + assert isinstance(tables.items, list) + assert tables.total == 7 + assert tables.offset == 0 + assert tables.limit == 3 + assert len(tables.items) == 3 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + # Search + tables = jamai.table.list_tables(table_type, search_query="be", limit=3) + assert isinstance(tables.items, list) + assert tables.total == 2 + assert tables.offset == 0 + assert tables.limit == 3 + assert len(tables.items) == 2 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + # Search + tables = jamai.table.list_tables(table_type, search_query="ast", limit=3) + assert isinstance(tables.items, list) + assert tables.total == 4 + assert tables.offset == 0 + assert tables.limit == 3 + assert len(tables.items) == 3 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + # Search with parent ID + tables = jamai.table.list_tables(table_type, search_query="ast", parent_id="beast") + assert isinstance(tables.items, list) + assert tables.total == 2 + assert tables.offset == 0 + assert tables.limit == 100 + assert len(tables.items) == 2 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + # Search with parent ID + tables = jamai.table.list_tables(table_type, search_query="as", parent_id="beast") + assert isinstance(tables.items, list) + assert tables.total == 3 + assert tables.offset == 0 + assert tables.limit == 100 + assert len(tables.items) == 3 + assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + + +@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) +def test_duplicate_table( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + # Duplicate with data + with _duplicate_table(jamai, table_type, TABLE_ID_A, TABLE_ID_B) as table: + # Add another to table A + _add_row( + jamai, + table_type, + False, + table_name=TABLE_ID_A, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + assert table.id == TABLE_ID_B + rows = jamai.table.list_table_rows(table_type, TABLE_ID_B) + assert len(rows.items) == 1 + + # Duplicate without data + with _duplicate_table( + jamai, table_type, TABLE_ID_A, TABLE_ID_C, include_data=False + ) as table: + assert table.id == TABLE_ID_C + rows = jamai.table.list_table_rows(table_type, TABLE_ID_C) + assert len(rows.items) == 0 + + # Deploy with data + with _duplicate_table(jamai, table_type, TABLE_ID_A, TABLE_ID_B, deploy=True) as table: + assert table.id == TABLE_ID_B + assert table.parent_id == TABLE_ID_A + rows = jamai.table.list_table_rows(table_type, TABLE_ID_B) + assert len(rows.items) == 2 + + # Deploy will always include data + with _duplicate_table( + jamai, table_type, TABLE_ID_A, TABLE_ID_C, deploy=True, include_data=False + ) as table: + assert table.id == TABLE_ID_C + assert table.parent_id == TABLE_ID_A + rows = jamai.table.list_table_rows(table_type, TABLE_ID_C) + assert len(rows.items) == 2 + + +@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( + "table_id_dst", + ["a_", "_a", "_aa", "aa_", "_a_", "-a", "a-", ".a", "a.", "a?b", "a b", "a" * 101], +) +def test_duplicate_table_invalid_name( + client_cls: Type[JamAI], + table_type: p.TableType, + table_id_dst: str, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table: + assert isinstance(table, p.TableMetaResponse) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + with pytest.raises(RuntimeError): + with _duplicate_table(jamai, table_type, TABLE_ID_A, table_id_dst): + pass + + +@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) +def test_create_child_table( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type) as table_a: + assert isinstance(table_a, p.TableMetaResponse) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + # Duplicate with data + with _create_child_table(jamai, table_type, TABLE_ID_A, TABLE_ID_B) as table_b: + assert isinstance(table_b, p.TableMetaResponse) + # Add another to table A + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + assert table_b.id == TABLE_ID_B + # Ensure the the parent id meta data has been correctly set. + assert table_b.parent_id == TABLE_ID_A + rows = jamai.table.list_table_rows(table_type, TABLE_ID_B) + assert len(rows.items) == 1 + + # Create child table with no dst id + with _create_child_table(jamai, table_type, TABLE_ID_A, None) as table_c: + assert isinstance(table_c.id, str) + assert table_c.id.startswith(TABLE_ID_A) + assert table_c.id != TABLE_ID_A + # Ensure the the parent id meta data has been correctly set. + assert table_c.parent_id == TABLE_ID_A + rows = jamai.table.list_table_rows(table_type, table_c.id) + assert len(rows.items) == 2 + + +@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) +def test_rename_table( + client_cls: Type[JamAI], + table_type: p.TableType, +): + jamai = client_cls() + with _create_table(jamai, table_type, TABLE_ID_A) as table: + assert isinstance(table, p.TableMetaResponse) + _add_row( + jamai, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + # Create child table + with _create_child_table(jamai, table_type, TABLE_ID_A, TABLE_ID_B) as child: + assert isinstance(child, p.TableMetaResponse) + # Rename + with _rename_table(jamai, table_type, TABLE_ID_A, TABLE_ID_C) as table: + rows = jamai.table.list_table_rows(table_type, TABLE_ID_C) + assert len(rows.items) == 1 + # Assert the old table is gone + with pytest.raises(ResourceNotFoundError): + jamai.table.list_table_rows(table_type, TABLE_ID_A) + # Assert the child table parent ID is updated + assert jamai.table.get_table(table_type, child.id).parent_id == TABLE_ID_C + # Add rows to both tables + _add_row( + jamai, + table_type, + False, + TABLE_ID_B, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + _add_row( + jamai, + table_type, + False, + TABLE_ID_C, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +def test_chat_table_gen_config( + client_cls: Type[JamAI], +): + jamai = client_cls() + cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=_get_chat_model(jamai), + system_prompt="You are a concise assistant.", + multi_turn=False, + temperature=0.001, + top_p=0.001, + max_tokens=20, + ), + ), + ] + with _create_table(jamai, "chat", cols=[], chat_cols=cols) as table: + cols = {c.id: c for c in table.cols} + # AI column gen config will be multi turn regardless of input params + assert cols["AI"].gen_config.multi_turn is True + + +if __name__ == "__main__": + test_add_drop_columns(JamAI, p.TableType.action) diff --git a/clients/python/tests/oss/test_admin.py b/clients/python/tests/oss/test_admin.py new file mode 100644 index 0000000..c7eb4bb --- /dev/null +++ b/clients/python/tests/oss/test_admin.py @@ -0,0 +1,79 @@ +from contextlib import asynccontextmanager +from typing import Type + +import pytest + +from jamaibase import JamAI, JamAIAsync +from jamaibase.protocol import LLMModelConfig, ModelDeploymentConfig, ModelListConfig, OkResponse +from jamaibase.utils import run + +CLIENT_CLS = [JamAI, JamAIAsync] +ORG_ID = "default" + + +@asynccontextmanager +async def _set_org_model_config( + jamai: JamAI | JamAIAsync, + org_id: str, + config: ModelListConfig, +): + old_config = await run(jamai.admin.organization.get_org_model_config, org_id) + try: + response = await run(jamai.admin.organization.set_org_model_config, org_id, config) + assert isinstance(response, OkResponse) + yield response + finally: + await run(jamai.admin.organization.set_org_model_config, org_id, old_config) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_set_org_model_config( + client_cls: Type[JamAI | JamAIAsync], +): + jamai = client_cls() + # Get model config + config = await run(jamai.admin.organization.get_org_model_config, ORG_ID) + assert isinstance(config, ModelListConfig) + assert isinstance(config.models, list) + assert len(config.models) == 0 + assert isinstance(config.llm_models, list) + assert isinstance(config.embed_models, list) + assert isinstance(config.rerank_models, list) + assert len(config.llm_models) == 0 + assert len(config.embed_models) == 0 + assert len(config.rerank_models) == 0 + llm_model_ids = [m.id for m in config.llm_models] + assert "ellm/new_model" not in llm_model_ids + model_ids = await run(jamai.model_names, capabilities=["chat"]) + assert "ellm/new_model" not in model_ids + # Set + new_config = config.model_copy(deep=True) + new_config.llm_models.append( + LLMModelConfig( + id="ellm/new_model", + name="ELLM New Model", + deployments=[ + ModelDeploymentConfig( + provider="ellm", + ) + ], + context_length=8000, + languages=["mul"], + capabilities=["chat"], + owned_by="ellm", + ) + ) + async with _set_org_model_config(jamai, ORG_ID, new_config) as response: + assert isinstance(response, OkResponse) + # Fetch again + new_config = await run(jamai.admin.organization.get_org_model_config, ORG_ID) + assert isinstance(new_config, ModelListConfig) + assert len(new_config.llm_models) == 1 + assert len(new_config.embed_models) == 0 + assert len(new_config.rerank_models) == 0 + llm_model_ids = [m.id for m in new_config.llm_models] + assert "ellm/new_model" in llm_model_ids + # Fetch model list + models = await run(jamai.model_names, capabilities=["chat"]) + assert isinstance(models, list) + assert "ellm/new_model" in models diff --git a/clients/python/tests/test_chat.py b/clients/python/tests/oss/test_chat.py similarity index 65% rename from clients/python/tests/test_chat.py rename to clients/python/tests/oss/test_chat.py index 82e62d8..edd867e 100644 --- a/clients/python/tests/test_chat.py +++ b/clients/python/tests/oss/test_chat.py @@ -1,5 +1,4 @@ -from asyncio.coroutines import iscoroutine -from typing import AsyncGenerator, Generator, Type +from typing import Type import pytest from flaky import flaky @@ -7,43 +6,19 @@ from jamaibase import JamAI, JamAIAsync from jamaibase import protocol as p +from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] -async def run(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - return await ret - return ret - - -async def run_gen_async(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - ret = await ret - if isinstance(ret, AsyncGenerator): - async for item in ret: - yield item - else: - yield ret - - -def run_gen_sync(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if isinstance(ret, Generator): - for item in ret: - yield item - else: - yield ret - - @pytest.mark.parametrize("client_cls", CLIENT_CLS) -async def test_model_info(client_cls: Type[JamAI | JamAIAsync]): - jamai = client_cls(project_id="", api_key="") - response = await run(jamai.model_info) +async def test_model_info( + client_cls: Type[JamAI | JamAIAsync], +): + jamai = client_cls() # Get all model info + response = await run(jamai.model_info) assert isinstance(response, p.ModelInfoResponse) assert len(response.data) > 0 assert isinstance(response.data[0], p.ModelInfo) @@ -92,8 +67,10 @@ async def test_model_info(client_cls: Type[JamAI | JamAIAsync]): @pytest.mark.parametrize("client_cls", CLIENT_CLS) -async def test_model_names(client_cls: Type[JamAI | JamAIAsync]): - jamai = client_cls(project_id="", api_key="") +async def test_model_names( + client_cls: Type[JamAI | JamAIAsync], +): + jamai = client_cls() response = await run(jamai.model_names) logger.info(f"response: {response}") logger.info(f"type(response): {type(response)}") @@ -120,7 +97,7 @@ async def test_model_names(client_cls: Type[JamAI | JamAIAsync]): response = await run(jamai.model_names, capabilities=["chat"]) assert isinstance(response, list) name_cat = ",".join(response) - assert "gpt-3.5-turbo" in name_cat + assert "gpt-4o-mini" in name_cat assert "embedding" not in name_cat assert "rerank" not in name_cat @@ -134,14 +111,14 @@ async def test_model_names(client_cls: Type[JamAI | JamAIAsync]): response = await run(jamai.model_names, capabilities=["embed"]) assert isinstance(response, list) name_cat = ",".join(response) - assert "gpt-3.5-turbo" not in name_cat + assert "gpt-4o-mini" not in name_cat assert "embedding" in name_cat assert "rerank" not in name_cat response = await run(jamai.model_names, capabilities=["rerank"]) assert isinstance(response, list) name_cat = ",".join(response) - assert "gpt-3.5-turbo" not in name_cat + assert "gpt-4o-mini" not in name_cat assert "embedding" not in name_cat assert "rerank" in name_cat @@ -152,7 +129,7 @@ def _get_chat_request(model: str, **kwargs): model=model, messages=[ p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user(f"What is a llama?"), + p.ChatEntry.user("What is a llama?"), ], temperature=0.001, top_p=0.001, @@ -162,60 +139,65 @@ def _get_chat_request(model: str, **kwargs): return request -def _get_models() -> list[str]: - models = JamAI(project_id="", api_key="").model_names(capabilities=["chat"]) - providers = list(set(m.split("/")[0] for m in models)) +def _get_models(return_all: bool = False) -> list[str]: + models = JamAI().model_names(capabilities=["chat"]) + if return_all: + return models + providers = sorted(set(m.split("/")[0] for m in models)) selected = [] for provider in providers: - if provider.startswith("ellm"): - continue selected.append([m for m in models if m.startswith(provider)][0]) return selected @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("model", _get_models()) -async def test_chat_completion(client_cls: Type[JamAI | JamAIAsync], model: str): - jamai = client_cls(project_id="", api_key="") +@pytest.mark.parametrize("model", _get_models(return_all=True)) +async def test_chat_completion( + client_cls: Type[JamAI | JamAIAsync], + model: str, +): + jamai = client_cls() # Non-streaming request = _get_chat_request(model, stream=False) - if isinstance(jamai, JamAIAsync): - response = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - response = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] - assert len(response) == 1 - response = response[0] + response = await run(jamai.generate_chat_completions, request) assert isinstance(response, p.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 assert isinstance(response.usage, p.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.usage.total_tokens == response.prompt_tokens + response.completion_tokens assert response.references is None # Streaming request.stream = True - if isinstance(jamai, JamAIAsync): - responses = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - responses = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] + 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)) > 1 assert all(r.references is None for r in responses) - assert isinstance(response.usage, p.CompletionUsage) - assert isinstance(response.prompt_tokens, int) - assert isinstance(response.completion_tokens, int) + 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 @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("model", _get_models()) -async def test_chat_opener(client_cls: Type[JamAI | JamAIAsync], model: str): - jamai = client_cls(project_id="", api_key="") +async def test_chat_opener( + client_cls: Type[JamAI | JamAIAsync], + model: str, +): + jamai = client_cls() # Non-streaming request = p.ChatRequest( @@ -223,23 +205,18 @@ async def test_chat_opener(client_cls: Type[JamAI | JamAIAsync], model: str): model=model, messages=[ p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.assistant("Hi, I am Sam. How may I help you?"), - p.ChatEntry.user("What is your first message?"), + p.ChatEntry.assistant("Sam has 7 apples."), + p.ChatEntry.user("How many apples does Sam have?"), ], temperature=0.001, top_p=0.001, max_tokens=30, stream=False, ) - if isinstance(jamai, JamAIAsync): - response = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - response = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] - assert len(response) == 1 - response = response[0] + response = await run(jamai.generate_chat_completions, request) assert isinstance(response, p.ChatCompletionChunk) assert isinstance(response.text, str) - assert "Sam" in response.text + assert "7" in response.text or "seven" in response.text.lower() assert len(response.text) > 1 assert isinstance(response.usage, p.CompletionUsage) assert isinstance(response.prompt_tokens, int) @@ -248,14 +225,11 @@ async def test_chat_opener(client_cls: Type[JamAI | JamAIAsync], model: str): # Streaming request.stream = True - if isinstance(jamai, JamAIAsync): - responses = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - responses = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] + 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 "Sam" in "".join(r.text for r in responses) + assert "7" in response.text or "seven" in response.text.lower() assert all(r.references is None for r in responses) assert isinstance(response.usage, p.CompletionUsage) assert isinstance(response.prompt_tokens, int) @@ -265,8 +239,11 @@ async def test_chat_opener(client_cls: Type[JamAI | JamAIAsync], model: str): @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("model", _get_models()) -async def test_chat_user_only(client_cls: Type[JamAI | JamAIAsync], model: str): - jamai = client_cls(project_id="", api_key="") +async def test_chat_user_only( + client_cls: Type[JamAI | JamAIAsync], + model: str, +): + jamai = client_cls() # Non-streaming request = p.ChatRequest( @@ -278,12 +255,7 @@ async def test_chat_user_only(client_cls: Type[JamAI | JamAIAsync], model: str): max_tokens=30, stream=False, ) - if isinstance(jamai, JamAIAsync): - response = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - response = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] - assert len(response) == 1 - response = response[0] + response = await run(jamai.generate_chat_completions, request) assert isinstance(response, p.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 @@ -294,10 +266,7 @@ async def test_chat_user_only(client_cls: Type[JamAI | JamAIAsync], model: str): # Streaming request.stream = True - if isinstance(jamai, JamAIAsync): - responses = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - responses = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] + 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) @@ -311,8 +280,11 @@ async def test_chat_user_only(client_cls: Type[JamAI | JamAIAsync], model: str): @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @pytest.mark.parametrize("model", _get_models()) -async def test_chat_system_only(client_cls: Type[JamAI | JamAIAsync], model: str): - jamai = client_cls(project_id="", api_key="") +async def test_chat_system_only( + client_cls: Type[JamAI | JamAIAsync], + model: str, +): + jamai = client_cls() # Non-streaming request = p.ChatRequest( @@ -324,12 +296,7 @@ async def test_chat_system_only(client_cls: Type[JamAI | JamAIAsync], model: str max_tokens=30, stream=False, ) - if isinstance(jamai, JamAIAsync): - response = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - response = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] - assert len(response) == 1 - response = response[0] + response = await run(jamai.generate_chat_completions, request) assert isinstance(response, p.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 @@ -340,10 +307,7 @@ async def test_chat_system_only(client_cls: Type[JamAI | JamAIAsync], model: str # Streaming request.stream = True - if isinstance(jamai, JamAIAsync): - responses = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - responses = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] + 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) @@ -356,9 +320,12 @@ async def test_chat_system_only(client_cls: Type[JamAI | JamAIAsync], model: str @flaky(max_runs=3, min_passes=1) @pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("model", ["openai/gpt-3.5-turbo"]) -async def test_long_chat_completion(client_cls: Type[JamAI | JamAIAsync], model: str): - jamai = client_cls(project_id="", api_key="") +@pytest.mark.parametrize("model", ["openai/gpt-4o-mini"]) +async def test_long_chat_completion( + client_cls: Type[JamAI | JamAIAsync], + model: str, +): + jamai = client_cls() # Streaming request = p.ChatRequest( @@ -366,24 +333,24 @@ async def test_long_chat_completion(client_cls: Type[JamAI | JamAIAsync], model: model=model, messages=[ p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user(" ".join(["What is a llama?"] * 5000)), + p.ChatEntry.user(" ".join(["What is a llama?"] * 50000)), ], temperature=0.001, top_p=0.001, max_tokens=50, stream=True, ) - if isinstance(jamai, JamAIAsync): - responses = [r async for r in run_gen_async(jamai.generate_chat_completions, request)] - else: - responses = [r for r in run_gen_sync(jamai.generate_chat_completions, request)] - assert len(responses) > 0 + responses = await run(jamai.generate_chat_completions, request) + assert len(responses) == 1 assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) - assert responses[-1].finish_reason == "error" + completion = responses[0] + assert completion.finish_reason == "error" + assert "ContextWindowExceededError" in completion.text assert all(r.references is None for r in responses) if __name__ == "__main__": import asyncio - asyncio.run(test_chat_opener(JamAI, model="anthropic/claude-3.5-sonnet")) + asyncio.run(test_chat_completion(JamAI, model="openai/gpt-4o-mini")) + asyncio.run(test_chat_completion(JamAIAsync, model="openai/gpt-4o-mini")) diff --git a/clients/python/tests/test_embeddings.py b/clients/python/tests/oss/test_embeddings.py similarity index 72% rename from clients/python/tests/test_embeddings.py rename to clients/python/tests/oss/test_embeddings.py index 69c58ca..13a3a01 100644 --- a/clients/python/tests/test_embeddings.py +++ b/clients/python/tests/oss/test_embeddings.py @@ -1,50 +1,21 @@ import base64 -from asyncio.coroutines import iscoroutine -from typing import AsyncGenerator, Generator, Type +from typing import Type import numpy as np import pytest from jamaibase import JamAI, JamAIAsync from jamaibase import protocol as p +from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] -async def run(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - return await ret - return ret - - -async def run_gen_async(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - ret = await ret - if isinstance(ret, AsyncGenerator): - async for item in ret: - yield item - else: - yield ret - - -def run_gen_sync(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if isinstance(ret, Generator): - for item in ret: - yield item - else: - yield ret - - def _get_models() -> list[str]: - models = JamAI(project_id="", api_key="").model_names(capabilities=["embed"]) - providers = list(set(m.split("/")[0] for m in models)) + models = JamAI().model_names(capabilities=["embed"]) + providers = sorted(set(m.split("/")[0] for m in models)) selected = [] for provider in providers: - if provider == "ellm": - continue selected.append([m for m in models if m.startswith(provider)][0]) return selected @@ -60,7 +31,7 @@ async def test_generate_embeddings( model: str, inputs: list[str] | str, ): - jamai = client_cls(project_id="", api_key="") + jamai = client_cls() kwargs = { "input": inputs, "model": model, diff --git a/clients/python/tests/oss/test_file.py b/clients/python/tests/oss/test_file.py new file mode 100644 index 0000000..279bedf --- /dev/null +++ b/clients/python/tests/oss/test_file.py @@ -0,0 +1,186 @@ +import os +import re +import tempfile +from io import BytesIO +from typing import Type +from urllib.parse import urlparse + +import httpx +import numpy as np +import pytest +from PIL import Image + +from jamaibase import JamAI, JamAIAsync +from jamaibase.protocol import ( + FileUploadResponse, + GetURLResponse, +) +from jamaibase.utils import run +from jamaibase.utils.io import generate_thumbnail + + +def read_file_content(file_path): + with open(file_path, "rb") as f: + return f.read() + + +# Define the paths to your test image files +IMAGE_FILES = [ + "clients/python/tests/files/jpeg/cifar10-deer.jpg", + "clients/python/tests/files/png/rabbit.png", + "clients/python/tests/files/gif/rabbit_cifar10-deer.gif", + "clients/python/tests/files/webp/rabbit_cifar10-deer.webp", +] + +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): + # Initialize the client + jamai = client_cls() + + # Ensure the image file exists + assert os.path.exists(image_file), f"Test image file does not exist: {image_file}" + # Upload the file + upload_response = await run(jamai.file.upload_file, image_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(image_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() + + # Create 25MB image file, assuming 3 bytes per pixel (RGB) and 8 bits per byte + side_length = int(np.sqrt((25 * 1024 * 1024) / 3)) + data = np.random.randint(0, 256, (side_length, side_length, 3), dtype=np.uint8) + img = Image.fromarray(data, "RGB") + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "large_image.png") + img.save(file_path, format="PNG") + + pattern = re.compile("File size exceeds .+ limit") + with pytest.raises(RuntimeError, match=pattern): + await run(jamai.file.upload_file, file_path) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_raw_urls(client_cls: Type[JamAI | JamAIAsync]): + jamai = client_cls() + # Upload files first + uploaded_uris = [] + for file in IMAGE_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): + 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 + + # Read the original file content + original_content = read_file_content(original_file) + + # Compare the contents + assert ( + original_content == downloaded_content + ), f"Content mismatch for file: {original_file}" + + # Check if the returned URIs are absolute paths + for url in response.urls: + parsed_uri = urlparse(url) + + if parsed_uri.scheme in ("http", "https"): + assert parsed_uri.netloc, f"Invalid HTTP/HTTPS URL: {url}" + elif parsed_uri.scheme == "file" or not parsed_uri.scheme: + file_path = parsed_uri.path if parsed_uri.scheme == "file" else url + assert os.path.isabs(file_path), f"File path is not absolute: {url}" + else: + raise ValueError(f"Unsupported URI or file not found: {url}") + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): + jamai = client_cls() + + # Upload files first + uploaded_uris = [] + for file in IMAGE_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) + + # Generate thumbnails and compare + for original_file, url in zip(IMAGE_FILES, response.urls, strict=True): + # Read original file content + original_content = read_file_content(original_file) + + # Generate thumbnail + expected_thumbnail = generate_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 + assert ( + expected_thumbnail == downloaded_thumbnail + ), f"Thumbnail mismatch for file: {original_file}" + + # Check if the returned URIs are valid + for url in response.urls: + parsed_uri = urlparse(url) + + if parsed_uri.scheme in ("http", "https"): + assert parsed_uri.netloc, f"Invalid HTTP/HTTPS URL: {url}" + else: + raise ValueError(f"Unsupported URI or file not found: {url}") + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_thumbnail_transparency(client_cls: Type[JamAI | JamAIAsync]): + jamai = client_cls() + response = await run( + jamai.file.upload_file, "clients/python/tests/files/png/github-mark-white.png" + ) + response = await run(jamai.file.get_thumbnail_urls, [response.uri]) + assert isinstance(response, GetURLResponse) + assert len(response.urls) == 1 + thumb_url = response.urls[0] + if thumb_url.startswith(("http://", "https://")): + downloaded_thumbnail = httpx.get(thumb_url, headers={"X-PROJECT-ID": "default"}).content + else: + downloaded_thumbnail = read_file_content(thumb_url) + + image = Image.open(BytesIO(downloaded_thumbnail)) + assert image.mode == "RGBA" diff --git a/clients/python/tests/oss/test_gen_executor.py b/clients/python/tests/oss/test_gen_executor.py new file mode 100644 index 0000000..dbdce44 --- /dev/null +++ b/clients/python/tests/oss/test_gen_executor.py @@ -0,0 +1,543 @@ +import asyncio +import time +from contextlib import asynccontextmanager + +import pytest +from flaky import flaky + +from jamaibase import JamAI, JamAIAsync +from jamaibase.exceptions import ResourceNotFoundError +from jamaibase.protocol import ( + ColumnSchemaCreate, + GenConfigUpdateRequest, + GenTableRowsChatCompletionChunks, + GenTableStreamChatCompletionChunk, + RegenStrategy, + RowAddRequest, + RowRegenRequest, + RowUpdateRequest, + TableSchemaCreate, + TableType, +) +from jamaibase.utils import run + +CLIENT_CLS = [JamAI, JamAIAsync] +REGEN_STRATEGY = [ + RegenStrategy.RUN_ALL, + RegenStrategy.RUN_BEFORE, + RegenStrategy.RUN_SELECTED, + RegenStrategy.RUN_AFTER, +] + +TABLE_ID_A = "table_a" + +IN_COLS = [ + ColumnSchemaCreate(id="in_01", dtype="str"), + ColumnSchemaCreate(id="in_02", dtype="str"), + ColumnSchemaCreate(id="in_03", dtype="str"), +] + +OUT_COLS = [ + ColumnSchemaCreate(id="out_01", dtype="str"), + ColumnSchemaCreate(id="out_02", dtype="str"), + ColumnSchemaCreate(id="out_03", dtype="str"), + ColumnSchemaCreate(id="out_04", dtype="str"), + ColumnSchemaCreate(id="out_05", dtype="str"), + ColumnSchemaCreate(id="out_06", dtype="str"), +] + + +def column_prompt( + user_content: str, + max_tokens: int, + model: str = "anthropic/claude-3-haiku-20240307", +): + return { + "id": "", + "model": model, + "messages": [ + { + "role": "system", + "content": "You are a concise assistant.", + }, + { + "role": "user", + "content": user_content, + }, + ], + "functions": [], + "function_call": "auto", + "temperature": 0.01, + "top_p": 0.1, + "stream": False, + "stop": [], + "max_tokens": max_tokens, + } + + +COLUMN_MAP_CONCURRENCY = { + "out_01": column_prompt( + "Count from `${in_01} plus -1 plus 2` to `${in_02}`, seperated by comma. Reply answer only", + 1000, + ), + "out_02": column_prompt( + "Count from `${in_01} plus 2 minus 1` to `${in_02}`, seperated by comma. Reply answer only", + 1000, + ), + "out_03": column_prompt( + "Count from `${in_01} minus 1 plus 2` to `${in_02}`, seperated by comma. Reply answer only", + 1000, + ), +} + +COLUMN_MAP_DEPENDENCY = { + "out_01": column_prompt( + "Solve: ${in_01} + ${in_02}, reply answer only.", + 10, + ), + "out_02": column_prompt( + "Solve: ${in_02} - ${in_01}, reply answer only.", + 10, + ), + "out_03": column_prompt( + "Solve: ${out_01} x ${out_02}, reply answer only.", + 10, + ), + "out_04": column_prompt( + "Solve: ${out_02} x ${out_03}, reply answer only.", + 10, + ), + "out_05": column_prompt( + "Solve: ${out_04} x 1 / 3, reply answer only, in 2 decimal places.", + 10, + ), +} + + +@asynccontextmanager +async def _create_table( + jamai: JamAI | JamAIAsync, + table_type: TableType, + cols: list[ColumnSchemaCreate], + table_id: str = TABLE_ID_A, +): + schema = TableSchemaCreate( + id=table_id, + cols=cols, + ) + try: + if table_type == TableType.action: + _ = await run(jamai.table.create_action_table, schema) + else: + raise ValueError(f"Invalid table type: {table_type}") + yield table_id + finally: + await run(jamai.table.delete_table, table_type, table_id) + + +async def _update_gen_config( + jamai: JamAI | JamAIAsync, table_type: TableType, gen_config: GenConfigUpdateRequest +): + await run(jamai.table.update_gen_config, table_type, gen_config) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("long_context_column_name", ["out_01", "out_04", "out_06"]) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_exceed_context_length( + client_cls: JamAI | JamAIAsync, + long_context_column_name: str, + stream: bool, +): + jamai = client_cls() + cols = IN_COLS[:] + OUT_COLS[:] + async with _create_table(jamai, TableType.action, cols) as table_id: + row_input_data = {"in_01": "Bouldering", "in_02": "Olympics 2024", "in_03": "Paris"} + model_name = "openai/gpt-4o-mini" + column_map = { + "out_01": column_prompt( + "Tell a very short story about ${in_01}, ${in_02} and ${in_03}.", 100, model_name + ), + "out_02": column_prompt("Rephrase ${out_01}.", 100, model_name), + "out_03": column_prompt("Rephrase ${out_02}.", 100, model_name), + "out_04": column_prompt("Rephrase ${out_03}.", 100, model_name), + "out_05": column_prompt("Rephrase ${out_04}.", 100, model_name), + "out_06": column_prompt("Rephrase ${out_05}.", 100, model_name), + } + + column_map[long_context_column_name]["messages"][-1]["content"] = "".join( + column_map[long_context_column_name]["messages"][-1]["content"] * 50000 + ) + + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + 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) + + column_gen = row[long_context_column_name]["value"] + assert column_gen.startswith("[ERROR]") + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_multicols_concurrency_timing( + client_cls: JamAI | JamAIAsync, + stream: bool, +): + jamai = client_cls() + cols = IN_COLS[:2] + OUT_COLS[:3] + async with _create_table(jamai, TableType.action, cols) as table_id: + row_input_data = {"in_01": "0", "in_02": "100"} + column_map = COLUMN_MAP_CONCURRENCY.copy() + + async def execute(): + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + start_time = time.time() + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest( + table_id=table_id, data=[row_input_data], stream=stream, concurrent=True + ), + ) + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + assert isinstance(chunks, GenTableRowsChatCompletionChunks) + execution_time = time.time() - start_time + return execution_time + + execution_time_3_cols = await execute() + column_map.pop("out_02") + column_map.pop("out_03") + execution_time_1_col = await execute() + + assert abs(execution_time_3_cols - execution_time_1_col) < (execution_time_1_col * 1.5) + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_multirows_multicols_concurrency_timing( + client_cls: JamAI | JamAIAsync, + stream: bool, +): + jamai = client_cls() + cols = IN_COLS[:2] + OUT_COLS[:3] + async with _create_table(jamai, TableType.action, cols) as table_id: + rows_input_data = [ + {"in_01": "0", "in_02": "200"}, + {"in_01": "1", "in_02": "201"}, + {"in_01": "2", "in_02": "202"}, + ] + column_map = COLUMN_MAP_CONCURRENCY + + async def execute(): + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + start_time = time.time() + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest( + table_id=table_id, data=rows_input_data, stream=stream, concurrent=True + ), + ) + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + assert isinstance(chunks, GenTableRowsChatCompletionChunks) + execution_time = time.time() - start_time + return execution_time + + execution_time_3_rows = await execute() + rows_input_data = rows_input_data[:1] + execution_time_1_row = await execute() + + assert abs(execution_time_3_rows - execution_time_1_row) < (execution_time_1_row * 1.5) + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_multicols_dependency( + client_cls: JamAI | JamAIAsync, + stream: bool, +): + jamai = client_cls() + cols = IN_COLS[:2] + OUT_COLS[:5] + async with _create_table(jamai, TableType.action, cols) as table_id: + row_input_data = {"in_01": "8", "in_02": "2"} + column_map = COLUMN_MAP_DEPENDENCY + ground_truths = { + "out_01": "10", + "out_02": "-6", + "out_03": "-60", + "out_04": "360", + "out_05": "120", + } + + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + 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) + + for output_column_name in column_map.keys(): + assert ground_truths[output_column_name] in row[output_column_name]["value"] + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("regen_strategy", REGEN_STRATEGY) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +@pytest.mark.parametrize( + "cols", + [ + # input columns + output columns + IN_COLS[:2] + OUT_COLS[:5], + # input columns and output columns interleaved + IN_COLS[:2] + OUT_COLS[:2] + IN_COLS[2:] + OUT_COLS[2:], + # only input columns + IN_COLS[:2], + ], +) +async def test_multicols_regen( + client_cls: JamAI | JamAIAsync, + regen_strategy: RegenStrategy, + stream: bool, + cols, +): + jamai = client_cls() + only_input_columns = True if len([col for col in cols if col in OUT_COLS]) == 0 else False + async with _create_table(jamai, TableType.action, cols) as table_id: + row_input_data = {"in_01": "8", "in_02": "2"} + regen_row_input_data = {"in_01": "9", "in_02": "8"} + column_map = COLUMN_MAP_DEPENDENCY + ground_truths = { + "out_01": "10", + "out_02": "-6", + "out_03": "-60", + "out_04": "360", + "out_05": "120", + } + if regen_strategy == RegenStrategy.RUN_ALL: + output_column_id = None + regen_ground_truths = { + "out_01": "17", + "out_02": "-1", + "out_03": "-17", + "out_04": "17", + "out_05": "5.67", + } + elif regen_strategy == RegenStrategy.RUN_BEFORE: + output_column_id = "out_03" + regen_ground_truths = { + "out_01": "17", + "out_02": "-1", + "out_03": "-17", + "out_04": "360", + "out_05": "120", + } + elif regen_strategy == RegenStrategy.RUN_SELECTED: + output_column_id = "out_02" + regen_ground_truths = { + "out_01": "10", + "out_02": "-1", + "out_03": "-60", + "out_04": "360", + "out_05": "120", + } + elif regen_strategy == RegenStrategy.RUN_AFTER: + output_column_id = "out_02" + regen_ground_truths = { + "out_01": "10", + "out_02": "-1", + "out_03": "-10", + "out_04": "10", + "out_05": "3.33", + } + + if not only_input_columns: + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + if not only_input_columns: + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + 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) + + if not only_input_columns: + for output_column_name in column_map.keys(): + assert ground_truths[output_column_name] in row[output_column_name]["value"] + + # Update input columns value + await run( + jamai.table.update_table_row, + TableType.action, + RowUpdateRequest(table_id=table_id, row_id=row_id, data=regen_row_input_data), + ) + + # Regen + chunks = await run( + jamai.table.regen_table_rows, + TableType.action, + RowRegenRequest( + table_id=table_id, + row_ids=[row_id], + regen_strategy=regen_strategy, + output_column_id=output_column_id, + stream=stream, + concurrent=True, + ), + ) + if not only_input_columns: + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + 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) + + if not only_input_columns: + for output_column_name in column_map.keys(): + assert regen_ground_truths[output_column_name] in row[output_column_name]["value"] + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("regen_strategy", REGEN_STRATEGY) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +async def test_multicols_regen_invalid_column_id( + client_cls: JamAI | JamAIAsync, + regen_strategy, + stream: bool, +): + jamai = client_cls() + cols = IN_COLS[:2] + OUT_COLS[:5] + invalid_output_column_id = "out_13" + async with _create_table(jamai, TableType.action, cols) as table_id: + row_input_data = {"in_01": "8", "in_02": "2"} + regen_row_input_data = {"in_01": "9", "in_02": "8"} + column_map = COLUMN_MAP_DEPENDENCY + ground_truths = { + "out_01": "10", + "out_02": "-6", + "out_03": "-60", + "out_04": "360", + "out_05": "120", + } + + gen_config = GenConfigUpdateRequest( + table_id=table_id, + column_map=column_map, + ) + await _update_gen_config(jamai, TableType.action, gen_config) + + chunks = await run( + jamai.table.add_table_rows, + TableType.action, + RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + ) + if stream: + assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + else: + 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) + + for output_column_name in column_map.keys(): + assert ground_truths[output_column_name] in row[output_column_name]["value"] + + # Update input columns value + await run( + jamai.table.update_table_row, + TableType.action, + RowUpdateRequest(table_id=table_id, row_id=row_id, data=regen_row_input_data), + ) + + # Regen + with pytest.raises( + ResourceNotFoundError, + match=( + f'`output_column_id` .*{invalid_output_column_id}.* is not found. ' + f"Available output columns:.*{'.*'.join(ground_truths.keys())}.*" + ), + ): + await run( + jamai.table.regen_table_rows, + TableType.action, + RowRegenRequest( + table_id=table_id, + row_ids=[row_id], + regen_strategy=regen_strategy, + output_column_id=invalid_output_column_id, + stream=stream, + concurrent=True, + ), + ) + + +if __name__ == "__main__": + asyncio.run(test_multicols_regen_invalid_column_id(CLIENT_CLS[-1], REGEN_STRATEGY[1], True)) diff --git a/clients/python/tests/test_io.py b/clients/python/tests/oss/test_io.py similarity index 99% rename from clients/python/tests/test_io.py rename to clients/python/tests/oss/test_io.py index 5218e28..f38e809 100644 --- a/clients/python/tests/test_io.py +++ b/clients/python/tests/oss/test_io.py @@ -1,4 +1,3 @@ -from datetime import datetime from os.path import dirname, join, realpath from tempfile import TemporaryDirectory diff --git a/clients/python/tests/oss/test_template.py b/clients/python/tests/oss/test_template.py new file mode 100644 index 0000000..c9aeb95 --- /dev/null +++ b/clients/python/tests/oss/test_template.py @@ -0,0 +1,316 @@ +from contextlib import asynccontextmanager +from io import BytesIO +from typing import Type + +import pytest + +from jamaibase import JamAI, JamAIAsync +from jamaibase import protocol as p +from jamaibase.utils import run + +CLIENT_CLS = [JamAI, JamAIAsync] +TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] + + +@asynccontextmanager +async def _create_gen_table( + jamai: JamAI, + table_type: p.TableType, + table_id: str, + model_id: str = "", + cols: list[p.ColumnSchemaCreate] | None = None, + chat_cols: list[p.ColumnSchemaCreate] | None = None, + embedding_model: str = "", + delete_first: bool = True, + delete: bool = True, +): + try: + if delete_first: + await run(jamai.table.delete_table, table_type, table_id) + if cols is None: + cols = [ + p.ColumnSchemaCreate(id="input", dtype="str"), + p.ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=p.LLMGenConfig( + model=model_id, + prompt="${input}", + max_tokens=3, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + p.ColumnSchemaCreate(id="User", dtype="str"), + p.ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=p.LLMGenConfig( + model=model_id, + system_prompt="You are an assistant.", + max_tokens=3, + ), + ), + ] + if table_type == p.TableType.action: + table = await run( + jamai.table.create_action_table, p.ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == p.TableType.knowledge: + table = await run( + jamai.table.create_knowledge_table, + p.KnowledgeTableSchemaCreate( + id=table_id, cols=cols, embedding_model=embedding_model + ), + ) + elif table_type == p.TableType.chat: + table = await run( + jamai.table.create_chat_table, + p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols), + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, p.TableMetaResponse) + yield table + finally: + if delete: + await run(jamai.table.delete_table, table_type, table_id) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_populate_templates(client_cls: Type[JamAI]): + client = client_cls() + response = await run(client.admin.backend.populate_templates) + assert isinstance(response, p.OkResponse) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_list_templates(client_cls: Type[JamAI]): + client = client_cls() + response = await run(client.template.list_templates) + assert len(response.items) == response.total + templates = response.items + assert len(templates) > 0 + assert all(isinstance(t, p.Template) for t in templates) + for template in templates: + assert len(template.id) > 0 + assert len(template.name) > 0 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_template(client_cls: Type[JamAI]): + client = client_cls() + # List templates + templates = (await run(client.template.list_templates)).items + assert len(templates) > 0 + template_id = templates[0].id + # Fetch template + template = await run(client.template.get_template, template_id) + assert isinstance(template, p.Template) + assert len(template.id) > 0 + assert len(template.name) > 0 + assert len(template.created_at) > 0 + assert len(template.tags) > 0 + assert all(isinstance(t, p.TemplateTag) for t in template.tags) + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_list_tables(client_cls: Type[JamAI]): + client = client_cls() + # List templates + templates = (await run(client.template.list_templates)).items + assert len(templates) > 0 + template_id = templates[0].id + # List tables + tables: list[p.TableMetaResponse] = [] + for table_type in TABLE_TYPES: + tables += (await run(client.template.list_tables, template_id, table_type)).items + assert len(tables) > 0 + assert all(isinstance(t, p.TableMetaResponse) for t in tables) + for table in tables: + assert len(table.id) > 0 + assert len(table.cols) > 0 + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + assert len(table.updated_at) > 0 + + # Create a template by exporting default project + async with _create_gen_table(client, "action", "b"): + async with _create_gen_table(client, "action", "a"): + data = await run( + client.admin.organization.export_project_as_template, + "default", + name="Template 试验", + tags=["sector:finance", "sector:科技"], + description="テンプレート description", + ) + new_template_id = "test_template" + with BytesIO(data) as f: + response = await run(client.admin.backend.add_template, f, new_template_id, True) + assert isinstance(response, p.OkResponse) + + # Search query + tables = ( + await run(client.template.list_tables, new_template_id, "action", search_query="xxx") + ).items + assert len(tables) == 0 + tables = ( + await run(client.template.list_tables, new_template_id, "action", search_query="a") + ).items + assert len(tables) == 1 + + # Sort + tables = (await run(client.template.list_tables, new_template_id, "action")).items + assert [t.id for t in tables] == ["a", "b"] + tables = ( + await run(client.template.list_tables, new_template_id, "action", order_descending=False) + ).items + assert [t.id for t in tables] == ["b", "a"] + tables = ( + await run( + client.template.list_tables, + new_template_id, + "action", + order_by="id", + order_descending=False, + ) + ).items + assert [t.id for t in tables] == ["a", "b"] + + # Offset and limit + tables = ( + await run( + client.template.list_tables, + new_template_id, + "action", + offset=0, + limit=1, + order_by="id", + order_descending=False, + ) + ).items + assert [t.id for t in tables] == ["a"] + tables = ( + await run( + client.template.list_tables, + new_template_id, + "action", + offset=1, + limit=1, + order_by="id", + order_descending=False, + ) + ).items + assert [t.id for t in tables] == ["b"] + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_get_table(client_cls: Type[JamAI]): + client = client_cls() + # Get template ID + templates = (await run(client.template.list_templates)).items + assert len(templates) > 0 + template_id = templates[0].id + # Get table + table_count = 0 + for table_type in TABLE_TYPES: + tables = (await run(client.template.list_tables, template_id, table_type)).items + if len(tables) == 0: + continue + table_count += len(tables) + table_id = tables[0].id + table = await run(client.template.get_table, template_id, table_type, table_id) + assert isinstance(table, p.TableMetaResponse) + assert len(table.id) > 0 + assert len(table.cols) > 0 + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + assert len(table.updated_at) > 0 + assert table_count > 0 + + +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +async def test_list_table_rows(client_cls: Type[JamAI]): + client = client_cls() + # Get template ID + templates = (await run(client.template.list_templates)).items + assert len(templates) > 0 + template_id = templates[0].id + # Get table + table_count = 0 + for table_type in TABLE_TYPES: + tables = (await run(client.template.list_tables, template_id, table_type)).items + if len(tables) == 0: + continue + table_count += len(tables) + table_id = tables[0].id + table = await run(client.template.get_table, template_id, table_type, table_id) + assert isinstance(table, p.TableMetaResponse) + assert len(table.id) > 0 + assert len(table.cols) > 0 + assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + assert len(table.updated_at) > 0 + # List rows + rows = ( + await run(client.template.list_table_rows, template_id, table_type, table_id, limit=5) + ).items + assert len(rows) > 4 + assert all(isinstance(r, dict) for r in rows) + for row in rows: + assert len({"ID", "Updated at"} - set(row.keys())) == 0 + # Test starting_after + subset = ( + await run( + client.template.list_table_rows, + template_id, + table_type, + table_id, + starting_after=rows[1]["ID"], + limit=2, + ) + ).items + assert subset[0]["ID"] == rows[2]["ID"] + # Test starting_after + offset + subset = ( + await run( + client.template.list_table_rows, + template_id, + table_type, + table_id, + starting_after=rows[1]["ID"], + offset=1, + limit=2, + ) + ).items + assert subset[0]["ID"] == rows[3]["ID"] + # Test vector decimal rounding + subset = ( + await run( + client.template.list_table_rows, + template_id, + table_type, + table_id, + starting_after=rows[1]["ID"], + offset=1, + limit=2, + vec_decimals=-1, + ) + ).items + assert subset[0]["ID"] == rows[3]["ID"] + subset = ( + await run( + client.template.list_table_rows, + template_id, + table_type, + table_id, + starting_after=rows[1]["ID"], + offset=1, + limit=2, + vec_decimals=1, + ) + ).items + assert subset[0]["ID"] == rows[3]["ID"] + assert table_count > 0 + + +if __name__ == "__main__": + print(JamAI().template.list_templates()) diff --git a/clients/python/tests/test_gen_executor.py b/clients/python/tests/test_gen_executor.py deleted file mode 100644 index 212e531..0000000 --- a/clients/python/tests/test_gen_executor.py +++ /dev/null @@ -1,899 +0,0 @@ -import random -import time -from asyncio.coroutines import iscoroutine -from typing import AsyncGenerator, Generator - -import pytest -from flaky import flaky -from loguru import logger - -from jamaibase import JamAI, JamAIAsync -from jamaibase.protocol import ( - ColumnSchemaCreate, - GenConfigUpdateRequest, - GenTableRowsChatCompletionChunks, - RowAddRequest, - RowRegenRequest, - TableSchemaCreate, - TableType, -) - -CLIENT_CLS = [JamAI, JamAIAsync] -GEN_TYPES = ["REGEN"] -DATA_LENGTHS = ["normal", "exceed"] - - -async def run(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - return await ret - return ret - - -async def run_gen_async(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if iscoroutine(ret): - ret = await ret - if isinstance(ret, AsyncGenerator): - async for item in ret: - yield item - else: - yield ret - - -def run_gen_sync(fn, *args, **kwargs): - ret = fn(*args, **kwargs) - if isinstance(ret, Generator): - for item in ret: - yield item - else: - yield ret - - -async def _create_table( - jamai: JamAI | JamAIAsync, - table_type: TableType, - cols_info: tuple[dict[str, str], dict[str, str]] = None, -): - table_id = f"{table_type.value}_{random.randint(10000, 99999)}" - schema = TableSchemaCreate( - id=table_id, - cols=( - [ - ColumnSchemaCreate(id="article 1", dtype="str"), - ColumnSchemaCreate(id="summary", dtype="str"), - ] - if cols_info is None - else ( - [ColumnSchemaCreate(id=k, dtype=v) for k, v in cols_info[0].items()] - + [ColumnSchemaCreate(id=k, dtype=v) for k, v in cols_info[1].items()] - ) - ), - ) - if table_type == TableType.action: - table = await run(jamai.create_action_table, schema) - else: - raise ValueError(f"Invalid table type: {table_type}") - return table, table_id - - -async def _update_gen_config( - jamai: JamAI | JamAIAsync, table_type: TableType, gen_config: GenConfigUpdateRequest -): - await run(jamai.update_gen_config, table_type, gen_config) - - -# --------------------------------------------------------- -# Test Cases for concurrent Execution -# --------------------------------------------------------- -def column_map_prompt(content: str, max_tokens: int): - return { - "id": "", - # "model": "openai/gpt-3.5-turbo", - # "model": "openai/gpt-4-turbo", - "model": "openai/gpt-4o", - "messages": [ - { - "role": "system", - "content": "You are a concise assistant.", - }, - { - "role": "user", - "content": content, - }, - ], - "functions": [], - "function_call": "auto", - "temperature": 0.01, - "top_p": 0.1, - "stream": False, - "stop": [], - "max_tokens": max_tokens, - } - - -def column_map_long_prompt(content: str, max_tokens: int): - return { - "id": "", - "model": "ellm/meta-llama/Llama-3-8B-Instruct", - # "model": "together/Qwen/Qwen1.5-0.5B-Chat", - "messages": [ - { - "role": "system", - "content": "You are a concise assistant.", - }, - { - "role": "user", - "content": " ".join([content] * 5000), - }, - ], - "functions": [], - "function_call": "auto", - "temperature": 0.01, - "top_p": 0.1, - "stream": False, - "stop": [], - "max_tokens": max_tokens, - } - - -def data(data_lengths=["normal"]): - input_dict = {"xx": "str", "yy": "str", "zz": "str"} - output_dict = { - "aa": "str", - "bb": "str", - "cc": "str", - "dd": "str", - "ee": "str", - "ff": "str", - } - output_dict2 = { - "aa": "str", - "bb": "str", - "cc": "str", - # --- - "dd": "str", - "ee": "str", - "ff": "str", - "dd2": "str", - "ee2": "str", - "ff2": "str", - # --- - "aa3": "str", - "bb3": "str", - "cc3": "str", - "dd3": "str", - "ee3": "str", - "ff3": "str", - "dd23": "str", - "ee23": "str", - "ff23": "str", - } - row = {"xx": "1", "yy": "2", "zz": "3"} - inv_nodes = [ # map - end: (start, expected_gen_output) - # { - # "aa": (["xx"], ""), - # "bb": (["yy"], ""), - # "cc": (["zz"], ""), - # # "dd": (["xx"], ""), - # # "ee": (["yy"], ""), - # # "ff": (["zz"], ""), - # }, - { - "aa": (["xx"], ""), - "bb": (["yy"], ""), - "cc": (["zz"], ""), - "dd": (["aa"], ">"), - "ee": (["bb"], ">"), - "ff": (["cc"], ">"), - }, - # { - # "aa": (["xx"], ""), - # "bb": (["yy"], ""), - # "cc": (["zz"], ""), - # "dd": (["aa", "bb"], "(> & >)"), - # "ee": (["cc"], ">"), - # "ff": ( - # ["dd", "ee"], - # "(> & >)> & >>)", - # ), - # }, - # { - # "aa": (["xx"], ""), - # "bb": (["yy"], ""), - # "cc": (["zz"], ""), - # "dd": (["aa", "bb"], "(> & >)"), - # "ee": (["cc", "dd"], "(> & > & >)>)"), - # "ff": ( - # ["dd", "ee"], - # "(> & >)> & > & > & >)>)>)", - # ), - # }, - # { - # "aa": (["xx", "yy"], "( & )"), - # "bb": (["aa"], " & )>"), - # "cc": (["zz", "bb"], "( & & )>>)"), - # "dd": (["cc"], " & & )>>)>"), - # "ee": ( - # ["cc", "dd"], - # "( & & )>>)> & & & )>>)>>)", - # ), - # "ff": ( - # ["aa", "bb", "cc", "dd", "ee"], - # "( & )> & & )>> & & & )>>)> & & & )>>)>> & & & )>>)> & & & )>>)>>)>)", - # ), - # }, - # { - # "aa": (["xx", "yy", "zz"], "( & & )"), - # "bb": (["aa"], " & & )>"), - # "cc": (["bb"], " & & )>>"), - # "dd": (["cc"], " & & )>>>"), - # "ee": (["yy", "zz"], "( & )"), - # "ff": ( - # ["dd", "ee"], - # "( & & )>>>> & & )>)", - # ), - # }, - # { - # "aa": (["xx"], ""), - # "bb": (["yy"], ""), - # "cc": (["zz"], ""), - # "dd": (["xx"], ""), - # "ee": (["yy"], ""), - # "ff": (["zz"], ""), - # }, - ] - inv_nodes2 = [ # map - end: (start, expected_gen_output) - { - "aa": (["xx"], ""), - "bb": (["yy"], ""), - "cc": (["zz"], ""), - # --- - "dd": (["aa"], ">"), - "ee": (["bb"], ">"), - "ff": (["cc"], ">"), - "dd2": (["aa"], ">"), - "ee2": (["bb"], ">"), - "ff2": (["cc"], ">"), - # --- - "aa3": (["dd2"], ">>"), - "bb3": (["ee2"], ">>"), - "cc3": (["ff2"], ">>"), - "dd3": (["dd2"], ">>"), - "ee3": (["ee2"], ">>"), - "ff3": (["ee2"], ">>"), - "dd23": (["dd2"], ">>"), - "ee23": (["ee2"], ">>"), - "ff23": (["ee2"], ">>"), - }, - # { - # "aa": (["xx"], ""), - # "bb": (["xx"], ""), - # "cc": (["xx"], ""), - # # --- - # "dd": (["xx"], ""), - # "ee": (["xx"], ""), - # "ff": (["xx"], ""), - # "dd2": (["xx"], ""), - # "ee2": (["xx"], ""), - # "ff2": (["xx"], ""), - # # --- - # "aa3": (["xx"], ""), - # "bb3": (["xx"], ""), - # "cc3": (["xx"], ""), - # "dd3": (["xx"], ""), - # "ee3": (["xx"], ""), - # "ff3": (["xx"], ""), - # "dd23": (["xx"], ""), - # "ee23": (["xx"], ""), - # "ff23": (["xx"], ""), - # } - ] - - def get_nodes_data(inv_nodes, output_dict, content_postfix, max_tokens, data_length): - nodes_data = [] - for inv_node in inv_nodes: - column_map = {} - expected_column_gen = {} - for end, (starts, expected_gen) in inv_node.items(): - sub_contents = [f"<{start}:${{{start}}}>" for start in starts] - if len(sub_contents) > 1: - sub_content = "(" + " & ".join(sub_contents) + ")" - else: - sub_content = sub_contents[0] - logger.info(end, starts) - logger.info(sub_content) - content = f"{sub_content} \n\n{content_postfix}" - column_map[end] = ( - column_map_prompt(content, max_tokens) - if data_length == "normal" - else column_map_long_prompt(content, max_tokens) - ) - expected_column_gen[end] = expected_gen - nodes_data.append( - (input_dict, output_dict, column_map, row, expected_column_gen, data_length) - ) - return nodes_data - - content_postfix = "Output exactly the content above, don't include any other information." - content_postfix2 = "Output exactly the content above, don't include any other information. Then create a story." - # all_nodes_data = get_nodes_data(inv_nodes2, output_dict2, content_postfix2, max_tokens=5) - # all_nodes_data = get_nodes_data(inv_nodes2, output_dict2, content_postfix2, max_tokens=1000) - # all_nodes_data = get_nodes_data(inv_nodes2, output_dict2, content_postfix2, max_tokens=500) - # all_nodes_data = get_nodes_data(inv_nodes2, output_dict2, content_postfix2, max_tokens=300) - # all_nodes_data = get_nodes_data(inv_nodes2, output_dict2, content_postfix, max_tokens=100) - all_nodes_data = [] - for data_length in data_lengths: - all_nodes_data += get_nodes_data( - # inv_nodes, output_dict, content_postfix, max_tokens=100, data_length=data_length - inv_nodes2, - output_dict2, - content_postfix, - max_tokens=100, - data_length=data_length, - ) - # all_nodes_data = get_nodes_data(inv_nodes, output_dict, content_postfix2, max_tokens=300) - return all_nodes_data - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("gen_type", GEN_TYPES) -@pytest.mark.parametrize( - "input_dict, output_dict, column_map, row, expected_column_gen, data_length", data() -) -async def test_nonstream_concurrent_execution( - client_cls: JamAI | JamAIAsync, - gen_type, - input_dict, - output_dict, - column_map, - row, - expected_column_gen, - data_length, -): - """ - Tests concurrent execution in non-streaming mode with dependencies. - """ - jamai = client_cls(project_id="", api_key="") - meta, table_id = await _create_table( - jamai, - TableType.action, - cols_info=( - input_dict, - output_dict, - ), - ) - gen_config = GenConfigUpdateRequest(table_id=table_id, column_map=column_map) - await _update_gen_config(jamai, TableType.action, gen_config) - - if isinstance(jamai, JamAIAsync): - response = [ - r - async for r in run_gen_async( - jamai.add_table_rows, - TableType.action, - RowAddRequest(table_id=table_id, data=[row], stream=False, concurrent=True), - ) - ] - - else: - response = [ - r - for r in run_gen_sync( - jamai.add_table_rows, - TableType.action, - RowAddRequest(table_id=table_id, data=[row], stream=False, concurrent=True), - ) - ] - - response = response[0] - assert isinstance(response, GenTableRowsChatCompletionChunks) - - # Verify all output columns were executed - for response in response.rows: - for output_column_name in output_dict.keys(): - assert output_column_name in response.columns - - if gen_type == "REGEN": - rows = await run(jamai.list_table_rows, TableType.action, table_id) - row_id = rows.items[0]["ID"] - if isinstance(jamai, JamAIAsync): - response = [ - r - async for r in run_gen_async( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, row_ids=[row_id], stream=False, concurrent=True - ), - ) - ] - - else: - response = [ - r - for r in run_gen_sync( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, row_ids=[row_id], stream=False, concurrent=True - ), - ) - ] - response = response[0] - assert isinstance(response, GenTableRowsChatCompletionChunks) - - # Get rows - rows = await run(jamai.list_table_rows, TableType.action, table_id) - for i in range(len(response.rows)): - row_id = rows.items[i]["ID"] - row = await run(jamai.get_table_row, TableType.action, table_id, row_id) - - # Compare generated outputs with expected outputs - for output_column_name in output_dict.keys(): - expected_gen = expected_column_gen[output_column_name] - column_gen = row[output_column_name]["value"] - len_expected_gen = len(expected_gen) - len_column_gen = len(column_gen) - if len_column_gen >= len_expected_gen: - assert column_gen[:len_expected_gen] == expected_gen - else: - assert column_gen == expected_gen[:len_column_gen] - - await run(jamai.delete_table, TableType.action, table_id) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("gen_type", GEN_TYPES) -@pytest.mark.parametrize( - "input_dict, output_dict, column_map, row, expected_column_gen, data_length", - data(DATA_LENGTHS), -) -async def test_stream_concurrent_execution( - client_cls: JamAI | JamAIAsync, - gen_type, - input_dict, - output_dict, - column_map, - row, - expected_column_gen, - data_length, -): - """ - Tests concurrent execution in streaming mode with dependencies. - """ - jamai = client_cls(project_id="", api_key="") - meta, table_id = await _create_table( - jamai, - TableType.action, - cols_info=( - input_dict, - output_dict, - ), - ) - gen_config = GenConfigUpdateRequest(table_id=table_id, column_map=column_map) - await _update_gen_config(jamai, TableType.action, gen_config) - - total_start_time = time.time() - first_chunk_times = {} - chunks = [] - if isinstance(jamai, JamAIAsync): - async for chunk in run_gen_async( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, - data=[row], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - else: - for chunk in run_gen_sync( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, - data=[row], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - for row_id_ in first_chunk_times.keys(): - for output_column_name in first_chunk_times[row_id_].keys(): - logger.debug( - f"> [Test] Time to first chunk for {output_column_name}: {first_chunk_times[row_id_].get(output_column_name, 'N/A'):.2f} seconds" - ) - break - - logger.debug( - f"> [Test] Stream Total Add Rows Time: {time.time() - total_start_time:.2f} seconds" - ) - - if gen_type == "REGEN": - rows = await run(jamai.list_table_rows, TableType.action, table_id) - row_id = rows.items[0]["ID"] - total_start_time = time.time() - first_chunk_times = {} - chunks = [] - if isinstance(jamai, JamAIAsync): - async for chunk in run_gen_async( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_id], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - else: - for chunk in run_gen_sync( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_id], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - for row_id_ in first_chunk_times.keys(): - for output_column_name in first_chunk_times[row_id_].keys(): - logger.debug( - f"> [Test] Time to first chunk for {output_column_name}: {first_chunk_times[row_id_].get(output_column_name, 'N/A'):.2f} seconds" - ) - break - - logger.debug( - f"> [Test] Stream Total Regen Rows Time: {time.time() - total_start_time:.2f} seconds" - ) - - # Get first rows - rows = await run(jamai.list_table_rows, TableType.action, table_id) - row_id = rows.items[0]["ID"] - row = await run(jamai.get_table_row, TableType.action, table_id, row_id) - - # Compare generated outputs with expected outputs - for output_column_name in output_dict.keys(): - expected_gen = expected_column_gen[output_column_name] - column_gen = row[output_column_name]["value"] - len_expected_gen = len(expected_gen) - len_column_gen = len(column_gen) if column_gen is not None else 0 - if data_length == "normal": - if len_column_gen >= len_expected_gen: - assert column_gen[:len_expected_gen] == expected_gen - else: - assert column_gen == expected_gen[:len_column_gen] - else: - assert column_gen.startswith("[ERROR]") - - await run(jamai.delete_table, TableType.action, table_id) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("gen_type", GEN_TYPES) -@pytest.mark.parametrize( - "input_dict, output_dict, column_map, row, expected_column_gen, data_length", data() -) -async def test_multirows_nonstream_concurrent_execution( - client_cls: JamAI | JamAIAsync, - gen_type, - input_dict, - output_dict, - column_map, - row, - expected_column_gen, - data_length, -): - """ - Tests concurrent execution in non-streaming mode with dependencies. - """ - jamai = client_cls(project_id="", api_key="") - meta, table_id = await _create_table( - jamai, - TableType.action, - cols_info=( - input_dict, - output_dict, - ), - ) - gen_config = GenConfigUpdateRequest(table_id=table_id, column_map=column_map) - await _update_gen_config(jamai, TableType.action, gen_config) - - total_start_time = time.time() - if isinstance(jamai, JamAIAsync): - response = [ - r - async for r in run_gen_async( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, data=[row, row, row], stream=False, concurrent=True - ), - ) - ] - - else: - response = [ - r - for r in run_gen_sync( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, data=[row, row, row], stream=False, concurrent=True - ), - ) - ] - - response = response[0] - assert isinstance(response, GenTableRowsChatCompletionChunks) - logger.debug(f"> Non-Stream Total Rows Time: {time.time() - total_start_time}") - - # Verify all output columns were executed - for response in response.rows: - for output_column_name in output_dict.keys(): - assert output_column_name in response.columns - - if gen_type == "REGEN": - total_start_time = time.time() - rows = await run(jamai.list_table_rows, TableType.action, table_id) - if isinstance(jamai, JamAIAsync): - response = [ - r - async for r in run_gen_async( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_item["ID"] for row_item in rows.items], - stream=False, - concurrent=True, - ), - ) - ] - - else: - response = [ - r - for r in run_gen_sync( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_item["ID"] for row_item in rows.items], - stream=False, - concurrent=True, - ), - ) - ] - response = response[0] - assert isinstance(response, GenTableRowsChatCompletionChunks) - logger.debug(f"> Non-Stream Total Regen Rows Time: {time.time() - total_start_time}") - - # Get rows - rows = await run(jamai.list_table_rows, TableType.action, table_id) - for i in range(len(response.rows)): - row_id = rows.items[i]["ID"] - row = await run(jamai.get_table_row, TableType.action, table_id, row_id) - - # Compare generated outputs with expected outputs - for output_column_name in output_dict.keys(): - expected_gen = expected_column_gen[output_column_name] - column_gen = row[output_column_name]["value"] - len_expected_gen = len(expected_gen) - len_column_gen = len(column_gen) - if len_column_gen >= len_expected_gen: - assert column_gen[:len_expected_gen] == expected_gen - else: - assert column_gen == expected_gen[:len_column_gen] - - await run(jamai.delete_table, TableType.action, table_id) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("gen_type", GEN_TYPES) -@pytest.mark.parametrize( - "input_dict, output_dict, column_map, row, expected_column_gen, data_length", - data(DATA_LENGTHS), -) -async def test_multirows_stream_concurrent_execution( - client_cls: JamAI | JamAIAsync, - gen_type, - input_dict, - output_dict, - column_map, - row, - expected_column_gen, - data_length, -): - """ - Tests concurrent execution in streaming mode with dependencies. - """ - jamai = client_cls(project_id="", api_key="") - meta, table_id = await _create_table( - jamai, - TableType.action, - cols_info=( - input_dict, - output_dict, - ), - ) - gen_config = GenConfigUpdateRequest(table_id=table_id, column_map=column_map) - await _update_gen_config(jamai, TableType.action, gen_config) - - total_start_time = time.time() - first_chunk_times = {} - chunks = [] - if isinstance(jamai, JamAIAsync): - async for chunk in run_gen_async( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, - data=[row, row, row], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - # if isinstance(chunk, ErrorChunk): - # logger.debug(f"Error Chunk: {chunk}") - # else: - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - else: - for chunk in run_gen_sync( - jamai.add_table_rows, - TableType.action, - RowAddRequest( - table_id=table_id, - data=[row], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - # if isinstance(chunk, ErrorChunk): - # logger.debug(f"Error Chunk: {chunk}") - # else: - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - for row_id_ in first_chunk_times.keys(): - for output_column_name in first_chunk_times[row_id_].keys(): - logger.debug( - f"> [Test] Time to first chunk for {output_column_name}: {first_chunk_times[row_id_].get(output_column_name, 'N/A'):.2f} seconds" - ) - break - - logger.debug( - f"> [Test] Stream Total Add Rows Time: {time.time() - total_start_time:.2f} seconds" - ) - - if gen_type == "REGEN": - rows = await run(jamai.list_table_rows, TableType.action, table_id) - total_start_time = time.time() - total_start_time = time.time() - first_chunk_times = {} - chunks = [] - if isinstance(jamai, JamAIAsync): - async for chunk in run_gen_async( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_item["ID"] for row_item in rows.items], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - # if isinstance(chunk, ErrorChunk): - # logger.debug(f"Error Chunk: {chunk}") - # else: - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - else: - for chunk in run_gen_sync( - jamai.regen_table_rows, - TableType.action, - RowRegenRequest( - table_id=table_id, - row_ids=[row_item["ID"] for row_item in rows.items], - stream=True, - concurrent=True, - ), - ): - chunks.append(chunk) - # if isinstance(chunk, ErrorChunk): - # logger.debug(f"Error Chunk: {chunk}") - # else: - if chunk.row_id not in first_chunk_times.keys(): - first_chunk_times[chunk.row_id] = {} - if chunk.output_column_name not in first_chunk_times[chunk.row_id].keys(): - first_chunk_times[chunk.row_id][chunk.output_column_name] = ( - time.time() - total_start_time - ) - - for row_id_ in first_chunk_times.keys(): - for output_column_name in first_chunk_times[row_id_].keys(): - logger.debug( - f"> [Test] Time to first chunk for {output_column_name}: {first_chunk_times[row_id_].get(output_column_name, 'N/A'):.2f} seconds" - ) - break - - logger.debug( - f"> [Test] Stream Total Regen Rows Time: {time.time() - total_start_time:.2f} seconds" - ) - - # Get first rows - rows = await run(jamai.list_table_rows, TableType.action, table_id) - row_id = rows.items[0]["ID"] - row = await run(jamai.get_table_row, TableType.action, table_id, row_id) - - # Compare generated outputs with expected outputs - for output_column_name in output_dict.keys(): - expected_gen = expected_column_gen[output_column_name] - column_gen = row[output_column_name]["value"] - len_expected_gen = len(expected_gen) - len_column_gen = len(column_gen) if column_gen is not None else 0 - if data_length == "normal": - if len_column_gen >= len_expected_gen: - assert column_gen[:len_expected_gen] == expected_gen - else: - assert column_gen == expected_gen[:len_column_gen] - else: - assert column_gen.startswith("[ERROR]") - - await run(jamai.delete_table, TableType.action, table_id) diff --git a/clients/python/tests/test_gen_table.py b/clients/python/tests/test_gen_table.py deleted file mode 100644 index f37490d..0000000 --- a/clients/python/tests/test_gen_table.py +++ /dev/null @@ -1,3220 +0,0 @@ -from collections import defaultdict -from contextlib import contextmanager -from decimal import Decimal -from os.path import join -from tempfile import TemporaryDirectory -from time import sleep -from typing import Any, Type - -import pandas as pd -import pytest -from flaky import flaky -from pydantic import ValidationError - -from jamaibase import JamAI -from jamaibase import protocol as p -from jamaibase.utils.io import csv_to_df, df_to_csv, json_loads - -CLIENT_CLS = [JamAI] -TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] - -TABLE_ID_A = "documents" -TABLE_ID_B = "xx" -TABLE_ID_C = "yy" -TABLE_ID_X = "zz" -TEXT = '"Arrival" is a 2016 American science fiction drama film directed by Denis Villeneuve and adapted by Eric Heisserer.' -TEXT_CN = ( - '"Arrival" 《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年美国科幻剧情片,由丹尼斯·维伦纽瓦执导,埃里克·海瑟尔改编。' -) -TEXT_JP = '"Arrival" 「Arrivalã€ã¯ã€ãƒ‰ã‚¥ãƒ‹ãƒ»ãƒ´ã‚£ãƒ«ãƒŒãƒ¼ãƒ´ãŒç›£ç£ã—ã€ã‚¨ãƒªãƒƒã‚¯ãƒ»ãƒã‚¤ã‚»ãƒ©ãƒ¼ãŒè„šè‰²ã—ãŸ2016å¹´ã®ã‚¢ãƒ¡ãƒªã‚«ã®SFドラマ映画ã§ã™ã€‚' - - -def _delete_tables(): - batch_size = 100 - jamai = JamAI() - for table_type in TABLE_TYPES: - offset, total = 0, 1 - while offset < total: - tables = jamai.list_tables(table_type, offset, batch_size) - assert isinstance(tables.items, list) - for table in tables.items: - jamai.delete_table(table_type, table.id) - total = tables.total - offset += batch_size - - -@pytest.fixture(scope="module", autouse=True) -def delete_tables(): - _delete_tables() - yield - _delete_tables() - - -def _get_chat_model() -> str: - models = JamAI().model_names(prefer="openai/gpt-3.5-turbo", capabilities=["chat"]) - return models[0] - - -def _get_embedding_model() -> str: - models = JamAI().model_names( - prefer="openai/text-embedding-3-small-512", capabilities=["embed"] - ) - return models[0] - - -def _get_reranking_model() -> str: - models = JamAI().model_names(prefer="cohere/rerank-english-v3.0", capabilities=["rerank"]) - return models[0] - - -def _rerun_on_fs_error_with_delay(err, *args): - sleep(1) - return "LanceError(IO): Generic LocalFileSystem error" in str(err) - - -@contextmanager -def _create_table(jamai: JamAI, table_type: p.TableType, name: str = TABLE_ID_A): - jamai.delete_table(table_type, name) - kwargs = dict( - id=name, - cols=[ - p.ColumnSchemaCreate(id="good", dtype=p.DtypeCreateEnum.bool_), - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate(id="stars", dtype=p.DtypeEnum.float_), - p.ColumnSchemaCreate(id="inputs", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user("Summarise this in ${words} words:\n\n${inputs}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are a wacky assistant.")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ] + kwargs["cols"] - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - else: - raise ValueError(f"Invalid table type: {table_type}") - try: - yield table - except Exception: - raise - finally: - jamai.delete_table(table_type, name) - - -def _add_row( - jamai: JamAI, - table_type: p.TableType, - stream: bool, - table_name: str = TABLE_ID_A, - data: dict | None = None, -): - if data is None: - data = dict(good=True, words=5, stars=7.9, inputs=TEXT) - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - data["Title"] = "Dune: Part Two." - data["Text"] = '"Dune: Part Two" is a 2024 American epic science fiction film.' - elif table_type == p.TableType.chat: - data["User"] = "Tell me a joke." - else: - raise ValueError(f"Invalid table type: {table_type}") - - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=table_name, data=[data], stream=stream), - ) - if stream: - return response - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert len(response.rows) == 1 - return response.rows[0] - - -def _assert_is_vector(x: Any): - assert isinstance(x, list), f"Not a list: {x}" - assert len(x) > 0, f"Not a non-empty list: {x}" - assert all(isinstance(v, float) for v in x), f"Not a list of floats: {x}" - - -def _collect_text(responses: list[p.GenTableStreamChatCompletionChunk], col: str): - return "".join(r.text for r in responses if r.output_column_name == col) - - -def _get_exponent(x: float) -> int: - return Decimal(str(x)).as_tuple().exponent - - -@flaky(max_runs=3, 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) -def test_create_delete_table(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table, _create_table(jamai, table_type, TABLE_ID_B): - assert isinstance(table, p.TableMetaResponse) - assert table.id == TABLE_ID_A - assert isinstance(table.cols, list) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - # Delete table B - table = jamai.get_table(table_type, TABLE_ID_B) - assert isinstance(table, p.TableMetaResponse) - jamai.delete_table(table_type, TABLE_ID_B) - with pytest.raises(RuntimeError): - jamai.get_table(table_type, TABLE_ID_B) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_create_table_with_valid_knowledge_table(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - knowledge_table_id = "test_knowledge_table" - - try: - # Create Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - - # Define schema for Action/Chat Table with a valid Knowledge Table reference - schema = p.TableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams(table_id=knowledge_table_id), # Valid reference - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - assert isinstance(table, p.TableMetaResponse) - assert table.id == TABLE_ID_A - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_create_table_with_invalid_knowledge_table( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - invalid_knowledge_table_id = "nonexistent_table" - - try: - # Define Schema for Action/Chat Table with an INVALID Knowledge Table reference - schema = p.TableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=invalid_knowledge_table_id - ), # Invalid reference - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - # Expect a RuntimeError (server-side ResourceNotFoundError) - with pytest.raises(RuntimeError): - if table_type == p.TableType.action: - jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - except Exception: - raise - finally: - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_create_table_with_valid_reranking_model(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - knowledge_table_id = "test_knowledge_table" - # Get a valid reranking model - valid_reranking_model = _get_reranking_model() - - try: - # Create Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - - # Define schema for Action/Chat Table with a VALID Reranking Model - schema = p.TableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=knowledge_table_id, reranking_model=valid_reranking_model - ), # Valid reranking model - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - assert isinstance(table, p.TableMetaResponse) - assert table.id == TABLE_ID_A - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_create_table_with_invalid_reranking_model( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - knowledge_table_id = "test_knowledge_table" - invalid_reranking_model = "nonexistent_reranker_model" - - try: - # Create Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - # Define schema for Action/Chat Table with an INVALID Reranking Model - schema = p.TableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=knowledge_table_id, - reranking_model=invalid_reranking_model, - ), # Invalid reranking model - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - with pytest.raises(RuntimeError): - if table_type == p.TableType.action: - jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, 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_create_table_without_llm_model( - client_cls: Type[JamAI], table_type: p.TableType, stream: bool -): - jamai = client_cls() - try: - kwargs = dict( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="inputs", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model="", - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user("Summarise ${inputs}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are a wacky assistant.")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ] + kwargs["cols"] - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - assert isinstance(table, p.TableMetaResponse) - assert table.id == TABLE_ID_A - summary_col = [c for c in table.cols if c.id == "summary"][0] - assert summary_col.gen_config["model"] is not None - assert len(summary_col.gen_config["model"]) > 0 - - # Try adding row - data = dict( - inputs="LanceDB is an open-source vector database for AI that's designed to store, manage, query and retrieve embeddings on large-scale multi-modal data." - ) - if table_type == p.TableType.knowledge: - data["Title"] = "Dune: Part Two." - data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=TABLE_ID_A, data=[data], stream=stream), - ) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" for r in responses) - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert len(response.rows) == 1 - row = response.rows[0] - assert isinstance(row, p.GenTableChatCompletionChunks) - assert "summary" in row.columns - except Exception: - raise - finally: - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, 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) -def test_add_drop_columns(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - - # --- COLUMN ADD --- # - cols = [ - p.ColumnSchemaCreate(id="add_bool", dtype=p.DtypeCreateEnum.bool_), - p.ColumnSchemaCreate(id="add_int", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate(id="add_float", dtype=p.DtypeCreateEnum.float_), - p.ColumnSchemaCreate(id="add_str", dtype=p.DtypeCreateEnum.str_), - ] - expected_cols = { - "ID", - "Updated at", - "good", - "words", - "stars", - "inputs", - "summary", - "add_bool", - "add_int", - "add_float", - "add_str", - } - if table_type == p.TableType.action: - table = jamai.add_action_columns(p.AddActionColumnSchema(id=TABLE_ID_A, cols=cols)) - elif table_type == p.TableType.knowledge: - table = jamai.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=TABLE_ID_A, cols=cols) - ) - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} - elif table_type == p.TableType.chat: - expected_cols |= {"User", "AI"} - table = jamai.add_chat_columns(p.AddChatColumnSchema(id=TABLE_ID_A, cols=cols)) - else: - raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - cols = set(c.id for c in table.cols) - assert cols == expected_cols, cols - # Existing row of new columns should contain None - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert all(set(r.keys()) == expected_cols for r in rows.items) - assert len(rows.items) == 1 - row = rows.items[0] - for col in ["add_bool", "add_int", "add_float", "add_str"]: - assert row[col]["value"] is None - # Test adding new rows - for i in range(5): - _add_row( - jamai, - table_type, - False, - data=dict( - good=True, - words=5 + i, - stars=9.9, - inputs=TEXT, - summary="", - add_bool=False, - add_int=0, - add_float=1.0, - add_str="pretty", - ), - ) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert all(set(r.keys()) == expected_cols for r in rows.items) - assert len(rows.items) == 6 - row = rows.items[0] # Should retrieve the latest row - for col in ["add_bool", "add_int", "add_float", "add_str"]: - assert row[col]["value"] is not None - - # --- COLUMN DROP --- # - table = jamai.drop_columns( - table_type, - p.ColumnDropRequest( - table_id=TABLE_ID_A, - column_names=["good", "stars", "add_bool", "add_int", "add_str"], - ), - ) - expected_cols = { - "ID", - "Updated at", - "words", - "inputs", - "summary", - "add_float", - } - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} - elif table_type == p.TableType.chat: - expected_cols |= {"User", "AI"} - else: - raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - cols = set(c.id for c in table.cols) - assert cols == expected_cols, cols - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 6 - assert all(set(r.keys()) == expected_cols for r in rows.items) - # Test adding a few rows - for i in range(5): - _add_row( - jamai, - table_type, - False, - data=dict(words=5 + i, inputs=TEXT, add_float=0.0), - ) - _add_row( - jamai, - table_type, - False, - data=dict(words=4, inputs=TEXT, summary="", add_float=1.0), - ) - _add_row( - jamai, - table_type, - False, - data=dict(words=3, inputs=TEXT, summary="", add_float=2.0), - ) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 13 - assert all(set(r.keys()) == expected_cols for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - - -@flaky(max_runs=3, 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) -def test_rename_columns(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - # Test rename empty table - table = jamai.rename_columns( - table_type, - p.ColumnRenameRequest(table_id=TABLE_ID_A, column_map=dict(good="nice")), - ) - assert isinstance(table, p.TableMetaResponse) - expected_cols = {"ID", "Updated at", "nice", "words", "stars", "inputs", "summary"} - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} - elif table_type == p.TableType.chat: - expected_cols |= {"User", "AI"} - else: - raise ValueError(f"Invalid table type: {table_type}") - cols = set(c.id for c in table.cols) - assert cols == expected_cols - - table = jamai.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) - cols = set(c.id for c in table.cols) - assert cols == expected_cols - # Test adding data with new column names - _add_row( - jamai, - table_type, - False, - data=dict(nice=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - # Test rename table with data - # Test also auto gen config reference update - table = jamai.rename_columns( - table_type, - p.ColumnRenameRequest(table_id=TABLE_ID_A, column_map=dict(words="length")), - ) - assert isinstance(table, p.TableMetaResponse) - expected_cols = {"ID", "Updated at", "nice", "length", "stars", "inputs", "summary"} - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} - elif table_type == p.TableType.chat: - expected_cols |= {"User", "AI"} - else: - raise ValueError(f"Invalid table type: {table_type}") - cols = set(c.id for c in table.cols) - assert cols == expected_cols - table = jamai.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) - cols = set(c.id for c in table.cols) - assert cols == expected_cols - # Test auto gen config reference update - response = _add_row( - jamai, - table_type, - True, - data=dict(nice=True, length=5, stars=9.9, inputs=TEXT), - ) - summary = _collect_text(list(response), "summary") - assert len(summary) > 0 - - -@flaky(max_runs=3, 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) -def test_reorder_columns(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - table = jamai.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) - - column_names = ["inputs", "good", "words", "stars", "summary"] - expected_order = ["ID", "Updated at", "good", "words", "stars", "inputs", "summary"] - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] - expected_order = ( - expected_order[:2] - + ["Title", "Title Embed", "Text", "Text Embed", "File ID"] - + expected_order[2:] - ) - elif table_type == p.TableType.chat: - column_names += ["User", "AI"] - expected_order = expected_order[:2] + ["User", "AI"] + expected_order[2:] - else: - raise ValueError(f"Invalid table type: {table_type}") - cols = [c.id for c in table.cols] - assert cols == expected_order, cols - # Test reorder empty table - table = jamai.reorder_columns( - table_type, - p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), - ) - expected_order = ["ID", "Updated at", "inputs", "good", "words", "stars", "summary"] - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] - elif table_type == p.TableType.chat: - expected_order += ["User", "AI"] - else: - raise ValueError(f"Invalid table type: {table_type}") - cols = [c.id for c in table.cols] - assert cols == expected_order, cols - table = jamai.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) - cols = [c.id for c in table.cols] - assert cols == expected_order, cols - # Test add row - response = _add_row( - jamai, - table_type, - True, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT), - ) - summary = _collect_text(list(response), "summary") - assert len(summary) > 0 - - # --- Test validation --- # - column_names = ["inputs", "good", "stars", "summary", "words"] - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID"] - elif table_type == p.TableType.chat: - column_names += ["User", "AI"] - else: - raise ValueError(f"Invalid table type: {table_type}") - with pytest.raises(RuntimeError, match="validation_error"): - jamai.reorder_columns( - table_type, - p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), - ) - - -@flaky(max_runs=3, 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_update_gen_config(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - table = jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest( - table_id=TABLE_ID_A, - column_map=dict( - summary=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "I am a unicorn.".'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump() - ), - ), - ) - assert isinstance(table, p.TableMetaResponse) - # Check gen config - table = jamai.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) - assert table.cols[-1].id == "summary" - assert table.cols[-1].gen_config is not None - assert "unicorn" in table.cols[-1].gen_config["messages"][-1]["content"] - # Test adding row - data = dict(good=True, words=5, stars=7.9, inputs=TEXT) - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - data["Title"] = "Dune: Part Two." - data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." - elif table_type == p.TableType.chat: - data["User"] = "Tell me a joke." - else: - raise ValueError(f"Invalid table type: {table_type}") - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=TABLE_ID_A, data=[data], stream=stream), - ) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" for r in responses) - assert "unicorn" in "".join(r.text for r in responses) - 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.GenTableRowsChatCompletionChunks) - assert len(response.rows) == 1 - row = response.rows[0] - assert isinstance(row, p.GenTableChatCompletionChunks) - assert row.object == "gen_table.completion.chunks" - assert "unicorn" in row.columns["summary"].text - assert isinstance(row.columns["summary"].usage, p.CompletionUsage) - assert isinstance(row.columns["summary"].prompt_tokens, int) - assert isinstance(row.columns["summary"].completion_tokens, int) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_update_gen_config_with_valid_knowledge_table( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - current_table_id = TABLE_ID_A - knowledge_table_id = "test_knowledge_table" - - try: - # Create a Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - - jamai.delete_table(table_type, current_table_id) - # Create a Action/Chat Table - schema = p.TableSchemaCreate( - id=current_table_id, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - # Update gen_config with valid Knowledge Table reference in RAGParams - updated_config = p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "Hello, world!"'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams(table_id=knowledge_table_id), # Reference Knowledge Table - ).model_dump() - - table = jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest( - table_id=current_table_id, - column_map=dict(AI=updated_config), - ), - ) - - assert isinstance(table, p.TableMetaResponse) - assert table.cols[-1].id == "AI" - assert table.cols[-1].gen_config is not None - assert table.cols[-1].gen_config["rag_params"]["table_id"] == knowledge_table_id - - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, current_table_id) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_update_gen_config_with_invalid_knowledge_table( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - current_table_id = TABLE_ID_A - invalid_knowledge_table_id = "nonexistent_table" - - try: - with _create_table(jamai, table_type) as action_table: - assert isinstance(action_table, p.TableMetaResponse) - - jamai.delete_table(table_type, current_table_id) - # Create a Action/Chat Table - schema = p.TableSchemaCreate( - id=current_table_id, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - # Update gen_config with an INVALID Knowledge Table reference in RAGParams - updated_config = p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "Hello, world!"'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams(table_id=invalid_knowledge_table_id), # Invalid reference! - ).model_dump() - - # Expect a RuntimeError (server-side ResourceNotFoundError) - with pytest.raises(RuntimeError): - jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest( - table_id=current_table_id, - column_map=dict(summary=updated_config), - ), - ) - except Exception: - raise - finally: - jamai.delete_table(table_type, current_table_id) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_update_gen_config_with_valid_reranking_model( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - current_table_id = TABLE_ID_A - knowledge_table_id = "test_knowledge_table" - # Get a valid reranking model - valid_reranking_model = _get_reranking_model() - - try: - # Create a Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - - jamai.delete_table(table_type, current_table_id) - # Create a Action/Chat Table - schema = p.TableSchemaCreate( - id=current_table_id, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - # Update gen_config with valid reranking model in RAGParams - updated_config = p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "Hello, world!"'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=knowledge_table_id, reranking_model=valid_reranking_model - ), # Valid reranking model - ).model_dump() - - table = jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest( - table_id=current_table_id, - column_map=dict(AI=updated_config), - ), - ) - - assert isinstance(table, p.TableMetaResponse) - assert table.cols[-1].id == "AI" - assert table.cols[-1].gen_config is not None - assert table.cols[-1].gen_config["rag_params"]["table_id"] == knowledge_table_id - assert ( - table.cols[-1].gen_config["rag_params"]["reranking_model"] == valid_reranking_model - ) - - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, current_table_id) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", [p.TableType.action, p.TableType.chat]) -def test_update_gen_config_with_invalid_reranking_model( - client_cls: Type[JamAI], table_type: p.TableType -): - jamai = client_cls() - current_table_id = TABLE_ID_A - knowledge_table_id = "test_knowledge_table" - invalid_reranking_model = "nonexistent_reranker_model" - - try: - # Create a Knowledge Table - with _create_table( - jamai, p.TableType.knowledge, name=knowledge_table_id - ) as knowledge_table: - assert isinstance(knowledge_table, p.TableMetaResponse) - - jamai.delete_table(table_type, current_table_id) - # Create a Action/Chat Table - schema = p.TableSchemaCreate( - id=current_table_id, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Summarize: ${User}"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - schema_dict = schema.model_dump() - - if table_type == p.TableType.action: - jamai.create_action_table(p.ActionTableSchemaCreate(**schema_dict)) - elif table_type == p.TableType.chat: - jamai.create_chat_table(p.ChatTableSchemaCreate(**schema_dict)) - else: - raise ValueError(f"Invalid table type: {table_type}") - - # Update gen_config with valid reranking model in RAGParams - updated_config = p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "Hello, world!"'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=knowledge_table_id, reranking_model=invalid_reranking_model - ), # Invalid reranking model - ).model_dump() - - with pytest.raises(RuntimeError): - jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest( - table_id=current_table_id, - column_map=dict(summary=updated_config), - ), - ) - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - jamai.delete_table(table_type, current_table_id) - - -@flaky(max_runs=3, 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_null_gen_config(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - table = jamai.update_gen_config( - table_type, - p.GenConfigUpdateRequest(table_id=TABLE_ID_A, column_map=dict(summary=None)), - ) - response = _add_row( - jamai, table_type, stream, data=dict(good=True, words=5, stars=9.9, inputs=TEXT) - ) - if stream: - # Must wait until stream ends - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - else: - assert isinstance(response, p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - assert row["summary"]["value"] is None - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_gen_config_empty_prompts( - client_cls: Type[JamAI], - table_type: p.TableType, - stream: bool, -): - jamai = client_cls() - table_name = TABLE_ID_A - jamai.delete_table(table_type, table_name) - try: - kwargs = dict( - id=table_name, - cols=[ - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system(""), p.ChatEntry.user("")], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ] + kwargs["cols"] - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - - assert isinstance(table, p.TableMetaResponse) - data = dict(words=5) - if table_type == p.TableType.knowledge: - data["Title"] = "Dune: Part Two." - data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=table_name, data=[data], stream=stream), - ) - if stream: - # Must wait until stream ends - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - summary = "".join(r.text for r in responses if r.output_column_name == "summary") - assert len(summary) > 0 - if table_type == p.TableType.chat: - ai = "".join(r.text for r in responses if r.output_column_name == "AI") - assert len(ai) > 0 - else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - except Exception: - raise - finally: - jamai.delete_table(table_type, table_name) - - -@flaky(max_runs=3, 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) -def test_gen_config_no_message( - client_cls: Type[JamAI], - table_type: p.TableType, -): - jamai = client_cls() - table_name = TABLE_ID_A - jamai.delete_table(table_type, table_name) - try: - with pytest.raises(ValidationError, match="at least 1 item"): - kwargs = dict( - id=table_name, - cols=[ - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ] + kwargs["cols"] - jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - except Exception: - raise - finally: - jamai.delete_table(table_type, table_name) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("stream", [True, False]) -def test_knowledge_table_embedding(client_cls: Type[JamAI], stream: bool): - jamai = client_cls() - try: - # Create Knowledge Table and add some rows - knowledge_table_id = TABLE_ID_A - table_type = p.TableType.knowledge - jamai.delete_table(table_type, knowledge_table_id) - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate( - id=knowledge_table_id, cols=[], embedding_model=_get_embedding_model() - ) - ) - assert isinstance(table, p.TableMetaResponse) - # Don't include embeddings - data = [ - dict( - Title="Six-spot burnet", - Text="The six-spot burnet is a day-flying moth of the family Zygaenidae.", - ), - # Test missing Title - dict( - Text="In machine learning, a neural network is a model inspired by biological neural networks in animal brains.", - ), - # Test missing Text - dict( - Title="A supercomputer is a type of computer with a high level of performance as compared to a general-purpose computer.", - ), - ] - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=knowledge_table_id, data=data, stream=stream), - ) - if stream: - responses = [r for r in response] - assert len(responses) == 0 # We currently dont return anything if LLM is not called - else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - # Check embeddings - rows = jamai.list_table_rows(table_type, knowledge_table_id) - assert isinstance(rows.items, list) - assert len(rows.items) == 3 - row = rows.items[2] - assert row["Title"]["value"] == data[0]["Title"], row - assert row["Text"]["value"] == data[0]["Text"], row - _assert_is_vector(row["Title Embed"]["value"]) - _assert_is_vector(row["Text Embed"]["value"]) - row = rows.items[1] - assert row["Title"]["value"] is None, row - assert row["Text"]["value"] == data[1]["Text"], row - _assert_is_vector(row["Text Embed"]["value"]) - row = rows.items[0] - assert row["Title"]["value"] == data[2]["Title"], row - assert row["Text"]["value"] is None, row - _assert_is_vector(row["Title Embed"]["value"]) - except Exception: - raise - finally: - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_rag(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - try: - # Create Knowledge Table and add some rows - knowledge_table_id = TABLE_ID_A - jamai.delete_table(p.TableType.knowledge, knowledge_table_id) - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate( - id=knowledge_table_id, cols=[], embedding_model=_get_embedding_model() - ) - ) - assert isinstance(table, p.TableMetaResponse) - response = jamai.add_table_rows( - p.TableType.knowledge, - p.RowAddRequest( - table_id=knowledge_table_id, - data=[ - dict( - Title="Six-spot burnet", - Text="The six-spot burnet is a day-flying moth of the family Zygaenidae.", - ), - # Test missing Title - dict( - Text="In machine learning, a neural network is a model inspired by biological neural networks in animal brains.", - ), - # Test missing Text - dict( - Title="A supercomputer is a type of computer with a high level of performance as compared to a general-purpose computer.", - ), - ], - stream=False, - ), - ) - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(p.TableType.knowledge, knowledge_table_id) - assert isinstance(rows.items, list) - assert len(rows.items) == 3 - - # Create the other table - name = TABLE_ID_B - jamai.delete_table(table_type, name) - kwargs = dict( - id=name, - cols=[ - p.ColumnSchemaCreate(id="question", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="rag", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("${question}? Summarise in ${words} words"), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - rag_params=p.RAGParams( - table_id=knowledge_table_id, - reranking_model=_get_reranking_model(), - search_query="", # Generate using LM - rerank=True, - ), - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are a wacky assistant.")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ] + kwargs["cols"] - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - assert isinstance(table, p.TableMetaResponse) - # Perform RAG - data = dict(question="What is a burnet?", words=5) - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=name, data=[data], stream=stream), - ) - if stream: - responses = [r for r in response] - assert len(responses) > 0 - if table_type == p.TableType.chat: - responses = [r for r in responses if r.output_column_name == "rag"] - assert isinstance(responses[0], p.GenTableStreamReferences) - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses[1:]) - else: - assert len(response.rows) > 0 - for row in response.rows: - assert isinstance(row, p.GenTableChatCompletionChunks) - assert len(row.columns) > 0 - if table_type == p.TableType.chat: - assert "AI" in row.columns - assert "rag" in row.columns - assert isinstance(row.columns["rag"], p.ChatCompletionChunk) - assert isinstance(row.columns["rag"].references, p.References) - - except Exception: - raise - finally: - jamai.delete_table(p.TableType.knowledge, TABLE_ID_A) - jamai.delete_table(table_type, TABLE_ID_B) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("stream", [True, False]) -def test_knowledge_table_null_source( - client_cls: Type[JamAI], - stream: bool, -): - jamai = client_cls() - table_name = TABLE_ID_A - table_type = p.TableType.knowledge - jamai.delete_table(table_type, table_name) - try: - kwargs = dict( - id=table_name, - cols=[ - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system(""), p.ChatEntry.user("")], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - assert isinstance(table, p.TableMetaResponse) - # Purposely leave out Title and Text - data = dict(words=5) - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=table_name, data=[data], stream=stream), - ) - if stream: - # Must wait until stream ends - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - summary = "".join(r.text for r in responses if r.output_column_name == "summary") - assert len(summary) > 0 - if table_type == p.TableType.chat: - ai = "".join(r.text for r in responses if r.output_column_name == "AI") - assert len(ai) > 0 - else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - except Exception: - raise - finally: - jamai.delete_table(table_type, table_name) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("stream", [True, False]) -def test_conversation_starter(client_cls: Type[JamAI], stream: bool): - jamai = client_cls() - table_name = TABLE_ID_A - table_type = p.TableType.chat - jamai.delete_table(table_type, table_name) - try: - table = jamai.create_chat_table( - p.ChatTableSchemaCreate( - id=table_name, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You help remember facts.")], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - p.ColumnSchemaCreate(id="words", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="summary", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are an assistant")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - ], - ) - ) - assert isinstance(table, p.TableMetaResponse) - # Add the starter - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=table_name, data=[dict(AI="x = 5")], stream=stream), - ) - if stream: - # Must wait until stream ends - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - # Chat with it - response = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=table_name, - data=[dict(User="x = ")], - stream=stream, - ), - ) - if stream: - # Must wait until stream ends - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - answer = "".join(r.text for r in responses if r.output_column_name == "AI") - assert "5" in answer - summary = "".join(r.text for r in responses if r.output_column_name == "summary") - assert len(summary) > 0 - else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) - except Exception: - raise - finally: - jamai.delete_table(table_type, table_name) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_add_row(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - response = _add_row(jamai, table_type, stream) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" 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" - assert len(response.columns["summary"].text) > 0 - assert isinstance(response.columns["summary"].usage, p.CompletionUsage) - assert isinstance(response.columns["summary"].prompt_tokens, int) - assert isinstance(response.columns["summary"].completion_tokens, int) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - assert row["good"]["value"] is True, row["good"] - assert row["words"]["value"] == 5, row["words"] - assert row["stars"]["value"] == 7.9, row["stars"] - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_add_row_wrong_dtype(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - response = _add_row(jamai, table_type, stream) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" for r in responses) - assert len("".join(r.text for r in responses)) > 0 - else: - assert isinstance(response, p.GenTableChatCompletionChunks) - assert response.object == "gen_table.completion.chunks" - assert len(response.columns["summary"].text) > 0 - - # Test adding data with wrong dtype - response = _add_row( - jamai, - table_type, - stream, - TABLE_ID_A, - data=dict(good="dummy1", words="dummy2", stars="dummy3", inputs=TEXT), - ) - if stream: - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - else: - assert isinstance(response, p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 2 - row = rows.items[0] - assert row["good"]["value"] is None, row["good"] - assert row["good"]["original"] == "dummy1", row["good"] - assert row["words"]["value"] is None, row["words"] - assert row["words"]["original"] == "dummy2", row["words"] - assert row["stars"]["value"] is None, row["stars"] - assert row["stars"]["original"] == "dummy3", row["stars"] - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_add_row_missing_columns(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - response = _add_row(jamai, table_type, stream) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" for r in responses) - assert len("".join(r.text for r in responses)) > 0 - else: - assert isinstance(response, p.GenTableChatCompletionChunks) - assert response.object == "gen_table.completion.chunks" - assert len(response.columns["summary"].text) > 0 - - # Test adding data with missing column - response = _add_row( - jamai, - table_type, - stream, - TABLE_ID_A, - data=dict(good="dummy1", inputs=TEXT), - ) - if stream: - responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - else: - assert isinstance(response, p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 2 - row = rows.items[0] - assert row["good"]["value"] is None, row["good"] - assert row["good"]["original"] == "dummy1", row["good"] - assert row["words"]["value"] is None, row["words"] - assert "original" not in row["words"], row["words"] - assert row["stars"]["value"] is None, row["stars"] - assert "original" not in row["stars"], row["stars"] - - -@flaky(max_runs=3, 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) -def test_update_row(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - row = _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy"), - ) - assert isinstance(row, p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - original_ts = row["Updated at"] - assert row["good"]["value"] is True, row["good"] - assert row["words"]["value"] == 5, row["words"] - assert row["stars"]["value"] == 9.9, row["stars"] - # Regular update - response = jamai.update_table_row( - table_type, - p.RowUpdateRequest( - table_id=TABLE_ID_A, - row_id=row["ID"], - data=dict(good=False, stars=1.0), - ), - ) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - assert row["good"]["value"] is False, row["good"] - assert row["words"]["value"] == 5, row["words"] - assert row["stars"]["value"] == 1.0, row["stars"] - assert row["Updated at"] > original_ts - - # Test updating data with wrong dtype - response = jamai.update_table_row( - table_type, - p.RowUpdateRequest( - table_id=TABLE_ID_A, - row_id=row["ID"], - data=dict(good="dummy", words="dummy", stars="dummy"), - ), - ) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - assert row["good"]["value"] is None, row["good"] - assert row["words"]["value"] is None, row["words"] - assert row["stars"]["value"] is None, row["stars"] - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_regen_rows(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - response = _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=10, stars=9.9, inputs=TEXT), - ) - assert isinstance(response, p.GenTableChatCompletionChunks) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - _id = row["ID"] - original_ts = row["Updated at"] - assert "arrival" in row["summary"]["value"].lower() - # Regen - jamai.update_table_row( - table_type, - p.RowUpdateRequest( - table_id=TABLE_ID_A, - row_id=_id, - data=dict( - inputs="Dune: Part Two is a 2024 American epic science fiction film directed and produced by Denis Villeneuve" - ), - ), - ) - response = jamai.regen_table_rows( - table_type, p.RowRegenRequest(table_id=TABLE_ID_A, row_ids=[_id], stream=stream) - ) - 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 ("summary", "AI") for r in responses) - else: - assert all(r.output_column_name == "summary" for r in responses) - assert len("".join(r.text for r in responses)) > 0 - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert response.rows[0].object == "gen_table.completion.chunks" - assert len(response.rows[0].columns["summary"].text) > 0 - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 1 - row = rows.items[0] - assert row["good"]["value"] is True - assert row["words"]["value"] == 10 - assert row["stars"]["value"] == 9.9 - assert row["Updated at"] > original_ts - assert "dune" in row["summary"]["value"].lower() - - -@flaky(max_runs=3, 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) -def test_delete_rows(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") - _add_row(jamai, table_type, False, data=data) - _add_row(jamai, table_type, False, data=data) - _add_row(jamai, table_type, False, data=data) - _add_row(jamai, table_type, False, data=data) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=7.9, inputs=TEXT_CN), - ) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=7.9, inputs=TEXT_JP), - ) - ori_rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(ori_rows.items, list) - assert len(ori_rows.items) == 6 - delete_id = ori_rows.items[0]["ID"] - - # Delete one row - response = jamai.delete_table_row(table_type, TABLE_ID_A, delete_id) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 5 - row_ids = set(r["ID"] for r in rows.items) - assert delete_id not in row_ids - # Delete multiple rows - delete_ids = [r["ID"] for r in ori_rows.items[1:4]] - response = jamai.delete_table_rows( - table_type, - p.RowDeleteRequest( - table_id=TABLE_ID_A, - row_ids=delete_ids, - ), - ) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert len(rows.items) == 2 - row_ids = set(r["ID"] for r in rows.items) - assert len(set(row_ids) & set(delete_ids)) == 0 - - -@flaky(max_runs=3, 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) -def test_get_and_list_rows(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - _add_row(jamai, table_type, False) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=5 / 3, inputs=TEXT, summary=""), - ) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=1 / 3, inputs=TEXT_CN, summary=""), - ) - _add_row( - jamai, - table_type, - False, - data=dict(good=False, words=5, stars=-5 / 3, inputs=TEXT_JP, summary=""), - ) - _add_row( - jamai, - table_type, - False, - data=dict(good=False, words=5, stars=-1 / 3, inputs=TEXT, summary=""), - ) - # Regular case - expected_cols = {"ID", "Updated at", "good", "words", "stars", "inputs", "summary"} - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID"} - elif table_type == p.TableType.chat: - expected_cols |= {"User", "AI"} - else: - raise ValueError(f"Invalid table type: {table_type}") - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert all(isinstance(r, dict) for r in rows.items) - assert rows.total == 5 - assert rows.offset == 0 - assert rows.limit == 100 - assert len(rows.items) == 5 - stars = [r["stars"]["value"] for r in rows.items] - assert stars[0] == -1 / 3 - assert stars[-1] == 7.9 - assert all(set(r.keys()) == expected_cols for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - # Test get row - _id = rows.items[0]["ID"] - row = jamai.get_table_row(table_type, TABLE_ID_A, _id) - assert row["ID"] == _id - assert set(row.keys()) == expected_cols - row = jamai.get_table_row(table_type, TABLE_ID_A, _id, columns=["good"]) - assert row["ID"] == _id - assert set(row.keys()) == {"ID", "Updated at", "good"} - - # Test various offset and limit - rows = jamai.list_table_rows(table_type, TABLE_ID_A, offset=0, limit=3) - assert isinstance(rows.items, list) - assert rows.total == 5 - assert rows.offset == 0 - assert rows.limit == 3 - assert len(rows.items) == 3 - stars = [r["stars"]["value"] for r in rows.items] - assert stars[0] == -1 / 3 - assert stars[-1] == 1 / 3 - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, offset=1, limit=3) - assert isinstance(rows.items, list) - assert rows.total == 5 - assert rows.offset == 1 - assert rows.limit == 3 - assert len(rows.items) == 3 - stars = [r["stars"]["value"] for r in rows.items] - assert stars[0] == -5 / 3 - assert stars[-1] == 5 / 3 - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, offset=4, limit=3) - assert isinstance(rows.items, list) - assert rows.total == 5 - assert rows.offset == 4 - assert rows.limit == 3 - assert len(rows.items) == 1 - stars = [r["stars"]["value"] for r in rows.items] - assert stars[0] == 7.9 - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, offset=6, limit=3) - assert isinstance(rows.items, list) - assert rows.total == 5 - assert rows.offset == 6 - assert rows.limit == 3 - assert len(rows.items) == 0 - - # Test specifying columns - rows = jamai.list_table_rows( - table_type, TABLE_ID_A, offset=1, limit=3, columns=["stars", "good"] - ) - assert isinstance(rows.items, list) - assert rows.total == 5 - assert rows.offset == 1 - assert rows.limit == 3 - assert len(rows.items) == 3 - assert all(set(r.keys()) == {"ID", "Updated at", "good", "stars"} for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - - # Invalid offset and limit - with pytest.raises(RuntimeError): - jamai.list_table_rows(table_type, TABLE_ID_A, offset=0, limit=0) - with pytest.raises(RuntimeError): - jamai.list_table_rows(table_type, TABLE_ID_A, offset=-1, limit=1) - - # Test search query - rows = jamai.list_table_rows( - table_type, - TABLE_ID_A, - offset=0, - limit=3, - search_query="dummy", - columns=["summary"], - ) - assert isinstance(rows.items, list) - assert rows.total == 2 - assert rows.offset == 0 - assert rows.limit == 3 - assert len(rows.items) == 2 - assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - rows = jamai.list_table_rows( - table_type, - TABLE_ID_A, - offset=1, - limit=3, - search_query="dummy", - columns=["summary"], - ) - assert isinstance(rows.items, list) - assert rows.total == 2 - assert rows.offset == 1 - assert rows.limit == 3 - assert len(rows.items) == 1 - assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - rows = jamai.list_table_rows( - table_type, - TABLE_ID_A, - offset=0, - limit=100, - search_query="yummy", - columns=["summary"], - ) - assert isinstance(rows.items, list) - assert rows.total == 1 - assert rows.offset == 0 - assert rows.limit == 100 - assert len(rows.items) == 1 - assert all(set(r.keys()) == {"ID", "Updated at", "summary"} for r in rows.items), [ - list(r.keys()) for r in rows.items - ] - - # --- Test precision --- # - # At least 10 decimals - rows = jamai.list_table_rows(table_type, TABLE_ID_A, limit=4) - assert isinstance(rows.items, list) - exponents = [_get_exponent(r["stars"]["value"]) for r in rows.items] - assert all(e < -10 for e in exponents) - for row in rows.items: - for v in row.values(): - if not isinstance(v, list): - continue - exponents = [_get_exponent(v["value"]) for r in rows.items] - assert all(e < -10 for e in exponents) - # 5 decimals - rows = jamai.list_table_rows( - table_type, TABLE_ID_A, limit=4, float_decimals=5, vec_decimals=5 - ) - assert isinstance(rows.items, list) - exponents = [_get_exponent(r["stars"]["value"]) for r in rows.items] - assert all(e == -5 for e in exponents) - for row in rows.items: - for v in row.values(): - if not isinstance(v, list): - continue - exponents = [_get_exponent(v["value"]) for r in rows.items] - assert all(e == -5 for e in exponents) - # 1 decimal - rows = jamai.list_table_rows( - table_type, TABLE_ID_A, limit=4, float_decimals=1, vec_decimals=1 - ) - assert isinstance(rows.items, list) - exponents = [_get_exponent(r["stars"]["value"]) for r in rows.items] - assert all(e == -1 for e in exponents) - for row in rows.items: - for v in row.values(): - if not isinstance(v, list): - continue - exponents = [_get_exponent(v["value"]) for r in rows.items] - assert all(e == -1 for e in exponents) - - -@flaky(max_runs=3, 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) -def test_get_and_list_tables(client_cls: Type[JamAI], table_type: p.TableType): - _delete_tables() - jamai = client_cls() - with ( - _create_table(jamai, table_type) as table, - _create_table(jamai, table_type, TABLE_ID_B), - _create_table(jamai, table_type, TABLE_ID_C), - _create_table(jamai, table_type, TABLE_ID_X), - ): - assert isinstance(table, p.TableMetaResponse) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - - # Regular case - table = jamai.get_table(table_type, TABLE_ID_B) - assert isinstance(table, p.TableMetaResponse) - assert table.id == TABLE_ID_B - - tables = jamai.list_tables(table_type) - assert isinstance(tables.items, list) - assert tables.total == 4 - assert tables.offset == 0 - assert tables.limit == 100 - assert len(tables.items) == 4 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) - - # Test various offset and limit - tables = jamai.list_tables(table_type, offset=3, limit=2) - assert isinstance(tables.items, list) - assert tables.total == 4 - assert tables.offset == 3 - assert tables.limit == 2 - assert len(tables.items) == 1 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) - - tables = jamai.list_tables(table_type, offset=4, limit=2) - assert isinstance(tables.items, list) - assert tables.total == 4 - assert tables.offset == 4 - assert tables.limit == 2 - assert len(tables.items) == 0 - - tables = jamai.list_tables(table_type, offset=5, limit=2) - assert isinstance(tables.items, list) - assert tables.total == 4 - assert tables.offset == 5 - assert tables.limit == 2 - assert len(tables.items) == 0 - - -@flaky(max_runs=3, 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) -def test_duplicate_table(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - - # Duplicate with data - table = jamai.duplicate_table(table_type, TABLE_ID_A, TABLE_ID_B) - # Add another to table A - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - assert table.id == TABLE_ID_B - rows = jamai.list_table_rows(table_type, TABLE_ID_B) - assert len(rows.items) == 1 - - # Duplicate without data - table = jamai.duplicate_table(table_type, TABLE_ID_A, TABLE_ID_C, include_data=False) - assert table.id == TABLE_ID_C - rows = jamai.list_table_rows(table_type, TABLE_ID_C) - assert len(rows.items) == 0 - - # Deploy with data - jamai.delete_table(table_type, TABLE_ID_B) - jamai.delete_table(table_type, TABLE_ID_C) - table = jamai.duplicate_table(table_type, TABLE_ID_A, TABLE_ID_B, deploy=True) - assert table.id == TABLE_ID_B - assert table.parent_id == TABLE_ID_A - rows = jamai.list_table_rows(table_type, TABLE_ID_B) - assert len(rows.items) == 2 - - # Deploy without data - table = jamai.duplicate_table( - table_type, TABLE_ID_A, TABLE_ID_C, deploy=True, include_data=False - ) - assert table.id == TABLE_ID_C - assert table.parent_id == TABLE_ID_A - rows = jamai.list_table_rows(table_type, TABLE_ID_C) - assert len(rows.items) == 0 - jamai.delete_table(table_type, TABLE_ID_B) - jamai.delete_table(table_type, TABLE_ID_C) - - -@flaky(max_runs=3, 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) -def test_rename_table(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - _add_row( - jamai, - table_type, - False, - data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), - ) - table = jamai.rename_table(table_type, TABLE_ID_A, TABLE_ID_B) - rows = jamai.list_table_rows(table_type, TABLE_ID_B) - assert len(rows.items) == 1 - with pytest.raises(RuntimeError): - jamai.list_table_rows(table_type, TABLE_ID_A) - jamai.delete_table(table_type, TABLE_ID_B) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -def test_column_interpolate(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - - @contextmanager - def _create_table(name: str): - jamai.delete_table(table_type, name) - kwargs = dict( - id=name, - cols=[ - p.ColumnSchemaCreate( - id="output0", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user('Say "Jan has 5 apples.".'), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - p.ColumnSchemaCreate(id="input0", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="output1", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user( - ( - "1. ${output0}\n2. Jan has ${input0} apples.\n\n" - "Do the statements agree with each other? Reply Yes or No." - ) - ), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - if table_type == p.TableType.action: - table = jamai.create_action_table(p.ActionTableSchemaCreate(**kwargs)) - elif table_type == p.TableType.knowledge: - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate(embedding_model=_get_embedding_model(), **kwargs) - ) - elif table_type == p.TableType.chat: - kwargs["cols"] = [ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are a wacky assistant.")], - temperature=0.001, - top_p=0.001, - max_tokens=5, - ).model_dump(), - ), - p.ColumnSchemaCreate(id="input0", dtype=p.DtypeCreateEnum.int_), - p.ColumnSchemaCreate( - id="output1", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - # Interpolate string and non-string input columns - p.ChatEntry.user( - ( - "1. ${AI}\n2. Jan has ${input0} apples.\n\n" - "Do the statements agree with each other? Reply Yes or No." - ) - ), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ] - table = jamai.create_chat_table(p.ChatTableSchemaCreate(**kwargs)) - else: - raise ValueError(f"Invalid table type: {table_type}") - try: - yield table - except Exception: - raise - finally: - jamai.delete_table(table_type, name) - - def _add_row(table_name, stream, data): - if table_type == p.TableType.action: - pass - elif table_type == p.TableType.knowledge: - data["Title"] = "Dune: Part Two." - data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." - elif table_type == p.TableType.chat: - data["User"] = 'Say "Jan has 5 apples.".' - else: - raise ValueError(f"Invalid table type: {table_type}") - response = jamai.add_table_rows( - table_type, - p.RowAddRequest(table_id=table_name, data=[data], stream=stream), - ) - return response if stream else response.rows[0] - - with _create_table(TABLE_ID_A): - # Streaming - response = list(_add_row(TABLE_ID_A, True, dict(input0=5))) - output0 = _collect_text(response, "output0") - ai = _collect_text(response, "AI") - answer = _collect_text(response, "output1") - assert "yes" in answer.lower(), f'output0="{output0}" ai="{ai}" answer="{answer}"' - response = list(_add_row(TABLE_ID_A, True, dict(input0=6))) - output0 = _collect_text(response, "output0") - ai = _collect_text(response, "AI") - answer = _collect_text(response, "output1") - assert "no" in answer.lower(), f'output0="{output0}" ai="{ai}" answer="{answer}"' - # Non-streaming - response = _add_row(TABLE_ID_A, False, dict(input0=5)) - answer = response.columns["output1"].text - assert "yes" in answer.lower(), f'columns={response.columns} answer="{answer}"' - response = _add_row(TABLE_ID_A, False, dict(input0=6)) - answer = response.columns["output1"].text - assert "no" in answer.lower(), f'columns={response.columns} answer="{answer}"' - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -def test_chat_thread_history(client_cls: Type[JamAI]): - table_type = p.TableType.chat - jamai = client_cls() - table = jamai.create_chat_table( - p.ChatTableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[p.ChatEntry.system("You are a concise assistant.")], - temperature=0.001, - top_p=0.001, - max_tokens=20, - ).model_dump(), - ), - p.ColumnSchemaCreate( - id="output", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=_get_chat_model(), - messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("Who is mentioned in `${AI}`? Reply with the name."), - ], - temperature=0.001, - top_p=0.001, - max_tokens=10, - ).model_dump(), - ), - ], - ) - ) - assert isinstance(table, p.TableMetaResponse) - response = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, data=[dict(User=".", AI="Jim has 5 apples.")], stream=True - ), - ) - output = _collect_text(list(response), "output").lower() - assert "jim" in output, output - response = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, data=[dict(User=".", AI="Jan has 5 apples.")], stream=True - ), - ) - output = _collect_text(list(response), "output").lower() - assert "jan" in output, output - response = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, data=[dict(User=".", AI="Jia has 5 apples.")], stream=True - ), - ) - output = _collect_text(list(response), "output").lower() - assert "jia" in output, output - response = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="List the names of people mentioned. Return JSON.")], - stream=True, - ), - ) - output = _collect_text(list(response), "output").lower() - assert "jim" in output, output - assert "jan" in output, output - assert "jia" in output, output - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -def test_get_conversation_thread(client_cls: Type[JamAI]): - jamai = client_cls() - table_type = p.TableType.chat - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="Tell me a joke.", AI="Knock knock", **data)], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="Who's there?", **data)], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - assert len(rows.rows) == 1 - second_row_id = rows.rows[0].row_id - chat = jamai.get_conversation_thread(TABLE_ID_A) - assert isinstance(chat, p.ChatThread) - assert len(chat.thread) == 5 - assert chat.thread[0].role == p.ChatRole.SYSTEM - assert chat.thread[1].role == p.ChatRole.USER - assert chat.thread[2].role == p.ChatRole.ASSISTANT - assert chat.thread[3].role == p.ChatRole.USER - assert chat.thread[4].role == p.ChatRole.ASSISTANT - assert isinstance(chat.thread[-1].content, str) - assert len(chat.thread[-1].content) > 0 - - # --- Test row ID filtering --- # - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="What is love?", AI="Baby don't hurt me", **data)], - stream=False, - ), - ) - # No filter - chat = jamai.get_conversation_thread(TABLE_ID_A) - assert isinstance(chat, p.ChatThread) - assert len(chat.thread) == 7 - # Filter (include = True) - chat = jamai.get_conversation_thread(TABLE_ID_A, second_row_id) - assert isinstance(chat, p.ChatThread) - assert len(chat.thread) == 5 - assert chat.thread[3].content == "Who's there?" - # Filter (include = False) - chat = jamai.get_conversation_thread(TABLE_ID_A, second_row_id, False) - assert isinstance(chat, p.ChatThread) - assert len(chat.thread) == 3 - assert chat.thread[1].content == "Tell me a joke." - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("stream", [True, False]) -def test_chat_regen(client_cls: Type[JamAI], stream: bool): - jamai = client_cls() - table_type = p.TableType.chat - model = _get_chat_model() - try: - table = jamai.create_chat_table( - p.ChatTableSchemaCreate( - id=TABLE_ID_A, - cols=[ - p.ColumnSchemaCreate(id="User", dtype=p.DtypeCreateEnum.str_), - p.ColumnSchemaCreate( - id="AI", - dtype=p.DtypeCreateEnum.str_, - gen_config=p.ChatRequest( - model=model, - messages=[p.ChatEntry.system("Follow instructions strictly.")], - temperature=0.001, - top_p=0.001, - max_tokens=50, - ).model_dump(), - ), - ], - ) - ) - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[ - dict( - User="Make a Python list, add 1 to it. Reply the list only.", - AI="[1]", - ) - ], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - assert len(rows.rows) == 1 - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="Add 2 to it")], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - assert len(rows.rows) == 1 - assert json_loads(rows.rows[0].columns["AI"].text) == [1, 2] - second_row_id = rows.rows[0].row_id - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="Add 3 to it")], - stream=False, - ), - ) - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(User="Add 4 to it")], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - assert len(rows.rows) == 1 - assert json_loads(rows.rows[0].columns["AI"].text) == [1, 2, 3, 4] - # Test regen - response = jamai.regen_table_rows( - table_type, - p.RowRegenRequest(table_id=TABLE_ID_A, row_ids=[second_row_id], stream=stream), - ) - 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) - assert all(r.output_column_name == "AI" for r in responses) - assert json_loads("".join(r.text for r in responses)) == [1, 2] - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert response.rows[0].object == "gen_table.completion.chunks" - assert json_loads(response.rows[0].columns["AI"].text) == [1, 2] - finally: - jamai.delete_table(table_type, TABLE_ID_A) - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -def test_hybrid_search(client_cls: Type[JamAI]): - jamai = client_cls() - table_type = p.TableType.knowledge - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(Title="Resume 2012", Text="Hi there, I am a farmer.", **data)], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[dict(Title="Resume 2013", Text="Hi there, I am a carpenter.", **data)], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - rows = jamai.add_table_rows( - table_type, - p.RowAddRequest( - table_id=TABLE_ID_A, - data=[ - dict( - Title="Byte Pair Encoding", - Text="BPE is a subword tokenization method.", - **data, - ) - ], - stream=False, - ), - ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) - sleep(1) # Optional, give it some time to index - # Rely on embedding - rows = jamai.hybrid_search( - table_type, - p.SearchRequest( - table_id=TABLE_ID_A, - query="language", - reranking_model=_get_reranking_model(), - limit=2, - ), - ) - assert len(rows) == 2 - assert "BPE" in rows[0]["Text"]["value"], rows - # Rely on FTS - rows = jamai.hybrid_search( - table_type, - p.SearchRequest( - table_id=TABLE_ID_A, - query="candidate 2013", - reranking_model=_get_reranking_model(), - limit=2, - ), - ) - assert len(rows) == 2 - assert "2013" in rows[0]["Title"]["value"], rows - # hybrid_search without reranker (RRF only) - rows = jamai.hybrid_search( - table_type, - p.SearchRequest( - table_id=TABLE_ID_A, - query="language", - reranking_model=None, - limit=2, - ), - ) - assert len(rows) == 2 - assert "BPE" in rows[0]["Text"]["value"], rows - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize( - "file_path", - [ - "clients/python/tests/pdf/salary 总结.pdf", - "clients/python/tests/pdf/1970_PSS_ThAT_mechanism.pdf", - "clients/python/tests/pdf_scan/1978_APL_FP_detrapping.PDF", - "clients/python/tests/pdf_mixed/digital_scan_combined.pdf", - "clients/python/tests/md/creative-story.md", - "clients/python/tests/txt/creative-story.txt", - "clients/python/tests/html/RAG and LLM Integration Guide.html", - "clients/python/tests/html/multilingual-code-examples.html", - "clients/python/tests/html/table.html", - "clients/python/tests/xml/weather-forecast-service.xml", - "clients/python/tests/json/company-profile.json", - "clients/python/tests/jsonl/llm-models.jsonl", - "clients/python/tests/docx/Recommendation Letter.docx", - "clients/python/tests/doc/Recommendation Letter.doc", - "clients/python/tests/pptx/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).pptx", - "clients/python/tests/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt", - "clients/python/tests/xlsx/Claims Form.xlsx", - "clients/python/tests/xls/Claims Form.xls", - "clients/python/tests/tsv/weather_observations.tsv", - "clients/python/tests/csv/company-profile.csv", - "clients/python/tests/csv/weather_observations_long.csv", - ], -) -def test_upload_file(client_cls: Type[JamAI], file_path: str): - jamai = client_cls() - table_type = p.TableType.knowledge - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - response = jamai.upload_file(p.FileUploadRequest(file_path=file_path, table_id=TABLE_ID_A)) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, TABLE_ID_A) - assert isinstance(rows.items, list) - assert all(isinstance(r, dict) for r in rows.items) - assert rows.total > 0 - assert rows.offset == 0 - assert rows.limit == 100 - assert len(rows.items) > 0 - assert all(isinstance(r["Title"]["value"], str) for r in rows.items) - 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) - - -@flaky(max_runs=3, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -def test_upload_long_file(client_cls: Type[JamAI]): - table_type = p.TableType.knowledge - table_id = TABLE_ID_A - with TemporaryDirectory() as tmp_dir: - try: - # Create a long CSV - data = [ - {"bool": True, "float": 0.0, "int": 0, "str": ""}, - {"bool": False, "float": -1.0, "int": -2, "str": "testing"}, - {"bool": None, "float": None, "int": None, "str": None}, - ] - file_path = join(tmp_dir, "long.csv") - df_to_csv(pd.DataFrame.from_dict(data * 100), file_path) - # Embed the CSV - jamai = client_cls() - table = jamai.create_knowledge_table( - p.KnowledgeTableSchemaCreate( - id=table_id, - cols=[], - embedding_model=_get_embedding_model(), - ) - ) - assert isinstance(table, p.TableMetaResponse) - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) - response = jamai.upload_file( - p.FileUploadRequest(file_path=file_path, table_id=table_id) - ) - assert isinstance(response, p.OkResponse) - rows = jamai.list_table_rows(table_type, table_id) - assert isinstance(rows.items, list) - assert all(isinstance(r, dict) for r in rows.items) - assert rows.total == 300 - assert rows.offset == 0 - assert rows.limit == 100 - assert len(rows.items) == 100 - assert all(isinstance(r["Title"]["value"], str) for r in rows.items) - 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) - except Exception: - raise - finally: - jamai.delete_table(table_type, table_id) - - -@flaky(max_runs=3, 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_import_data_complete(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - - # Complete CSV - with TemporaryDirectory() as tmp_dir: - file_path = join(tmp_dir, "test_import_data_complete.csv") - chat_data = {"User": ".", "AI": "."} - data = [ - {"good": True, "words": 5, "stars": 0.0, "inputs": "", "summary": "", **chat_data}, - { - "good": False, - "words": 5, - "stars": 1.0, - "inputs": TEXT, - "summary": "", - **chat_data, - }, - { - "good": True, - "words": 5, - "stars": 2.0, - "inputs": TEXT_CN, - "summary": "", - **chat_data, - }, - { - "good": False, - "words": 5, - "stars": 3.0, - "inputs": TEXT_JP, - "summary": "", - **chat_data, - }, - ] - df = pd.DataFrame.from_dict(data).astype( - { - "good": "bool", - "words": "int32", - "stars": "float32", - "inputs": "string", - "summary": "string", - } - ) - df_to_csv(df, file_path) - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, - table_id=TABLE_ID_A, - stream=stream, - ), - ) - if stream: - responses = [r for r in response] - assert len(responses) == 0 - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert response.object == "gen_table.completion.rows" - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, vec_decimals=2) - assert isinstance(rows.items, list) - assert len(rows.items) == 4 - for row, d in zip(rows.items[::-1], data): - for k, v in d.items(): - if k not in row: - continue - if v == "": - assert ( - row[k]["value"] is None - ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" - else: - assert ( - row[k]["value"] == v - ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" - - -@flaky(max_runs=3, 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_import_data_incomplete(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - - # CSV without input column - with TemporaryDirectory() as tmp_dir: - file_path = join(tmp_dir, "test_import_data_complete.csv") - cols = ["good", "words", "stars", "inputs", "summary"] - chat_data = {"User": ".", "AI": "."} - data = [ - {"good": True, "stars": 0.0, "inputs": TEXT, "summary": TEXT, **chat_data}, - {"good": False, "stars": 1.0, "inputs": TEXT, "summary": TEXT, **chat_data}, - {"good": True, "stars": 2.0, "inputs": TEXT_CN, "summary": TEXT, **chat_data}, - {"good": False, "stars": 3.0, "inputs": TEXT_JP, "summary": TEXT, **chat_data}, - ] - df = pd.DataFrame.from_dict(data).astype( - { - "good": "bool", - "stars": "float32", - "inputs": "string", - "summary": "string", - } - ) - df_to_csv(df, file_path) - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, - table_id=TABLE_ID_A, - stream=stream, - ), - ) - if stream: - responses = [r for r in response] - assert len(responses) == 0 - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert response.object == "gen_table.completion.rows" - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, vec_decimals=2) - assert isinstance(rows.items, list) - assert len(rows.items) == 4 - for row, d in zip(rows.items[::-1], data): - for k in cols: - if k not in d: - assert ( - row[k]["value"] is None - ), f"Imported data should be None: col=`{k}` val={row[k]}" - else: - assert ( - row[k]["value"] == d[k] - ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{d[k]}`" - - -@flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("client_cls", CLIENT_CLS) -@pytest.mark.parametrize("table_type", TABLE_TYPES) -@pytest.mark.parametrize("stream", [True, False]) -def test_import_data_with_generation( - client_cls: Type[JamAI], table_type: p.TableType, stream: bool -): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - - # CSV without output column - with TemporaryDirectory() as tmp_dir: - chat_data = {"User": ".", "AI": "."} - data = [ - {"good": False, "words": 5, "stars": 1.0, "inputs": TEXT, **chat_data}, - {"good": False, "words": 5, "stars": 3.0, "inputs": TEXT_JP, **chat_data}, - ] - file_path = join(tmp_dir, "test_import_data_with_generation.csv") - df = pd.DataFrame.from_dict(data).astype( - { - "good": "bool", - "words": "int32", - "stars": "float32", - "inputs": "string", - } - ) - df_to_csv(df, file_path) - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, - table_id=TABLE_ID_A, - stream=stream, - ), - ) - if stream: - responses = [r for r in response] - assert len(responses) > 0 - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) - assert all(r.object == "gen_table.completion.chunk" for r in responses) - assert all(r.output_column_name == "summary" for r in responses) - summaries = defaultdict(list) - for r in responses: - if r.output_column_name != "summary": - continue - summaries[r.row_id].append(r.text) - summaries = {k: "".join(v) for k, v in summaries.items()} - assert len(summaries) == 2 - assert all(len(v) > 0 for v in summaries.values()) - 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.GenTableRowsChatCompletionChunks) - assert response.object == "gen_table.completion.rows" - for row in response.rows: - assert len(row.columns["summary"].text) > 0 - assert isinstance(row.columns["summary"].usage, p.CompletionUsage) - assert isinstance(row.columns["summary"].prompt_tokens, int) - assert isinstance(row.columns["summary"].completion_tokens, int) - - rows = jamai.list_table_rows(table_type, TABLE_ID_A, vec_decimals=2) - assert isinstance(rows.items, list) - assert len(rows.items) == 2 - for row, d in zip(rows.items[::-1], data): - for k, v in d.items(): - if k not in row: - continue - if v == "": - assert ( - row[k]["value"] is None - ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" - else: - assert ( - row[k]["value"] == v - ), f"Imported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" - - -@flaky(max_runs=3, 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_import_data_empty(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - - with TemporaryDirectory() as tmp_dir: - # Empty - file_path = join(tmp_dir, "empty.csv") - df_to_csv(pd.DataFrame(columns=[]), file_path) - with pytest.raises(RuntimeError, match="invalid"): - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, table_id=TABLE_ID_A, stream=stream - ), - ) - if stream: - response = list(response) - # No rows - file_path = join(tmp_dir, "empty.csv") - df_to_csv( - pd.DataFrame(columns=["good", "words", "stars", "inputs", "summary"]), file_path - ) - with pytest.raises(RuntimeError, match="empty"): - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, table_id=TABLE_ID_A, stream=stream - ), - ) - if stream: - response = list(response) - rows = jamai.list_table_rows(table_type, TABLE_ID_A, vec_decimals=2) - assert isinstance(rows.items, list) - assert len(rows.items) == 0 - - -@flaky(max_runs=3, 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) -def test_export_data(client_cls: Type[JamAI], table_type: p.TableType): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) - data = [ - {"good": True, "words": 5, "stars": 0.0, "inputs": TEXT, "summary": TEXT}, - {"good": False, "words": 5, "stars": 1.0, "inputs": TEXT, "summary": TEXT}, - {"good": True, "words": 5, "stars": 2.0, "inputs": TEXT_CN, "summary": TEXT}, - {"good": False, "words": 5, "stars": 3.0, "inputs": TEXT_JP, "summary": TEXT}, - ] - for d in data: - _add_row(jamai, table_type, False, data=d) - rows = jamai.list_table_rows(table_type, TABLE_ID_A, vec_decimals=2) - assert isinstance(rows.items, list) - assert len(rows.items) == 4 - - csv_data = jamai.export_table_data(table_type, TABLE_ID_A).decode("utf-8") - exported_rows = csv_to_df(csv_data).to_dict(orient="records") - assert len(exported_rows) == 4 - for row, d in zip(exported_rows, data): - for k, v in d.items(): - assert row[k] == v, f"Exported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" - - -@flaky(max_runs=3, 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_import_export_round_trip(client_cls: Type[JamAI], table_type: p.TableType, stream: bool): - jamai = client_cls() - with _create_table(jamai, table_type) as table: - with TemporaryDirectory() as tmp_dir: - assert isinstance(table, p.TableMetaResponse) - data = [ - {"good": True, "words": 5, "stars": 0.0, "inputs": TEXT, "summary": TEXT}, - {"good": False, "words": 5, "stars": 1.0, "inputs": TEXT, "summary": TEXT}, - {"good": True, "words": 5, "stars": 2.0, "inputs": TEXT_CN, "summary": TEXT}, - {"good": False, "words": 5, "stars": 3.0, "inputs": TEXT_JP, "summary": TEXT}, - ] - file_path = join(tmp_dir, "test_import_export_round_trip.csv") - df = pd.DataFrame.from_dict(data).astype( - { - "good": "bool", - "words": "int32", - "stars": "float32", - "inputs": "string", - "summary": "string", - } - ) - df_to_csv(df, file_path) - response = jamai.import_table_data( - table_type, - p.TableDataImportRequest( - file_path=file_path, - table_id=TABLE_ID_A, - stream=stream, - ), - ) - if stream: - responses = [r for r in response] - if table_type == p.TableType.chat: - assert len(responses) > 0 - else: - assert len(responses) == 0 - else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) - assert response.object == "gen_table.completion.rows" - - csv_data = jamai.export_table_data(table_type, TABLE_ID_A).decode("utf-8") - exported_df = csv_to_df(csv_data)[df.columns.tolist()] - assert df.eq(exported_df).all(axis=None) - - -if __name__ == "__main__": - test_import_export_round_trip(JamAI, p.TableType.chat, True) diff --git a/clients/typescript/.gitignore b/clients/typescript/.gitignore index 2eea525..5d7a8f1 100644 --- a/clients/typescript/.gitignore +++ b/clients/typescript/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +.rollup.cache \ No newline at end of file diff --git a/clients/typescript/.prettierignore b/clients/typescript/.prettierignore new file mode 100644 index 0000000..832fa83 --- /dev/null +++ b/clients/typescript/.prettierignore @@ -0,0 +1,5 @@ +.rollup.cache +coverage +dist +docs-autogen-ts +node_modules diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 03871b8..910c304 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -42,7 +42,7 @@ Welcome to JamAI Base – the real-time database that orchestrates Large Languag ### Flexibility -- **LLM Support**: Supports any LLMs, including OpenAI GPT-4, Anthropic Claude 3, Mistral AI Mixtral, and Meta Llama3. +- **LLM Support**: Supports any LLMs, including OpenAI GPT-4, Anthropic Claude 3, and Meta Llama3. - **Capabilities**: Leverage state-of-the-art AI capabilities effortlessly. ### Declarative Paradigm @@ -157,19 +157,6 @@ const jamai = new JamAI({ }); ``` -Create an API client with basic authorization credentials: - -```javascript -import JamAI from "jamaibase"; - -const jamai = new JamAI({ - baseURL: "https://api.jamaibase.com", - credentials: { - username: "your-username", - password: "your-password" - } -}); -``` Create an API client with maxretry and timeout: @@ -210,7 +197,7 @@ import JamAI from "jamaibase/index.umd.js"; Types can be imported from resources: ```javascript -import { ChatRequest } from "jamaibase/resources/llm/chat"; +import { ChatRequest } from "jamaibase/dist/resources/llm/chat"; let response: ChatRequest; ``` @@ -221,7 +208,7 @@ Example of adding a row to action table: ```javascript try { - const response = await jamai.addRow({ + const response = await jamai.table.addRow({ table_type: "action", table_id: "workout-suggestion", data: [ @@ -244,7 +231,7 @@ Example of adding row with streaming output ```javascript try { - const stream = await jamai.addRowStream({ + const stream = await jamai.table.addRowStream({ table_type: "action", table_id: "action-table-example-1", data: [ @@ -300,7 +287,7 @@ To integrate JamAI into a React application, follow these steps: 2. Install jamai ```bash - npm install jamai + npm install jamaibase ``` 3. Create and Use the JamAI Client in your React component @@ -310,7 +297,7 @@ To integrate JamAI into a React application, follow these steps: import { useEffect, useState } from "react"; import JamAI from "jamaibase"; -import { PageListTableMetaResponse } from "jamaibase/resources/gen_tables/tables"; +import { PageListTableMetaResponse } from "jamaibase/dist/resources/gen_tables/tables"; export default function Home() { const [tableData, setTableData] = useState(); @@ -323,7 +310,7 @@ export default function Home() { projectId: process.env.JAMAI_PROJECT_ID, }); try { - const response = await jamai.listTables({ + const response = await jamai.table.listTables({ table_type: "action", }); setTableData(response); @@ -411,7 +398,7 @@ export async function GET(request: NextRequest) { const tableType = (searchParams.get("type") || "action") as TableTypes; try { - let data: PageListTableMetaResponse = await jamai.listTables({ + let data: PageListTableMetaResponse = await jamai.table.listTables({ table_type: tableType, }); return NextResponse.json(data); @@ -433,7 +420,7 @@ export async function GET(request: NextRequest) { "use client"; -import { PageListTableMetaResponse } from "jamaibase/resources/gen_tables/tables"; +import { PageListTableMetaResponse } from "jamaibase/dist/resources/gen_tables/tables"; import { ChangeEvent, useEffect, useState } from "react"; export default function Home() { @@ -640,7 +627,7 @@ export const actions = { console.log("data: ", data); try { - const response = await jamai.createActionTable({ + const response = await jamai.table.createActionTable({ id: tableId!, cols: [{ id: columnName!, dtype: columnDType! }], }); @@ -880,7 +867,7 @@ export default defineEventHandler(async (event) => { const { type = "action" } = getQuery(event); try { - const data = await jamai.listTables({ table_type: type }); + const data = await jamai.table.listTables({ table_type: type }); return { success: true, data: data }; } catch (error) { console.error("Error fetching tables:", error); @@ -980,7 +967,7 @@ export default defineEventHandler(async (event) => { const { table_id, column_name, column_d_type } = await readBody(event); try { - const response = await jamai.createActionTable({ + const response = await jamai.table.createActionTable({ id: table_id, cols: [{ id: column_name, dtype: column_d_type }] }); diff --git a/clients/typescript/__tests__/embeddedLogo.png b/clients/typescript/__tests__/embeddedLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..53b456413bda482576dda3b38b65da7598044dd8 GIT binary patch literal 2129 zcmV-X2(I^uP)0Mbw@e|=KPr`qUnmr+N~w3vQgb*QK4`UC?N@CO*H+7%RjnCiaNqjAbR|-2 z?MTi5$PbFe;yONk%^Sz}pFMllIE@~Ka|62G!6)TM&~+EOhNtXZhHYD*uaF*lw@0JV zj=Wl*H{qcTTSli>+FqPjF$A=2;2P@o4->-KnLy??$f?PeR9*J*;iy$f#rQyKkV= zPJx72YZyhE7;97(vnJvubjX;yb6(_VN#uC(jvOtPrLz>vugfCc#mjPXZclzW_Z;{@ zj+V<9qQ~)~FTHna@xj0DNe&n#Y4pxn^08KzBrtT;T{;Qh_YJNFCsp&DfYIW@9)td^WAv*^y#LfWD_U! zxp5E$*ENEH85A)}-^nOmQ};e=b2LD5b?a8x@AnT}`NnZfeNrlwu0DG7sBOqYIG_bu27k-y3ffic zgr%jWl`sq=hdu;uFRcgtVNUKtocO>&k%tFn+ok8hu|A}O;lqZdzj5P66Kk|#3v_j4 zfXJj{pE|*Y71svP^8g*#y*(Ik$I9UY%M}blDl1MM5`9zp9>%$vYP{bb8}-Q-_4vaN z))AbF4CZR;{Vxy4xV6@9{Plk}5TJ?_OU&V+Ou_>5_+!!JJ54knygWYw5kgQwPs>YPn){g1mK>2#cWq3@{Z3 zn46$kHMm_TFdCR9!5@lA*`A|ZMY4ukAy>wWon?iap`>^6Q<@%W?nmH6dZjGxc=<;e z&SBG12E{xpIRIz0U=;CIM|Te76!1LHmn3Cep$*t0^ZMs0h9e%}b3KL9ei#e}?NfMK z;`jn|?v{s5`zMsXh9`(@xc-a7;E-9HW z(Q_Hp#LaX7hF1*U8e-=bQo@>?0t*WZk>UFu(`9r)+$ZnfGndl~@WUbaF53X-;LG;V zrgWQ1CxFc;ejc5>Ki@eIjF3CLW_EPDU7b35z1}*%$K0Le7y(l9>r!^-qeZ2I*Imrw zj+dAcC6^B;4*5247fhqFWfW$kqEnGY!{KFDU+B3?eW7dFrZCnGW82n5QzN>FvvCM= zbZHvibo$af>4pG$gpwSZ3T~U9(kXyW`<$J$+H`dC(>B|hO7IZv8aCjj=^c zja!9NNWTO6HfV3#qHJ!Pz9ptc&^ETl&kcRVy5clK6M*bdH~t7f|2^pMntdswnx;2J ze%Cw+4)A@K>6r9DlfLYvK0(9Kw5gF@n`?uiAA@HK?3ym@m44r4p*n>UpOd-^ACAr4Os4(A#lt@lzg?xWDx+y$n;X>{_;<_*)j z))0Lf&d#HS_OqVuau9%6x6-8?C+omoH+Q_ zmp+P9JW{~M68G^?KsqX5R-6a8ZNr4Ph*wr&l-$1J2!kIz^S^HVCY=W!&YgZ4v@+(M z!I_Y~IX^E3aPC()n()Qk=}XwP_*StrdG4 zIN^J`j@TNbOjoAY;-v6%y=WQ(-G#AR(=|m5U7R!qqlEuxnA9jW3(mBVy*b12>mJT! zF&8Gk#KHRa*x@EnCTPk8sbHG}j-eXU=nfB!fj8^criI@CMJZTli-LJ&00000NkvXX Hu0mjfSY-)o literal 0 HcmV?d00001 diff --git a/clients/typescript/__tests__/file.test.ts b/clients/typescript/__tests__/file.test.ts new file mode 100644 index 0000000..65f2482 --- /dev/null +++ b/clients/typescript/__tests__/file.test.ts @@ -0,0 +1,158 @@ +import JamAI from "@/index"; +import { GetUrlResponseSchema, UploadFileResponseSchema } from "@/resources/files/types"; +import dotenv from "dotenv"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; + +dotenv.config({ + path: "__tests__/.env" +}); + +describe("APIClient File", () => { + let client: JamAI; + jest.setTimeout(30000); + jest.retryTimes(1, { + logErrorsBeforeRetry: true + }); + + let myuuid = uuidv4(); + let projectName = `unittest-project-${myuuid}`; + + let projectId: string; + let organizationId: string; + let userId = `unittest-user-${myuuid}`; + + beforeAll(async () => { + // cloud + if (process.env["JAMAI_API_KEY"]) { + // create user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + id: userId, + name: "TS SDK Tester", + description: "I am a TS SDK Tester", + email: "kamil.kzs2017@gmail.com" + }) + }); + const userData = await responseUser.json(); + + userId = userData.id; + + // create organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + creator_user_id: userId, + tier: "free", + name: "Company", + active: true, + credit: 30.0, + credit_grant: 1.0, + llm_tokens_usage_mtok: 70, + db_usage_gib: 2.0, + file_usage_gib: 3.0, + egress_usage_gib: 4.0 + }) + }); + const organizationData = await responseOrganization.json(); + + organizationId = organizationData?.id; + } else { + // OSS + // fetch organization + + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/default`); + + const organizationData = await responseOrganization.json(); + organizationId = organizationData?.id; + } + + // create project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + name: projectName, + organization_id: organizationId + }) + }); + + const projectData = await responseProject.json(); + + projectId = projectData?.id; + + client = new JamAI({ + baseURL: process.env["BASEURL"]!, + token: process.env["JAMAI_API_KEY"]!, + projectId: projectId + }); + }); + + afterAll(async function () { + // delete project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects/${projectId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const projectData = await responseProject.json(); + + if (process.env["JAMAI_API_KEY"]) { + // delete organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/${organizationId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const organizationData = await responseOrganization.json(); + // delete user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users/${userId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const userData = await responseUser.json(); + } + }); + + it("file upload by file path, get raw url, get thumb url", async () => { + const responseUploadFile = await client.file.uploadFile({ + file_path: path.resolve(__dirname, "./embeddedLogo.png") + }); + + const parsedDataUploadFile = UploadFileResponseSchema.parse(responseUploadFile); + expect(parsedDataUploadFile).toEqual(responseUploadFile); + + const responseGetRawUrls = await client.file.getRawUrls({ + uris: [responseUploadFile.uri] + }); + + const parsedDataGetRawUrl = GetUrlResponseSchema.parse(responseGetRawUrls); + expect(parsedDataGetRawUrl).toEqual(responseGetRawUrls); + + const responseGetThumbUrls = await client.file.getThumbUrls({ + uris: [responseUploadFile.uri] + }); + + const parsedDataGetThumbUrl = GetUrlResponseSchema.parse(responseGetThumbUrls); + expect(parsedDataGetThumbUrl).toEqual(responseGetThumbUrls); + }); +}); diff --git a/clients/typescript/__tests__/gentable.test.ts b/clients/typescript/__tests__/gentable.test.ts index 8558dd3..9632938 100644 --- a/clients/typescript/__tests__/gentable.test.ts +++ b/clients/typescript/__tests__/gentable.test.ts @@ -15,6 +15,7 @@ import { beforeAll, describe, expect, it, jest } from "@jest/globals"; import { AssertionError } from "assert"; import csvParser from "csv-parser"; import dotenv from "dotenv"; +import { File } from "formdata-node"; import { promises as fs } from "fs"; import { Readable } from "stream"; import { v4 as uuidv4 } from "uuid"; @@ -23,28 +24,146 @@ dotenv.config({ path: "__tests__/.env" }); -describe("APIClient Gentable Action Table API", () => { +let llmModel: string; +let embeddingModel: string; + +describe("APIClient Gentable", () => { let client: JamAI; jest.setTimeout(30000); jest.retryTimes(1, { logErrorsBeforeRetry: true }); - beforeAll(() => { + let myuuid = uuidv4(); + let projectName = `unittest-project-${myuuid}`; + + let projectId: string; + let organizationId: string; + let userId = `unittest-user-${myuuid}`; + + beforeAll(async () => { + // cloud + if (process.env["JAMAI_API_KEY"]) { + // create user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + id: userId, + name: "TS SDK Tester", + description: "I am a TS SDK Tester", + email: "kamil.kzs2017@gmail.com" + }) + }); + const userData = await responseUser.json(); + userId = userData.id; + + // create organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + creator_user_id: userId, + tier: "free", + name: "Company", + active: true, + credit: 30.0, + credit_grant: 1.0, + llm_tokens_usage_mtok: 70, + db_usage_gib: 2.0, + file_usage_gib: 3.0, + egress_usage_gib: 4.0 + }) + }); + const organizationData = await responseOrganization.json(); + + organizationId = organizationData?.id; + } else { + // OSS + // fetch organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/default`); + + const organizationData = await responseOrganization.json(); + organizationId = organizationData?.id; + } + + // create project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + name: projectName, + organization_id: organizationId + }) + }); + + const projectData = await responseProject.json(); + projectId = projectData?.id; + client = new JamAI({ baseURL: process.env["BASEURL"]!, - apiKey: process.env["JAMAI_APIKEY"]!, - projectId: process.env["PROJECT_ID"]! + token: process.env["JAMAI_API_KEY"]!, + projectId: projectId + }); + + const models = await client.llm.modelInfo(); + + const selectedLlmModel = models.data.find((model) => model.capabilities.includes("chat")); + llmModel = selectedLlmModel?.id ? selectedLlmModel.id : "openai/gpt-4o-mini"; + + const selectedEmbeddingModel = models.data.find((model) => model.capabilities.includes("embed")); + embeddingModel = selectedEmbeddingModel?.id ? selectedEmbeddingModel.id : "ellm/sentence-transformers/all-MiniLM-L6-v2"; + }); + + afterAll(async function () { + // delete project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects/${projectId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } }); + const projectData = await responseProject.json(); + + if (process.env["JAMAI_API_KEY"]) { + // delete organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/${organizationId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const organizationData = await responseOrganization.json(); + // delete user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users/${userId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const userData = await responseUser.json(); + } }); async function* _getTable(tableType: string) { // setup let myuuid = uuidv4(); - let table_id = `unittest-${myuuid}`; + let table_id = `unittest-table-${myuuid}`; if (tableType === "action") { - const createActionTableResponse = await client.createActionTable({ + const createActionTableResponse = await client.table.createActionTable({ id: table_id, cols: [ { @@ -56,17 +175,8 @@ describe("APIClient Gentable Action Table API", () => { id: "suggestions", dtype: "str", gen_config: { - model: "openai/gpt-3.5-turbo", - messages: [ - { - role: "system", - content: "" - }, - { - role: "user", - content: "Suggest a followup questions on ${question}." - } - ], + model: llmModel, + prompt: "Suggest a followup questions on ${question}.", temperature: 1, max_tokens: 100, top_p: 0.1 @@ -76,17 +186,8 @@ describe("APIClient Gentable Action Table API", () => { id: "suggestions2", dtype: "str", gen_config: { - model: "openai/gpt-3.5-turbo", - messages: [ - { - role: "system", - content: "" - }, - { - role: "user", - content: "Suggest a followup questions on ${question}." - } - ], + model: llmModel, + temperature: 1, max_tokens: 100, top_p: 0.1 @@ -95,14 +196,14 @@ describe("APIClient Gentable Action Table API", () => { ] }); } else if (tableType === "knowledge") { - const response = await client.createKnowledgeTable({ - embedding_model: "openai/text-embedding-3-large-1024", + const response = await client.table.createKnowledgeTable({ + embedding_model: embeddingModel, id: table_id, cols: [] }); // console.log(`CONTEXT MANAGER: createKnowledgeTable ${tableType}`); } else if (tableType === "chat") { - const response = await client.createChatTable({ + const response = await client.table.createChatTable({ id: table_id, cols: [ { @@ -114,12 +215,7 @@ describe("APIClient Gentable Action Table API", () => { dtype: "str", gen_config: { model: "", - messages: [ - { - role: "system", - content: "" - } - ] + system_prompt: "" } } ] @@ -130,7 +226,7 @@ describe("APIClient Gentable Action Table API", () => { }); } - const getTableResponse = await client.getTable({ + const getTableResponse = await client.table.getTable({ table_type: tableType, table_id: table_id }); @@ -144,7 +240,7 @@ describe("APIClient Gentable Action Table API", () => { yield parsedgetTableResponseData; } finally { // cleanup - const deleteTableResponse = await client.deleteTable({ + const deleteTableResponse = await client.table.deleteTable({ table_id: table_id, table_type: tableType }); @@ -152,7 +248,7 @@ describe("APIClient Gentable Action Table API", () => { expect(deleteTableResponse).toHaveProperty("ok"); expect(deleteTableResponse.ok).toBeTruthy(); - const listTablesResponse = await client.listTables({ + const listTablesResponse = await client.table.listTables({ table_type: tableType }); @@ -199,10 +295,9 @@ describe("APIClient Gentable Action Table API", () => { } it("get table - action table creation and deletion", async () => { - // let myuuid = getRandomInt(1000); let myuuid = uuidv4(); const actionTableId = `unittest-createActionTable-${myuuid}`; - const createActionTableResponse = await client.createActionTable({ + const createActionTableResponse = await client.table.createActionTable({ id: actionTableId, cols: [ { @@ -214,17 +309,8 @@ describe("APIClient Gentable Action Table API", () => { id: "suggestions", dtype: "str", gen_config: { - model: "openai/gpt-3.5-turbo", - messages: [ - { - role: "system", - content: "" - }, - { - role: "user", - content: "Suggest a followup questions on ${question}." - } - ], + model: llmModel, + prompt: "Suggest a followup questions on ${question}.", temperature: 1, max_tokens: 100, top_p: 0.1 @@ -233,7 +319,7 @@ describe("APIClient Gentable Action Table API", () => { ] }); - const getTableResponse = await client.getTable({ + const getTableResponse = await client.table.getTable({ table_type: "action", table_id: actionTableId }); @@ -241,7 +327,7 @@ describe("APIClient Gentable Action Table API", () => { const parsedgetTableResponseData = TableMetaResponseSchema.parse(getTableResponse); expect(parsedgetTableResponseData).toEqual(getTableResponse); - const deleteTableResponse = await client.deleteTable({ + const deleteTableResponse = await client.table.deleteTable({ table_id: actionTableId, table_type: "action" }); @@ -249,7 +335,7 @@ describe("APIClient Gentable Action Table API", () => { expect(deleteTableResponse).toHaveProperty("ok"); expect(deleteTableResponse.ok).toBeTruthy(); - const listTablesResponse = await client.listTables({ + const listTablesResponse = await client.table.listTables({ table_type: "action" }); @@ -265,7 +351,7 @@ describe("APIClient Gentable Action Table API", () => { it("list tables - action - without limit and offset", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.listTables({ + const response = await client.table.listTables({ table_type: "action" }); @@ -283,7 +369,7 @@ describe("APIClient Gentable Action Table API", () => { it("list tables - action - with limit", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.listTables({ + const response = await client.table.listTables({ table_type: "action", limit: 3 }); @@ -302,7 +388,7 @@ describe("APIClient Gentable Action Table API", () => { it("list tables - action - with offset", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.listTables({ + const response = await client.table.listTables({ table_type: "action", offset: 3 }); @@ -314,7 +400,7 @@ describe("APIClient Gentable Action Table API", () => { it("list tables - action - with limit and offset", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.listTables({ + const response = await client.table.listTables({ table_type: "action", limit: 3, offset: 1 @@ -326,28 +412,33 @@ describe("APIClient Gentable Action Table API", () => { }); it("list tables - knowledge - with limit and offset", async () => { - for await (const { id: table_id } of _getTable("knowledge")) { - const response = await client.listTables({ - table_type: "knowledge", - limit: 3, - offset: 0 - }); - - const parsedData = PageListTableMetaResponseSchema.parse(response); - expect(parsedData).toEqual(response); - let tableList: string[] = []; - for (const item of parsedData.items) { - if (item.id === table_id) { - tableList.push(item.id); + try { + for await (const { id: table_id } of _getTable("knowledge")) { + const response = await client.table.listTables({ + table_type: "knowledge", + limit: 3, + offset: 0 + }); + + const parsedData = PageListTableMetaResponseSchema.parse(response); + expect(parsedData).toEqual(response); + let tableList: string[] = []; + for (const item of parsedData.items) { + if (item.id === table_id) { + tableList.push(item.id); + } } + expect(tableList.length).toEqual(1); } - expect(tableList.length).toEqual(1); + } catch (err: any) { + console.log("------------------------------"); + console.log("error: ", err.response.data); } }); it("list tables - chat - with limit and offset", async () => { for await (const { id: table_id } of _getTable("chat")) { - const response = await client.listTables({ + const response = await client.table.listTables({ table_type: "chat", limit: 1 }); @@ -366,7 +457,7 @@ describe("APIClient Gentable Action Table API", () => { it("get table - action", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.getTable({ + const response = await client.table.getTable({ table_type: "action", table_id: table_id }); @@ -378,7 +469,7 @@ describe("APIClient Gentable Action Table API", () => { it("get table - knowledge", async () => { for await (const { id: table_id } of _getTable("knowledge")) { - const response = await client.getTable({ + const response = await client.table.getTable({ table_type: "knowledge", table_id: table_id }); @@ -390,7 +481,7 @@ describe("APIClient Gentable Action Table API", () => { it("get table - chat", async () => { for await (const { id: table_id } of _getTable("chat")) { - const response = await client.getTable({ + const response = await client.table.getTable({ table_type: "chat", table_id: table_id }); @@ -402,7 +493,7 @@ describe("APIClient Gentable Action Table API", () => { it("add row to action table with reindex", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -424,7 +515,7 @@ describe("APIClient Gentable Action Table API", () => { it("add row to action table without reindex", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -446,7 +537,7 @@ describe("APIClient Gentable Action Table API", () => { it("add row to action table - stream with reindex", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRowStream({ + const response = await client.table.addRowStream({ table_type: "action", data: [ { @@ -478,7 +569,7 @@ describe("APIClient Gentable Action Table API", () => { it("add row to action table - stream without reindex", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRowStream({ + const response = await client.table.addRowStream({ table_type: "action", data: [ { @@ -510,7 +601,7 @@ describe("APIClient Gentable Action Table API", () => { it("list rows - without limit and offset", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -530,7 +621,7 @@ describe("APIClient Gentable Action Table API", () => { reindex: false, concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -543,7 +634,7 @@ describe("APIClient Gentable Action Table API", () => { it("list rows - without limit", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -564,7 +655,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, offset: 3 @@ -578,7 +669,7 @@ describe("APIClient Gentable Action Table API", () => { it("list rows - without offset", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -599,7 +690,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, limit: 2 @@ -613,7 +704,7 @@ describe("APIClient Gentable Action Table API", () => { it("list rows - with offset and limit", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -634,7 +725,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, offset: 0, @@ -649,7 +740,7 @@ describe("APIClient Gentable Action Table API", () => { it("list rows - with column ids", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -670,7 +761,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, columns: ["question"] @@ -684,7 +775,7 @@ describe("APIClient Gentable Action Table API", () => { it("get row - with row id", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -705,7 +796,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, limit: 2 @@ -715,7 +806,7 @@ describe("APIClient Gentable Action Table API", () => { expect(parsedData).toEqual(listRowResponse); expect(parsedData.items.length).toEqual(2); // console.log(parsedData.items); - const getRowResponse = await client.getRow({ + const getRowResponse = await client.table.getRow({ table_type: "action", table_id: table_id, row_id: parsedData.items![0]!["ID"]! @@ -727,7 +818,7 @@ describe("APIClient Gentable Action Table API", () => { it("get row - with multiple columns", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -748,7 +839,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, limit: 2 @@ -758,7 +849,7 @@ describe("APIClient Gentable Action Table API", () => { expect(parsedData).toEqual(listRowResponse); expect(parsedData.items.length).toEqual(2); // console.log(parsedData.items); - const getRowResponse = await client.getRow({ + const getRowResponse = await client.table.getRow({ table_type: "action", table_id: table_id, row_id: parsedData.items![0]!["ID"]!, @@ -774,7 +865,7 @@ describe("APIClient Gentable Action Table API", () => { it("delete row from action table", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -795,7 +886,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -806,7 +897,7 @@ describe("APIClient Gentable Action Table API", () => { // console.log(parsedData.items![0]!['ID']!); - const deleteRowResponse = await client.deleteRow({ + const deleteRowResponse = await client.table.deleteRow({ table_id: table_id, table_type: "action", row_id: parsedData.items![0]!["ID"]!, @@ -816,7 +907,7 @@ describe("APIClient Gentable Action Table API", () => { expect(deleteRowResponse).toHaveProperty("ok"); expect(deleteRowResponse.ok).toBeTruthy(); - const listRowResponse2 = await client.listRows({ + const listRowResponse2 = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -829,7 +920,7 @@ describe("APIClient Gentable Action Table API", () => { it("delete multiple rows", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -850,7 +941,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -867,7 +958,7 @@ describe("APIClient Gentable Action Table API", () => { // console.log(parsedData.items![0]!['ID']!); - const deleteRowsResponse = await client.deleteRows({ + const deleteRowsResponse = await client.table.deleteRows({ table_type: "action", table_id: table_id, where: `\`ID\` IN (${row_ids.map((i) => `'${i}'`).join(",")})`, @@ -877,7 +968,7 @@ describe("APIClient Gentable Action Table API", () => { expect(deleteRowsResponse).toHaveProperty("ok"); expect(deleteRowsResponse.ok).toBeTruthy(); - const listRowResponse2 = await client.listRows({ + const listRowResponse2 = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -892,7 +983,7 @@ describe("APIClient Gentable Action Table API", () => { for await (const { id: table_id } of _getTable("action")) { const table_id_dst = `unittest-rename-table-${uuidv4()}`; - const renameTableResponse = await client.renameTable({ + const renameTableResponse = await client.table.renameTable({ table_id_src: table_id, table_type: "action", table_id_dst: table_id_dst @@ -901,7 +992,7 @@ describe("APIClient Gentable Action Table API", () => { expect(renameTableResponse).toHaveProperty("id"); expect(renameTableResponse["id"]).toEqual(table_id_dst); - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -922,7 +1013,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const renameTableResponse2 = await client.renameTable({ + const renameTableResponse2 = await client.table.renameTable({ table_id_src: table_id_dst, table_type: "action", table_id_dst: table_id @@ -936,7 +1027,7 @@ describe("APIClient Gentable Action Table API", () => { for await (const { id: table_id } of _getTable("knowledge")) { const table_id_dst = `unittest-rename-table-${uuidv4()}`; - const renameTableResponse = await client.renameTable({ + const renameTableResponse = await client.table.renameTable({ table_id_src: table_id, table_type: "knowledge", table_id_dst: table_id_dst @@ -945,7 +1036,7 @@ describe("APIClient Gentable Action Table API", () => { expect(renameTableResponse).toHaveProperty("id"); expect(renameTableResponse["id"]).toEqual(table_id_dst); - const renameTableResponse2 = await client.renameTable({ + const renameTableResponse2 = await client.table.renameTable({ table_id_src: table_id_dst, table_type: "knowledge", table_id_dst: table_id @@ -959,7 +1050,7 @@ describe("APIClient Gentable Action Table API", () => { for await (const { id: table_id } of _getTable("action")) { const table_id_dst = `unittest-duplicated-without-data-${uuidv4()}`; - const response = await client.duplicateTable({ + const response = await client.table.duplicateTable({ table_id_src: table_id, table_type: "action", table_id_dst: table_id_dst, @@ -969,17 +1060,37 @@ describe("APIClient Gentable Action Table API", () => { expect(response).toHaveProperty("id"); expect(response["id"]).toEqual(table_id_dst); - const deleteTableResponse = await client.deleteTable({ + const deleteTableResponse = await client.table.deleteTable({ table_id: table_id_dst, table_type: "action" }); } }); + it("duplicate table - withoutout destination id]", async () => { + for await (const { id: table_id } of _getTable("action")) { + const response = await client.table.duplicateTable({ + table_id_src: table_id, + table_type: "action", + include_data: false, + create_as_child: true + }); + + expect(response).toHaveProperty("id"); + + const deleteTableResponse = await client.table.deleteTable({ + table_id: response.id, + table_type: "action" + }); + } + }); + it("get conversation thread", async () => { - for await (const { id: table_id } of _getTable("chat")) { - const response = await client.getConversationThread({ - table_id: table_id + for await (const table of _getTable("chat")) { + const response = await client.table.getConversationThread({ + table_id: table.id, + table_type: "chat", + column_id: table.cols.length ? table.cols[3]?.id! : "" }); const parsedData = GetConversationThreadResponseSchema.parse(response); @@ -993,7 +1104,7 @@ describe("APIClient Gentable Action Table API", () => { question: "renamed-question" }; - const response = await client.renameColumns({ + const response = await client.table.renameColumns({ table_type: "action", table_id: table_id, column_map: columnMap @@ -1017,7 +1128,7 @@ describe("APIClient Gentable Action Table API", () => { it("reorder columns", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.reorderColumns({ + const response = await client.table.reorderColumns({ table_id: table_id, table_type: "action", column_names: ["question", "suggestions2", "suggestions"] @@ -1037,7 +1148,7 @@ describe("APIClient Gentable Action Table API", () => { } ]; - const response = await client.addActionColumns({ + const response = await client.table.addActionColumns({ id: table_id, cols: columnsToAdd }); @@ -1051,7 +1162,7 @@ describe("APIClient Gentable Action Table API", () => { it("drop columns", async () => { for await (const { id: table_id } of _getTable("action")) { const dropedColumns = ["suggestions"]; - const response = await client.dropColumns({ + const response = await client.table.dropColumns({ table_id: table_id, table_type: "action", column_names: dropedColumns @@ -1065,18 +1176,13 @@ describe("APIClient Gentable Action Table API", () => { it("update gen config", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.updateGenConfig({ + const response = await client.table.updateGenConfig({ table_type: "action", table_id: table_id, column_map: { suggestions: { - model: "openai/gpt-3.5-turbo", - messages: [ - { - role: "system", - content: "this is system prompt with updated config" - } - ] + model: llmModel, + system_prompt: "this is system prompt with updated config" } } }); @@ -1088,7 +1194,7 @@ describe("APIClient Gentable Action Table API", () => { it("regen row - action table", async () => { for await (const { id: table_id } of _getTable("action")) { - const response = await client.addRow({ + const response = await client.table.addRow({ table_type: "action", data: [ { @@ -1109,7 +1215,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -1127,7 +1233,7 @@ describe("APIClient Gentable Action Table API", () => { // console.log(parsedlistRowResponseData); // console.log(row_ids); - const regenRowResponse = await client.regenRow({ + const regenRowResponse = await client.table.regenRow({ table_type: "action", table_id: table_id, row_ids: row_ids, @@ -1141,7 +1247,7 @@ describe("APIClient Gentable Action Table API", () => { // const parsedregenRowResponseData = GenTableRowsChatCompletionChunksSchema.parse(regenRowResponse); // expect(parsedregenRowResponseData).toEqual(regenRowResponse); - // const listRowResponse2 = await client.listRows({ + // const listRowResponse2 = await client.table.listRows({ // table_type: "action", // table_id: table_id, // }); @@ -1154,7 +1260,7 @@ describe("APIClient Gentable Action Table API", () => { it("regen row - action table - stream", async () => { for await (const { id: table_id } of _getTable("action")) { - await client.addRow({ + await client.table.addRow({ table_type: "action", data: [ { @@ -1175,7 +1281,7 @@ describe("APIClient Gentable Action Table API", () => { concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -1193,7 +1299,7 @@ describe("APIClient Gentable Action Table API", () => { // console.log(parsedlistRowResponseData); // console.log(row_ids); - const response = await client.regenRowStream({ + const response = await client.table.regenRowStream({ table_type: "action", table_id: table_id, row_ids: row_ids, @@ -1216,7 +1322,7 @@ describe("APIClient Gentable Action Table API", () => { it("update row", async () => { for await (const { id: table_id } of _getTable("action")) { - await client.addRow({ + await client.table.addRow({ table_type: "action", data: [ { @@ -1236,7 +1342,7 @@ describe("APIClient Gentable Action Table API", () => { reindex: false, concurrent: true }); - const listRowResponse = await client.listRows({ + const listRowResponse = await client.table.listRows({ table_type: "action", table_id: table_id, limit: 2 @@ -1246,7 +1352,7 @@ describe("APIClient Gentable Action Table API", () => { expect(parsedData).toEqual(listRowResponse); expect(parsedData.items.length).toEqual(2); - const response = await client.updateRow({ + const response = await client.table.updateRow({ table_type: "action", data: { question: "how to update rows on jamaibase?", @@ -1264,7 +1370,7 @@ describe("APIClient Gentable Action Table API", () => { it("hybrid search", async () => { for await (const { id: table_id } of _getTable("action")) { - await client.addRow({ + await client.table.addRow({ table_type: "action", data: [ { @@ -1284,7 +1390,7 @@ describe("APIClient Gentable Action Table API", () => { reindex: true, concurrent: true }); - const response = await client.hybridSearch({ + const response = await client.table.hybridSearch({ table_type: "action", table_id: table_id, query: "kong", @@ -1301,10 +1407,10 @@ describe("APIClient Gentable Action Table API", () => { }); // @TODO test with api.jamaibase.com - it("upload file to knowledge table", async () => { + it("Embed file to knowledge table", async () => { for await (const { id: table_id } of _getTable("knowledge")) { const file = new File(["My aim in life by chatgpt"], "sample.txt", { type: "text/plain" }); - const response = await client.uploadFile({ + const response = await client.table.embedFile({ file: file, table_id: table_id }); @@ -1315,7 +1421,7 @@ describe("APIClient Gentable Action Table API", () => { it("Export table data in csv", async () => { for await (const { id: table_id } of _getTable("action")) { - const responseAddRow = await client.addRow({ + const responseAddRow = await client.table.addRow({ table_type: "action", data: [ { @@ -1335,14 +1441,14 @@ describe("APIClient Gentable Action Table API", () => { reindex: false, concurrent: true }); - const responseListRows = await client.listRows({ + const responseListRows = await client.table.listRows({ table_type: "action", table_id: table_id }); expect(responseAddRow.rows.length).toBe(responseListRows.items.length); - const exportTableDataResponse = await client.exportTableData({ + const exportTableDataResponse = await client.table.exportTableData({ table_type: "action", table_id: table_id }); @@ -1366,7 +1472,7 @@ describe("APIClient Gentable Action Table API", () => { it("Export table data in tsv", async () => { for await (const { id: table_id } of _getTable("action")) { - const responseAddRow = await client.addRow({ + const responseAddRow = await client.table.addRow({ table_type: "action", data: [ { @@ -1386,14 +1492,14 @@ describe("APIClient Gentable Action Table API", () => { reindex: false, concurrent: true }); - const responseListRows = await client.listRows({ + const responseListRows = await client.table.listRows({ table_type: "action", table_id: table_id }); expect(responseAddRow.rows.length).toBe(responseListRows.items.length); - const exportTableDataResponse = await client.exportTableData({ + const exportTableDataResponse = await client.table.exportTableData({ table_type: "action", table_id: table_id, delimiter: "\t" @@ -1439,7 +1545,7 @@ describe("APIClient Gentable Action Table API", () => { await _dfToCsv(data, filePath); - const importDataResponse = await client.importTableData({ + const importDataResponse = await client.table.importTableData({ file_path: filePath, table_id: table_id, table_type: "action" @@ -1447,7 +1553,7 @@ describe("APIClient Gentable Action Table API", () => { tmpobj.removeCallback(); - const rows = await client.listRows({ + const rows = await client.table.listRows({ table_type: "action", table_id: table_id }); @@ -1494,7 +1600,7 @@ describe("APIClient Gentable Action Table API", () => { await _dfToCsv(data, filePath); - const importDataResponse = await client.importTableDataStream({ + const importDataResponse = await client.table.importTableDataStream({ file_path: filePath, table_id: table_id, table_type: "action" diff --git a/clients/typescript/__tests__/llm.test.ts b/clients/typescript/__tests__/llm.test.ts index 544c218..b22f9d2 100644 --- a/clients/typescript/__tests__/llm.test.ts +++ b/clients/typescript/__tests__/llm.test.ts @@ -3,36 +3,172 @@ import { ChatCompletionChunkSchema, ChatRequest } from "@/resources/llm/chat"; import { EmbeddingResponseSchema } from "@/resources/llm/embedding"; import { ModelInfoResponseSchema, ModelNamesResponseSchema } from "@/resources/llm/model"; import dotenv from "dotenv"; +import { v4 as uuidv4 } from "uuid"; dotenv.config({ path: "__tests__/.env" }); +let llmModel: string; +let embeddingModel: string; + describe("APIClient LLM", () => { let client: JamAI; jest.setTimeout(30000); - jest.retryTimes(0); + jest.retryTimes(1, { + logErrorsBeforeRetry: true + }); + + let myuuid = uuidv4(); + let projectName = `unittest-project-${myuuid}`; + + let projectId: string; + let organizationId: string; + let userId = `unittest-user-${myuuid}`; + let requestDataChat: ChatRequest; + + beforeAll(async () => { + // cloud + if (process.env["JAMAI_API_KEY"]) { + // create user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + id: userId, + name: "TS SDK Tester", + description: "I am a TS SDK Tester", + email: "kamil.kzs2017@gmail.com" + }) + }); + const userData = await responseUser.json(); + + userId = userData.id; + + // create organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + creator_user_id: userId, + tier: "free", + name: "Company", + active: true, + credit: 30.0, + credit_grant: 1.0, + llm_tokens_usage_mtok: 70, + db_usage_gib: 2.0, + file_usage_gib: 3.0, + egress_usage_gib: 4.0 + }) + }); + const organizationData = await responseOrganization.json(); + + organizationId = organizationData?.id; + } else { + // OSS + // fetch organization + + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/default`); + + const organizationData = await responseOrganization.json(); + organizationId = organizationData?.id; + } + + // create project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + name: projectName, + organization_id: organizationId + }) + }); + + const projectData = await responseProject.json(); + + projectId = projectData?.id; - beforeAll(() => { client = new JamAI({ baseURL: process.env["BASEURL"]!, - apiKey: process.env["JAMAI_APIKEY"]!, - projectId: process.env["PROJECT_ID"]! + token: process.env["JAMAI_API_KEY"]!, + projectId: projectId + }); + + const models = await client.llm.modelInfo(); + + const selectedLlmModel = models.data.find((model) => model.capabilities.includes("chat")); + llmModel = selectedLlmModel?.id ? selectedLlmModel.id : "openai/gpt-4o-mini"; + + const selectedEmbeddingModel = models.data.find((model) => model.capabilities.includes("embed")); + embeddingModel = selectedEmbeddingModel?.id ? selectedEmbeddingModel.id : "ellm/sentence-transformers/all-MiniLM-L6-v2"; + + requestDataChat = { + model: llmModel, + messages: [ + { role: "system", content: "you are a helpful assistant." }, + { role: "user", content: "Hello, what is the capital of Bangladesh?" } + ], + max_tokens: 100, + temperature: 0.1, + top_p: 0.1, + presence_penalty: 0, + frequency_penalty: 0, + logit_bias: undefined + }; + }); + + afterAll(async function () { + // delete project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects/${projectId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } }); - // const credential = Buffer.from(`${process.env["username"]}:${process.env["password"]}`).toString("base64"); - // client.setAuthHeader(`Basic ${credential}`); + const projectData = await responseProject.json(); + + if (process.env["JAMAI_API_KEY"]) { + // delete organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/${organizationId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const organizationData = await responseOrganization.json(); + // delete user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users/${userId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const userData = await responseUser.json(); + } }); it("get model info", async () => { - const response = await client.modelInfo(); + const response = await client.llm.modelInfo(); const parsedData = ModelInfoResponseSchema.parse(response); expect(parsedData).toEqual(response); - // expect(false).toEqual(true); }); it("get model info with capabilities", async () => { - const response = await client.modelInfo({ + const response = await client.llm.modelInfo({ capabilities: ["chat"] }); @@ -41,8 +177,8 @@ describe("APIClient LLM", () => { }); it("get model info with name", async () => { - const response = await client.modelInfo({ - model: "openai/gpt-3.5-turbo" + const response = await client.llm.modelInfo({ + model: llmModel }); const parsedData = ModelInfoResponseSchema.parse(response); @@ -50,8 +186,8 @@ describe("APIClient LLM", () => { }); it("get model info with both params", async () => { - const response = await client.modelInfo({ - model: "openai/gpt-3.5-turbo", + const response = await client.llm.modelInfo({ + model: llmModel, capabilities: ["chat"] }); @@ -60,14 +196,14 @@ describe("APIClient LLM", () => { }); it("get model name", async () => { - const response = await client.modelNames(); + const response = await client.llm.modelNames(); const parsedData = ModelNamesResponseSchema.parse(response); expect(parsedData).toEqual(response); }); it("get model name with capabilities", async () => { - const response = await client.modelNames({ + const response = await client.llm.modelNames({ capabilities: ["image"] }); @@ -76,8 +212,8 @@ describe("APIClient LLM", () => { }); it("get model info with prefer", async () => { - const response = await client.modelNames({ - prefer: "openai/gpt-3.5-turbo" + const response = await client.llm.modelNames({ + prefer: llmModel }); const parsedData = ModelNamesResponseSchema.parse(response); @@ -85,8 +221,8 @@ describe("APIClient LLM", () => { }); it("get model info with both params", async () => { - const response = await client.modelNames({ - prefer: "openai/gpt-3.5-turbo", + const response = await client.llm.modelNames({ + prefer: llmModel, capabilities: ["chat"] }); @@ -94,33 +230,19 @@ describe("APIClient LLM", () => { expect(parsedData).toEqual(response); }); - const requestDataChat: ChatRequest = { - model: "openai/gpt-3.5-turbo", - messages: [ - { role: "system", content: "you are a helpful assistant." }, - { role: "user", content: "Hello, what is the capital of Bangladesh?" } - ], - max_tokens: 100, - tools: null, - // tool_choice: null, - // n: 1, - temperature: 0.1, - top_p: 0.1, - presence_penalty: 0, - frequency_penalty: 0, - logit_bias: undefined - }; - it("generate chat completion", async () => { - const response = await client.generateChatCompletions(requestDataChat); - - // console.log("response: ", response.choices[0]?.message); + try { + console.log("model: ", requestDataChat.model); + const response = await client.llm.generateChatCompletions(requestDataChat); - expect(ChatCompletionChunkSchema.parse(response)).toEqual(response); + expect(ChatCompletionChunkSchema.parse(response)).toEqual(response); + } catch (err: any) { + console.log("error: ", err.response.data); + } }); it("generate chat completion - stream", async () => { - const response = await client.generateChatCompletionsStream(requestDataChat); + const response = await client.llm.generateChatCompletionsStream(requestDataChat); expect(response).toBeInstanceOf(ReadableStream); const reader = response.getReader(); @@ -137,9 +259,9 @@ describe("APIClient LLM", () => { }); it("generate embedding", async () => { - const response = await client.generateEmbeddings({ + const response = await client.llm.generateEmbeddings({ type: "document", - model: "ellm/BAAI/bge-m3", + model: embeddingModel, input: "This is embedding test" }); diff --git a/clients/typescript/__tests__/template.test.ts b/clients/typescript/__tests__/template.test.ts new file mode 100644 index 0000000..ad6b837 --- /dev/null +++ b/clients/typescript/__tests__/template.test.ts @@ -0,0 +1,216 @@ +import JamAI from "@/index"; +import { + GetTableResponseSchema, + GetTemplateResponseSchema, + ListTableRowsResponseSchema, + ListTablesResponseSchema, + ListTemplatesResponseSchema +} from "@/resources/templates/types"; +import dotenv from "dotenv"; +import { v4 as uuidv4 } from "uuid"; + +dotenv.config({ + path: "__tests__/.env" +}); + +describe("APIClient Templates", () => { + let client: JamAI; + jest.setTimeout(30000); + jest.retryTimes(1, { + logErrorsBeforeRetry: true + }); + + let myuuid = uuidv4(); + let projectName = `unittest-project-${myuuid}`; + + let projectId: string; + let organizationId: string; + let userId = `unittest-user-${myuuid}`; + + beforeAll(async () => { + // cloud + if (process.env["JAMAI_API_KEY"]) { + // create user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + id: userId, + name: "TS SDK Tester", + description: "I am a TS SDK Tester", + email: "kamil.kzs2017@gmail.com" + }) + }); + const userData = await responseUser.json(); + + userId = userData.id; + + // create organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + creator_user_id: userId, + tier: "free", + name: "Company", + active: true, + credit: 30.0, + credit_grant: 1.0, + llm_tokens_usage_mtok: 70, + db_usage_gib: 2.0, + file_usage_gib: 3.0, + egress_usage_gib: 4.0 + }) + }); + const organizationData = await responseOrganization.json(); + + organizationId = organizationData?.id; + } else { + // OSS + // fetch organization + + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/default`); + + const organizationData = await responseOrganization.json(); + organizationId = organizationData?.id; + } + + // create project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + }, + body: JSON.stringify({ + name: projectName, + organization_id: organizationId + }) + }); + + const projectData = await responseProject.json(); + + projectId = projectData?.id; + + client = new JamAI({ + baseURL: process.env["BASEURL"]!, + token: process.env["JAMAI_API_KEY"]!, + projectId: projectId + }); + }); + + afterAll(async function () { + // delete project + const responseProject = await fetch(`${process.env["BASEURL"]}/api/admin/org/v1/projects/${projectId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const projectData = await responseProject.json(); + + if (process.env["JAMAI_API_KEY"]) { + // delete organization + const responseOrganization = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/organizations/${organizationId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const organizationData = await responseOrganization.json(); + // delete user + const responseUser = await fetch(`${process.env["BASEURL"]}/api/admin/backend/v1/users/${userId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env["JAMAI_API_KEY"]}` + } + }); + const userData = await responseUser.json(); + } + }); + + it("list templates", async () => { + const response = await client.template.listTemplates(); + + const parsedData = ListTemplatesResponseSchema.parse(response); + expect(parsedData).toEqual(response); + }); + + it("get template", async () => { + const templates = await client.template.listTemplates({}); + + if (templates.items.length && templates.items[0]) { + const response = await client.template.getTemplate({ template_id: templates.items[0].id }); + + const parsedData = GetTemplateResponseSchema.parse(response); + expect(parsedData).toEqual(response); + } + }); + + it("list tables", async () => { + const templates = await client.template.listTemplates(); + + if (templates.items.length && templates.items[0]) { + const response = await client.template.listTables({ + template_id: templates.items[0].id, + table_type: "action" + }); + + const parsedData = ListTablesResponseSchema.parse(response); + expect(parsedData).toEqual(response); + } + }); + + it("get table", async () => { + const templates = await client.template.listTemplates(); + + if (templates.items.length && templates.items[0]) { + const tables = await client.template.listTables({ + template_id: templates.items[0].id, + table_type: "action" + }); + + if (tables.items.length && tables.items[0]) { + const response = await client.template.getTable({ + template_id: templates.items[0].id, + table_type: "action", + table_id: tables.items[0].id + }); + + const parsedData = GetTableResponseSchema.parse(response); + expect(parsedData).toEqual(response); + } + } + }); + + it("list table rows", async () => { + const templates = await client.template.listTemplates(); + + if (templates.items.length && templates.items[0]) { + const tables = await client.template.listTables({ + template_id: templates.items[0].id, + table_type: "action" + }); + + if (tables.items.length && tables.items[0]) { + const response = await client.template.listTableRows({ + template_id: templates.items[0].id, + table_type: "action", + table_id: tables.items[0].id + }); + + const parsedData = ListTableRowsResponseSchema.parse(response); + expect(parsedData).toEqual(response); + } + } + }); +}); diff --git a/clients/typescript/build b/clients/typescript/build index 7a26761..690e10c 100644 --- a/clients/typescript/build +++ b/clients/typescript/build @@ -1,15 +1,15 @@ #!/bin/bash set -e -node scripts/fix-test-include-tsconfig.cjs -rm -rf dist && npx microbundle --tsconfig tsconfig.json --no-sourcemap && tsc-alias -p tsconfig.json +node scripts/remove-tests-tsconfig.cjs +# rm -rf dist && npx microbundle --tsconfig tsconfig.json --no-sourcemap && tsc-alias -p tsconfig.json +rimraf dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json && rollup -c --bundleConfigAsCjs -cp -rp README.md dist -cp dist/index.d.ts dist/index.d.mts +# cp -rp README.md dist -for file in LICENSE CHANGELOG.md; do - if [ -e "${file}" ]; then cp "${file}" dist; fi -done +# for file in LICENSE CHANGELOG.md; do +# if [ -e "${file}" ]; then cp "${file}" dist; fi +# done node scripts/make-dist-package-json.cjs > dist/package.json @@ -18,6 +18,11 @@ node scripts/make-dist-package-json.cjs > dist/package.json (cd dist && node -e 'require("jamaibase")') (cd dist && node -e 'import("jamaibase")' --input-type=module) + +# include "__tests__" folder in tsconfig to facilitate unit test. +node scripts/include-tests-tsconfig.cjs + + # npm exec tsc-multi # node scripts/fix-index-exports.cjs # cp dist/index.d.ts dist/index.d.mts diff --git a/clients/typescript/jest.config.ts b/clients/typescript/jest.config.ts index 2ff626b..febd598 100644 --- a/clients/typescript/jest.config.ts +++ b/clients/typescript/jest.config.ts @@ -6,8 +6,8 @@ const config: Config = { testMatch: ["**/__tests__/**/*.test.[jt]s?(x)"], verbose: true, moduleNameMapper: { - "@/(.*)": "/src/$1", - }, + "@/(.*)": "/src/$1" + } }; export default config; diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json new file mode 100644 index 0000000..b2a4fce --- /dev/null +++ b/clients/typescript/package-lock.json @@ -0,0 +1,8753 @@ +{ + "name": "jamaibase", + "version": "0.2.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jamaibase", + "version": "0.2.1", + "license": "Apache-2.0", + "dependencies": { + "agentkeepalive": "^4.5.0", + "axios": "^1.6.8", + "axios-retry": "^4.1.0", + "csv-parser": "^3.0.0", + "formdata-node": "^6.0.3", + "mime-types": "^2.1.35", + "path-browserify": "^1.0.1", + "uuid": "^9.0.1", + "zod": "^3.22.5" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.40.0", + "@jest/globals": "^29.7.0", + "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-dynamic-import-vars": "^2.1.2", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/jest": "^29.5.12", + "@types/mime-types": "^2.1.4", + "@types/node": "^20.12.7", + "@types/path-browserify": "^1.0.3", + "@types/tmp": "^0.2.6", + "@types/uuid": "^9.0.8", + "@typescript-eslint/parser": "^7.7.0", + "jest": "^29.7.0", + "jsdoc": "^4.0.2", + "openapi-typescript": "^6.7.5", + "openapi-zod-client": "^1.18.1", + "rimraf": "^6.0.1", + "rollup": "^4.21.3", + "rollup-plugin-copy": "^3.5.0", + "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-node-builtins": "^2.1.2", + "rollup-plugin-node-globals": "^1.4.0", + "rollup-plugin-polyfill-node": "^0.13.0", + "tmp": "^0.2.3", + "ts-jest": "^29.1.2", + "tsc-alias": "^1.8.10", + "tsconfig-paths-jest": "^0.0.1", + "typedoc": "^0.25.13", + "typedoc-plugin-pages": "^1.1.0", + "typescript": "^5.4.5", + "zod": "^3.22.5" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.5.4", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.5.4.tgz", + "integrity": "sha512-o2fsypTGU0WxRxbax8zQoHiIB4dyrkwYfcm8TxZ+bx9pCzcWZbQtiMqpgBvWA/nJ2TrGjK5adCLfTH8wUeU/Wg==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.0.tgz", + "integrity": "sha512-9Kt7EuS/7WbMAUv2gSziqjvxwDbFSg3Xeyfuj5laUODX8o/k/CpsAKiQ8W7/R88eXFTMbJYg6+7uAmOWNKmwnw==", + "dev": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "9.0.6", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.6.3", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", + "integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "js-yaml": "^3.13.1" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", + "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", + "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", + "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", + "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", + "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.24.1", + "@babel/helper-validator-identifier": "^7.24.5", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.40.0.tgz", + "integrity": "sha512-v2d8PkLaDq80uQdTMlDpqdj8G0uJYs8CGtfWxrC/TSbjDJcT0O0Vzd3cBepJAYKCo+v2tgKF8/HyuJkh9EkXsw==", + "dev": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.5.4", + "c12": "1.10.0", + "camelcase": "8.0.0", + "commander": "12.0.0", + "handlebars": "4.7.8" + }, + "bin": { + "openapi-ts": "bin/index.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true, + "peer": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, + "node_modules/@jsdoc/salty": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", + "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v12.0.0" + } + }, + "node_modules/@liuli-util/fs-extra": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@liuli-util/fs-extra/-/fs-extra-0.1.0.tgz", + "integrity": "sha512-eaAyDyMGT23QuRGbITVY3SOJff3G9ekAAyGqB9joAnTBmqvFN+9a1FazOdO70G6IUqgpKV451eBHYSRcOJ/FNQ==", + "dev": true, + "dependencies": { + "@types/fs-extra": "^9.0.13", + "fs-extra": "^10.1.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-alias": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.0.tgz", + "integrity": "sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==", + "dev": true, + "dependencies": { + "slash": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-alias/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.1.tgz", + "integrity": "sha512-UnsKoZK6/aGIH6AdkptXhNvhaqftcjq3zZdT+LY5Ftms6JR06nADcDsYp5hTU9E2lbJUEOhdlY5J4DNTneM+jQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^10.4.1", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@rollup/plugin-dynamic-import-vars": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-dynamic-import-vars/-/plugin-dynamic-import-vars-2.1.2.tgz", + "integrity": "sha512-4lr2oXxs9hcxtGGaK8s0i9evfjzDrAs7ngw28TqruWKTEm0+U4Eljb+F6HXGYdFv8xRojQlrQwV7M/yxeh3yzQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "astring": "^1.8.5", + "estree-walker": "^2.0.2", + "fast-glob": "^3.2.12", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.2.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", + "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz", + "integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", + "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "dev": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "dev": true + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true + }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@types/path-browserify": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.3.tgz", + "integrity": "sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==", + "dev": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.0.tgz", + "integrity": "sha512-fNcDm3wSwVM8QYL4HKVBggdIPAy9Q41vcvC/GtDobw3c4ndVT3K6cqudUmjHPw8EAp4ufax0o58/xvWaP2FmTg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", + "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", + "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", + "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", + "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true, + "peer": true + }, + "node_modules/@zodios/core": { + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/@zodios/core/-/core-10.9.6.tgz", + "integrity": "sha512-aH4rOdb3AcezN7ws8vDgBfGboZMk2JGGzEq/DtW65MhnRxyTGRuLJRWVQ/2KxDgWvV2F5oTkAS+5pnjKbl0n+A==", + "dev": true, + "peerDependencies": { + "axios": "^0.x || ^1.0.0", + "zod": "^3.x" + } + }, + "node_modules/abstract-leveldown": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-0.12.4.tgz", + "integrity": "sha512-TOod9d5RDExo6STLMGa+04HGkl+TlMfbDnTyN93/ETJ9DpQ0DaYLqcMZlbXvdc4W3vVo1Qrl+WhSp8zvDsJ+jA==", + "dev": true, + "dependencies": { + "xtend": "~3.0.0" + } + }, + "node_modules/abstract-leveldown/node_modules/xtend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", + "integrity": "sha512-sp/sT9OALMjRW1fKDlPeuSZlDQpkqReA0pyJukniWbTGoEKefHxhGJynE3PNhUMlcM8qWIjPwecwCw4LArS5Eg==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.1.0.tgz", + "integrity": "sha512-svdth4H00yhlsjBbjfLQ/sMLkXqeLxhiFC1nE1JtkN/CIssGxqk0UwTEdrVjwA2gr3yJkAulwvDSIm4z4HyPvg==", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-0.8.2.tgz", + "integrity": "sha512-pfqikmByp+lifZCS0p6j6KreV6kNU6Apzpm2nKOk+94cZb/jvle55+JxWiByUQ0Wo/+XnDXEy5MxxKMb6r0VIw==", + "dev": true, + "dependencies": { + "readable-stream": "~1.0.26" + } + }, + "node_modules/bl/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/bl/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browserify-fs/-/browserify-fs-1.0.0.tgz", + "integrity": "sha512-8LqHRPuAEKvyTX34R6tsw4bO2ro6j9DmlYBhiYWHRM26Zv2cBw1fJOU0NeUQ0RkXkPn/PFBjhA0dm4AgaBurTg==", + "dev": true, + "dependencies": { + "level-filesystem": "^1.0.1", + "level-js": "^2.1.3", + "levelup": "^0.18.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", + "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", + "dev": true, + "dependencies": { + "bn.js": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz", + "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", + "dev": true, + "dependencies": { + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.5", + "hash-base": "~3.0", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.7", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-es6": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/buffer-es6/-/buffer-es6-4.9.3.tgz", + "integrity": "sha512-Ibt+oXxhmeYJSsCkODPqNpPmyegefiD8rfutH1NYGhMZQhSp95Rz7haemgnJ6dxa6LT+JLLbtgOMORRluwKktw==", + "dev": true + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-1.10.0.tgz", + "integrity": "sha512-0SsG7UDhoRWcuSvKWHaXmu5uNjDCDN3nkQLRL4Q42IlFy+ze58FcCoI3uPwINXinkz7ZinbhEgyzYFw9u9ZV8g==", + "dev": true, + "dependencies": { + "chokidar": "^3.6.0", + "confbox": "^0.1.3", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.1", + "jiti": "^1.21.0", + "mlly": "^1.6.1", + "ohash": "^1.1.3", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.0.3", + "rc9": "^2.1.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001611", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001611.tgz", + "integrity": "sha512-19NuN1/3PjA3QI8Eki55N8my4LzfkMCRLgCVfrl/slbSAchQfV0+GwjPrK3rq37As4UCLlM/DHajbKkAqbv92Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.1.19.tgz", + "integrity": "sha512-IO78I0y6JcSpEPHzK4obKdsL7E7oLdRVDVOLwr2Hkbjsb+Eoz0dxW6tef0WizoKu0gLC4oZSZuEF4U2K6w1WQw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", + "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "dependencies": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + }, + "engines": { + "node": "*" + } + }, + "node_modules/csv-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.0.0.tgz", + "integrity": "sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "peer": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deferred-leveldown": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-0.2.0.tgz", + "integrity": "sha512-+WCbb4+ez/SZ77Sdy1iadagFiVzMB89IKOBhglgnUkVxOxRWmmFsz8UDSNWh4Rhq+3wr/vMFlYj+rdEwWUDdng==", + "dev": true, + "dependencies": { + "abstract-leveldown": "~0.12.1" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/destr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", + "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==", + "dev": true + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.740", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.740.tgz", + "integrity": "sha512-Yvg5i+iyv7Xm18BRdVPVm8lc7kgxM3r6iwqCH2zB7QZy1kZRNmd0Zqm0zcD9XoFREE5/5rwIuIAOT+/mzGcnZg==", + "dev": true + }, + "node_modules/elliptic": { + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", + "dev": true, + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "peer": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eval-estree-expression": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eval-estree-expression/-/eval-estree-expression-1.1.0.tgz", + "integrity": "sha512-6ZAHSb0wsqxutjk2lXZcW7btSc51I8BhlIetit0wIf5sOb5xDNBrIqe0g8RFyQ/EW6Xwn1szrtButztU7Vdj1Q==", + "dev": true + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "peer": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "peer": true + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreach": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", + "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==", + "dev": true + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-node": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", + "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fwd-stream": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fwd-stream/-/fwd-stream-1.0.4.tgz", + "integrity": "sha512-q2qaK2B38W07wfPSQDKMiKOD5Nzv2XyuvQlrmh1q0pxyHNanKHq8lwQ6n9zHucAwA5EbzRJKEgds2orn88rYTg==", + "dev": true, + "dependencies": { + "readable-stream": "~1.0.26-4" + } + }, + "node_modules/fwd-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/fwd-stream/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/fwd-stream/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.3.tgz", + "integrity": "sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.2.3", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.3", + "nypm": "^0.3.8", + "ohash": "^1.1.3", + "pathe": "^1.1.2", + "tar": "^6.2.0" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "peer": true + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha512-EeeoJKjTyt868liAlVmcv2ZsUfGHlE3Q+BICOXcZiwN3osr5Q/zFGYmTJpoIzuaSTAwndFy+GqhEwlU4L3j4Ow==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/idb-wrapper": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/idb-wrapper/-/idb-wrapper-1.7.2.tgz", + "integrity": "sha512-zfNREywMuf0NzDo9mVsL0yegjsirJxHpKHvWcyRozIqQy89g0a3U+oBPOCN4cc0oCiOuYgZHimzaW/R46G1Mpg==", + "dev": true + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", + "integrity": "sha512-ajQCouIvkcSnl2iRdK70Jug9mohIHVX9uKpoWnl115ov0R5mzBvRrXxrnHbsA+8AdwCwc/sfw7HXmd4I5EJBdQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-0.1.2.tgz", + "integrity": "sha512-GkfZZlIZtpkFrqyAXPQSRBMsaHAw+CgoKe2HXAkjd/sfoI9+hS8PT4wg2rJxdQyUKr7N2vHJbg7/jQtE5l5vBQ==", + "dev": true + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", + "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isbuffer": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/isbuffer/-/isbuffer-0.0.0.tgz", + "integrity": "sha512-xU+NoHp+YtKQkaM2HsQchYn0sltxMxew0HavMfHbjnucBoTSGbw745tL+Z7QBANleWM1eEQMenEpi174mIeS4g==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", + "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/jest-changed-files/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/jest-changed-files/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-changed-files/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-changed-files/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/jest-changed-files/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", + "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "peer": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-blobs": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/level-blobs/-/level-blobs-0.1.7.tgz", + "integrity": "sha512-n0iYYCGozLd36m/Pzm206+brIgXP8mxPZazZ6ZvgKr+8YwOZ8/PPpYC5zMUu2qFygRN8RO6WC/HH3XWMW7RMVg==", + "dev": true, + "dependencies": { + "level-peek": "1.0.6", + "once": "^1.3.0", + "readable-stream": "^1.0.26-4" + } + }, + "node_modules/level-blobs/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/level-blobs/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/level-blobs/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true + }, + "node_modules/level-filesystem": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/level-filesystem/-/level-filesystem-1.2.0.tgz", + "integrity": "sha512-PhXDuCNYpngpxp3jwMT9AYBMgOvB6zxj3DeuIywNKmZqFj2djj9XfT2XDVslfqmo0Ip79cAd3SBy3FsfOZPJ1g==", + "dev": true, + "dependencies": { + "concat-stream": "^1.4.4", + "errno": "^0.1.1", + "fwd-stream": "^1.0.4", + "level-blobs": "^0.1.7", + "level-peek": "^1.0.6", + "level-sublevel": "^5.2.0", + "octal": "^1.0.0", + "once": "^1.3.0", + "xtend": "^2.2.0" + } + }, + "node_modules/level-fix-range": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-1.0.2.tgz", + "integrity": "sha512-9llaVn6uqBiSlBP+wKiIEoBa01FwEISFgHSZiyec2S0KpyLUkGR4afW/FCZ/X8y+QJvzS0u4PGOlZDdh1/1avQ==", + "dev": true + }, + "node_modules/level-hooks": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/level-hooks/-/level-hooks-4.5.0.tgz", + "integrity": "sha512-fxLNny/vL/G4PnkLhWsbHnEaRi+A/k8r5EH/M77npZwYL62RHi2fV0S824z3QdpAk6VTgisJwIRywzBHLK4ZVA==", + "dev": true, + "dependencies": { + "string-range": "~1.2" + } + }, + "node_modules/level-js": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/level-js/-/level-js-2.2.4.tgz", + "integrity": "sha512-lZtjt4ZwHE00UMC1vAb271p9qzg8vKlnDeXfIesH3zL0KxhHRDjClQLGLWhyR0nK4XARnd4wc/9eD1ffd4PshQ==", + "dev": true, + "dependencies": { + "abstract-leveldown": "~0.12.0", + "idb-wrapper": "^1.5.0", + "isbuffer": "~0.0.0", + "ltgt": "^2.1.2", + "typedarray-to-buffer": "~1.0.0", + "xtend": "~2.1.2" + } + }, + "node_modules/level-js/node_modules/xtend": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", + "integrity": "sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==", + "dev": true, + "dependencies": { + "object-keys": "~0.4.0" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/level-peek": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/level-peek/-/level-peek-1.0.6.tgz", + "integrity": "sha512-TKEzH5TxROTjQxWMczt9sizVgnmJ4F3hotBI48xCTYvOKd/4gA/uY0XjKkhJFo6BMic8Tqjf6jFMLWeg3MAbqQ==", + "dev": true, + "dependencies": { + "level-fix-range": "~1.0.2" + } + }, + "node_modules/level-sublevel": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/level-sublevel/-/level-sublevel-5.2.3.tgz", + "integrity": "sha512-tO8jrFp+QZYrxx/Gnmjawuh1UBiifpvKNAcm4KCogesWr1Nm2+ckARitf+Oo7xg4OHqMW76eAqQ204BoIlscjA==", + "dev": true, + "dependencies": { + "level-fix-range": "2.0", + "level-hooks": ">=4.4.0 <5", + "string-range": "~1.2.1", + "xtend": "~2.0.4" + } + }, + "node_modules/level-sublevel/node_modules/level-fix-range": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-2.0.0.tgz", + "integrity": "sha512-WrLfGWgwWbYPrHsYzJau+5+te89dUbENBg3/lsxOs4p2tYOhCHjbgXxBAj4DFqp3k/XBwitcRXoCh8RoCogASA==", + "dev": true, + "dependencies": { + "clone": "~0.1.9" + } + }, + "node_modules/level-sublevel/node_modules/object-keys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.2.0.tgz", + "integrity": "sha512-XODjdR2pBh/1qrjPcbSeSgEtKbYo7LqYNq64/TPuCf7j9SfDD3i21yatKoIy39yIWNvVM59iutfQQpCv1RfFzA==", + "deprecated": "Please update to the latest object-keys", + "dev": true, + "dependencies": { + "foreach": "~2.0.1", + "indexof": "~0.0.1", + "is": "~0.2.6" + } + }, + "node_modules/level-sublevel/node_modules/xtend": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.0.6.tgz", + "integrity": "sha512-fOZg4ECOlrMl+A6Msr7EIFcON1L26mb4NY5rurSkOex/TWhazOrg6eXD/B0XkuiYcYhQDWLXzQxLMVJ7LXwokg==", + "dev": true, + "dependencies": { + "is-object": "~0.1.2", + "object-keys": "~0.2.0" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/levelup": { + "version": "0.18.6", + "resolved": "https://registry.npmjs.org/levelup/-/levelup-0.18.6.tgz", + "integrity": "sha512-uB0auyRqIVXx+hrpIUtol4VAPhLRcnxcOsd2i2m6rbFIDarO5dnrupLOStYYpEcu8ZT087Z9HEuYw1wjr6RL6Q==", + "dev": true, + "dependencies": { + "bl": "~0.8.1", + "deferred-leveldown": "~0.2.0", + "errno": "~0.1.1", + "prr": "~0.0.0", + "readable-stream": "~1.0.26", + "semver": "~2.3.1", + "xtend": "~3.0.0" + } + }, + "node_modules/levelup/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/levelup/node_modules/prr": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz", + "integrity": "sha512-LmUECmrW7RVj6mDWKjTXfKug7TFGdiz9P18HMcO4RHL+RW7MCOGNvpj5j47Rnp6ne6r4fZ2VzyUWEpKbg+tsjQ==", + "dev": true + }, + "node_modules/levelup/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/levelup/node_modules/semver": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-2.3.2.tgz", + "integrity": "sha512-abLdIKCosKfpnmhS52NCTjO4RiLspDfsn37prjzGrp9im5DPJOgh82Os92vtwGh6XdQryKI/7SREZnV+aqiXrA==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/levelup/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "dev": true + }, + "node_modules/levelup/node_modules/xtend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", + "integrity": "sha512-sp/sT9OALMjRW1fKDlPeuSZlDQpkqReA0pyJukniWbTGoEKefHxhGJynE3PNhUMlcM8qWIjPwecwCw4LArS5Eg==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/ltgt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", + "dev": true + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", + "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-fetch-native": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", + "dev": true + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nypm": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.3.8.tgz", + "integrity": "sha512-IGWlC6So2xv6V4cIDmoV0SwwWx7zLG086gyqkyumteH2fIgCAM4nDVFB2iDRszDvmdSVW9xb1N+2KjQ6C7d4og==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.2.3", + "execa": "^8.0.1", + "pathe": "^1.1.2", + "ufo": "^1.4.0" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/object-keys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", + "integrity": "sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==", + "dev": true + }, + "node_modules/octal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/octal/-/octal-1.0.0.tgz", + "integrity": "sha512-nnda7W8d+A3vEIY+UrDQzzboPf1vhs4JYVhff5CDkq9QNoZY7Xrxeo/htox37j9dZf7yNHevZzqtejWgy1vCqQ==", + "dev": true + }, + "node_modules/ohash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.3.tgz", + "integrity": "sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==", + "dev": true + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true + }, + "node_modules/openapi-typescript": { + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-6.7.5.tgz", + "integrity": "sha512-ZD6dgSZi0u1QCP55g8/2yS5hNJfIpgqsSGHLxxdOjvY7eIrXzj271FJEQw33VwsZ6RCtO/NOuhxa7GBWmEudyA==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.3", + "fast-glob": "^3.3.2", + "js-yaml": "^4.1.0", + "supports-color": "^9.4.0", + "undici": "^5.28.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + } + }, + "node_modules/openapi-zod-client": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/openapi-zod-client/-/openapi-zod-client-1.18.1.tgz", + "integrity": "sha512-L0GzU/7Sx9ugbWWoQwOJdKtyxr8ZnjxIK2RJP63//OkmKws2w7c5HSgS2bdNxPVCIp/eJuYk+CtaKfvCoJ08Yw==", + "dev": true, + "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "@liuli-util/fs-extra": "^0.1.0", + "@zodios/core": "^10.3.1", + "axios": "^1.6.0", + "cac": "^6.7.14", + "handlebars": "^4.7.7", + "openapi-types": "^12.0.2", + "openapi3-ts": "3.1.0", + "pastable": "^2.2.1", + "prettier": "^2.7.1", + "tanu": "^0.1.13", + "ts-pattern": "^5.0.1", + "whence": "^2.0.0", + "zod": "^3.19.1" + }, + "bin": { + "openapi-zod-client": "bin.js" + } + }, + "node_modules/openapi3-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.1.0.tgz", + "integrity": "sha512-1qKTvCCVoV0rkwUh1zq5o8QyghmwYPuhdvtjv1rFjuOnJToXhQyF8eGjNETQ8QmGjr9Jz/tkAKLITIl2s7dw3A==", + "dev": true, + "dependencies": { + "yaml": "^2.1.3" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "peer": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-asn1": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", + "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", + "dev": true, + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "hash-base": "~3.0", + "pbkdf2": "^3.1.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pastable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pastable/-/pastable-2.2.1.tgz", + "integrity": "sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==", + "dev": true, + "dependencies": { + "@babel/core": "^7.20.12", + "ts-toolbelt": "^9.6.0", + "type-fest": "^3.5.3" + }, + "engines": { + "node": ">=14.x" + }, + "peerDependencies": { + "react": ">=17", + "xstate": ">=4.32.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "xstate": { + "optional": true + } + } + }, + "node_modules/pastable/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "dev": true, + "dependencies": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", + "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.2.0", + "mlly": "^1.2.0", + "pathe": "^1.1.0" + } + }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-es6": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/process-es6/-/process-es6-0.11.6.tgz", + "integrity": "sha512-GYBRQtL4v3wgigq10Pv58jmTbFXlIiTbSfgnNqZLY0ldUPqy1rRxDI5fCjoCpnM6TqmHQI8ydzTBXW86OYc0gA==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", + "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.1.tgz", + "integrity": "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "node_modules/rollup": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-copy": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.5.0.tgz", + "integrity": "sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA==", + "dev": true, + "dependencies": { + "@types/fs-extra": "^8.0.1", + "colorette": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "10.0.1", + "is-plain-object": "^3.0.0" + }, + "engines": { + "node": ">=8.3" + } + }, + "node_modules/rollup-plugin-copy/node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rollup-plugin-copy/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/rollup-plugin-copy/node_modules/globby": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", + "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", + "dev": true, + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-copy/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/rollup-plugin-copy/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/rollup-plugin-dts": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.1.1.tgz", + "integrity": "sha512-aSHRcJ6KG2IHIioYlvAOcEq6U99sVtqDDKVhnwt70rW6tsz3tv5OSjEiWcgzfsHdLyGXZ/3b/7b/+Za3Y6r1XA==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.10" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.24.2" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0" + } + }, + "node_modules/rollup-plugin-node-builtins": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-builtins/-/rollup-plugin-node-builtins-2.1.2.tgz", + "integrity": "sha512-bxdnJw8jIivr2yEyt8IZSGqZkygIJOGAWypXvHXnwKAbUcN4Q/dGTx7K0oAJryC/m6aq6tKutltSeXtuogU6sw==", + "dev": true, + "dependencies": { + "browserify-fs": "^1.0.0", + "buffer-es6": "^4.9.2", + "crypto-browserify": "^3.11.0", + "process-es6": "^0.11.2" + } + }, + "node_modules/rollup-plugin-node-globals": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-globals/-/rollup-plugin-node-globals-1.4.0.tgz", + "integrity": "sha512-xRkB+W/m1KLIzPUmG0ofvR+CPNcvuCuNdjVBVS7ALKSxr3EDhnzNceGkGi1m8MToSli13AzKFYH4ie9w3I5L3g==", + "dev": true, + "dependencies": { + "acorn": "^5.7.3", + "buffer-es6": "^4.9.3", + "estree-walker": "^0.5.2", + "magic-string": "^0.22.5", + "process-es6": "^0.11.6", + "rollup-pluginutils": "^2.3.1" + } + }, + "node_modules/rollup-plugin-node-globals/node_modules/acorn": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", + "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/rollup-plugin-node-globals/node_modules/estree-walker": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.5.2.tgz", + "integrity": "sha512-XpCnW/AE10ws/kDAs37cngSkvgIR8aN3G0MS85m7dUpuK2EREo9VJ00uvw6Dg/hXEpfsE1I1TvJOJr+Z+TL+ig==", + "dev": true + }, + "node_modules/rollup-plugin-node-globals/node_modules/magic-string": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", + "integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==", + "dev": true, + "dependencies": { + "vlq": "^0.2.2" + } + }, + "node_modules/rollup-plugin-polyfill-node": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-polyfill-node/-/rollup-plugin-polyfill-node-0.13.0.tgz", + "integrity": "sha512-FYEvpCaD5jGtyBuBFcQImEGmTxDTPbiHjJdrYIp+mFIwgXiXabxvKUK7ZT9P31ozu2Tqm9llYQMRWsfvTMTAOw==", + "dev": true, + "dependencies": { + "@rollup/plugin-inject": "^5.0.4" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz", + "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==", + "dev": true, + "dependencies": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-range": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/string-range/-/string-range-1.2.2.tgz", + "integrity": "sha512-tYft6IFi8SjplJpxCUxyqisD3b+R2CSkomrtJYCkvuf1KuCAWgz7YXt4O0jip7efpfCemwHEzTEAO8EuOYgh3w==", + "dev": true + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tanu": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tanu/-/tanu-0.1.13.tgz", + "integrity": "sha512-UbRmX7ccZ4wMVOY/Uw+7ji4VOkEYSYJG1+I4qzbnn4qh/jtvVbrm6BFnF12NQQ4+jGv21wKmjb1iFyUSVnBWcQ==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0", + "typescript": "^4.7.4" + } + }, + "node_modules/tanu/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "peer": true + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-jest": { + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-pattern": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.1.1.tgz", + "integrity": "sha512-i+owkHr5RYdQxj8olUgRrqpiWH9x27PuWVfXwDmJ/n/CoF/SAa7WW1i2oUpPDMQpJ4U+bGRUcZkVq7i1m3zFCg==", + "dev": true + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true + }, + "node_modules/tsc-alias": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.10.tgz", + "integrity": "sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/tsconfig-paths-jest": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths-jest/-/tsconfig-paths-jest-0.0.1.tgz", + "integrity": "sha512-YKhUKqbteklNppC2NqL7dv1cWF8eEobgHVD5kjF1y9Q4ocqpBiaDlYslQ9eMhtbqIPRrA68RIEXqknEjlxdwxw==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true + }, + "node_modules/typedarray-to-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-1.0.4.tgz", + "integrity": "sha512-vjMKrfSoUDN8/Vnqitw2FmstOfuJ73G6CrSEKnf11A6RmasVxHqfeBcnTb6RsL4pTMuV5Zsv9IiHRphMZyckUw==", + "dev": true + }, + "node_modules/typedoc": { + "version": "0.25.13", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz", + "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", + "dev": true, + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.7" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x" + } + }, + "node_modules/typedoc-default-themes": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.10.2.tgz", + "integrity": "sha512-zo09yRj+xwLFE3hyhJeVHWRSPuKEIAsFK5r2u47KL/HBKqpwdUSanoaz5L34IKiSATFrjG5ywmIu98hPVMfxZg==", + "dev": true, + "dependencies": { + "lunr": "^2.3.8" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/typedoc-plugin-pages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-pages/-/typedoc-plugin-pages-1.1.0.tgz", + "integrity": "sha512-pmCCp3G2aCeEWb829dcVe9Pl+pI5OsaqfyrkEutcAHZi6IMVfQ5G5NdrkIkFCGhJU/DY04rGrVdynWqnaO5/jg==", + "dev": true, + "dependencies": { + "compare-versions": "^3.6.0", + "typedoc-default-themes": "^0.10.1" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true + }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/v8-to-istanbul": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", + "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vlq": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", + "integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==", + "dev": true + }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==", + "dev": true + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/whence": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whence/-/whence-2.0.0.tgz", + "integrity": "sha512-exmM13v2lg8juBbfS2tao/alV68jyryPXS+jf29NBNGLzE2hRgmzvQFQGX5CxNfH4Ag9qRqd6gGpXTH2JxqKHg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.15.7", + "eval-estree-expression": "^1.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + }, + "node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz", + "integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clients/typescript/package.json b/clients/typescript/package.json new file mode 100644 index 0000000..04bb4b6 --- /dev/null +++ b/clients/typescript/package.json @@ -0,0 +1,105 @@ +{ + "name": "jamaibase", + "version": "0.3.0", + "description": "JamAIBase Client SDK (JS/TS). JamAI Base: Let Your Database Orchestrate LLMs and RAG", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "browser": "dist/index.umd.js", + "types": "dist/index.d.ts", + "scripts": { + "test": "jest --runInBand --detectOpenHandles --forceExit", + "format": "prettier --write .", + "clear-cache": "jest --clearCache", + "build": "/bin/bash build", + "openapi-to-zod": "openapi-zod-client openapi.json -o zodschema/zodmodels.ts", + "doc-ts-moduler": "typedoc --includeVersion --tsconfig tsconfig.build.json --includes ./dist/*.d.ts --includes ./dist/**/*.d.ts --includes ./dist/resources/**/*.d.ts --out docs-autogen-ts", + "doc-ts": "typedoc --readme ./README.md --includeVersion --tsconfig tsconfig.build.json --entryPoints ./dist/index.d.ts --out docs-autogen-ts && cp JamAI_Base_Cover.png docs-autogen-ts/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/EmbeddedLLM/JAM.ai.dev/" + }, + "private": false, + "keywords": [ + "jam-ai", + "jamai", + "jamaibase" + ], + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs.js", + "browser": "./dist/index.umd.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./index.mjs": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "./index.cjs.js": { + "types": "./dist/index.d.ts", + "default": "./index.cjs.js" + }, + "./index.umd.js": { + "types": "./dist/index.d.ts", + "default": "./dist/index.umd.js" + } + }, + "files": [ + "dist/**/*" + ], + "author": "EmbeddedLLM, Tan Tun Jian", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/EmbeddedLLM/JAM.ai.dev/issues" + }, + "homepage": "https://github.com/EmbeddedLLM/JAM.ai.dev/#readme", + "dependencies": { + "agentkeepalive": "^4.5.0", + "axios": "^1.6.8", + "axios-retry": "^4.1.0", + "csv-parser": "^3.0.0", + "formdata-node": "^6.0.3", + "mime-types": "^2.1.35", + "path-browserify": "^1.0.1", + "uuid": "^9.0.1", + "zod": "^3.22.5" + }, + "devDependencies": { + "@typescript-eslint/parser": "^7.7.0", + "@hey-api/openapi-ts": "^0.40.0", + "@jest/globals": "^29.7.0", + "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-dynamic-import-vars": "^2.1.2", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/jest": "^29.5.12", + "@types/mime-types": "^2.1.4", + "@types/node": "^20.12.7", + "@types/path-browserify": "^1.0.3", + "@types/tmp": "^0.2.6", + "@types/uuid": "^9.0.8", + "jest": "^29.7.0", + "jsdoc": "^4.0.2", + "openapi-typescript": "^6.7.5", + "openapi-zod-client": "^1.18.1", + "rimraf": "^6.0.1", + "rollup": "^4.21.3", + "rollup-plugin-copy": "^3.5.0", + "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-node-builtins": "^2.1.2", + "rollup-plugin-node-globals": "^1.4.0", + "rollup-plugin-polyfill-node": "^0.13.0", + "tmp": "^0.2.3", + "ts-jest": "^29.1.2", + "tsc-alias": "^1.8.10", + "tsconfig-paths-jest": "^0.0.1", + "typedoc": "^0.25.13", + "typedoc-plugin-pages": "^1.1.0", + "typescript": "^5.4.5", + "zod": "^3.22.5" + } +} diff --git a/clients/typescript/scripts/fix-index-exports.cjs b/clients/typescript/scripts/fix-index-exports.cjs index b61b2ea..b3c5e3e 100644 --- a/clients/typescript/scripts/fix-index-exports.cjs +++ b/clients/typescript/scripts/fix-index-exports.cjs @@ -1,14 +1,8 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); -const indexJs = - process.env['DIST_PATH'] ? - path.resolve(process.env['DIST_PATH'], 'index.js') - : path.resolve(__dirname, '..', 'dist', 'index.js'); +const indexJs = process.env["DIST_PATH"] ? path.resolve(process.env["DIST_PATH"], "index.js") : path.resolve(__dirname, "..", "dist", "index.js"); -let before = fs.readFileSync(indexJs, 'utf8'); -let after = before.replace( - /^\s*exports\.default\s*=\s*(\w+)/m, - 'exports = module.exports = $1;\nexports.default = $1', -); -fs.writeFileSync(indexJs, after, 'utf8'); +let before = fs.readFileSync(indexJs, "utf8"); +let after = before.replace(/^\s*exports\.default\s*=\s*(\w+)/m, "exports = module.exports = $1;\nexports.default = $1"); +fs.writeFileSync(indexJs, after, "utf8"); diff --git a/clients/typescript/scripts/include-tests-tsconfig.cjs b/clients/typescript/scripts/include-tests-tsconfig.cjs new file mode 100644 index 0000000..55ce915 --- /dev/null +++ b/clients/typescript/scripts/include-tests-tsconfig.cjs @@ -0,0 +1,43 @@ +const fs = require("fs"); + +// Path to the tsconfig.json file +const tsConfigPath = "./tsconfig.json"; + +// Read the tsconfig.json file +fs.readFile(tsConfigPath, "utf8", (err, data) => { + if (err) { + console.error(`Error reading the file: ${err}`); + return; + } + + try { + // Parse the JSON data + const tsConfig = JSON.parse(data); + + // Check if the "include" field exists and is an array + if (!Array.isArray(tsConfig.include)) { + tsConfig.include = []; // If "include" doesn't exist, create it as an empty array + } + + // Check if "__tests__" is already in the array + if (!tsConfig.include.includes("__tests__")) { + // Add "__tests__" to the "include" array + tsConfig.include.push("__tests__"); + } + + // Convert the modified object back to JSON + const updatedTsConfig = JSON.stringify(tsConfig, null, 2); + + // Write the updated JSON back to the file + fs.writeFile(tsConfigPath, updatedTsConfig, "utf8", (err) => { + if (err) { + console.error(`Error writing the file: ${err}`); + return; + } + + console.log("tsconfig.json has been updated successfully (include '__tests__' folder.)"); + }); + } catch (err) { + console.error(`Error parsing JSON: ${err}`); + } +}); diff --git a/clients/typescript/scripts/make-dist-package-json.cjs b/clients/typescript/scripts/make-dist-package-json.cjs index 62ac19d..424b950 100644 --- a/clients/typescript/scripts/make-dist-package-json.cjs +++ b/clients/typescript/scripts/make-dist-package-json.cjs @@ -9,7 +9,7 @@ function processExportMap(m) { } processExportMap(pkgJson.exports); -for (const key of ["types", "main", "module", "unpkg", "umd:main"]) { +for (const key of ["types", "main", "module", "unpkg", "umd:main", "browser"]) { if (typeof pkgJson[key] === "string") pkgJson[key] = pkgJson[key].replace(/^(\.\/)?dist\//, "./"); } diff --git a/clients/typescript/scripts/postprocess-files.cjs b/clients/typescript/scripts/postprocess-files.cjs index b08e28c..e7adf87 100644 --- a/clients/typescript/scripts/postprocess-files.cjs +++ b/clients/typescript/scripts/postprocess-files.cjs @@ -1,30 +1,27 @@ -const fs = require('fs'); -const path = require('path'); -const { parse } = require('@typescript-eslint/parser'); +const fs = require("fs"); +const path = require("path"); +const { parse } = require("@typescript-eslint/parser"); -const pkgImportPath = process.env['PKG_IMPORT_PATH'] ?? 'jamaisdk/' +const pkgImportPath = process.env["PKG_IMPORT_PATH"] ?? "jamaisdk/"; -const distDir = - process.env['DIST_PATH'] ? - path.resolve(process.env['DIST_PATH']) - : path.resolve(__dirname, '..', 'dist'); -const distSrcDir = path.join(distDir, 'src'); +const distDir = process.env["DIST_PATH"] ? path.resolve(process.env["DIST_PATH"]) : path.resolve(__dirname, "..", "dist"); +const distSrcDir = path.join(distDir, "src"); /** * Quick and dirty AST traversal */ function traverse(node, visitor) { - if (!node || typeof node.type !== 'string') return; - visitor.node?.(node); - visitor[node.type]?.(node); - for (const key in node) { - const value = node[key]; - if (Array.isArray(value)) { - for (const elem of value) traverse(elem, visitor); - } else if (value instanceof Object) { - traverse(value, visitor); + if (!node || typeof node.type !== "string") return; + visitor.node?.(node); + visitor[node.type]?.(node); + for (const key in node) { + const value = node[key]; + if (Array.isArray(value)) { + for (const elem of value) traverse(elem, visitor); + } else if (value instanceof Object) { + traverse(value, visitor); + } } - } } /** @@ -37,34 +34,28 @@ function traverse(node, visitor) { * The replaced ranges must not be overlapping. */ function replaceRanges(code, replacer) { - const replacements = []; - replacer({ replace: (range, replacement) => replacements.push({ range, replacement }) }); + const replacements = []; + replacer({ replace: (range, replacement) => replacements.push({ range, replacement }) }); - if (!replacements.length) return code; - replacements.sort((a, b) => a.range[0] - b.range[0]); - const overlapIndex = replacements.findIndex( - (r, index) => index > 0 && replacements[index - 1].range[1] > r.range[0], - ); - if (overlapIndex >= 0) { - throw new Error( - `replacements overlap: ${JSON.stringify(replacements[overlapIndex - 1])} and ${JSON.stringify( - replacements[overlapIndex], - )}`, - ); - } + if (!replacements.length) return code; + replacements.sort((a, b) => a.range[0] - b.range[0]); + const overlapIndex = replacements.findIndex((r, index) => index > 0 && replacements[index - 1].range[1] > r.range[0]); + if (overlapIndex >= 0) { + throw new Error(`replacements overlap: ${JSON.stringify(replacements[overlapIndex - 1])} and ${JSON.stringify(replacements[overlapIndex])}`); + } - const parts = []; - let end = 0; - for (const { - range: [from, to], - replacement, - } of replacements) { - if (from > end) parts.push(code.substring(end, from)); - parts.push(replacement); - end = to; - } - if (end < code.length) parts.push(code.substring(end)); - return parts.join(''); + const parts = []; + let end = 0; + for (const { + range: [from, to], + replacement + } of replacements) { + if (from > end) parts.push(code.substring(end, from)); + parts.push(replacement); + end = to; + } + if (end < code.length) parts.push(code.substring(end)); + return parts.join(""); } /** @@ -72,94 +63,91 @@ function replaceRanges(code, replacer) { * @returns the transformed code */ function mapModulePaths(code, iteratee) { - const ast = parse(code, { range: true }); - return replaceRanges(code, ({ replace }) => - traverse(ast, { - node(node) { - switch (node.type) { - case 'ImportDeclaration': - case 'ExportNamedDeclaration': - case 'ExportAllDeclaration': - case 'ImportExpression': - if (node.source) { - const { range, value } = node.source; - const transformed = iteratee(value); - if (transformed !== value) { - replace(range, JSON.stringify(transformed)); - } + const ast = parse(code, { range: true }); + return replaceRanges(code, ({ replace }) => + traverse(ast, { + node(node) { + switch (node.type) { + case "ImportDeclaration": + case "ExportNamedDeclaration": + case "ExportAllDeclaration": + case "ImportExpression": + if (node.source) { + const { range, value } = node.source; + const transformed = iteratee(value); + if (transformed !== value) { + replace(range, JSON.stringify(transformed)); + } + } + } } - } - }, - }), - ); + }) + ); } async function* walk(dir) { - for await (const d of await fs.promises.opendir(dir)) { - const entry = path.join(dir, d.name); - if (d.isDirectory()) yield* walk(entry); - else if (d.isFile()) yield entry; - } + for await (const d of await fs.promises.opendir(dir)) { + const entry = path.join(dir, d.name); + if (d.isDirectory()) yield* walk(entry); + else if (d.isFile()) yield entry; + } } async function postprocess() { - for await (const file of walk(path.resolve(__dirname, '..', 'dist'))) { - if (!/\.([cm]?js|(\.d)?[cm]?ts)$/.test(file)) continue; + for await (const file of walk(path.resolve(__dirname, "..", "dist"))) { + if (!/\.([cm]?js|(\.d)?[cm]?ts)$/.test(file)) continue; - const code = await fs.promises.readFile(file, 'utf8'); + const code = await fs.promises.readFile(file, "utf8"); - let transformed = mapModulePaths(code, (importPath) => { - if (file.startsWith(distSrcDir)) { - if (importPath.startsWith(pkgImportPath)) { - // convert self-references in dist/src to relative paths - let relativePath = path.relative( - path.dirname(file), - path.join(distSrcDir, importPath.substring(pkgImportPath.length)), - ); - if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`; - return relativePath; - } - return importPath; - } - if (importPath.startsWith('.')) { - // add explicit file extensions to relative imports - const { dir, name } = path.parse(importPath); - const ext = /\.mjs$/.test(file) ? '.mjs' : '.js'; - return `${dir}/${name}${ext}`; - } - return importPath; - }); + let transformed = mapModulePaths(code, (importPath) => { + if (file.startsWith(distSrcDir)) { + if (importPath.startsWith(pkgImportPath)) { + // convert self-references in dist/src to relative paths + let relativePath = path.relative(path.dirname(file), path.join(distSrcDir, importPath.substring(pkgImportPath.length))); + if (!relativePath.startsWith(".")) relativePath = `./${relativePath}`; + return relativePath; + } + return importPath; + } + if (importPath.startsWith(".")) { + // add explicit file extensions to relative imports + const { dir, name } = path.parse(importPath); + const ext = /\.mjs$/.test(file) ? ".mjs" : ".js"; + return `${dir}/${name}${ext}`; + } + return importPath; + }); - if (file.startsWith(distSrcDir) && !file.endsWith('_shims/index.d.ts')) { - // strip out `unknown extends Foo ? never :` shim guards in dist/src - // to prevent errors from appearing in Go To Source - transformed = transformed.replace( - new RegExp('unknown extends (typeof )?\\S+ \\? \\S+ :\\s*'.replace(/\s+/, '\\s+'), 'gm'), - // replace with same number of characters to avoid breaking source maps - (match) => ' '.repeat(match.length), - ); - } + if (file.startsWith(distSrcDir) && !file.endsWith("_shims/index.d.ts")) { + // strip out `unknown extends Foo ? never :` shim guards in dist/src + // to prevent errors from appearing in Go To Source + transformed = transformed.replace( + new RegExp("unknown extends (typeof )?\\S+ \\? \\S+ :\\s*".replace(/\s+/, "\\s+"), "gm"), + // replace with same number of characters to avoid breaking source maps + (match) => " ".repeat(match.length) + ); + } - if (file.endsWith('.d.ts')) { - // work around bad tsc behavior - // if we have `import { type Readable } from '@EmbeddedLLM/jamaisdk/_shims/index'`, - // tsc sometimes replaces `Readable` with `import("stream").Readable` inline - // in the output .d.ts - transformed = transformed.replace(/import\("stream"\).Readable/g, 'Readable'); - } + if (file.endsWith(".d.ts")) { + // work around bad tsc behavior + // if we have `import { type Readable } from '@EmbeddedLLM/jamaisdk/_shims/index'`, + // tsc sometimes replaces `Readable` with `import("stream").Readable` inline + // in the output .d.ts + transformed = transformed.replace(/import\("stream"\).Readable/g, "Readable"); + } - // strip out lib="dom" and types="node" references; these are needed at build time, - // but would pollute the user's TS environment - transformed = transformed.replace( - /^ *\/\/\/ * ' '.repeat(match.length - 1) + '\n', - ); + // strip out lib="dom" and types="node" references; these are needed at build time, + // but would pollute the user's TS environment + transformed = transformed.replace( + /^ *\/\/\/ * " ".repeat(match.length - 1) + "\n" + ); - if (transformed !== code) { - await fs.promises.writeFile(file, transformed, 'utf8'); - console.error(`wrote ${path.relative(process.cwd(), file)}`); + if (transformed !== code) { + await fs.promises.writeFile(file, transformed, "utf8"); + console.error(`wrote ${path.relative(process.cwd(), file)}`); + } } - } } postprocess(); diff --git a/clients/typescript/scripts/fix-test-include-tsconfig.cjs b/clients/typescript/scripts/remove-tests-tsconfig.cjs similarity index 97% rename from clients/typescript/scripts/fix-test-include-tsconfig.cjs rename to clients/typescript/scripts/remove-tests-tsconfig.cjs index 7614db6..1d91f56 100644 --- a/clients/typescript/scripts/fix-test-include-tsconfig.cjs +++ b/clients/typescript/scripts/remove-tests-tsconfig.cjs @@ -35,7 +35,7 @@ fs.readFile(tsConfigPath, "utf8", (err, data) => { return; } - console.log("tsconfig.json has been updated successfully."); + console.log("tsconfig.json has been updated successfully (remove '__tests__' folder.)"); }); } catch (err) { console.error(`Error parsing JSON: ${err}`); diff --git a/clients/typescript/src/helpers/utils.browser.ts b/clients/typescript/src/helpers/utils.browser.ts new file mode 100644 index 0000000..231dd77 --- /dev/null +++ b/clients/typescript/src/helpers/utils.browser.ts @@ -0,0 +1,34 @@ +export const getOSInfoBrwoser = () => { + const userAgent = window.navigator.userAgent; + const platform = window.navigator.platform; + const architecture = window.navigator.userAgent.includes("WOW64") || window.navigator.userAgent.includes("Win64") ? "x64" : "x86"; + + let browser = "Unknown"; + + if (userAgent.includes("Firefox")) { + browser = "Firefox"; + } else if (userAgent.includes("Chrome")) { + browser = "Chrome"; + } else if (userAgent.includes("Safari")) { + browser = "Safari"; + } else if (userAgent.includes("Edge")) { + browser = "Edge"; + } else if (userAgent.includes("Opera") || userAgent.includes("OPR")) { + browser = "Opera"; + } + + let os = "Unknown OS"; + if (platform?.startsWith("Win")) { + os = "Windows"; + } else if (platform?.startsWith("Mac")) { + os = "macOS"; + } else if (platform?.startsWith("Linux")) { + os = "Linux"; + } else if (/Android/.test(userAgent)) { + os = "Android"; + } else if (/iPhone|iPad|iPod/.test(userAgent)) { + os = "iOS"; + } + + return `${browser}-${os}; ${architecture}`; +}; diff --git a/clients/typescript/src/helpers/utils.node.ts b/clients/typescript/src/helpers/utils.node.ts new file mode 100644 index 0000000..38f6f12 --- /dev/null +++ b/clients/typescript/src/helpers/utils.node.ts @@ -0,0 +1,49 @@ +export const getFileName = async (filePath: string) => { + if (typeof window === "undefined") { + const path = await import("path"); + return path?.basename(filePath!); + } + return ""; +}; + +export const readFile = async (filePath: string) => { + if (typeof window === "undefined") { + const { promises: fs } = await import("fs"); + return await fs?.readFile(filePath!); + } + return ""; +}; + +export const getMimeType = async (filePath: string) => { + if (typeof window === "undefined") { + const mime = await import("mime-types"); + return mime?.lookup(filePath!) || "application/octet-stream"; + } + return ""; +}; + +export const getOSInfoNode = async () => { + if (typeof window === "undefined") { + const os = await import("os"); + const platform = os?.platform() || ""; + const arch = os?.arch(); + let osName = "Unknown OS"; + + switch (platform) { + case "win32": + osName = "Windows"; + break; + case "darwin": + osName = "macOS"; + break; + case "linux": + osName = "Linux"; + break; + default: + osName = platform; + } + + return `${osName} ${os?.release()}; ${arch}`; + } + return ""; +}; diff --git a/clients/typescript/src/utils.ts b/clients/typescript/src/helpers/utils.ts similarity index 55% rename from clients/typescript/src/utils.ts rename to clients/typescript/src/helpers/utils.ts index ccbb485..4a5afc5 100644 --- a/clients/typescript/src/utils.ts +++ b/clients/typescript/src/helpers/utils.ts @@ -23,3 +23,26 @@ export function applyMixins(derivedCtor: any, constructors: any[]) { export const passthrough = (schema: ZodObject) => { return schema.and(z.record(z.string(), z.any())); }; + +export const isRunningInBrowser = () => { + return ( + // @ts-ignore + typeof window !== "undefined" && + // @ts-ignore + typeof window.document !== "undefined" && + // @ts-ignore + typeof navigator !== "undefined" + ); +}; + +// Define a generic function to create a pagination schema +export function createPaginationSchema(itemSchema: z.ZodType) { + return passthrough( + z.object({ + items: z.array(itemSchema).describe("List of items paginated items.").default([]), + offset: z.number().describe("Number of skipped items.").default(0), + limit: z.number().describe("Number of items per page.").default(100), + total: z.number().describe("Total number of items.").default(0) + }) + ); +} diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index d84f9be..7f61ee2 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -1,11 +1,77 @@ -import { Base } from "@/resources/base"; +import { Base, TConfig } from "@/resources/base"; +import { Files } from "@/resources/files"; import { GenTable } from "@/resources/gen_tables"; import { LLM } from "@/resources/llm"; -import { applyMixins } from "@/utils"; +import { Templates } from "@/resources/templates"; +import Agent from "agentkeepalive"; +import { AxiosResponse } from "axios"; -class JamAI extends Base {} -interface JamAI extends GenTable, LLM {} +class JamAI extends Base { + public table: GenTable; + public llm: LLM; + public template: Templates; + public file: Files; -applyMixins(JamAI, [GenTable, LLM]); + /** + * Creates an instance of APIClient. + * @param {string} baseURL Base URL for the API requests. Default url is - https://api.jamaibase.com + * @param {string} token PAT. + * @param {string} projectId Project ID. + * @param {number=} [maxRetries=0] Maximum number of retries for failed requests. Defaults value is 0. + * @param {AxiosInstance} [httpClient] Axios instance for making HTTP requests. If not provided, a default instance will be created. + * @param {number} [timeout] Timeout (ms) for the requests. Default value is none. + */ + constructor(config: TConfig) { + super(config); + this.table = new GenTable(config); + this.llm = new LLM(config); + this.template = new Templates(config); + this.file = new Files(config); + } + + public override setApiKey(token: string){ + super.setApiKey(token) + } + + public override setProjId(projectId: string){ + super.setProjId(projectId) + } + + /** + * Options for configuring the HTTP agent. + * @property {Boolean} [keepAlive=true] - Keep sockets around in a pool to be used by other requests in the future. Default is true. + * @property {Number} [keepAliveMsecs=1000] - Initial delay for TCP Keep-Alive packets when keepAlive is enabled. Defaults to 1000 milliseconds. Only relevant if keepAlive is true. + * @property {Number} [freeSocketTimeout=20000] - Timeout for free sockets after inactivity, in milliseconds. Default is 20000 milliseconds. Only relevant if keepAlive is true. + * @property {Number} [timeout] - Timeout for working sockets after inactivity, in milliseconds. Default is calculated as freeSocketTimeout * 2 if greater than or equal to 8000 milliseconds, otherwise the default is 8000 milliseconds. + * @property {Number} [maxSockets=Infinity] - Maximum number of sockets to allow per host. Default is Infinity. + * @property {Number} [maxFreeSockets=10] - Maximum number of free sockets per host to keep open. Only relevant if keepAlive is true. Default is 10. + * @property {Number} [socketActiveTTL=null] - Sets the time to live for active sockets, even if in use. If not set, sockets are released only when free. Default is null. + */ + public override setHttpagentConfig(payload: Agent.HttpOptions) { + super.setHttpagentConfig(payload); + } + + /** + * Options for configuring the HTTP agent. + * @property {Boolean} [keepAlive=true] - Keep sockets around in a pool to be used by other requests in the future. Default is true. + * @property {Number} [keepAliveMsecs=1000] - Initial delay for TCP Keep-Alive packets when keepAlive is enabled. Defaults to 1000 milliseconds. Only relevant if keepAlive is true. + * @property {Number} [freeSocketTimeout=20000] - Timeout for free sockets after inactivity, in milliseconds. Default is 20000 milliseconds. Only relevant if keepAlive is true. + * @property {Number} [timeout] - Timeout for working sockets after inactivity, in milliseconds. Default is calculated as freeSocketTimeout * 2 if greater than or equal to 8000 milliseconds, otherwise the default is 8000 milliseconds. + * @property {Number} [maxSockets=Infinity] - Maximum number of sockets to allow per host. Default is Infinity. + * @property {Number} [maxFreeSockets=10] - Maximum number of free sockets per host to keep open. Only relevant if keepAlive is true. Default is 10. + * @property {Number} [socketActiveTTL=null] - Sets the time to live for active sockets, even if in use. If not set, sockets are released only when free. Default is null. + */ + public override setHttpsagentConfig(payload: Agent.HttpsOptions) { + super.setHttpsagentConfig(payload) + } + + public override setAuthHeader(header: string) { + super.setAuthHeader(header) + } + + public override async health(): Promise { + return await super.health() + } +} export default JamAI; diff --git a/clients/typescript/src/resources/base.ts b/clients/typescript/src/resources/base.ts index 6fc0d5f..16f9fad 100644 --- a/clients/typescript/src/resources/base.ts +++ b/clients/typescript/src/resources/base.ts @@ -1,18 +1,10 @@ -import { createHttpAgent, createHttpsAgent } from "@/utils"; +import { createHttpAgent, createHttpsAgent, isRunningInBrowser } from "@/helpers/utils"; +import { getOSInfoBrwoser } from "@/helpers/utils.browser"; +import { getOSInfoNode } from "@/helpers/utils.node"; import Agent from "agentkeepalive"; import axios, { AxiosInstance, AxiosResponse } from "axios"; import axiosRetry from "axios-retry"; - -export const isRunningInBrowser = () => { - return ( - // @ts-ignore - typeof window !== "undefined" && - // @ts-ignore - typeof window.document !== "undefined" && - // @ts-ignore - typeof navigator !== "undefined" - ); -}; +import { z, ZodSchema } from "zod"; /** * Configuration type for initializing the APIClient. @@ -26,51 +18,68 @@ type BaseConfig = { type ConfigWithBaseURL = BaseConfig & { baseURL: string; - apiKey?: string; + token?: string; projectId?: string; - dangerouslyAllowBrowser?: boolean; }; type ConfigWithoutBaseURL = BaseConfig & { baseURL?: string; - apiKey: string; + token: string; projectId: string; - dangerouslyAllowBrowser?: boolean; }; -type Config = ConfigWithBaseURL | ConfigWithoutBaseURL; +export type TConfig = ConfigWithBaseURL | ConfigWithoutBaseURL; export abstract class Base { protected maxRetries: number; protected httpClient: AxiosInstance; protected timeout: number | undefined; + private sdkVersion = "0.3"; /** * Creates an instance of APIClient. * @param {string} baseURL Base URL for the API requests. Default url is - https://api.jamaibase.com - * @param {string} apiKey apiKey. + * @param {string} token PAT. * @param {string} projectId Project ID. * @param {number=} [maxRetries=0] Maximum number of retries for failed requests. Defaults value is 0. * @param {AxiosInstance} [httpClient] Axios instance for making HTTP requests. If not provided, a default instance will be created. * @param {number} [timeout] Timeout (ms) for the requests. Default value is none. */ - constructor({ baseURL, apiKey, projectId, maxRetries = 0, httpClient, timeout, dangerouslyAllowBrowser = false }: Config) { + constructor({ baseURL, token, projectId, maxRetries = 0, httpClient, timeout, dangerouslyAllowBrowser = false }: TConfig) { this.maxRetries = maxRetries; this.httpClient = httpClient || axios.create({}); this.timeout = timeout; if (!dangerouslyAllowBrowser && isRunningInBrowser()) { throw new Error( - "It looks like you're running in a browser-like environment.\n\nThis is disabled by default, as it risks exposing your secret API credentials to attackers.\nIf you understand the risks and have appropriate mitigations in place,\nyou can set the `dangerouslyAllowBrowser` option to `true`, e.g.,\n\nnew JamAI({ apiKey, dangerouslyAllowBrowser: true });" + "It looks like you're running in a browser-like environment.\n\nThis is disabled by default, as it risks exposing your secret API credentials to attackers.\nIf you understand the risks and have appropriate mitigations in place,\nyou can set the `dangerouslyAllowBrowser` option to `true`, e.g.,\n\nnew JamAI({ token, dangerouslyAllowBrowser: true });" ); } + // Setting up the interceptor + this.httpClient.interceptors.request.use( + async (config) => { + const userAgent = await this.generateUserAgent(); + config.headers["User-Agent"] = userAgent; + + return config; + }, + (error) => { + // Handle the request error here + return Promise.reject(error); + } + ); + // add baseurl to axios instance this.httpClient.defaults.baseURL = baseURL || "https://api.jamaibase.com"; // add apikey and project id to header if provided - if (apiKey && projectId) { - this.setApiKeyProjId(apiKey, projectId); + if (token) { + this.setApiKey(token); + } + + if (projectId) { + this.setProjId(projectId); } // add timeout to client @@ -93,8 +102,11 @@ export abstract class Base { } // add agent pool - this.httpClient.defaults.httpAgent = createHttpAgent(); - this.httpClient.defaults.httpsAgent = createHttpsAgent(); + if (!isRunningInBrowser()) { + this.httpClient.defaults.httpAgent = createHttpAgent(); + this.httpClient.defaults.httpsAgent = createHttpsAgent(); + } + // (TODO): add agent for browser (default browser) } /** @@ -107,7 +119,7 @@ export abstract class Base { * @property {Number} [maxFreeSockets=10] - Maximum number of free sockets per host to keep open. Only relevant if keepAlive is true. Default is 10. * @property {Number} [socketActiveTTL=null] - Sets the time to live for active sockets, even if in use. If not set, sockets are released only when free. Default is null. */ - public setHttpagentConfig(payload: Agent.HttpOptions) { + protected setHttpagentConfig(payload: Agent.HttpOptions) { this.httpClient.defaults.httpAgent = createHttpAgent(payload); } @@ -121,20 +133,69 @@ export abstract class Base { * @property {Number} [maxFreeSockets=10] - Maximum number of free sockets per host to keep open. Only relevant if keepAlive is true. Default is 10. * @property {Number} [socketActiveTTL=null] - Sets the time to live for active sockets, even if in use. If not set, sockets are released only when free. Default is null. */ - public setHttpsagentConfig(payload: Agent.HttpsOptions) { + protected setHttpsagentConfig(payload: Agent.HttpsOptions) { this.httpClient.defaults.httpsAgent = createHttpsAgent(payload); } - public async getHealth(): Promise { - let getURL = `/health`; + protected async health(): Promise { + let getURL = `/api/health`; return this.httpClient.get(getURL); } - public setApiKeyProjId(apiKey: string, projectId: string) { - this.httpClient.defaults.headers.common["Authorization"] = `Bearer ${apiKey}`; + protected setApiKey(token: string) { + this.httpClient.defaults.headers.common["Authorization"] = `Bearer ${token}`; + } + protected setProjId(projectId: string) { this.httpClient.defaults.headers.common["X-PROJECT-ID"] = projectId; } - public setAuthHeader(header: string) { + protected setAuthHeader(header: string) { this.httpClient.defaults.headers.common["Authorization"] = header; } + + // Helper method to log warnings if present + protected logWarning(response: AxiosResponse): void { + const warning = response.headers["warning"]; + if (warning) { + console.warn(warning); + } + } + + // Helper method to handle response validation + protected handleResponse>(response: AxiosResponse, schema?: T): z.infer { + this.logWarning(response); + + if (response.status !== 200) { + throw new Error(`Received Error Status: ${response.status}`); + } + if (schema) { + const parsedData = schema.parse(response.data) as z.infer; + return parsedData; + } else { + return response.data; + } + } + + // Method to get language and version (TypeScript or JavaScript) + private getLanguageAndVersion(): { language: string; version: string } { + try { + // Check if TypeScript is being used + const tsVersion = require("typescript").version; + return { language: "TypeScript", version: tsVersion }; + } catch (error) { + // Fallback to JavaScript if TypeScript is not detected + return { language: "JavaScript", version: process.version }; + } + } + + private async generateUserAgent(): Promise { + const sdkVersion = this.sdkVersion; + const { language, version } = this.getLanguageAndVersion(); + let osInfo = ""; + if (isRunningInBrowser()) { + osInfo = getOSInfoBrwoser(); + } else { + osInfo = await getOSInfoNode(); + } + return `SDK/${sdkVersion} (${language}/${version}; ${osInfo})`; + } } diff --git a/clients/typescript/src/resources/files/index.ts b/clients/typescript/src/resources/files/index.ts new file mode 100644 index 0000000..728bdbb --- /dev/null +++ b/clients/typescript/src/resources/files/index.ts @@ -0,0 +1,65 @@ +import { isRunningInBrowser } from "@/helpers/utils"; +import { getFileName, getMimeType, readFile } from "@/helpers/utils.node"; +import { Base } from "@/resources/base"; +import { + GetUrlRequestSchema, + GetUrlResponseSchema, + IGetUrlRequest, + IGetUrlResponse, + IUploadFileRequest, + IUploadFileResponse, + UploadFileRequestSchema, + UploadFileResponseSchema +} from "./types"; + +export class Files extends Base { + public async uploadFile(params: IUploadFileRequest): Promise { + const apiURL = `/api/v1/files/upload/`; + + const parsedParams = UploadFileRequestSchema.parse(params); + + // Create FormData to send as multipart/form-data + const formData = new FormData(); + if (parsedParams.file) { + formData.append("file", parsedParams.file, parsedParams.file.name); + } else if (parsedParams.file_path) { + if (!isRunningInBrowser()) { + const mimeType = await getMimeType(parsedParams.file_path!); + const fileName = await getFileName(parsedParams.file_path!); + const data = await readFile(parsedParams.file_path!); + const file = new Blob([data], { type: mimeType }); + formData.append("file", file, fileName); + } else { + throw new Error("Pass File instead of file path if you are using this function in client."); + } + } else { + throw new Error("Either File or file_path is required."); + } + + const response = await this.httpClient.post(apiURL, formData, { + headers: { + "Content-Type": "multipart/form-data" + } + }); + + return this.handleResponse(response, UploadFileResponseSchema); + } + + public async getRawUrls(params: IGetUrlRequest): Promise { + const parsedParams = GetUrlRequestSchema.parse(params); + const apiURL = `/api/v1/files/url/raw`; + const response = await this.httpClient.post(apiURL, { + uris: parsedParams.uris + }); + return this.handleResponse(response, GetUrlResponseSchema); + } + + public async getThumbUrls(params: IGetUrlRequest): Promise { + const parsedParams = GetUrlRequestSchema.parse(params); + const apiURL = `/api/v1/files/url/thumb`; + const response = await this.httpClient.post(apiURL, { + uris: parsedParams.uris + }); + return this.handleResponse(response, GetUrlResponseSchema); + } +} diff --git a/clients/typescript/src/resources/files/types.ts b/clients/typescript/src/resources/files/types.ts new file mode 100644 index 0000000..14aba8a --- /dev/null +++ b/clients/typescript/src/resources/files/types.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const UploadFileRequestSchema = z.object({ + file: z + .any() + .refine((value) => value instanceof File, { + message: "Value must be a File object" + }) + .optional(), + file_path: z.string().optional() +}); + +export const UploadFileResponseSchema = z.object({ + object: z.literal("file.upload"), + uri: z.string() +}); + +export const GetUrlRequestSchema = z.object({ + uris: z.array(z.string()) +}); +export const GetUrlResponseSchema = z.object({ + object: z.literal("file.urls"), + urls: z.array(z.string()) +}); + +export type IUploadFileRequest = z.input; +export type IUploadFileResponse = z.infer; + +export type IGetUrlRequest = z.input; +export type IGetUrlResponse = z.infer; diff --git a/clients/typescript/src/resources/gen_tables/action.ts b/clients/typescript/src/resources/gen_tables/action.ts index 6d01394..c15c471 100644 --- a/clients/typescript/src/resources/gen_tables/action.ts +++ b/clients/typescript/src/resources/gen_tables/action.ts @@ -3,5 +3,5 @@ import { z } from "zod"; export const CreateActionTableRequestSchema = TableSchemaCreateSchema; export const AddActionColumnRequestSchema = TableSchemaCreateSchema; -export type CreateActionTableRequest = z.infer; -export type AddActionColumnRequest = z.infer; +export type CreateActionTableRequest = z.input; +export type AddActionColumnRequest = z.input; diff --git a/clients/typescript/src/resources/gen_tables/chat.ts b/clients/typescript/src/resources/gen_tables/chat.ts index e8369c7..c95baf4 100644 --- a/clients/typescript/src/resources/gen_tables/chat.ts +++ b/clients/typescript/src/resources/gen_tables/chat.ts @@ -1,9 +1,13 @@ -import { IdSchema, TableSchemaCreateSchema } from "@/resources/gen_tables/tables"; +import { IdSchema, TableSchemaCreateSchema, TableTypesSchema } from "@/resources/gen_tables/tables"; import { ChatCompletionChunkSchema, ChatEntrySchema, ReferencesSchema } from "@/resources/llm/chat"; import { z } from "zod"; export const GetConversationThreadRequestSchema = z.object({ - table_id: IdSchema + table_id: IdSchema, + column_id: IdSchema, + row_id: z.string().default(""), + table_type: TableTypesSchema, + include: z.boolean().default(true) }); export const GetConversationThreadResponseSchema = z.object({ @@ -34,9 +38,9 @@ export const GenTableStreamReferencesSchema = ReferencesSchema.extend({ }); export const CreateChatTableRequestSchema = TableSchemaCreateSchema; -export type CreateChatTableRequest = z.infer; +export type CreateChatTableRequest = z.input; -export type GetConversationThreadRequest = z.infer; +export type GetConversationThreadRequest = z.input; export type GetConversationThreadResponse = z.infer; export type GenTableChatCompletionChunks = z.infer; export type GenTableRowsChatCompletionChunks = z.infer; diff --git a/clients/typescript/src/resources/gen_tables/index.ts b/clients/typescript/src/resources/gen_tables/index.ts index 97afa06..8ff882c 100644 --- a/clients/typescript/src/resources/gen_tables/index.ts +++ b/clients/typescript/src/resources/gen_tables/index.ts @@ -1,10 +1,16 @@ import { Base } from "@/resources/base"; -import { promises as fs } from "fs"; -import mime from "mime-types"; -import { AddActionColumnRequest, CreateActionTableRequest } from "@/resources/gen_tables/action"; +import { isRunningInBrowser } from "@/helpers/utils"; +import { getFileName, getMimeType, readFile } from "@/helpers/utils.node"; +import { + AddActionColumnRequest, + AddActionColumnRequestSchema, + CreateActionTableRequest, + CreateActionTableRequestSchema +} from "@/resources/gen_tables/action"; import { CreateChatTableRequest, + CreateChatTableRequestSchema, GenTableRowsChatCompletionChunks, GenTableRowsChatCompletionChunksSchema, GenTableStreamChatCompletionChunk, @@ -12,18 +18,21 @@ import { GenTableStreamReferences, GenTableStreamReferencesSchema, GetConversationThreadRequest, + GetConversationThreadRequestSchema, GetConversationThreadResponse, GetConversationThreadResponseSchema } from "@/resources/gen_tables/chat"; -import { CreateKnowledgeTableRequest, UploadFileRequest } from "@/resources/gen_tables/knowledge"; +import { CreateKnowledgeTableRequest, CreateKnowledgeTableRequestSchema, UploadFileRequest } from "@/resources/gen_tables/knowledge"; import { AddColumnRequest, + AddColumnRequestSchema, AddRowRequest, DeleteRowRequest, DeleteRowsRequest, DeleteTableRequest, DropColumnsRequest, DuplicateTableRequest, + DuplicateTableRequestSchema, ExportTableRequest, ExportTableRequestSchema, GetRowRequest, @@ -35,6 +44,7 @@ import { HybridSearchResponseSchema, ImportTableRequest, ListTableRequest, + ListTableRequestSchema, ListTableRowsRequest, ListTableRowsRequestSchema, OkResponse, @@ -51,53 +61,118 @@ import { TableMetaResponse, TableMetaResponseSchema, UpdateGenConfigRequest, + UpdateGenConfigRequestSchema, UpdateRowRequest } from "@/resources/gen_tables/tables"; import { ChunkError } from "@/resources/shared/error"; -import axios from "axios"; -import path from "path"; +import axios, { AxiosResponse } from "axios"; +import { Blob, FormData } from "formdata-node"; export class GenTable extends Base { - public async listTables({ table_type, limit = 100, offset = 0, parent_id }: ListTableRequest): Promise { - let getURL = `/api/v1/gen_tables/${table_type}?offset=${offset}&limit=${limit}`; - if (parent_id) { - getURL = getURL + `&parent_id=${parent_id}`; + // Helper method to handle stream responses + private handleGenTableStreamResponse( + response: AxiosResponse + ): ReadableStream { + this.logWarning(response); + + if (response.status != 200) { + throw new Error(`Received Error Status: ${response.status}`); } - const response = await this.httpClient.get(getURL); + return new ReadableStream({ + async start(controller: ReadableStreamDefaultController) { + response.data.on("data", (data: any) => { + data = data.toString(); + if (data.endsWith("\n\n")) { + const lines = data + .split("\n\n") + .filter((i: string) => i.trim()) + .flatMap((line: string) => line.split("\n")); // Split by \n to handle collation - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + for (const line of lines) { + const chunk = line + .toString() + .replace(/^data: /, "") + .replace(/data: \[DONE\]\s+$/, ""); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = PageListTableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); + if (chunk.trim() === "[DONE]") return; + + try { + const parsedValue = JSON.parse(chunk); + if (parsedValue["object"] === "gen_table.completion.chunk") { + controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); + } else if (parsedValue["object"] === "gen_table.references") { + controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); + } else { + throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); + } + } catch (err: any) { + if (err instanceof ChunkError) { + controller.error(new ChunkError(err.message)); + } else { + continue; + } + } + } + } else { + const chunk = data + .toString() + .replace(/^data: /, "") + .replace(/data: \[DONE\]\s+$/, ""); + + if (chunk.trim() === "[DONE]") return; + + try { + const parsedValue = JSON.parse(chunk); + if (parsedValue["object"] === "gen_table.completion.chunk") { + controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); + } else if (parsedValue["object"] === "gen_table.references") { + controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); + } else { + throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); + } + } catch (err: any) { + if (err instanceof ChunkError) { + controller.error(new ChunkError(err.message)); + } + } + } + }); + + response.data.on("error", (data: any) => { + controller.error("Unexpected Error."); + }); + + response.data.on("end", () => { + if (controller.desiredSize !== null) { + controller.close(); + } + }); } }); } - public async getTable(params: TableMetaRequest): Promise { - let getURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}`; + public async listTables(params: ListTableRequest): Promise { + const parsedParams = ListTableRequestSchema.parse(params); + let getURL = `/api/v1/gen_tables/${params.table_type}`; - const response = await this.httpClient.get(getURL); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + delete (parsedParams as any).table_type; - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); + const response = await this.httpClient.get(getURL, { + params: { + ...parsedParams, + search_query: encodeURIComponent(parsedParams.search_query) } }); + + return this.handleResponse(response, PageListTableMetaResponseSchema); + } + + public async getTable(params: TableMetaRequest): Promise { + let getURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}`; + + const response = await this.httpClient.get(getURL); + return this.handleResponse(response, TableMetaResponseSchema); } public async listRows(params: ListTableRowsRequest): Promise { @@ -109,7 +184,8 @@ export class GenTable extends Base { search_query: encodeURIComponent(parsedParams.search_query), columns: parsedParams.columns ? parsedParams.columns?.map(encodeURIComponent) : [], float_decimals: parsedParams.float_decimals, - vec_decimals: parsedParams.vec_decimals + vec_decimals: parsedParams.vec_decimals, + order_descending: parsedParams.order_descending }, paramsSerializer: (params) => { return Object.entries(params) @@ -118,17 +194,7 @@ export class GenTable extends Base { } }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status == 200) { - const parsedData = PageListTableRowsResponseSchema.parse(response.data); - return parsedData; - } else { - throw new Error(`Received Error Status: ${response.status}`); - } + return this.handleResponse(response, PageListTableRowsResponseSchema); } public async getRow(params: GetRowRequest): Promise { @@ -146,103 +212,55 @@ export class GenTable extends Base { } }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status == 200) { - const parsedData = GetRowResponseSchema.parse(response.data); - return parsedData; - } else { - throw new Error(`Received Error Status: ${response.status}`); - } + return this.handleResponse(response, GetRowResponseSchema); } public async getConversationThread(params: GetConversationThreadRequest): Promise { - let getURL = `/api/v1/gen_tables/chat/${params.table_id}/thread?table_id=${params.table_id}`; - const response = await this.httpClient.get(getURL); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + const parsedParams = GetConversationThreadRequestSchema.parse(params); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = GetConversationThreadResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); + let getURL = `/api/v1/gen_tables/${parsedParams.table_type}/${parsedParams.table_id}/thread`; + const response = await this.httpClient.get(getURL, { + params: { + column_id: parsedParams.column_id, + row_id: parsedParams.row_id, + include: parsedParams.include } }); + + return this.handleResponse(response, GetConversationThreadResponseSchema); } /* * Gen Table Create */ public async createActionTable(params: CreateActionTableRequest): Promise { + const parsedParams = CreateActionTableRequestSchema.parse(params); const apiURL = "/api/v1/gen_tables/action"; const response = await this.httpClient.post( apiURL, { - ...params, + ...parsedParams, stream: false }, {} ); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async createChatTable(params: CreateChatTableRequest): Promise { + const parsedParams = CreateChatTableRequestSchema.parse(params); const apiURL = "/api/v1/gen_tables/chat"; - const response = await this.httpClient.post(apiURL, params); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + const response = await this.httpClient.post(apiURL, parsedParams); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async createKnowledgeTable(params: CreateKnowledgeTableRequest): Promise { + const parsedParams = CreateKnowledgeTableRequestSchema.parse(params); const apiURL = "/api/v1/gen_tables/knowledge"; - const response = await this.httpClient.post(apiURL, params); + const response = await this.httpClient.post(apiURL, parsedParams); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } /* @@ -252,19 +270,7 @@ export class GenTable extends Base { let deleteURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}`; const response = await this.httpClient.delete(deleteURL); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = OkResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, OkResponseSchema); } public async deleteRow(params: DeleteRowRequest): Promise { @@ -276,19 +282,7 @@ export class GenTable extends Base { } }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = OkResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, OkResponseSchema); } /** @@ -300,68 +294,43 @@ export class GenTable extends Base { table_id: params.table_id, where: params.where // Optional. SQL where clause. If not provided, will match all rows and thus deleting all table content. }); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - resolve(OkResponseSchema.parse({ ok: true })); - } else { - console.error("Received Error Status: ", response.status); - resolve(OkResponseSchema.parse({ ok: false })); - } - }); + return this.handleResponse(response, OkResponseSchema); } /* * Gen Table Update */ - public async renameTable(params: RenameTableRequest): Promise { let postURL = `/api/v1/gen_tables/${params.table_type}/rename/${params.table_id_src}/${params.table_id_dst}`; const response = await this.httpClient.post(postURL, {}, {}); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } - public async duplicateTable({ - table_id_dst, - table_id_src, - table_type, - include_data = true, - deploy = false - }: DuplicateTableRequest): Promise { - let postURL = `/api/v1/gen_tables/${table_type}/duplicate/${table_id_src}/${table_id_dst}?include_data=${include_data}&deploy=${deploy}`; - const response = await this.httpClient.post(postURL, {}, {}); + public async duplicateTable(params: DuplicateTableRequest): Promise { + if ("deploy" in params) { + console.warn(`The "deploy" argument is deprecated, use "create_as_child" instead.`); + params.create_as_child = params.deploy as boolean; - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); + delete params.deploy; } - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); + const parsedParams = DuplicateTableRequestSchema.parse(params); + + let postURL = `/api/v1/gen_tables/${params.table_type}/duplicate/${params.table_id_src}`; + const response = await this.httpClient.post( + postURL, + {}, + { + params: { + table_id_dst: parsedParams.table_id_dst, + include_data: parsedParams.include_data, + create_as_child: parsedParams.create_as_child + } } - }); + ); + + return this.handleResponse(response, TableMetaResponseSchema); } public async renameColumns(params: RenameColumnsRequest): Promise { @@ -375,19 +344,7 @@ export class GenTable extends Base { {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async reorderColumns(params: ReorderColumnsRequest): Promise { @@ -401,19 +358,7 @@ export class GenTable extends Base { {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async dropColumns(params: DropColumnsRequest): Promise { @@ -427,103 +372,47 @@ export class GenTable extends Base { {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async addActionColumns(params: AddActionColumnRequest): Promise { + const parsedParams = AddActionColumnRequestSchema.parse(params); let postURL = `/api/v1/gen_tables/action/columns/add`; - const response = await this.httpClient.post(postURL, params); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + const response = await this.httpClient.post(postURL, parsedParams); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async addKnowledgeColumns(params: AddColumnRequest): Promise { + const parsedParams = AddColumnRequestSchema.parse(params); let postURL = `/api/v1/gen_tables/knowledge/columns/add`; - const response = await this.httpClient.post(postURL, params); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + const response = await this.httpClient.post(postURL, parsedParams); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async addChatColumns(params: AddColumnRequest): Promise { + const parsedParams = AddColumnRequestSchema.parse(params); let postURL = `/api/v1/gen_tables/chat/columns/add`; - const response = await this.httpClient.post(postURL, params); + const response = await this.httpClient.post(postURL, parsedParams); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async updateGenConfig(params: UpdateGenConfigRequest): Promise { + const parsedParams = UpdateGenConfigRequestSchema.parse(params); let postURL = `/api/v1/gen_tables/${params.table_type}/gen_config/update`; const response = await this.httpClient.post( postURL, { - table_id: params.table_id, - column_map: params.column_map + table_id: parsedParams.table_id, + column_map: parsedParams.column_map }, {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = TableMetaResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, TableMetaResponseSchema); } public async addRowStream(params: AddRowRequest): Promise> { @@ -543,91 +432,12 @@ export class GenTable extends Base { } ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); - } - - const stream = new ReadableStream({ - async start(controller: ReadableStreamDefaultController) { - response.data.on("data", (data: any) => { - data = data.toString(); - if (data.endsWith("\n\n")) { - const lines = data - .split("\n\n") - .filter((i: string) => i.trim()) - .flatMap((line: string) => line.split("\n")); //? Split by \n to handle collation - for (const line of lines) { - const chunk = line - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err: any) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } else { - continue; - } - } - } - } else { - const chunk = data - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err: any) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } - } - } - }); - - response.data.on("error", (data: any) => { - controller.error("Unexpected Error."); - }); - - response.data.on("end", () => { - if (controller.desiredSize !== null) { - controller.close(); - } - }); - } - }); - - return stream; + return this.handleGenTableStreamResponse(response); } public async addRow(params: AddRowRequest): Promise { const url = `/api/v1/gen_tables/${params.table_type}/rows/add`; + const response = await this.httpClient.post( url, { @@ -640,19 +450,7 @@ export class GenTable extends Base { {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = GenTableRowsChatCompletionChunksSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); } public async regenRowStream(params: RegenRowRequest) { @@ -672,87 +470,7 @@ export class GenTable extends Base { } ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); - } - - const stream = new ReadableStream({ - async start(controller: ReadableStreamDefaultController) { - response.data.on("data", (data: any) => { - data = data.toString(); - if (data.endsWith("\n\n")) { - const lines = data - .split("\n\n") - .filter((i: string) => i.trim()) - .flatMap((line: string) => line.split("\n")); //? Split by \n to handle collation - - for (const line of lines) { - const chunk = line - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } - continue; - } - } - } else { - const chunk = data - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err: any) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } - } - } - }); - - response.data.on("error", (data: any) => { - controller.error("Unexpected error."); - }); - - response.data.on("end", () => { - if (controller.desiredSize !== null) { - controller.close(); - } - }); - } - }); - - return stream; + return this.handleGenTableStreamResponse(response); } public async regenRow(params: RegenRowRequest): Promise { @@ -768,19 +486,7 @@ export class GenTable extends Base { }, {} ); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = GenTableRowsChatCompletionChunksSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); } public async updateRow(params: UpdateRowRequest): Promise { @@ -792,19 +498,7 @@ export class GenTable extends Base { reindex: params.reindex }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - resolve(OkResponseSchema.parse({ ok: true })); - } else { - console.error("Received Error Status: ", response.status); - resolve(OkResponseSchema.parse({ ok: false })); - } - }); + return this.handleResponse(response, OkResponseSchema); } public async hybridSearch(params: HybridSearchRequest): Promise { @@ -814,21 +508,13 @@ export class GenTable extends Base { const response = await this.httpClient.post(apiURL, requestBody); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = HybridSearchResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, HybridSearchResponseSchema); } + /** + * @deprecated This method will be removed in future versions. + * Use the embedFile method instead. + */ // Function to upload a file public async uploadFile(params: UploadFileRequest): Promise { const apiURL = `/api/v1/gen_tables/knowledge/upload_file`; @@ -836,15 +522,17 @@ export class GenTable extends Base { // Create FormData to send as multipart/form-data const formData = new FormData(); if (params.file) { - formData.append("file", params.file); - formData.append("file_name", params.file.name); + formData.append("file", params.file, params.file.name); } else if (params.file_path) { - const mimeType = mime.lookup(params.file_path!) || "application/octet-stream"; - const fileName = path.basename(params.file_path!); - const data = await fs.readFile(params.file_path!); - const file = new Blob([data], { type: mimeType }); - formData.append("file", file); - formData.append("file_name", fileName); + if (!isRunningInBrowser()) { + const mimeType = await getMimeType(params.file_path!); + const fileName = await getFileName(params.file_path!); + const data = await readFile(params.file_path!); + const file = new Blob([data], { type: mimeType }); + formData.append("file", file, fileName); + } else { + throw new Error("Pass File instead of file path if you are using this function in client."); + } } else { throw new Error("Either File or file_path is required."); } @@ -864,16 +552,46 @@ export class GenTable extends Base { } }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); + return this.handleResponse(response, OkResponseSchema); + } + + public async embedFile(params: UploadFileRequest): Promise { + const apiURL = `/api/v1/gen_tables/knowledge/embed_file`; + + // Create FormData to send as multipart/form-data + const formData = new FormData(); + if (params.file) { + formData.append("file", params.file, params.file.name); + } else if (params.file_path) { + if (!isRunningInBrowser()) { + const mimeType = await getMimeType(params.file_path!); + const fileName = await getFileName(params.file_path!); + const data = await readFile(params.file_path!); + const file = new Blob([data], { type: mimeType }); + formData.append("file", file, fileName); + } else { + throw new Error("Pass File instead of file path if you are using this method in client."); + } + } else { + throw new Error("Either File or file_path is required."); } + formData.append("table_id", params.table_id); - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); + // Optional: Add additional fields if required by the API + if (params?.chunk_size) { + formData.append("chunk_size", params.chunk_size.toString()); + } + if (params?.chunk_overlap) { + formData.append("chunk_overlap", params.chunk_overlap.toString()); } - return response.data; + const response = await this.httpClient.post(apiURL, formData, { + headers: { + "Content-Type": "multipart/form-data" + } + }); + + return this.handleResponse(response, OkResponseSchema); } public async importTableData(params: ImportTableRequest): Promise { @@ -882,17 +600,18 @@ export class GenTable extends Base { const delimiter = params.delimiter ? params.delimiter : ","; const formData = new FormData(); - if (params.file) { - formData.append("file", params.file); - formData.append("file_name", params.file.name); + formData.append("file", params.file, params.file.name); } else if (params.file_path) { - const mimeType = mime.lookup(params.file_path!) || "application/octet-stream"; - const fileName = path.basename(params.file_path!); - const data = await fs.readFile(params.file_path!); - const file = new Blob([data], { type: mimeType }); - formData.append("file", file); - formData.append("file_name", fileName); + if (!isRunningInBrowser()) { + const mimeType = await getMimeType(params.file_path!); + const fileName = await getFileName(params.file_path!); + const data = await readFile(params.file_path!); + const file = new Blob([data], { type: mimeType }); + formData.append("file", file, fileName); + } else { + throw new Error("Pass File instead of file path if you are using this function in client."); + } } else { throw new Error("Either File or file_path is required."); } @@ -907,18 +626,7 @@ export class GenTable extends Base { } }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); - } - - const parsedData = GenTableRowsChatCompletionChunksSchema.parse(response.data); - - return parsedData; + return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); } public async importTableDataStream( @@ -929,17 +637,18 @@ export class GenTable extends Base { const delimiter = params.delimiter ? params.delimiter : ","; const formData = new FormData(); - if (params.file) { - formData.append("file", params.file); - formData.append("file_name", params.file.name); + formData.append("file", params.file, params.file.name); } else if (params.file_path) { - const mimeType = mime.lookup(params.file_path!) || "application/octet-stream"; - const fileName = path.basename(params.file_path!); - const data = await fs.readFile(params.file_path!); - const file = new Blob([data], { type: mimeType }); - formData.append("file", file); - formData.append("file_name", fileName); + if (!isRunningInBrowser()) { + const mimeType = await getMimeType(params.file_path!); + const fileName = await getFileName(params.file_path!); + const data = await readFile(params.file_path!); + const file = new Blob([data], { type: mimeType }); + formData.append("file", file, fileName); + } else { + throw new Error("Pass File instead of file path if you are using this function in client."); + } } else { throw new Error("Either File or file_path is required."); } @@ -955,87 +664,7 @@ export class GenTable extends Base { responseType: "stream" }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); - } - - const stream = new ReadableStream({ - async start(controller: ReadableStreamDefaultController) { - response.data.on("data", (data: any) => { - data = data.toString(); - if (data.endsWith("\n\n")) { - const lines = data - .split("\n\n") - .filter((i: string) => i.trim()) - .flatMap((line: string) => line.split("\n")); //? Split by \n to handle collation - for (const line of lines) { - const chunk = line - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err: any) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } else { - continue; - } - } - } - } else { - const chunk = data - .toString() - .replace(/^data: /, "") - .replace(/data: \[DONE\]\s+$/, ""); - - if (chunk.trim() == "[DONE]") return; - - try { - const parsedValue = JSON.parse(chunk); - if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); - } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); - } else { - throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); - } - } catch (err: any) { - if (err instanceof ChunkError) { - controller.error(new ChunkError(err.message)); - } - } - } - }); - - response.data.on("error", (data: any) => { - controller.error("Unexpected Error."); - }); - - response.data.on("end", () => { - if (controller.desiredSize !== null) { - controller.close(); - } - }); - } - }); - - return stream; + return this.handleGenTableStreamResponse(response); } public async exportTableData(params: ExportTableRequest): Promise { @@ -1056,16 +685,7 @@ export class GenTable extends Base { responseType: "arraybuffer" }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - if (response.status != 200) { - throw new Error(`Received Error Status: ${response.status}`); - } - - return new Uint8Array(response.data); + return this.handleResponse(response); } catch (error) { if (axios.isAxiosError(error)) { // Convert buffer data to string for better readability in error diff --git a/clients/typescript/src/resources/gen_tables/knowledge.ts b/clients/typescript/src/resources/gen_tables/knowledge.ts index 69166a4..8627263 100644 --- a/clients/typescript/src/resources/gen_tables/knowledge.ts +++ b/clients/typescript/src/resources/gen_tables/knowledge.ts @@ -1,18 +1,22 @@ import { IdSchema, TableSchemaCreateSchema } from "@/resources/gen_tables/tables"; import { z } from "zod"; -import { zfd } from "zod-form-data"; export const CreateKnowledgeTableRequestSchema = TableSchemaCreateSchema.extend({ embedding_model: z.string() }); -export type CreateKnowledgeTableRequest = z.infer; +export type CreateKnowledgeTableRequest = z.input; -export const UploadFileRequestSchema = zfd.formData({ - file: zfd.file().optional(), - file_path: zfd.text().optional(), - table_id: zfd.text(IdSchema), - chunk_size: zfd.numeric(z.number().gt(0)).optional(), - chunk_overlap: zfd.numeric(z.number().min(0)).optional() +export const UploadFileRequestSchema = z.object({ + file: z + .any() + .refine((value) => value instanceof File, { + message: "Value must be a File object" + }) + .optional(), + file_path: z.string().optional(), + table_id: IdSchema, + chunk_size: z.number().gt(0).optional(), + chunk_overlap: z.number().min(0).optional() }); export type UploadFileRequest = z.infer; diff --git a/clients/typescript/src/resources/gen_tables/tables.ts b/clients/typescript/src/resources/gen_tables/tables.ts index 95e7dff..b86bb01 100644 --- a/clients/typescript/src/resources/gen_tables/tables.ts +++ b/clients/typescript/src/resources/gen_tables/tables.ts @@ -1,19 +1,11 @@ -import { ChatRequestSchema } from "@/resources/llm/chat"; -import { passthrough } from "@/utils"; +import { createPaginationSchema } from "@/helpers/utils"; +import { RAGParamsSchema } from "@/resources/llm/chat"; import { z } from "zod"; -import { zfd } from "zod-form-data"; - -// Define a generic function to create a pagination schema -function createPaginationSchema(itemSchema: z.ZodType) { - return passthrough( - z.object({ - items: z.array(itemSchema).describe("List of items paginated items.").default([]), - offset: z.number().describe("Number of skipped items.").default(0), - limit: z.number().describe("Number of items per page.").default(100), - total: z.number().describe("Total number of items.").default(0) - }) - ); -} + +export const GenTableOrderBy = Object.freeze({ + ID: "id", // Sort by `id` column + UPDATED_AT: "updated_at" // Sort by `updated_at` column +}); export const QueryRequestParams = z.object({ offset: z.number().describe("Number of skipped items.").default(0), @@ -22,22 +14,45 @@ export const QueryRequestParams = z.object({ 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"]); +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 DtypeEnumSchema = z.enum(["int", "int8", "float", "float64", "float32", "float16", "bool", "str", "date-time", "file", "bytes"]); + +export const EmbedGenConfigSchema = z.object({ + object: z.literal("gen_config.embed").default("gen_config.embed"), + embedding_model: z.string(), + source_column: z.string() +}); +export const LLMGenConfigSchema = z.object({ + object: z.literal("gen_config.llm").default("gen_config.llm"), + model: z.string().default(""), + prompt: z.string().default(""), + system_prompt: z.string().default(""), + multi_turn: z.boolean().default(false), + rag_params: RAGParamsSchema.nullable().default(null), + temperature: z.number().min(0.001).max(2.0).default(0.2), + top_p: z.number().min(0.001).max(1.0).default(0.6), + stop: z.array(z.string()).nullable().default(null), + max_tokens: z.number().int().min(1).default(2048), + presence_penalty: z.number().default(0.0), + frequency_penalty: z.number().default(0.0), + logit_bias: z.record(z.string(), z.any()).default({}) +}); + export const ColumnSchemaSchema = z.object({ id: z.string(), - dtype: DtypeEnumSchema.default("str").optional(), - vlen: z.number().int().gte(0).default(0).optional(), - index: z.boolean().default(true).optional(), - gen_config: z.union([ChatRequestSchema.partial(), z.null()]).optional() + dtype: DtypeEnumSchema.default("str"), + vlen: z.number().int().gte(0).default(0), + index: z.boolean().default(true), + gen_config: z.union([LLMGenConfigSchema, EmbedGenConfigSchema, z.null()]).optional() }); export const ColumnSchemaCreateSchema = ColumnSchemaSchema.extend({ id: IdSchema, - dtype: DtypeCreateEnumSchema.default("str").optional() + dtype: DtypeCreateEnumSchema.default("str") }); export const TableSchemaCreateSchema = z.object({ @@ -46,12 +61,13 @@ export const TableSchemaCreateSchema = z.object({ }); export let ListTableRequestSchema = QueryRequestParams.extend({ - parent_id: z.union([z.string(), z.null()]).optional() -}) - .partial() - .extend({ - table_type: TableTypesSchema - }); + parent_id: z.union([z.string(), z.null()]).optional(), + table_type: TableTypesSchema, + search_query: z.string().default(""), + order_by: z.string().optional().default(GenTableOrderBy.UPDATED_AT), + order_descending: z.boolean().optional().default(true), + count_rows: z.boolean().optional().default(false) +}); export const TableMetaResponseSchema = z.object({ id: z.string(), @@ -77,7 +93,8 @@ export const ListTableRowsRequestSchema = QueryRequestParams.extend({ columns: z.array(IdSchema).nullable().optional(), search_query: z.string().default(""), float_decimals: z.number().int().default(0), - vec_decimals: z.number().int().default(0) + vec_decimals: z.number().int().default(0), + order_descending: z.boolean().default(true) }); export const ListTableRowsResponseSchema = z.record(z.string(), z.any()); @@ -85,7 +102,7 @@ export const ListTableRowsResponseSchema = z.record(z.string(), z.any()); export const GetRowRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, - row_id: IdSchema, + row_id: z.string(), columns: z.array(IdSchema).nullable().optional(), float_decimals: z.number().int().default(0), vec_decimals: z.number().int().default(0) @@ -114,9 +131,15 @@ export const RenameTableRequestSchema = z.object({ export const DuplicateTableRequestSchema = z.object({ table_type: TableTypesSchema, table_id_src: TableIdSchema, - table_id_dst: TableIdSchema, - include_data: z.boolean().optional(), - deploy: z.boolean().optional() + table_id_dst: TableIdSchema.nullable().default(null), + include_data: z.boolean().optional().default(true), + create_as_child: z.boolean().optional().default(false) +}); + +export const CreateChildTableRequestSchema = z.object({ + table_type: TableTypesSchema, + table_id_src: TableIdSchema, + table_id_dst: TableIdSchema }); export const RenameColumnsRequestScheme = z.object({ @@ -133,14 +156,13 @@ export const ReorderColumnsRequestScheme = z.object({ export const AddColumnRequestSchema = z.object({ id: TableIdSchema, - // cols: z.array(ColumnSchemaSchema), cols: z.array(ColumnSchemaCreateSchema) }); export const UpdateGenConfigRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, - column_map: z.record(IdSchema, z.record(z.any(), z.any())) + column_map: z.record(z.string(), z.union([LLMGenConfigSchema, EmbedGenConfigSchema, z.null()])) }); export const DeleteRowRequestSchema = z.object({ @@ -171,7 +193,7 @@ export const RegenRowRequestSchema = z.object({ export const UpdateRowRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, - row_id: IdSchema, + row_id: z.string(), data: z.record(IdSchema, z.any()), reindex: z.boolean().nullable().default(null) }); @@ -204,12 +226,17 @@ export const CreateTableRequestSchema = z.object({ cols: z.array(ColumnSchemaSchema) }); -export const ImportTableRequestSchema = zfd.formData({ - file_path: zfd.text().optional(), - file: zfd.file().optional(), - table_id: zfd.text(IdSchema), - table_type: zfd.text(TableTypesSchema), - delimiter: zfd.text().default(",").optional() +export const ImportTableRequestSchema = z.object({ + file_path: z.string().optional(), + file: z + .any() + .refine((value) => value instanceof File, { + message: "Value must be a File object" + }) + .optional(), + table_id: TableIdSchema, + table_type: TableTypesSchema, + delimiter: z.string().default(",").optional() }); export const ExportTableRequestSchema = z.object({ @@ -223,30 +250,31 @@ export type TableTypes = z.infer; export type ListTableRowsRequest = z.input; export type ListTableRowsResponse = z.infer; export type DtypeEnum = z.infer; -export type ColumnSchema = z.infer; -export type ColumnSchemaCreate = z.infer; -export type ListTableRequest = z.infer; +export type ColumnSchema = z.input; +export type ColumnSchemaCreate = z.input; +export type ListTableRequest = z.input; export type PageListTableRowsResponse = z.infer; export type PageListTableMetaResponse = z.infer; -export type TableMetaRequest = z.infer; +export type TableMetaRequest = z.input; export type TableMetaResponse = z.infer; export type GetRowRequest = z.input; export type GetRowResponse = z.infer; export type OkResponse = z.infer; -export type DeleteTableRequest = z.infer; -export type RenameTableRequest = z.infer; -export type DuplicateTableRequest = z.infer; +export type DeleteTableRequest = z.input; +export type RenameTableRequest = z.input; +export type DuplicateTableRequest = z.input; +export type CreateChildTableRequest = z.input; export type RenameColumnsRequest = z.infer; export type ReorderColumnsRequest = z.infer; export type DropColumnsRequest = ReorderColumnsRequest; -export type AddColumnRequest = z.infer; -export type UpdateGenConfigRequest = z.infer; -export type DeleteRowRequest = z.infer; -export type AddRowRequest = z.infer; -export type RegenRowRequest = z.infer; -export type UpdateRowRequest = z.infer; +export type AddColumnRequest = z.input; +export type UpdateGenConfigRequest = z.input; +export type DeleteRowRequest = z.input; +export type AddRowRequest = z.input; +export type RegenRowRequest = z.input; +export type UpdateRowRequest = z.input; export type DeleteRowsRequest = z.infer; export type HybridSearchRequest = z.input; export type HybridSearchResponse = z.infer; export type ExportTableRequest = z.input; -export type ImportTableRequest = z.infer; +export type ImportTableRequest = z.input; diff --git a/clients/typescript/src/resources/llm/chat.ts b/clients/typescript/src/resources/llm/chat.ts index b16d924..1682d0f 100644 --- a/clients/typescript/src/resources/llm/chat.ts +++ b/clients/typescript/src/resources/llm/chat.ts @@ -8,11 +8,11 @@ const ChatRoleSchema = z.enum(["system", "user", "assistant", "function"]); export const ChatEntrySchema = z.object({ role: ChatRoleSchema, - content: z.string(), + content: z.union([z.string(), z.array(z.record(z.union([z.string(), z.record(z.string())])))]), name: z.string().optional().nullable() }); -const RAGParamsSchema = z.object({ +export const RAGParamsSchema = z.object({ search_query: z.string().optional(), k: z.number().optional(), fetch_k: z.number().optional(), @@ -53,22 +53,20 @@ export const ChatCompletionUsageSchema = z.object({ // }; export const ChatRequestSchema = z.object({ - id: z.string().optional(), - model: z.string().optional(), + id: z.string().default(""), + model: z.string().default(""), messages: z.array(ChatEntrySchema), - rag_params: RAGParamsSchema.nullable().optional(), - tools: z.array(ToolSpecSchema).nullable().optional(), - tool_choice: z.union([z.string(), ToolSpecSchema]).nullable().optional(), - temperature: z.number().optional().default(1.0).optional(), - top_p: z.number().optional().default(1.0).optional(), - n: z.number().optional().default(1).optional(), - // stream: z.boolean().optional().default(true), - stop: z.array(z.string()).optional(), - max_tokens: z.number().default(2048).optional(), - presence_penalty: z.number().optional().default(0.0).optional(), - frequency_penalty: z.number().optional().default(0.0).optional(), - logit_bias: z.record(z.string(), z.any()).default({}).optional(), - user: z.string().default("").optional() + rag_params: RAGParamsSchema.nullable().default(null), + temperature: z.number().min(0.001).max(2.0).default(0.2), + top_p: z.number().min(0.001).max(1.0).default(0.6), + n: z.number().default(1), + stream: z.boolean().default(true), + stop: z.array(z.string()).nullable().default(null), + max_tokens: z.number().int().min(1).default(2048), + presence_penalty: z.number().default(0.0), + frequency_penalty: z.number().default(0.0), + logit_bias: z.record(z.string(), z.any()).default({}), + user: z.string().default("") }); export const ChatCompletionChoiceSchema = z.object({ @@ -131,7 +129,7 @@ export type FunctionSpec = z.infer; export type ToolSpec = z.infer; export type FunctionChoiceSpec = z.infer; export type ChatCompletionUsage = z.infer; -export type ChatRequest = z.infer; +export type ChatRequest = z.input; export type Chunk = z.infer; export type References = z.infer; export type ChatCompletionChoice = z.infer; diff --git a/clients/typescript/src/resources/llm/index.ts b/clients/typescript/src/resources/llm/index.ts index e9f0be0..762d558 100644 --- a/clients/typescript/src/resources/llm/index.ts +++ b/clients/typescript/src/resources/llm/index.ts @@ -3,6 +3,7 @@ import { ChatCompletionChunk, ChatCompletionChunkSchema, ChatRequest, + ChatRequestSchema, References, ReferencesSchema, StreamChatCompletionChunk, @@ -18,78 +19,18 @@ import { ModelNamesResponseSchema } from "@/resources/llm/model"; import { ChunkError } from "@/resources/shared/error"; +import { AxiosResponse } from "axios"; import { z } from "zod"; export class LLM extends Base { - public async modelInfo(params?: ModelInfoRequest): Promise { - let getURL = `/api/v1/models`; - - const response = await this.httpClient.get(getURL, { - params: params, - paramsSerializer: { - indexes: false - } - }); + // Helper method to handle chat stream responses + private handleChatStreamResponse(response: AxiosResponse): ReadableStream { + this.logWarning(response); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); + if (response.status != 200) { + throw new Error(`Received Error Status: ${response.status}`); } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = ModelInfoResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); - } - - public async modelNames(params?: ModelNamesRequest): Promise { - let getURL = `/api/v1/model_names`; - - const response = await this.httpClient.get(getURL, { - params: params, - paramsSerializer: { - indexes: false - } - }); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = ModelNamesResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); - } - - public async generateChatCompletionsStream(params: ChatRequest): Promise> { - const apiURL = "/api/v1/chat/completions"; - const response = await this.httpClient.post( - apiURL, - { - ...params, - stream: true - }, - { - responseType: "stream" - } - ); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - const stream = new ReadableStream({ + return new ReadableStream({ async start(controller: ReadableStreamDefaultController) { response.data.on("data", (data: any) => { data = data.toString(); @@ -97,7 +38,7 @@ export class LLM extends Base { const lines = data .split("\n\n") .filter((i: string) => i.trim()) - .flatMap((line: string) => line.split("\n")); //? Split by \n to handle collation + .flatMap((line: string) => line.split("\n")); // Split by \n to handle collation for (const line of lines) { const chunk = line @@ -105,7 +46,7 @@ export class LLM extends Base { .replace(/^data: /, "") .replace(/data: \[DONE\]\s+$/, ""); - if (chunk.trim() == "[DONE]") return; + if (chunk.trim() === "[DONE]") return; try { const parsedValue = JSON.parse(chunk); @@ -116,7 +57,7 @@ export class LLM extends Base { } else { throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); } - } catch (err) { + } catch (err: any) { if (err instanceof ChunkError) { controller.error(new ChunkError(err.message)); } @@ -129,7 +70,7 @@ export class LLM extends Base { .replace(/^data: /, "") .replace(/data: \[DONE\]\s+$/, ""); - if (chunk.trim() == "[DONE]") return; + if (chunk.trim() === "[DONE]") return; try { const parsedValue = JSON.parse(chunk); @@ -148,7 +89,7 @@ export class LLM extends Base { } }); - response.data.on("error", (data: any) => { + response.data.on("error", () => { controller.error("Unexpected Error"); }); @@ -159,35 +100,54 @@ export class LLM extends Base { }); } }); + } - return stream; + public async modelInfo(params?: ModelInfoRequest): Promise { + let getURL = `/api/v1/models`; + + const response = await this.httpClient.get(getURL, { + params: params, + paramsSerializer: { + indexes: false + } + }); + + return this.handleResponse(response, ModelInfoResponseSchema); + } + + public async modelNames(params?: ModelNamesRequest): Promise { + let getURL = `/api/v1/model_names`; + + const response = await this.httpClient.get(getURL, { + params: params, + paramsSerializer: { + indexes: false + } + }); + + return this.handleResponse(response, ModelNamesResponseSchema); + } + + public async generateChatCompletionsStream(params: ChatRequest): Promise> { + const parsedParams = ChatRequestSchema.parse(params); + parsedParams.stream = true; + const apiURL = "/api/v1/chat/completions"; + const response = await this.httpClient.post(apiURL, parsedParams, { + responseType: "stream" + }); + + return this.handleChatStreamResponse(response); } public async generateChatCompletions(params: ChatRequest): Promise { + const parsedParams = ChatRequestSchema.parse(params); + parsedParams.stream = false; + const apiURL = "/api/v1/chat/completions"; - const response = await this.httpClient.post( - apiURL, - { - ...params, - stream: false - }, - {} - ); - - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } + const response = await this.httpClient.post(apiURL, parsedParams, {}); - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = ChatCompletionChunkSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, ChatCompletionChunkSchema); } public async generateEmbeddings(params: z.input): Promise { @@ -199,18 +159,6 @@ export class LLM extends Base { ...parsedParams }); - const warning = response.headers["warning"]; - if (warning) { - console.warn(warning); - } - - return new Promise((resolve, reject) => { - if (response.status == 200) { - const parsedData = EmbeddingResponseSchema.parse(response.data); - resolve(parsedData); - } else { - console.error("Received Error Status: ", response.status); - } - }); + return this.handleResponse(response, EmbeddingResponseSchema); } } diff --git a/clients/typescript/src/resources/llm/model.ts b/clients/typescript/src/resources/llm/model.ts index b727519..e43437b 100644 --- a/clients/typescript/src/resources/llm/model.ts +++ b/clients/typescript/src/resources/llm/model.ts @@ -9,7 +9,7 @@ export const ModelInfoRequestSchema = z.object({ }); export const ModelInfoSchema = z.object({ - id: z.string().default("openai/gpt-3.5-turbo"), + id: z.string().default("openai/gpt-4o-mini"), object: z.string(), name: z.string(), context_length: z.number().default(16384), diff --git a/clients/typescript/src/resources/templates/index.ts b/clients/typescript/src/resources/templates/index.ts new file mode 100644 index 0000000..8c56a36 --- /dev/null +++ b/clients/typescript/src/resources/templates/index.ts @@ -0,0 +1,81 @@ +import { Base } from "@/resources/base"; +import { + GetTableRequestSchema, + GetTableResponseSchema, + GetTemplateRequestSchema, + GetTemplateResponseSchema, + IGetTableRequest, + IGetTableResponse, + IGetTemplateRequest, + IGetTemplateResponse, + IListTableRowsRequest, + IListTableRowsResponse, + IListTablesRequest, + IListTablesResponse, + IListTemplatesRequest, + IListTemplatesResponse, + ListTableRowsRequestSchema, + ListTableRowsResponseSchema, + ListTablesRequestSchema, + ListTablesResponseSchema, + ListTemplatesRequestSchema, + ListTemplatesResponseSchema +} from "./types"; + +export class Templates extends Base { + public async listTemplates(params: IListTemplatesRequest = {}): Promise { + const parsedParams = ListTemplatesRequestSchema.parse(params); + + let getURL = `/api/public/v1/templates`; + + const response = await this.httpClient.get(getURL, { + params: { + search_query: encodeURIComponent(parsedParams.search_query) + } + }); + + return this.handleResponse(response, ListTemplatesResponseSchema); + } + + public async getTemplate(params: IGetTemplateRequest): Promise { + const parsedParams = GetTemplateRequestSchema.parse(params); + let getURL = `/api/public/v1/templates/${parsedParams.template_id}`; + + const response = await this.httpClient.get(getURL); + + return this.handleResponse(response, GetTemplateResponseSchema); + } + + public async listTables(params: IListTablesRequest): Promise { + const parsedParams = ListTablesRequestSchema.parse(params); + let getURL = `/api/public/v1/templates/${parsedParams.template_id}/gen_tables/${parsedParams.table_type}`; + + const response = await this.httpClient.get(getURL); + + return this.handleResponse(response, ListTablesResponseSchema); + } + + public async getTable(params: IGetTableRequest): Promise { + const parsedParams = GetTableRequestSchema.parse(params); + let getURL = `/api/public/v1/templates/${parsedParams.template_id}/gen_tables/${parsedParams.table_type}/${parsedParams.table_id}`; + + const response = await this.httpClient.get(getURL); + + return this.handleResponse(response, GetTableResponseSchema); + } + + public async listTableRows(params: IListTableRowsRequest): Promise { + const parsedParams = ListTableRowsRequestSchema.parse(params); + let getURL = `/api/public/v1/templates/${parsedParams.template_id}/gen_tables/${parsedParams.table_type}/${parsedParams.table_id}/rows`; + + delete (parsedParams as any).template_id; + delete (parsedParams as any).table_type; + delete (parsedParams as any).table_id; + + const response = await this.httpClient.get(getURL, { + params: parsedParams + }); + + return this.handleResponse(response, ListTableRowsResponseSchema); + } +} diff --git a/clients/typescript/src/resources/templates/types.ts b/clients/typescript/src/resources/templates/types.ts new file mode 100644 index 0000000..3c64129 --- /dev/null +++ b/clients/typescript/src/resources/templates/types.ts @@ -0,0 +1,71 @@ +import { createPaginationSchema } from "@/helpers/utils"; +import { TableIdSchema, TableMetaResponseSchema, TableTypesSchema } from "@/resources/gen_tables/tables"; +import { z } from "zod"; + +const TemplateTagSchema = z.object({ + id: z.string() +}); +const TemplateSchema = z.object({ + id: z.string(), + name: z.string(), + created_at: z.string(), + tags: z.array(TemplateTagSchema) +}); + +// List Templates +export const ListTemplatesRequestSchema = z.object({ + search_query: z.string().default("") +}); +export const ListTemplatesResponseSchema = createPaginationSchema(TemplateSchema); + +// Get Template +export const GetTemplateRequestSchema = z.object({ + template_id: z.string() +}); +export const GetTemplateResponseSchema = TemplateSchema; + +// List Table +export const ListTablesRequestSchema = z.object({ + table_type: TableTypesSchema, + template_id: z.string() +}); +export const ListTablesResponseSchema = createPaginationSchema(TableMetaResponseSchema); + +// Get Table +export const GetTableRequestSchema = z.object({ + template_id: z.string(), + table_type: TableTypesSchema, + table_id: TableIdSchema +}); +export const GetTableResponseSchema = TableMetaResponseSchema; + +// List Table Rows +export const ListTableRowsRequestSchema = z.object({ + template_id: z.string(), + table_type: z.string(), + table_id: TableIdSchema, + starting_after: z.string().nullable().optional(), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + order_by: z.string().default("Updated at"), + order_descending: z.boolean().default(true), + float_decimals: z.number().int().min(0).default(0), + vec_decimals: z.number().int().min(0).default(0) +}); +export const ListTableRowsResponseSchema = createPaginationSchema(z.record(z.string(), z.any())); + +// Types +export type IListTemplatesRequest = z.input; +export type IListTemplatesResponse = z.infer; + +export type IGetTemplateRequest = z.input; +export type IGetTemplateResponse = z.infer; + +export type IListTablesRequest = z.input; +export type IListTablesResponse = z.infer; + +export type IGetTableRequest = z.input; +export type IGetTableResponse = z.infer; + +export type IListTableRowsRequest = z.input; +export type IListTableRowsResponse = z.infer; diff --git a/clients/typescript/tsconfig.build.json b/clients/typescript/tsconfig.build.json new file mode 100644 index 0000000..6a1c061 --- /dev/null +++ b/clients/typescript/tsconfig.build.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "include": ["dist"], + "exclude": ["**/*.spec.ts"], + "compilerOptions": { + "rootDir": "./dist", + "paths": { + "jamai/*": ["dist/*"], + "jamai": ["dist/index.ts"] + }, + // "outDir": "./dist", + // "esModuleInterop": true, + // "allowSyntheticDefaultImports": true, + // "noEmit": false, + // "declaration": true, + // "declarationMap": true, + // "pretty": true, + // "sourceMap": true + "noEmit": false, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "pretty": true, + "sourceMap": true + } +} diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json new file mode 100644 index 0000000..bd5c5e6 --- /dev/null +++ b/clients/typescript/tsconfig.json @@ -0,0 +1,51 @@ +{ + "include": [ + "src/**/*", + "__tests__" + ], + "exclude": [ + "node_modules", + "lib", + "dist", + "**/*spec.ts" + ], + "compilerOptions": { + "emitDeclarationOnly": true, + "module": "ESNext", + "removeComments": false, + "declaration": true, + "allowSyntheticDefaultImports": true, + "target": "ESNext", + "sourceMap": false, + "outDir": "./dist", + "baseUrl": "./", + "incremental": false, + "esModuleInterop": true, + "moduleResolution": "Node", + "noUncheckedIndexedAccess": true, + "paths": { + "@/*": [ + "src/*" + ], + "@": [ + "src/index.ts" + ] + }, + "noEmit": false, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "alwaysStrict": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "skipLibCheck": true + } +} \ No newline at end of file diff --git a/docker/Dockerfile.docio b/docker/Dockerfile.docio index 1288581..bb3ecba 100644 --- a/docker/Dockerfile.docio +++ b/docker/Dockerfile.docio @@ -1,15 +1,12 @@ FROM docker.io/embeddedllminfo/jamaibase:ci -WORKDIR /app - -COPY --chown=$MAMBA_USER:$MAMBA_USER . . -ARG MAMBA_DOCKERFILE_ACTIVATE=1 # (otherwise python will not be found) - RUN apt-get update && apt-get install -y --no-install-recommends \ git \ && apt-get clean && rm -rf /var/lib/apt/lists/* -RUN cd /app/clients/python \ - && python -m pip install --no-cache-dir --upgrade . \ - && cd /app/services/docio \ - && python -m pip install --no-cache-dir --upgrade . +WORKDIR /app + +COPY --chown=$MAMBA_USER:$MAMBA_USER ./services/docio /app/services/docio +ARG MAMBA_DOCKERFILE_ACTIVATE=1 # (otherwise python will not be found) + +RUN cd /app/services/docio && python -m pip install --no-cache-dir --upgrade . diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend index 8e9565f..e23bbea 100644 --- a/docker/Dockerfile.frontend +++ b/docker/Dockerfile.frontend @@ -1,16 +1,18 @@ FROM node:20-alpine -ARG CHECK_ORIGIN=false ARG JAMAI_URL=http://owl:6969 -ARG JAMAI_SERVICE_KEY= +ARG PUBLIC_JAMAI_URL= +ARG PUBLIC_IS_SPA=false +ARG CHECK_ORIGIN=false WORKDIR /app COPY ./services/app . +RUN mv .env.example .env RUN npm ci -RUN PUBLIC_IS_LOCAL=true PUBLIC_IS_SPA=false JAMAI_URL=${JAMAI_URL} PUBLIC_JAMAI_URL= JAMAI_SERVICE_KEY=${JAMAI_SERVICE_KEY} CHECK_ORIGIN=${CHECK_ORIGIN} npx vite build +RUN JAMAI_URL=${JAMAI_URL} PUBLIC_JAMAI_URL=${PUBLIC_JAMAI_URL} PUBLIC_IS_SPA=${PUBLIC_IS_SPA} CHECK_ORIGIN=${CHECK_ORIGIN} npx vite build RUN mv temp build RUN apk --no-cache add curl -CMD ["node", "server"] \ No newline at end of file +CMD ["node", "server"] diff --git a/docker/Dockerfile.owl b/docker/Dockerfile.owl index c93809c..6c797d9 100644 --- a/docker/Dockerfile.owl +++ b/docker/Dockerfile.owl @@ -1,4 +1,4 @@ -FROM python:3.10 +FROM python:3.12 RUN pip install --no-cache-dir --upgrade setuptools diff --git a/docker/amd.yml b/docker/amd.yml new file mode 100644 index 0000000..696fcac --- /dev/null +++ b/docker/amd.yml @@ -0,0 +1,60 @@ +services: + infinity: + image: michaelf34/infinity:0.0.66-rocm + entrypoint: + [ + "/bin/sh", + "-c", + "(. /app/.venv/bin/activate && HIP_VISIBLE_DEVICES=0 infinity_emb v2 --port 6909 --model-id $${EMBEDDING_MODEL} --model-warmup --device cuda --engine torch --no-bettertransformer --no-compile &);(. /app/.venv/bin/activate && HIP_VISIBLE_DEVICES=1 infinity_emb v2 --port 6919 --model-id $${RERANKER_MODEL} --model-warmup --device cuda --engine torch --no-bettertransformer --no-compile)", + ] + # # https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html + # # instruction to specify the AMD GPU resources + # device: + # - /dev/kfd + # - /dev/dri/renderD128 # GPU ID 0 + # - /dev/dri/renderD136 # GPU ID 1 + # # $ ls -l /dev/dri + # # Those renderD* file that has a very long link are pointing to the GPUs + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD128 -> ../../devices/pci0000:16/0000:16:01.0/0000:17:00.0/0000:18:01.0/0000:1a:00.0/0000:1b:00.0/0000:1c:00.0/drm/renderD128 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD129 -> ../../devices/platform/amdgpu_xcp_0/drm/renderD129 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD130 -> ../../devices/platform/amdgpu_xcp_1/drm/renderD130 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD131 -> ../../devices/platform/amdgpu_xcp_2/drm/renderD131 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD132 -> ../../devices/platform/amdgpu_xcp_3/drm/renderD132 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD133 -> ../../devices/platform/amdgpu_xcp_4/drm/renderD133 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD134 -> ../../devices/platform/amdgpu_xcp_5/drm/renderD134 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD135 -> ../../devices/platform/amdgpu_xcp_6/drm/renderD135 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD136 -> ../../devices/pci0000:3c/0000:3c:01.0/0000:3d:00.0/0000:3e:01.0/0000:40:00.0/0000:41:00.0/0000:42:00.0/drm/renderD136 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD137 -> ../../devices/platform/amdgpu_xcp_7/drm/renderD137 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD138 -> ../../devices/platform/amdgpu_xcp_8/drm/renderD138 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD139 -> ../../devices/platform/amdgpu_xcp_9/drm/renderD139 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD140 -> ../../devices/platform/amdgpu_xcp_10/drm/renderD140 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD141 -> ../../devices/platform/amdgpu_xcp_11/drm/renderD141 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD142 -> ../../devices/platform/amdgpu_xcp_12/drm/renderD142 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD143 -> ../../devices/platform/amdgpu_xcp_13/drm/renderD143 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD144 -> ../../devices/pci0000:4f/0000:4f:01.0/0000:50:00.0/0000:51:01.0/0000:53:00.0/0000:54:00.0/0000:55:00.0/drm/renderD144 + # lrwxrwxrwx 1 root root 0 Oct 21 19:08 renderD145 -> ../../devices/platform/amdgpu_xcp_14/drm/renderD145 + cap_add: + - SYS_PTRACE + security_opt: + - seccomp=unconfined + devices: + - /dev/kfd + - /dev/dri/renderD128 + - /dev/dri/renderD136 + group_add: + - video + # Alternatively, you could use privileged mode (use with caution): + # privileged: true + + docio: + cap_add: + - SYS_PTRACE + devices: + - /dev/kfd + - /dev/dri/renderD128 + security_opt: + - seccomp:unconfined + group_add: + - video + # Alternatively, you could use privileged mode (use with caution): + # privileged: true diff --git a/docker/compose.amd.yml b/docker/compose.amd.yml new file mode 100644 index 0000000..a77af99 --- /dev/null +++ b/docker/compose.amd.yml @@ -0,0 +1,4 @@ +include: + - path: + - compose.cpu.yml + - amd.yml diff --git a/docker/compose.cpu.ollama.yml b/docker/compose.cpu.ollama.yml new file mode 100644 index 0000000..4fed391 --- /dev/null +++ b/docker/compose.cpu.ollama.yml @@ -0,0 +1,43 @@ +include: + - path: + - compose.cpu.yml + - ollama.yml + +services: + ollama: + image: ollama/ollama + volumes: + - ${PWD}/ollama:/root/.ollama + ports: + - "11434:11434" + entrypoint: [ + "sh", + "-c", + "ollama serve & \ + sleep 1; \ + ATTEMPTS=0; \ + MAX_ATTEMPTS=5; \ + while [ $$ATTEMPTS -lt $$MAX_ATTEMPTS ]; do \ + ollama ps > /dev/null 2>&1; \ + if [ $$? -eq 0 ]; then \ + break; \ + fi; \ + sleep 3; \ + ATTEMPTS=$$((ATTEMPTS+1)); \ + done; \ + if [ $$ATTEMPTS -eq $$MAX_ATTEMPTS ]; then \ + echo 'ollama serve did not start in time'; \ + exit 1; \ + fi; \ + ollama pull phi3.5 && ollama cp phi3.5 microsoft/Phi3.5-mini-instruct; \ + tail -f /dev/null", + ] + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "ollama show microsoft/Phi3.5-mini-instruct || exit 1"] + interval: 20s + timeout: 2s + retries: 20 + start_period: 20s + networks: + - jamai diff --git a/docker/compose.cpu.yml b/docker/compose.cpu.yml index 3650e8a..bc2c9a7 100644 --- a/docker/compose.cpu.yml +++ b/docker/compose.cpu.yml @@ -1,12 +1,11 @@ services: infinity: - image: michaelf34/infinity:0.0.32 - container_name: jamai_infinity + image: michaelf34/infinity:0.0.55 entrypoint: [ "/bin/sh", "-c", - "(. /app/.venv/bin/activate && infinity_emb --port 6909 --model-name-or-path $${EMBEDDING_MODEL} --model-warmup --device cpu &);(. /app/.venv/bin/activate && infinity_emb --port 6919 --model-name-or-path $${RERANKER_MODEL} --model-warmup --device cpu )", + "(. /app/.venv/bin/activate && infinity_emb v2 --port 6909 --model-id $${EMBEDDING_MODEL} --model-warmup --device cpu &);(. /app/.venv/bin/activate && infinity_emb v2 --port 6919 --model-id $${RERANKER_MODEL} --model-warmup --device cpu )", ] healthcheck: test: ["CMD-SHELL", "curl --fail http://localhost:6909/health && curl --fail http://localhost:6919/health || exit 1"] @@ -24,7 +23,6 @@ services: unstructuredio: image: downloads.unstructured.io/unstructured-io/unstructured-api:latest - container_name: jamai_unstructuredio 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"] @@ -41,8 +39,8 @@ services: context: .. dockerfile: docker/Dockerfile.docio image: jamai/docio + pull_policy: build command: ["python", "-m", "docio.entrypoints.api"] - container_name: jamai_docio healthcheck: test: ["CMD-SHELL", "curl --fail http://localhost:6979/health || exit 1"] interval: 10s @@ -79,7 +77,7 @@ services: context: .. dockerfile: docker/Dockerfile.owl image: jamai/owl - container_name: jamai_owl + pull_policy: build command: ["python", "-m", "owl.entrypoints.api"] depends_on: infinity: @@ -102,21 +100,46 @@ services: 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: - CHECK_ORIGIN: "false" - JAMAI_URL: "http://owl:6969" - JAMAI_SERVICE_KEY: "" + JAMAI_URL: ${JAMAI_URL} + PUBLIC_JAMAI_URL: ${PUBLIC_JAMAI_URL} + PUBLIC_IS_SPA: ${PUBLIC_IS_SPA} + CHECK_ORIGIN: ${CHECK_ORIGIN} image: jamai/frontend - container_name: jamai_frontend + pull_policy: build command: ["node", "server"] depends_on: owl: @@ -138,5 +161,25 @@ services: 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/docker/compose.nvidia.yml b/docker/compose.nvidia.yml index 6b8b039..5424af5 100644 --- a/docker/compose.nvidia.yml +++ b/docker/compose.nvidia.yml @@ -1,56 +1,4 @@ -services: - infinity: - deploy: - resources: - reservations: - devices: - - driver: nvidia - device_ids: ["0"] - capabilities: [gpu] - extends: - file: compose.cpu.yml - service: infinity - - unstructuredio: - extends: - file: compose.cpu.yml - service: unstructuredio - - docio: - deploy: - resources: - reservations: - devices: - - driver: nvidia - device_ids: ["0"] - capabilities: [gpu] - extends: - file: compose.cpu.yml - service: docio - - dragonfly: - extends: - file: compose.cpu.yml - service: dragonfly - - owl: - depends_on: - infinity: - condition: service_healthy - unstructuredio: - condition: service_healthy - docio: - condition: service_healthy - dragonfly: - condition: service_started - extends: - file: compose.cpu.yml - service: owl - - frontend: - extends: - file: compose.cpu.yml - service: frontend - -networks: - jamai: +include: + - path: + - compose.cpu.yml + - nvidia.yml diff --git a/docker/nvidia.yml b/docker/nvidia.yml new file mode 100644 index 0000000..aa44725 --- /dev/null +++ b/docker/nvidia.yml @@ -0,0 +1,24 @@ +services: + infinity: + entrypoint: + [ + "/bin/sh", + "-c", + "(. /app/.venv/bin/activate && infinity_emb v2 --port 6909 --model-id $${EMBEDDING_MODEL} --model-warmup --device cuda &);(. /app/.venv/bin/activate && infinity_emb v2 --port 6919 --model-id $${RERANKER_MODEL} --model-warmup --device cuda )", + ] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] + capabilities: [gpu] + + docio: + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] + capabilities: [gpu] diff --git a/docker/ollama.yml b/docker/ollama.yml new file mode 100644 index 0000000..ce53422 --- /dev/null +++ b/docker/ollama.yml @@ -0,0 +1,4 @@ +services: + owl: + environment: + - OWL_MODELS_CONFIG="models_ollama.json" diff --git a/scripts/compile_api_exe.ps1 b/scripts/compile_api_exe.ps1 index e6cbffe..f3c140c 100644 --- a/scripts/compile_api_exe.ps1 +++ b/scripts/compile_api_exe.ps1 @@ -3,5 +3,5 @@ cd .\clients\python pip install . cd .\..\..\services\api pip install -e . -pip install pyinstaller +pip install pyinstaller==6.9.0 pyinstaller api.spec \ No newline at end of file diff --git a/scripts/compile_jamaibase_app.ps1 b/scripts/compile_jamaibase_app.ps1 new file mode 100644 index 0000000..feb0ef6 --- /dev/null +++ b/scripts/compile_jamaibase_app.ps1 @@ -0,0 +1,13 @@ +.\scripts\remove_cloud_modules.ps1 +cd .\services\app +npm i +# Load the content of the .env file into a variable +$content = Get-Content .\.env.example +# Modify the content +$content = $content -replace 'PUBLIC_JAMAI_URL=""', 'PUBLIC_JAMAI_URL="http://localhost:6969"' +$content = $content -replace 'PUBLIC_IS_SPA="false"', 'PUBLIC_IS_SPA="true"' +# Add CHECK_ORIGIN=false to the content +$content += 'CHECK_ORIGIN="false"' +# Write the updated content back to the .env file +$content | Set-Content .\.env +npm run make \ No newline at end of file diff --git a/services/api/src/owl/scripts/compile_reqs.py b/scripts/compile_reqs.py similarity index 100% rename from services/api/src/owl/scripts/compile_reqs.py rename to scripts/compile_reqs.py diff --git a/scripts/copy_repo.sh b/scripts/copy_repo.sh index fcecb3d..e6fc125 100644 --- a/scripts/copy_repo.sh +++ b/scripts/copy_repo.sh @@ -12,3 +12,4 @@ cp -r JAM.ai.dev/. . source scripts/remove_cloud_modules.sh rm -rf JAM.ai.dev/ sed -i -e 's:JAM.ai.dev:JamAIBase:g' README.md +mv .env.example .env diff --git a/scripts/migrate_model_json.py b/scripts/migrate_model_json.py new file mode 100644 index 0000000..ec82f8c --- /dev/null +++ b/scripts/migrate_model_json.py @@ -0,0 +1,56 @@ +import json +import sys + +from owl.protocol import ModelListConfig + + +def transform_json(original_json): + new_json = {"llm_models": [], "embed_models": [], "rerank_models": []} + ellm_json = {"llm_models": [], "embed_models": [], "rerank_models": []} + for config_type in ["llm_models", "embed_models", "rerank_models"]: + configs = original_json.get(config_type, []) + for config in configs: + if config.get("deployments", None) is None: + # Extract the provider from the id + provider, _ = config["id"].split("/", 1) + + # Create the ModelDeploymentConfig instance + deployment_config = { + "litellm_id": config.get("litellm_id", ""), + "api_base": config.get("api_base", ""), + "provider": provider, + } + # Create the ModelConfig instance + model_config = { + k: v + for k, v in config.items() + if k not in ["litellm_id", "api_base", "provider", "internal_only"] + } + model_config["deployments"] = [deployment_config] + + else: + model_config = config + if config.get("internal_only", False): + ellm_json[config_type].append(model_config) + else: + new_json[config_type].append(model_config) + val = ModelListConfig.model_validate(new_json) + print(val) + val = ModelListConfig.model_validate(ellm_json) + print(val) + return new_json, ellm_json + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python migrate_model_json.py ") + sys.exit(1) + original_json_path = sys.argv[1] + with open(original_json_path, "r") as f: + original_json = json.load(f) + transformed_json, internal_json = transform_json(original_json) + with open(original_json_path, "w") as f: + f.write(json.dumps(transformed_json, indent=4)) + if sum([len(internal_json[x]) for x in internal_json.keys()]) > 0: + with open(f"{original_json_path.split('.json')[0]}_internal.json", "w") as f: + f.write(json.dumps(internal_json, indent=4)) diff --git a/scripts/migration_v030.py b/scripts/migration_v030.py new file mode 100644 index 0000000..4d31a07 --- /dev/null +++ b/scripts/migration_v030.py @@ -0,0 +1,172 @@ +import os +import re +import sqlite3 +from datetime import datetime, timezone +from os.path import dirname, isdir, join +from shutil import copy2 + +import orjson +from loguru import logger +from pydantic_settings import BaseSettings, SettingsConfigDict + +import owl +from jamaibase.protocol import GEN_CONFIG_VAR_PATTERN, ColumnSchema, LLMGenConfig + + +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): + 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) + + +def restore(db_dir: str): + for org_id in os.listdir(db_dir): + org_dir = join(db_dir, org_id) + if not isdir(org_dir): + continue + for proj_id in os.listdir(org_dir): + proj_dir = join(org_dir, proj_id) + if not isdir(proj_dir): + continue + for table_type in ["action", "knowledge", "chat"]: + bak_files = list( + sorted( + f + for f in os.listdir(proj_dir) + if f.startswith(table_type) and f.endswith(".db_BAK") + ) + ) + src_path = join(proj_dir, bak_files[0]) + dst_path = join(proj_dir, f'{bak_files[0].split("_")[0]}.db') + os.remove(dst_path) + copy2(src_path, dst_path) + + +def find_sqlite_files(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(os.path.join(root, filename)) + return sqlite_files + + +def add_table_meta_columns(db_path): + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(TableMeta)") + columns = cursor.fetchall() + column_names = [column[1] for column in columns] + + # Add "version" if it does not exist + if "version" not in column_names: + default_version = owl.__version__ + cursor.execute( + f"ALTER TABLE TableMeta ADD COLUMN version TEXT DEFAULT '{default_version}'" + ) + conn.commit() + print(f"└─ Added 'version' column with default value '{default_version}'.") + else: + print("└─ 'version' column already exists.") + + # Add "meta" if it does not exist + if "meta" not in column_names: + cursor.execute("ALTER TABLE TableMeta ADD COLUMN meta JSON DEFAULT '{}'") + conn.commit() + print("└─ Added 'meta' column with default value '{}'.") + else: + print("└─ 'meta' column already exists.") + + cursor.close() + conn.close() + except Exception as e: + logger.exception(f"└─ Error adding columns due to {e}") + + +def update_gen_table(db_path: str): + record = None + 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}) Checking table: {table_id}") + for col in cols: + col = ColumnSchema.model_validate(col) + if db_path.endswith("chat.db") and col.id.lower() == "ai": + if col.gen_config is None: + col.gen_config = LLMGenConfig( + system_prompt=( + f'You are an agent named "{table_id}". Be helpful. Provide answers based on the information given. ' + "Ensuring that your reply is easy to understand and is accessible to all users. " + "Be factual and do not hallucinate." + ), + prompt="${User}", + multi_turn=True, + ) + else: + col.gen_config.multi_turn = True + ref_col_ids = re.findall(GEN_CONFIG_VAR_PATTERN, col.gen_config.prompt) + if "User" in ref_col_ids: + if len(ref_col_ids) == 1: + col.gen_config.prompt = "${User}" + else: + col.gen_config.prompt = f"${{User}} {col.gen_config.prompt}" + 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() + cursor.close() + conn.close() + except Exception as e: + logger.exception(f"└─ Error updating GenTable column due to {e}: {record}") + + +if __name__ == "__main__": + sqlite_files = find_sqlite_files(ENV_CONFIG.owl_db_dir) + backup_dir = f"{ENV_CONFIG.owl_db_dir}_BAK_{NOW}" + print(f'Backing up DB dir "{ENV_CONFIG.owl_db_dir}" to "{backup_dir}"') + os.makedirs(backup_dir, exist_ok=False) + + 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) + add_table_meta_columns(db_file) + update_gen_table(db_file) diff --git a/scripts/remove_cloud_modules.ps1 b/scripts/remove_cloud_modules.ps1 index 06e98d4..8cbddcd 100644 --- a/scripts/remove_cloud_modules.ps1 +++ b/scripts/remove_cloud_modules.ps1 @@ -1,7 +1,11 @@ -Get-ChildItem -Recurse -File -Filter "cloud*.py" | Remove-Item -Force -Get-ChildItem -Recurse -File -Filter "cloud*.json" | Remove-Item -Force +Get-ChildItem -Recurse -File -Filter "cloud_*.py" | Remove-Item -Force +Get-ChildItem -Recurse -File -Filter "cloud_*.json" | Remove-Item -Force +Get-ChildItem -Recurse -File -Filter "*_cloud.json" | Remove-Item -Force Get-ChildItem -Recurse -File -Filter "compose.*.cloud.yml" | Remove-Item -Force Get-ChildItem -Recurse -Directory -Filter "(cloud)" | Remove-Item -Recurse -Force +if (Test-Path -Path "docker\enterprise") { + Remove-Item -Path "docker\enterprise" -Recurse -Force +} # Remove a file or folder quietly # Like linux "rm -rf" @@ -14,3 +18,4 @@ function quiet_rm($item) } quiet_rm "services/app/ecosystem.config.cjs" quiet_rm "services/appecosystem.json" +quiet_rm ".github/workflows/trigger-push-gh-image.yml" diff --git a/scripts/remove_cloud_modules.sh b/scripts/remove_cloud_modules.sh index 55231db..a827108 100644 --- a/scripts/remove_cloud_modules.sh +++ b/scripts/remove_cloud_modules.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash -find . -type f -name "cloud*.py" -delete -find . -type f -name "cloud*.json" -delete +rm -rf docker/enterprise/ +find . -type f -name "cloud_*.py" -delete +find . -type f -name "cloud_*.json" -delete +find . -type f -name "*_cloud.json" -delete find . -type f -name "compose.*.cloud.yml" -delete find . -type d -name "(cloud)" -exec rm -rf {} + rm -f services/app/ecosystem.config.cjs rm -f services/app/ecosystem.json +rm -f .github/workflows/trigger-push-gh-image.yml diff --git a/services/api/MANIFEST.in b/services/api/MANIFEST.in new file mode 100644 index 0000000..5b0063f --- /dev/null +++ b/services/api/MANIFEST.in @@ -0,0 +1 @@ +exclude tests/**/* \ No newline at end of file diff --git a/services/api/api.spec b/services/api/api.spec index 9d8adba..c94c036 100644 --- a/services/api/api.spec +++ b/services/api/api.spec @@ -1,5 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- +import glob from pathlib import Path from PyInstaller.utils.hooks import collect_all @@ -9,9 +10,15 @@ print(Path("src/owl/entrypoints/api.py").resolve().as_posix()) datas_list = [ (Path("src/owl/entrypoints/api.py").resolve().as_posix(), 'owl/entrypoints'), - (Path("src/owl/configs/models.json").resolve().as_posix(), 'owl') + (Path("src/owl/configs/models_aipc.json").resolve().as_posix(), 'owl/configs'), ] +# Add parquet and JSON files from templates directory +template_files = glob.glob("src/owl/templates/**/*.parquet", recursive=True) +template_files += glob.glob("src/owl/templates/**/*.json", recursive=True) +for file in template_files: + datas_list.append((file, str(Path(file).parent.relative_to("src")))) + hiddenimports_list = ['multipart', "tiktoken_ext.openai_public", "tiktoken_ext"] def add_package(package_name): @@ -63,4 +70,4 @@ coll = COLLECT( upx=True, upx_exclude=[], name='api', -) +) \ No newline at end of file diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml index a758c2c..45069f4 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -5,6 +5,7 @@ # https://docs.pytest.org/en/latest/customize.html?highlight=pyproject#pyproject-toml [tool.pytest.ini_options] +timeout = 90 log_cli = true asyncio_mode = "auto" # log_cli_level = "DEBUG" @@ -17,35 +18,56 @@ filterwarnings = [ "ignore::DeprecationWarning:flatbuffers.*", ] - # ----------------------------------------------------------------------------- -# Black (Option-less formatter) configuration -# https://black.readthedocs.io/en/stable/index.html +# Ruff configuration +# https://docs.astral.sh/ruff/ -[tool.black] +[tool.ruff] line-length = 99 -target-version = ["py310"] -include = '\.pyi?$|\.ipynb' +indent-width = 4 +target-version = "py310" +extend-include = [".pyi?$", ".ipynb"] +extend-exclude = ["archive/*"] +respect-gitignore = true -# ----------------------------------------------------------------------------- -# For sorting imports -# This is used by VS Code to sort imports -# https://code.visualstudio.com/docs/python/editing#_sort-imports -# https://timothycrosley.github.io/isort/ - -[tool.isort] -# Profile -# Base profile type to use for configuration. Profiles include: black, django, -# pycharm, google, open_stack, plone, attrs, hug. As well as any shared profiles. -# Default: `` -profile = "black" -# Treat project as a git repository and ignore files listed in .gitignore -# Default: `False` -skip_gitignore = true -# The max length of an import line (used for wrapping long imports). -# Default: `79` -line_length = 99 -known_first_party = ["jamaibase", "owl", "docio"] +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +docstring-code-format = true + +[tool.ruff.lint] +# 1. Enable flake8-bugbear (`B`) rules, in addition to the defaults. +select = ["E1", "E4", "E7", "E9", "F", "I", "W1", "W2", "W3", "W6", "B"] + +# 2. Avoid enforcing line-length violations (`E501`) +ignore = ["E501"] + +# 3. Avoid trying to fix flake8-bugbear (`B`) violations. +unfixable = ["B"] + +# 4. Ignore `E402` (import violations) in all `__init__.py` files, and in selected subdirectories. +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["E402"] +"**/{tests,docs,tools}/*" = ["E402"] + +[tool.ruff.lint.isort] +known-first-party = ["jamaibase", "owl", "docio"] + +[tool.ruff.lint.flake8-bugbear] +# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.File", + "fastapi.Form", + "fastapi.Path", + "fastapi.Query", +] # ----------------------------------------------------------------------------- # setuptools @@ -70,63 +92,79 @@ classifiers = [ # https://pypi.org/classifiers/ "Operating System :: Unix", ] dependencies = [ - "fastapi~=0.111.0", + "aioboto3~=7.0.0", + "aiobotocore~=2.15.0", + "aiofiles~=24.1.0", + "authlib~=1.3.2", + "boto3~=1.35.7", + "celery~=5.4.0", + "duckdb~=1.1.3", + "fastapi[standard]~=0.115.2", "filelock~=3.15.1", + "flower~=2.0.1", "gunicorn~=22.0.0", "httpx~=0.27.0", - "jamaibase>=0.2.0", - "lancedb==0.8.0", # 0.9.0 has issues with row deletion - "langchain-community~=0.2.5", - "langchain~=0.2.5", - "litellm~=1.41.13", + "itsdangerous~=2.2.0", + "jamaibase>=0.2.1", + # lancedb 0.9.0 has issues with row deletion + "lancedb==0.12.0", + "langchain-community~=0.2.12", + "langchain~=0.2.14", + "litellm~=1.48.17", "loguru~=0.7.2", - "numpy~=1.26.4", - "openai~=1.34.0", + "natsort[fast]>=8.4.0", + "numpy>=1.26.4", + "openai>=1.51.0", "openmeter~=1.0.0b89", - "orjson~=3.10.5", + "orjson~=3.10.7", "pandas~=2.2", - "Pillow~=10.3.0", - "pyarrow~=15.0.0", + "Pillow~=10.4.0", + "pyarrow~=17.0.0", "pycryptodomex~=3.20.0", - "pydantic-settings~=2.3.3", - "pydantic~=2.7.4", - "pyjwt~=2.8.0", - "pylance==0.11.0", # 0.13.0 has issues with row deletion + "pydantic-settings~=2.4.0", + "pydantic[email,timezone]~=2.8.2", + "pyjwt~=2.9.0", + # pylance 0.13.0 has issues with row deletion + "pylance==0.16.0", "python-multipart~=0.0.9", - "redis[hiredis]~=5.0.6", - "sqlmodel~=0.0.19", + "redis[hiredis]~=5.0.8", + "SQLAlchemy>=2.0", + "sqlmodel~=0.0.21", "srsly~=2.4.8", + # starlette 0.38.3 and 0.38.4 seem to have issues with background tasks + "starlette==0.38.2", "stripe~=9.12.0", "tantivy~=0.22.0", - "tenacity~=8.4.1", + "tenacity~=8.5.0", "tiktoken~=0.7.0", "toml~=0.10.2", - "tqdm~=4.66.4", - "typer[all]~=0.12.3", + "tqdm~=4.66.5", + "typer[all]~=0.12.4", "typing_extensions>=4.12.2", "unstructured-client @ git+https://github.com/EmbeddedLLM/unstructured-python-client.git@fix-nested-asyncio-conflict-with-uvloop#egg=unstructured-client", - "uuid-utils~=0.8.0", + "uuid-utils~=0.9.0", "uuid7~=0.1.0", - "uvicorn[standard]~=0.28.1", # 0.30.x seems to have issues with shutdown signal handling? + # uvicorn 0.29.x shutdown seems unclean and 0.30.x child process sometimes dies + "uvicorn[standard]~=0.28.1", ] # Sort your dependencies https://sortmylist.com/ dynamic = ["version"] [project.optional-dependencies] -lint = ["black~=24.4.2", "flake8~=7.0.0"] +lint = ["ruff~=0.6.1"] test = [ "flaky~=3.8.1", - "locust~=2.29.1", - "mypy~=1.10.1", - "pytest-asyncio>=0.23.7", + "mypy~=1.11.1", + "pytest-asyncio>=0.23.8", "pytest-cov~=5.0.0", - "pytest~=8.2.2", + "pytest-timeout>=2.3.1", + "pytest~=8.3.2", ] docs = [ - "furo~=2024.5.6", # Sphinx theme (nice looking, with dark mode) - "myst-parser~=3.0.1", + "furo~=2024.8.6", # Sphinx theme (nice looking, with dark mode) + "myst-parser~=4.0.0", "sphinx-autobuild~=2024.4.16", "sphinx-copybutton~=0.5.2", - "sphinx~=7.3.7", + "sphinx>=7.4.7", # sphinx-rtd-theme requires < 8 "sphinx_rtd_theme~=2.0.0", # Sphinx theme ] build = [ @@ -147,4 +185,4 @@ version = { attr = "owl.version.__version__" } where = ["src"] [tool.setuptools.package-data] -owl = ["**/*.json"] +owl = ["**/*.json", "**/*.parquet"] diff --git a/services/api/src/owl/__init__.py b/services/api/src/owl/__init__.py index e69de29..8c77e2c 100644 --- a/services/api/src/owl/__init__.py +++ b/services/api/src/owl/__init__.py @@ -0,0 +1,6 @@ +from loguru import logger + +from owl.version import __version__ + +logger.disable("owl") +__all__ = ["__version__"] diff --git a/services/api/src/owl/billing.py b/services/api/src/owl/billing.py index 12421f6..788dd7f 100644 --- a/services/api/src/owl/billing.py +++ b/services/api/src/owl/billing.py @@ -1,49 +1,663 @@ +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from time import perf_counter + +import stripe +from cloudevents.conversion import to_dict +from cloudevents.http import CloudEvent +from fastapi import Request +from loguru import logger +from openmeter.aio import Client as OpenMeterAsyncClient + +from jamaibase import JamAIAsync +from jamaibase.exceptions import InsufficientCreditsError +from jamaibase.protocol import EventCreate, OrganizationRead +from owl.configs.manager import CONFIG, ENV_CONFIG, ProductType +from owl.db.gen_table import GenerativeTable +from owl.protocol import ( + EmbeddingModelConfig, + LLMGenConfig, + LLMModelConfig, + RerankingModelConfig, + UserAgent, +) +from owl.utils import uuid7_str + +if ENV_CONFIG.stripe_api_key_plain.strip() == "": + STRIPE_CLIENT = None +else: + STRIPE_CLIENT = stripe.StripeClient( + api_key=ENV_CONFIG.stripe_api_key_plain, + http_client=stripe.RequestsClient(), + max_network_retries=5, + ) +if ENV_CONFIG.openmeter_api_key_plain.strip() == "": + OPENMETER_CLIENT = None +else: + # Async client can be initialized by importing the `Client` from `openmeter.aio` + OPENMETER_CLIENT = OpenMeterAsyncClient( + endpoint="https://openmeter.cloud", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {ENV_CONFIG.openmeter_api_key_plain}", + }, + retry_status=3, + retry_total=5, + ) +CLIENT = JamAIAsync(token=ENV_CONFIG.service_key_plain) + + class BillingManager: - def __init__(self, *args, **kwargs) -> None: - pass + def __init__( + self, + *, + organization: OrganizationRead | None = None, + project_id: str = "", + user_id: str = "", + openmeter_client: OpenMeterAsyncClient = OPENMETER_CLIENT, + client: JamAIAsync | None = CLIENT, + request: Request | None = None, + ) -> None: + self.org = organization + self.project_id = project_id + self.user_id = user_id + self.openmeter_client = openmeter_client + self.client = client + self.request = request + if request is None: + self.user_agent = UserAgent(is_browser=False, agent="") + else: + self.user_agent: UserAgent = request.state.user_agent + self.is_oss = ENV_CONFIG.is_oss + self._events = [] + self._deltas = defaultdict(float) + self._values = defaultdict(float) + self._cost = 0.0 @property def total_balance(self) -> float: - return 0.0 + if self.is_oss or self.org is None: + return 0.0 + return self.org.credit + self.org.credit_grant + + def _compute_cost( + self, + product_type: ProductType, + remaining_quota: float, + usage: float, + ) -> float: + if self.org is None: + return 0.0 + prices = CONFIG.get_pricing() + try: + product = prices.plans[self.org.tier].products[product_type] + except Exception as e: + logger.warning(f"Failed to fetch product: {e}") + return 0.0 + cost = 0.0 + remaining_usage = (usage - remaining_quota) if remaining_quota > 0 else usage + for tier in product.tiers: + if remaining_usage <= 0: + break + if tier.up_to is not None and remaining_usage > tier.up_to: + tier_usage = tier.up_to + else: + tier_usage = remaining_usage + cost += tier_usage * float(tier.unit_amount_decimal) + remaining_usage -= tier_usage + if cost > 0: + self._cost += cost + self._events += [ + CloudEvent( + attributes={ + "type": "spent", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "spent_usd": cost, + "category": product_type, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + return cost + + async def process_all(self) -> None: + try: + if self.is_oss or self.org is None: + return + # No billing events for admin API + if self.request is not None and "api/admin" in self.request.url.path: + return + + if self.request is not None and self.request.scope.get("route", None): + # https://stackoverflow.com/a/72239186 + path = self.request.scope.get("root_path", "") + self.request.scope["route"].path + self._events += [ + CloudEvent( + attributes={ + "type": "request_count", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "method": self.request.method, + "path": path, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ), + ] - async def process_all(self, *args, **kwargs) -> None: - return + # Process credits + # Deduct from credit_grant first + if self.org.credit_grant >= self._cost: + credit_deduct = 0.0 + credit_grant_deduct = self._cost + else: + credit_deduct = self._cost - self.org.credit_grant + credit_grant_deduct = self.org.credit_grant + if credit_deduct > 0: + self._deltas[ProductType.CREDIT] -= credit_deduct + if credit_grant_deduct > 0: + self._deltas[ProductType.CREDIT_GRANT] -= credit_grant_deduct + # Update records + if len(self._deltas) > 0 or len(self._values) > 0: + await self.client.admin.backend.add_event( + EventCreate( + id=uuid7_str(), + organization_id=self.org.id, + deltas=self._deltas, + values=self._values, + ) + ) + # Send OpenMeter events + if ( + self.openmeter_client is not None + and self.org.openmeter_id is not None + and len(self._events) > 0 + ): + t0 = perf_counter() + await self.openmeter_client.ingest_events([to_dict(e) for e in self._events]) + logger.info( + ( + f"{self.request.state.id} - OpenMeter events ingestion: " + if self.request is not None + else "OpenMeter events ingestion: " + ) + + ( + f"t={(perf_counter() - t0) * 1e3:,.2f} ms " + f"num_events={len(self._events):,d}" + ) + ) + except Exception as e: + logger.exception(f"Failed to process billing events due to error: {e}") + + def _quota_ok( + self, + quota: float, + usage: float, + provider: str | None = None, + ): + # OSS has no billing + if self.is_oss: + return True + # If there is credit left + if self.total_balance > 0: + return True + # If user provides their own API key + if self.org.external_keys.get(provider, "").strip(): + return True + # If it's a ELLM model and there is quota left + has_quota = (quota - usage) > 0 + if provider is None: + return has_quota + elif provider.startswith("ellm") and has_quota: + return True + return False # --- LLM Usage --- # - def check_llm_quota(self, *args, **kwargs) -> None: - return + def check_llm_quota(self, model_id: str) -> None: + if self.is_oss or self.org is None: + return + provider = model_id.split("/")[0] + if self._quota_ok( + self.org.llm_tokens_quota_mtok, self.org.llm_tokens_usage_mtok, provider + ): + return + # Return different error message depending if request came from browser + if self.request is not None and self.user_agent.is_browser: + model_id = self.request.state.all_models.get_llm_model_info(model_id).name + raise InsufficientCreditsError( + f"Insufficient LLM token quota or credits for model: {model_id}" + ) - def check_gen_table_llm_quota(self, *args, **kwargs): - return + def check_gen_table_llm_quota( + self, + table: GenerativeTable, + table_id: str, + ) -> None: + if self.is_oss or self.org is None: + return + with table.create_session() as session: + meta = table.open_meta(session, table_id) + for c in meta.cols_schema: + if not isinstance(c.gen_config, LLMGenConfig): + continue + self.check_llm_quota(c.gen_config.model) - def create_llm_events(self, *args, **kwargs): - return + def create_llm_events( + self, + model: str, + input_tokens: int, + output_tokens: int, + ) -> None: + if self.is_oss or self.org is None: + return + if input_tokens < 1: + logger.warning(f"Input token count should be > 0, received: {input_tokens}") + input_tokens = 1 + if output_tokens < 1: + logger.warning(f"Output token count should be > 0, received: {output_tokens}") + output_tokens = 1 + self._events += [ + CloudEvent( + attributes={ + "type": ProductType.LLM_TOKENS, + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "model": model, + "tokens": v, + "type": t, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + for t, v in [("input", input_tokens), ("output", output_tokens)] + ] + provider = model.split("/")[0] + model_config: LLMModelConfig = self.request.state.all_models.get_llm_model_info(model) + input_cost_per_mtoken = model_config.input_cost_per_mtoken + output_cost_per_mtoken = model_config.output_cost_per_mtoken + llm_credit_mtok = max(0.0, self.org.llm_tokens_quota_mtok - self.org.llm_tokens_usage_mtok) + input_mtoken = input_tokens / 1e6 + output_mtoken = output_tokens / 1e6 - async def process_llm_usage(self) -> None: - return + if provider.startswith("ellm"): + self._deltas[ProductType.LLM_TOKENS] += input_mtoken + output_mtoken + if provider.startswith("ellm") and llm_credit_mtok > 0: + # Deduct input tokens first + if llm_credit_mtok >= input_mtoken: + input_mtoken = 0.0 + output_mtoken = max(0.0, output_mtoken - llm_credit_mtok) + else: + input_mtoken = max(0.0, input_mtoken - llm_credit_mtok) + cost = input_cost_per_mtoken * input_mtoken + output_cost_per_mtoken * output_mtoken + elif self.org.external_keys.get(provider, "").strip(): + cost = 0.0 + else: + cost = input_cost_per_mtoken * input_mtoken + output_cost_per_mtoken * output_mtoken - # --- Egress Usage --- # + if cost > 0: + self._cost += cost + self._events += [ + CloudEvent( + attributes={ + "type": "spent", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "spent_usd": cost, + "category": ProductType.LLM_TOKENS, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + + # --- Embedding Usage --- # + + def check_embedding_quota(self, model_id: str) -> None: + if self.is_oss or self.org is None: + return + provider = model_id.split("/")[0] + if self._quota_ok( + self.org.embedding_tokens_quota_mtok, self.org.embedding_tokens_usage_mtok, provider + ): + return + # Return different error message depending if request came from browser + if self.request is not None and self.user_agent.is_browser: + model_id = self.request.state.all_models.get_embed_model_info(model_id).name + raise InsufficientCreditsError( + f"Insufficient Embedding token quota or credits for model: {model_id}" + ) + + def create_embedding_events( + self, + model: str, + token_usage: int, + ) -> None: + if self.is_oss or self.org is None: + return + if token_usage < 1: + logger.warning(f"Token usage should be >= 1, received: {token_usage}") + token_usage = 1 + # Create the CloudEvent for embedding token usage + self._events += [ + CloudEvent( + attributes={ + "type": ProductType.EMBEDDING_TOKENS, + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "model": model, + "tokens": token_usage, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + + # Determine the provider from the model string + provider = model.split("/")[0] + # Get tokens in per mtoken unit + model_config: EmbeddingModelConfig = self.request.state.all_models.get_embed_model_info( + model + ) + cost_per_mtoken = model_config.cost_per_mtoken + embedding_credit_mtok = max( + 0.0, self.org.embedding_tokens_quota_mtok - self.org.embedding_tokens_usage_mtok + ) + token_usage_mtok = token_usage / 1e6 + + if provider.startswith("ellm"): + self._deltas[ProductType.EMBEDDING_TOKENS] += token_usage_mtok - def check_egress_quota(self, *args, **kwargs) -> None: - return + if provider.startswith("ellm") and embedding_credit_mtok > 0: + cost = max(0.0, token_usage_mtok - embedding_credit_mtok) * cost_per_mtoken + elif self.org.external_keys.get(provider, "").strip(): + cost = 0.0 + else: + cost = token_usage_mtok * cost_per_mtoken - def create_egress_events(self, *args, **kwargs): - return + # If there is a cost, update the total cost and create a CloudEvent for the spending + if cost > 0: + self._cost += cost + self._events += [ + CloudEvent( + attributes={ + "type": "spent", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "spent_usd": cost, + "category": ProductType.EMBEDDING_TOKENS, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] - async def process_egress_usage(self, *args, **kwargs) -> None: - return + # --- Reranker Usage --- # + + def check_reranker_quota(self, model_id: str) -> None: + if self.is_oss or self.org is None: + return + provider = model_id.split("/")[0] + if self._quota_ok( + self.org.reranker_quota_ksearch, self.org.reranker_usage_ksearch, provider + ): + return + # Return different error message depending if request came from browser + if self.request is not None and self.user_agent.is_browser: + model_id = self.request.state.all_models.get_rerank_model_info(model_id).name + raise InsufficientCreditsError( + f"Insufficient Reranker search quota or credits for model: {model_id}" + ) + + def create_reranker_events( + self, + model: str, + num_searches: int, + ) -> None: + if self.is_oss or self.org is None: + return + if num_searches < 1: + logger.warning(f"Number of searches should be >= 1, received: {num_searches}") + num_searches = 1 + + # Create the CloudEvent for rerank search usage + self._events += [ + CloudEvent( + attributes={ + "type": ProductType.RERANKER_SEARCHES, + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "model": model, + "searches": num_searches, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + + # Determine the provider from the model string + provider = model.split("/")[0] + + # Get search cost per ksearch unit + model_config: RerankingModelConfig = self.request.state.all_models.get_rerank_model_info( + model + ) + cost_per_ksearch = model_config.cost_per_ksearch + + remaining_rerank_ksearches = ( + self.org.reranker_quota_ksearch - self.org.reranker_usage_ksearch + ) + num_ksearches = num_searches / 1e3 + + if provider.startswith("ellm"): + self._deltas[ProductType.RERANKER_SEARCHES] += num_ksearches + + if provider.startswith("ellm") and remaining_rerank_ksearches > 0: + cost = max(0.0, num_ksearches - remaining_rerank_ksearches) * cost_per_ksearch + elif self.org.external_keys.get(provider, "").strip(): + cost = 0.0 + else: + cost = cost_per_ksearch * num_ksearches + + # If there is a cost, update the total cost and create a CloudEvent for the spending + if cost > 0: + self._cost += cost + self._events += [ + CloudEvent( + attributes={ + "type": "spent", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "spent_usd": cost, + "category": ProductType.RERANKER_SEARCHES, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + + # --- Egress Usage --- # + + def check_egress_quota(self) -> None: + if self.is_oss or self.org is None: + return + if self._quota_ok(self.org.egress_quota_gib, self.org.egress_usage_gib): + return + raise InsufficientCreditsError("Insufficient egress quota or credits.") + + def create_egress_events(self, amount_gb: float) -> None: + if self.is_oss or self.org is None: + return + if amount_gb <= 0: + logger.warning(f"Egress amount should be > 0, received: {amount_gb}") + return + self._events += [ + CloudEvent( + attributes={ + "type": "bandwidth", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "amount_gb": amount_gb, + "type": ProductType.EGRESS, + "org_id": self.org.id, + "project_id": self.project_id, + "user_id": self.user_id, + "agent": self.user_agent.agent, + "agent_version": self.user_agent.agent_version, + "architecture": self.user_agent.architecture, + "system": self.user_agent.system, + "system_version": self.user_agent.system_version, + "language": self.user_agent.language, + "language_version": self.user_agent.language_version, + }, + ) + ] + self._compute_cost( + ProductType.EGRESS, self.org.egress_quota_gib - self.org.egress_usage_gib, amount_gb + ) + self._deltas[ProductType.EGRESS] += amount_gb # --- Storage Usage --- # def check_db_storage_quota(self) -> None: - return + if self.is_oss or self.org is None: + return + if self._quota_ok(self.org.db_quota_gib, self.org.db_usage_gib): + return + raise InsufficientCreditsError("Insufficient DB storage quota.") def check_file_storage_quota(self) -> None: - return - - def get_storage_usage(self): - return + if self.is_oss or self.org is None: + return + if self._quota_ok(self.org.file_quota_gib, self.org.file_usage_gib): + return + raise InsufficientCreditsError("Insufficient file storage quota.") - async def process_storage_usage(self): - return + def create_storage_events(self, db_usage_gib: float, file_usage_gib: float) -> None: + if self.is_oss or self.org is None: + return + if db_usage_gib <= 0: + logger.warning(f"DB storage usage should be > 0, received: {db_usage_gib}") + return + if file_usage_gib <= 0: + logger.warning(f"File storage usage should be > 0, received: {file_usage_gib}") + return + # Wait for at least `min_wait` before recomputing + now = datetime.now(timezone.utc) + min_wait = timedelta(minutes=max(5.0, ENV_CONFIG.owl_compute_storage_period_min)) + # Wait because quota refresh might be called a few times + quota_reset_at = datetime.fromisoformat(self.org.quota_reset_at) + if (now - quota_reset_at) <= min_wait: + return + self._events += [ + CloudEvent( + attributes={ + "type": "storage", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "amount_gb": db_usage_gib, + "type": "db", + "org_id": self.org.id, + }, + ), + CloudEvent( + attributes={ + "type": "storage", + "source": "owl", + "subject": self.org.openmeter_id, + }, + data={ + "amount_gb": file_usage_gib, + "type": "file", + "org_id": self.org.id, + }, + ), + ] + self._values[ProductType.DB_STORAGE] = db_usage_gib + self._values[ProductType.FILE_STORAGE] = file_usage_gib diff --git a/services/api/src/owl/configs/manager.py b/services/api/src/owl/configs/manager.py index 3875da0..30f3677 100644 --- a/services/api/src/owl/configs/manager.py +++ b/services/api/src/owl/configs/manager.py @@ -1,20 +1,24 @@ import os from decimal import Decimal from enum import Enum -from functools import lru_cache +from functools import cached_property, lru_cache +from os.path import abspath from pathlib import Path -from typing import Any +from typing import Annotated, Any import redis from loguru import logger -from pydantic import BaseModel, Field, SecretStr, computed_field +from pydantic import BaseModel, Field, SecretStr, computed_field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from redis.backoff import ExponentialBackoff +from redis.exceptions import ConnectionError, TimeoutError +from redis.retry import Retry from owl.protocol import ( - EmbeddingModelConfig, - LLMModelConfig, + EXAMPLE_CHAT_MODEL, + EXAMPLE_EMBEDDING_MODEL, + EXAMPLE_RERANKING_MODEL, ModelListConfig, - RerankingModelConfig, ) CURR_DIR = Path(__file__).resolve().parent @@ -22,45 +26,67 @@ class EnvConfig(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore", cli_parse_args=True + env_file=".env", env_file_encoding="utf-8", extra="ignore", cli_parse_args=False ) # API configs + owl_is_prod: bool = False owl_cache_purge: bool = False owl_db_dir: str = "db" + owl_file_dir: str = "file://file" owl_log_dir: str = "logs" - owl_port: int = 7770 + owl_file_proxy_url: str = "localhost:6969" owl_host: str = "0.0.0.0" - owl_workers: int = 2 - owl_service: str = "" - default_project: str = "default" - default_org: str = "default" + owl_port: int = 6969 + owl_workers: int = 1 + owl_max_concurrency: int = 300 + default_org_id: str = "default" + default_project_id: str = "default" owl_redis_host: str = "dragonfly" + 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_compute_storage_period_min: float = 1 - owl_models_config: str = str(CURR_DIR / "models.json") - owl_pricing_config: str = str(CURR_DIR / "cloud_pricing.json") - owl_llm_pricing_config: str = str(CURR_DIR / "cloud_pricing_llm.json") + owl_models_config: str = "models.json" + owl_pricing_config: str = "cloud_pricing.json" + # Starling configs + s3_endpoint: str = "" + s3_access_key_id: str = "" + s3_secret_access_key: SecretStr = "" + s3_backup_bucket_name: str = "" # Generative Table configs + owl_table_lock_timeout_sec: int = 15 owl_reindex_period_sec: int = 60 owl_immediate_reindex_max_rows: int = 2000 owl_optimize_period_sec: int = 60 owl_remove_version_older_than_mins: float = 5.0 owl_concurrent_rows_batch_size: int = 3 owl_concurrent_cols_batch_size: int = 5 + owl_max_write_batch_size: int = 1000 # Loader configs docio_url: str = "http://docio:6979/api/docio" unstructuredio_url: str = "http://unstructuredio:6989" + # PDF Loader configs + owl_fast_pdf_parsing: bool = True # LLM configs + owl_llm_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 60 + owl_embed_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 60 cohere_api_base: str = "https://api.cohere.ai/v1" jina_api_base: str = "https://api.jina.ai/v1" voyage_api_base: str = "https://api.voyageai.com/v1" clip_api_base: str = "http://localhost:51010" - # Keys + # Auth Keys + owl_session_secret: SecretStr = "oh yeah" + owl_github_client_id: str = "" + owl_github_client_secret: SecretStr = "" owl_encryption_key: SecretStr = "" service_key: SecretStr = "" + service_key_alt: SecretStr = "" + # Keys unstructuredio_api_key: SecretStr = "ellm" stripe_api_key: SecretStr = "" openmeter_api_key: SecretStr = "" + custom_api_key: SecretStr = "" openai_api_key: SecretStr = "" anthropic_api_key: SecretStr = "" gemini_api_key: SecretStr = "" @@ -69,15 +95,54 @@ class EnvConfig(BaseSettings): together_api_key: SecretStr = "" jina_api_key: SecretStr = "" voyage_api_key: SecretStr = "" + hyperbolic_api_key: SecretStr = "" + cerebras_api_key: SecretStr = "" + sambanova_api_key: SecretStr = "" + + @model_validator(mode="after") + def make_paths_absolute(self): + self.owl_db_dir = abspath(self.owl_db_dir) + self.owl_log_dir = abspath(self.owl_log_dir) + self.owl_models_config: str = str(CURR_DIR / self.owl_models_config) + self.owl_pricing_config: str = str(CURR_DIR / self.owl_pricing_config) + return self + + @model_validator(mode="after") + def check_alternate_service_key(self): + if self.service_key_alt.get_secret_value().strip() == "": + self.service_key_alt = self.service_key + return self + + @cached_property + def is_oss(self): + if self.service_key.get_secret_value() == "": + return True + return not (CURR_DIR.parent / "routers" / "cloud_admin.py").is_file() + + @property + def s3_secret_access_key_plain(self): + return self.s3_secret_access_key.get_secret_value() @property def owl_encryption_key_plain(self): return self.owl_encryption_key.get_secret_value() + @property + def owl_session_secret_plain(self): + return self.owl_session_secret.get_secret_value() + + @property + def owl_github_client_secret_plain(self): + return self.owl_github_client_secret.get_secret_value() + @property def service_key_plain(self): return self.service_key.get_secret_value() + @property + def service_key_alt_plain(self): + return self.service_key_alt.get_secret_value() + @property def unstructuredio_api_key_plain(self): return self.unstructuredio_api_key.get_secret_value() @@ -90,6 +155,10 @@ def stripe_api_key_plain(self): def openmeter_api_key_plain(self): return self.openmeter_api_key.get_secret_value() + @property + def custom_api_key_plain(self): + return self.custom_api_key.get_secret_value() + @property def openai_api_key_plain(self): return self.openai_api_key.get_secret_value() @@ -122,33 +191,23 @@ def jina_api_key_plain(self): def voyage_api_key_plain(self): return self.voyage_api_key.get_secret_value() + @property + def hyperbolic_api_key_plain(self): + return self.hyperbolic_api_key.get_secret_value() + + @property + def cerebras_api_key_plain(self): + return self.cerebras_api_key.get_secret_value() + + @property + def sambanova_api_key_plain(self): + return self.sambanova_api_key.get_secret_value() -ENV_CONFIG = EnvConfig() MODEL_CONFIG_KEY = " models" PRICES_KEY = " prices" -LLM_PRICES_KEY = " llm_prices" -LOGS = { - "stderr": { - "level": "INFO", - "serialize": False, - "backtrace": False, - "diagnose": True, - "enqueue": True, - "catch": True, - }, - f"{ENV_CONFIG.owl_log_dir}/owl.log": { - "level": "INFO", - "serialize": False, - "backtrace": False, - "diagnose": True, - "enqueue": True, - "catch": True, - "rotation": "50 MB", - "delay": False, - "watch": False, - }, -} +INTERNAL_ORG_ID_KEY = " internal_org_id" +ENV_CONFIG = EnvConfig() # Create db dir try: os.makedirs(ENV_CONFIG.owl_db_dir, exist_ok=False) @@ -157,19 +216,57 @@ def voyage_api_key_plain(self): class PlanName(str, Enum): - default = "default" - free = "free" - pro = "pro" - team = "team" + DEFAULT = "default" + FREE = "free" + PRO = "pro" + TEAM = "team" + DEMO = "_demo" + PARTNER = "_partner" + DEBUG = "_debug" + + def __str__(self) -> str: + return self.value + + +_product2column = dict( + credit=("credit",), + credit_grant=("credit_grant",), + llm_tokens=("llm_tokens_quota_mtok", "llm_tokens_usage_mtok"), + embedding_tokens=( + "embedding_tokens_quota_mtok", + "embedding_tokens_usage_mtok", + ), + reranker_searches=("reranker_quota_ksearch", "reranker_usage_ksearch"), + db_storage=("db_quota_gib", "db_usage_gib"), + file_storage=("file_quota_gib", "file_usage_gib"), + egress=("egress_quota_gib", "egress_usage_gib"), +) class ProductType(str, Enum): - credit = "credit" - credit_grant = "credit_grant" - llm_tokens = "llm_tokens" - db_storage = "db_storage" - file_storage = "file_storage" - egress = "egress" + CREDIT = "credit" + CREDIT_GRANT = "credit_grant" + LLM_TOKENS = "llm_tokens" + EMBEDDING_TOKENS = "embedding_tokens" + RERANKER_SEARCHES = "reranker_searches" + DB_STORAGE = "db_storage" + FILE_STORAGE = "file_storage" + EGRESS = "egress" + + def __str__(self) -> str: + return self.value + + @property + def quota_column(self) -> str: + return _product2column[self.value][0] + + @property + def usage_column(self) -> str: + return _product2column[self.value][-1] + + @classmethod + def exclude_credits(cls) -> list["ProductType"]: + return [p for p in cls if not p.value.startswith("credit")] class Tier(BaseModel): @@ -189,7 +286,10 @@ class Tier(BaseModel): class Product(BaseModel): - name: str + name: str = Field( + min_length=1, + description="Plan name.", + ) included: Tier = Tier(unit_amount_decimal=0, up_to=0) tiers: list[Tier] unit: str = Field( @@ -198,6 +298,7 @@ class Product(BaseModel): class Plan(BaseModel): + name: str stripe_price_id_live: str stripe_price_id_test: str flat_amount_decimal: Decimal = Field( @@ -207,12 +308,9 @@ class Plan(BaseModel): description="Credit amount included in USD.", ) max_users: int = Field( - description=( - "Maximum number of users per organization. " - "Amount of quota will be scaled by the number of users." - ), + description="Maximum number of users per organization.", ) - products: dict[str, Product] = Field( + products: dict[ProductType, Product] = Field( description="Mapping of price name to tier list where each element represents a pricing tier.", ) @@ -227,32 +325,80 @@ def stripe_price_id(self) -> str: class Price(BaseModel): + object: str = Field( + default="prices.plans", + description="Type of API response object.", + examples=["prices.plans"], + ) plans: dict[PlanName, Plan] = Field( description="Mapping of price plan name to price plan.", ) -class LLMPricing(BaseModel): - products: dict[ProductType, Product] = Field( - description="Mapping of price name to tier list where each element represents a pricing tier.", +class _ModelPrice(BaseModel): + id: str = Field( + description=( + '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], + ) + name: str = Field( + description="Name of the model.", + examples=["OpenAI GPT-4o Mini"], + ) + + +class LLMModelPrice(_ModelPrice): + input_cost_per_mtoken: float = Field( + description="Cost in USD per million (mega) input / prompt token.", + ) + output_cost_per_mtoken: float = Field( + description="Cost in USD per million (mega) output / completion token.", + ) + + +class EmbeddingModelPrice(_ModelPrice): + cost_per_mtoken: float = Field( + description="Cost in USD per million embedding tokens.", + ) + + +class RerankingModelPrice(_ModelPrice): + cost_per_ksearch: float = Field(description="Cost in USD for a thousand searches.") + + +class ModelPrice(BaseModel): + object: str = Field( + default="prices.models", + description="Type of API response object.", + examples=["prices.models"], ) + llm_models: list[LLMModelPrice] = [] + embed_models: list[EmbeddingModelPrice] = [] + rerank_models: list[RerankingModelPrice] = [] class Config: def __init__(self): self.use_redis = ENV_CONFIG.owl_workers > 1 if self.use_redis: - logger.info("Using Redis as cache.") - self._redis = redis.Redis(host=ENV_CONFIG.owl_redis_host, port=6379, db=0) + logger.debug("Using Redis as cache.") + self._redis = redis.Redis( + host=ENV_CONFIG.owl_redis_host, + port=ENV_CONFIG.owl_redis_port, + db=0, + # https://redis.io/kb/doc/22wxq63j93/how-to-manage-client-reconnections-in-case-of-errors-with-redis-py + retry=Retry(ExponentialBackoff(cap=10, base=1), 25), + retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError], + health_check_interval=1, + ) else: - logger.info("Using in-memory dict as cache.") + logger.debug("Using in-memory dict as cache.") self._data = {} - def get(self, key: str, default: Any = None) -> Any: - try: - return self[key] - except KeyError: - return default + def get(self, key: str) -> Any: + return self[key] def set(self, key: str, value: str) -> None: self[key] = value @@ -274,14 +420,15 @@ def __setitem__(self, key: str, value: str) -> None: else: self._data[key] = value - def __getitem__(self, key: str) -> str: + def __getitem__(self, key: str) -> str | None: if self.use_redis: item = self._redis.get(key) - if item is None: - raise KeyError(key) - return item.decode("utf-8") + return None if item is None else item.decode("utf-8") else: - return self._data[key] + try: + return self._data[key] + except KeyError: + return None def __delitem__(self, key) -> None: if self.use_redis: @@ -294,7 +441,7 @@ def __contains__(self, key) -> bool: if self.use_redis: self._redis.exists(key) else: - key in self._data + return key in self._data def __repr__(self) -> str: if self.use_redis: @@ -303,113 +450,88 @@ def __repr__(self) -> str: _data = self._data return repr(_data) + def get_internal_organization_id(self) -> str: + org_id = self[INTERNAL_ORG_ID_KEY] + if org_id is None: + org_id = ENV_CONFIG.owl_internal_org_id + self[INTERNAL_ORG_ID_KEY] = org_id + return org_id + + def set_internal_organization_id(self, organization_id: str) -> None: + self[INTERNAL_ORG_ID_KEY] = organization_id + logger.info(f"Internal organization ID set to: {organization_id}") + + @property + def internal_organization_id(self) -> str: + return self.get_internal_organization_id() + @staticmethod @lru_cache(maxsize=1) - def _load_model_config() -> ModelListConfig: + def _load_model_config_from_json(json: str) -> ModelListConfig: + models = ModelListConfig.model_validate_json(json) + return models + + def _load_model_config_from_file(self) -> ModelListConfig: # Validate JSON file with open(ENV_CONFIG.owl_models_config, "r") as f: - models = ModelListConfig.model_validate_json(f.read()) + models = self._load_model_config_from_json(f.read()) return models def get_model_json(self) -> str: - models = self.get(MODEL_CONFIG_KEY) - if models is None: - models = self._load_model_config().model_dump_json() - self[MODEL_CONFIG_KEY] = models - logger.warning(f"Model config set to: {models}") - return models + model_json = self[MODEL_CONFIG_KEY] + 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: model_json = self[MODEL_CONFIG_KEY] if model_json is None: model_json = self.get_model_json() - return ModelListConfig.model_validate_json(model_json) + return self._load_model_config_from_json(model_json) def set_model_config(self, body: ModelListConfig) -> None: - config_json = body.model_dump_json() - self[MODEL_CONFIG_KEY] = config_json + self[MODEL_CONFIG_KEY] = body.model_dump_json() logger.info(f"Model config set to: {body}") try: with open(ENV_CONFIG.owl_models_config, "w") as f: - f.write(config_json) + f.write(body.model_dump_json(exclude_defaults=True)) except Exception as e: logger.warning(f"Failed to update `{ENV_CONFIG.owl_models_config}`: {e}") - def get_model_info( - self, - model_type: str, - model_id: str, - ) -> LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig: - models = getattr(self.get_model_config(), model_type) - infos = [m for m in models if m.id == model_id] - if len(infos) == 0: - raise ValueError( - f"Invalid model ID: {model_id}. Available models: {[m.id for m in models]}" - ) - return infos[0] - - def get_llm_model_info(self, model_id: str) -> LLMModelConfig: - return self.get_model_info("llm_models", model_id) - - def get_embed_model_info(self, model_id: str) -> EmbeddingModelConfig: - return self.get_model_info("embed_models", model_id) - - def get_rerank_model_info(self, model_id: str) -> RerankingModelConfig: - return self.get_model_info("rerank_models", model_id) + def get_model_pricing(self) -> ModelPrice: + return ModelPrice.model_validate(self.get_model_config().model_dump(exclude={"object"})) @staticmethod @lru_cache(maxsize=1) - def _load_pricing() -> Price: + def _load_pricing_from_json(json: str) -> Price: + pricing = Price.model_validate_json(json) + return pricing + + def _load_pricing_from_file(self) -> Price: # Validate JSON file with open(ENV_CONFIG.owl_pricing_config, "r") as f: - pricing = Price.model_validate_json(f.read()) + pricing = self._load_pricing_from_json(f.read()) return pricing def get_pricing(self) -> Price: - pricing_json = self.get(PRICES_KEY) + pricing_json = self[PRICES_KEY] if pricing_json is None: - pricing = self._load_pricing() + pricing = self._load_pricing_from_file() self[PRICES_KEY] = pricing.model_dump_json() logger.warning(f"Pricing set to: {pricing}") return pricing - return Price.model_validate_json(pricing_json) + return self._load_pricing_from_json(pricing_json) def set_pricing(self, body: Price) -> None: - pricing_json = body.model_dump_json() - self[PRICES_KEY] = pricing_json + self[PRICES_KEY] = body.model_dump_json() logger.info(f"Pricing set to: {body}") try: with open(ENV_CONFIG.owl_pricing_config, "w") as f: - f.write(pricing_json) + f.write(body.model_dump_json(exclude_defaults=True)) except Exception as e: logger.warning(f"Failed to update `{ENV_CONFIG.owl_pricing_config}`: {e}") - @staticmethod - @lru_cache(maxsize=1) - def _load_llm_pricing() -> Price: - # Validate JSON file - with open(ENV_CONFIG.owl_llm_pricing_config, "r") as f: - pricing = Price.model_validate_json(f.read()) - return pricing - - def get_llm_pricing(self) -> Price: - pricing_json = self.get(LLM_PRICES_KEY) - if pricing_json is None: - pricing = self._load_llm_pricing() - self[LLM_PRICES_KEY] = pricing.model_dump_json() - logger.warning(f"LLM pricing set to: {pricing}") - return pricing - return Price.model_validate_json(pricing_json) - - def set_llm_pricing(self, body: Price) -> None: - pricing_json = body.model_dump_json() - self[PRICES_KEY] = pricing_json - logger.info(f"Pricing set to: {body}") - try: - with open(ENV_CONFIG.owl_llm_pricing_config, "w") as f: - f.write(pricing_json) - except Exception as e: - logger.warning(f"Failed to update `{ENV_CONFIG.owl_llm_pricing_config}`: {e}") - CONFIG = Config() diff --git a/services/api/src/owl/configs/models.json b/services/api/src/owl/configs/models.json index 6989c0b..7bf6dbb 100644 --- a/services/api/src/owl/configs/models.json +++ b/services/api/src/owl/configs/models.json @@ -1,159 +1,155 @@ { "llm_models": [ { - "id": "openai/gpt-3.5-turbo", - "object": "model", - "name": "OpenAI GPT-3.5 Turbo", - "context_length": 16385, - "languages": ["en", "cn"], - "capabilities": ["chat"], - "owned_by": "openai" - }, - { - "id": "openai/gpt-4o", - "object": "model", - "name": "OpenAI GPT-4", - "context_length": 8192, - "languages": ["en", "cn"], - "capabilities": ["chat"], - "owned_by": "openai" + "id": "openai/gpt-4o-mini", + "name": "OpenAI GPT-4o Mini", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat", "image"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "openai" + } + ] }, { "id": "anthropic/claude-3-haiku-20240307", - "object": "model", - "name": "Claude 3 Haiku", - "context_length": 200000, - "languages": ["en"], - "capabilities": ["chat"], - "owned_by": "anthropic" - }, - { - "id": "anthropic/claude-3-sonnet-20240229", - "object": "model", - "name": "Claude 3 Sonnet", - "context_length": 200000, - "languages": ["en"], - "capabilities": ["chat"], - "owned_by": "anthropic" - }, - { - "id": "anthropic/claude-3-opus-20240229", - "object": "model", - "name": "Claude 3 Opus", + "name": "Anthropic Claude 3 Haiku", "context_length": 200000, - "languages": ["en"], - "capabilities": ["chat"], - "owned_by": "anthropic" - }, - { - "id": "together_ai/Qwen/Qwen1.5-14B-Chat", - "object": "model", - "name": "Qwen 1.5 Chat (14B)", - "context_length": 32768, - "languages": ["en", "cn"], - "capabilities": ["chat"], - "owned_by": "together_ai" - }, - { - "id": "together_ai/Qwen/Qwen1.5-72B-Chat", - "object": "model", - "name": "Qwen 1.5 Chat (72B)", - "context_length": 4096, - "languages": ["en", "cn"], + "languages": ["mul"], "capabilities": ["chat"], - "owned_by": "together_ai" + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "anthropic" + } + ] }, { - "id": "together_ai/mistralai/Mixtral-8x22B-Instruct-v0.1", - "object": "model", - "name": "Mixtral Instruct v0.1 (8x22B / 141B)", - "context_length": 65536, - "languages": ["en"], + "id": "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", + "name": "Together AI Meta Llama 3.1 (8B)", + "context_length": 130000, + "languages": ["mul"], "capabilities": ["chat"], - "owned_by": "together_ai" + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "together_ai" + } + ] } ], - "embed_models": [ { - "id": "openai/text-embedding-3-large-3072", - "litellm_id": "text-embedding-3-large", - "context_length": 8192, - "embedding_size": 3072, + "id": "ellm/sentence-transformers/all-MiniLM-L6-v2", + "name": "ELLM MiniLM L6 v2", + "context_length": 512, + "embedding_size": 384, "languages": ["mul"], - "owned_by": "openai" + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "openai/sentence-transformers/all-MiniLM-L6-v2", + "api_base": "http://infinity:6909", + "provider": "ellm" + } + ] }, { - "id": "openai/text-embedding-3-large-1024", - "litellm_id": "text-embedding-3-large", + "id": "openai/text-embedding-3-large-3072", + "name": "OpenAI Text Embedding 3 Large (3072-dim)", "context_length": 8192, - "embedding_size": 1024, - "dimensions": 1024, + "embedding_size": 3072, "languages": ["mul"], - "owned_by": "openai" + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] }, { "id": "openai/text-embedding-3-large-256", - "litellm_id": "text-embedding-3-large", + "name": "OpenAI Text Embedding 3 Large (256-dim)", "context_length": 8192, "embedding_size": 256, "dimensions": 256, "languages": ["mul"], - "owned_by": "openai" - }, - { - "id": "openai/text-embedding-3-small-1536", - "litellm_id": "text-embedding-3-small", - "context_length": 8192, - "embedding_size": 1536, - "languages": ["mul"], - "owned_by": "openai" + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] }, { "id": "openai/text-embedding-3-small-512", - "litellm_id": "text-embedding-3-small", + "name": "OpenAI Text Embedding 3 Small (512-dim)", "context_length": 8192, "embedding_size": 512, "dimensions": 512, "languages": ["mul"], - "owned_by": "openai" - }, - { - "id": "cohere/embed-english-v3.0", - "litellm_id": "embed-english-v3.0", - "context_length": 512, - "embedding_size": 1024, - "languages": ["en"], - "owned_by": "cohere" + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-small", + "api_base": "", + "provider": "openai" + } + ] }, { "id": "cohere/embed-multilingual-v3.0", - "litellm_id": "embed-multilingual-v3.0", + "name": "Cohere Embed Multilingual v3.0", "context_length": 512, "embedding_size": 1024, "languages": ["mul"], - "owned_by": "cohere" - }, - { - "id": "ellm/sentence-transformers/all-MiniLM-L6-v2", - "litellm_id": "openai/sentence-transformers/all-MiniLM-L6-v2", - "context_length": 8192, - "embedding_size": 1024, - "languages": ["mul"], - "api_base": "http://infinity:6909", - "owned_by": "ellm" + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "embed-multilingual-v3.0", + "api_base": "", + "provider": "cohere" + } + ] } ], - "rerank_models": [ - { "id": "cohere/rerank-english-v3.0", "context_length": 512, "languages": ["en"], "owned_by": "cohere" }, - { "id": "cohere/rerank-multilingual-v3.0", "context_length": 512, "languages": ["mul"], "owned_by": "cohere" }, { "id": "ellm/cross-encoder/ms-marco-TinyBERT-L-2", - "context_length": 8192, + "name": "ELLM TinyBERT L2", + "context_length": 512, + "languages": ["en"], + "capabilities": ["rerank"], + "deployments": [ + { + "litellm_id": "", + "api_base": "http://infinity:6919", + "provider": "ellm" + } + ] + }, + { + "id": "cohere/rerank-multilingual-v3.0", + "name": "Cohere Rerank Multilingual v3.0", + "context_length": 512, "languages": ["mul"], - "api_base": "http://infinity:6919", - "owned_by": "ellm" + "capabilities": ["rerank"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "cohere" + } + ] } ] } diff --git a/services/api/src/owl/configs/models_aipc.json b/services/api/src/owl/configs/models_aipc.json new file mode 100644 index 0000000..8b0ace0 --- /dev/null +++ b/services/api/src/owl/configs/models_aipc.json @@ -0,0 +1,241 @@ +{ + "llm_models": [ + { + "id": "ellm/phi3-mini-int4", + "name": "ELLM Phi-3 Instruct", + "context_length": 4096, + "languages": ["en", "cn"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "openai/phi3-mini-int4", + "api_base": "http://localhost:5555/v1", + "provider": "ellm" + } + ] + }, + { + "id": "openai/gpt-4o-mini", + "name": "OpenAI GPT-4o Mini", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat", "image"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/gpt-4o", + "name": "OpenAI GPT-4o", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat", "image"], + "deployments": [ + { + "litellm_id": "openai/gpt-4o-2024-08-06", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/gpt-4-turbo", + "name": "OpenAI GPT-4 Turbo", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat", "image"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "anthropic/claude-3.5-sonnet", + "name": "Anthropic Claude 3.5 Sonnet", + "context_length": 200000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "anthropic/claude-3-5-sonnet-20240620", + "api_base": "", + "provider": "anthropic" + } + ] + }, + { + "id": "anthropic/claude-3-haiku-20240307", + "name": "Anthropic Claude 3 Haiku", + "context_length": 200000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "anthropic" + } + ] + }, + { + "id": "anthropic/claude-3-sonnet-20240229", + "name": "Anthropic Claude 3 Sonnet", + "context_length": 200000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "anthropic" + } + ] + }, + { + "id": "anthropic/claude-3-opus-20240229", + "name": "Anthropic Claude 3 Opus", + "context_length": 200000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "anthropic" + } + ] + }, + { + "id": "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo", + "name": "Together AI Meta Llama 3.1 (405B)", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "together_ai" + } + ] + } + ], + "embed_models": [ + { + "id": "ellm/sentence-transformers/all-MiniLM-L6-v2", + "name": "ELLM MiniLM L6 v2", + "context_length": 512, + "embedding_size": 384, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "openai/sentence-transformers/all-MiniLM-L6-v2", + "api_base": "http://infinity:6909", + "provider": "ellm" + } + ] + }, + { + "id": "openai/text-embedding-3-large-3072", + "name": "OpenAI Text Embedding 3 Large (3072-dim)", + "context_length": 8192, + "embedding_size": 3072, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/text-embedding-3-large-256", + "name": "OpenAI Text Embedding 3 Large (256-dim)", + "context_length": 8192, + "embedding_size": 256, + "dimensions": 256, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/text-embedding-3-small-512", + "name": "OpenAI Text Embedding 3 Small (512-dim)", + "context_length": 8192, + "embedding_size": 512, + "dimensions": 512, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-small", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "cohere/embed-multilingual-v3.0", + "name": "Cohere Embed Multilingual v3.0", + "context_length": 512, + "embedding_size": 1024, + "languages": ["mul"], + "capabilities": ["embed"], + "owned_by": "cohere", + "deployments": [ + { + "litellm_id": "embed-multilingual-v3.0", + "api_base": "", + "provider": "cohere" + } + ] + } + ], + "rerank_models": [ + { + "id": "ellm/cross-encoder/ms-marco-TinyBERT-L-2", + "name": "ELLM TinyBERT L2", + "context_length": 512, + "languages": ["en"], + "capabilities": ["rerank"], + "deployments": [ + { + "litellm_id": "", + "api_base": "http://infinity:6919", + "provider": "ellm" + } + ] + }, + { + "id": "cohere/rerank-multilingual-v3.0", + "name": "Cohere Rerank Multilingual v3.0", + "context_length": 512, + "languages": ["mul"], + "capabilities": ["rerank"], + "owned_by": "cohere", + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "cohere" + } + ] + } + ] +} diff --git a/services/api/src/owl/configs/models_ollama.json b/services/api/src/owl/configs/models_ollama.json new file mode 100644 index 0000000..afb08e4 --- /dev/null +++ b/services/api/src/owl/configs/models_ollama.json @@ -0,0 +1,171 @@ +{ + "llm_models": [ + { + "id": "openai/gpt-4o-mini", + "name": "OpenAI GPT-4o Mini", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "anthropic/claude-3-haiku-20240307", + "name": "Anthropic Claude 3 Haiku", + "context_length": 200000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "anthropic" + } + ] + }, + { + "id": "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo", + "name": "Together AI Meta Llama 3.1 (405B)", + "context_length": 128000, + "languages": ["mul"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "together_ai" + } + ] + }, + { + "id": "ellm/microsoft/Phi3.5-mini-instruct", + "name": "ELLM Phi3.5 mini instruct (3.8B)", + "context_length": 131072, + "languages": ["en"], + "capabilities": ["chat"], + "deployments": [ + { + "litellm_id": "ollama_chat/microsoft/Phi3.5-mini-instruct", + "api_base": "http://ollama:11434", + "provider": "ellm" + } + ] + } + ], + "embed_models": [ + { + "id": "ellm/sentence-transformers/all-MiniLM-L6-v2", + "name": "ELLM MiniLM L6 v2", + "context_length": 512, + "embedding_size": 384, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "openai/sentence-transformers/all-MiniLM-L6-v2", + "api_base": "http://infinity:6909", + "provider": "ellm" + } + ] + }, + { + "id": "openai/text-embedding-3-large-3072", + "name": "OpenAI Text Embedding 3 Large (3072-dim)", + "context_length": 8192, + "embedding_size": 3072, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/text-embedding-3-large-256", + "name": "OpenAI Text Embedding 3 Large (256-dim)", + "context_length": 8192, + "embedding_size": 256, + "dimensions": 256, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-large", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "openai/text-embedding-3-small-512", + "name": "OpenAI Text Embedding 3 Small (512-dim)", + "context_length": 8192, + "embedding_size": 512, + "dimensions": 512, + "languages": ["mul"], + "capabilities": ["embed"], + "deployments": [ + { + "litellm_id": "text-embedding-3-small", + "api_base": "", + "provider": "openai" + } + ] + }, + { + "id": "cohere/embed-multilingual-v3.0", + "name": "Cohere Embed Multilingual v3.0", + "context_length": 512, + "embedding_size": 1024, + "languages": ["mul"], + "capabilities": ["embed"], + "owned_by": "cohere", + "deployments": [ + { + "litellm_id": "embed-multilingual-v3.0", + "api_base": "", + "provider": "cohere" + } + ] + } + ], + "rerank_models": [ + { + "id": "ellm/cross-encoder/ms-marco-TinyBERT-L-2", + "name": "ELLM TinyBERT L2", + "context_length": 512, + "languages": ["en"], + "capabilities": ["rerank"], + "deployments": [ + { + "litellm_id": "", + "api_base": "http://infinity:6919", + "provider": "ellm" + } + ] + }, + { + "id": "cohere/rerank-multilingual-v3.0", + "name": "Cohere Rerank Multilingual v3.0", + "context_length": 512, + "languages": ["mul"], + "capabilities": ["rerank"], + "owned_by": "cohere", + "deployments": [ + { + "litellm_id": "", + "api_base": "", + "provider": "cohere" + } + ] + } + ] +} diff --git a/services/api/src/owl/db/__init__.py b/services/api/src/owl/db/__init__.py index 46b5d56..9af6c28 100644 --- a/services/api/src/owl/db/__init__.py +++ b/services/api/src/owl/db/__init__.py @@ -1,26 +1,47 @@ +from functools import lru_cache +from os import makedirs +from os.path import dirname from typing import Type +from urllib.parse import urlsplit from loguru import logger -from sqlalchemy import Engine, NullPool, Pool, event +from sqlalchemy import Engine, NullPool, Pool, QueuePool, event from sqlalchemy.exc import OperationalError -from sqlmodel import SQLModel, create_engine +from sqlmodel import MetaData, SQLModel, create_engine, text + +from owl.configs.manager import ENV_CONFIG def _pragma_on_connect(dbapi_con, con_record): dbapi_con.execute("pragma foreign_keys = ON;\n") dbapi_con.execute("pragma journal_mode = WAL;\n") dbapi_con.execute("pragma synchronous = normal;\n") + dbapi_con.execute("pragma journal_size_limit = 6144000;\n") # dbapi_con.execute("pragma temp_store = memory;\n") # dbapi_con.execute("pragma mmap_size = 30000000000;\n") +def _do_connect(dbapi_connection, connection_record): + # Disable pysqlite's emitting of the BEGIN statement entirely. + # Also stops it from emitting COMMIT before any DDL. + dbapi_connection.isolation_level = None + + +def _do_begin(conn): + # Emit our own BEGIN + conn.exec_driver_sql("BEGIN") + + def create_sqlite_engine( db_url: str, + *, connect_args: dict | None = None, poolclass: Type[Pool] | None = None, echo: bool = False, **kwargs, ) -> Engine: + db_dir = dirname(urlsplit(db_url).path.replace("/", "", 1)) + makedirs(db_dir, exist_ok=True) engine = create_engine( db_url, connect_args=connect_args or {"check_same_thread": False}, @@ -29,11 +50,39 @@ def create_sqlite_engine( **kwargs, ) event.listen(engine, "connect", _pragma_on_connect) + # Enabling these seems to lead to DB locking issues + # https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#pysqlite-serializable + # https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#aiosqlite-serializable + # event.listen(engine, "connect", _do_connect) + # event.listen(engine, "begin", _do_begin) return engine def create_sql_tables(db_class: Type[SQLModel], engine: Engine): try: db_class.metadata.create_all(engine) - except OperationalError as e: - logger.warning(f"Failed to create DB tables: {e}") + except Exception as e: + logger.exception(f"Failed to create DB tables: {e}") + if not isinstance(e, OperationalError): + raise + + +@lru_cache(maxsize=1000) +def cached_text(query: str): + return text(query) + + +MAIN_ENGINE = create_sqlite_engine( + f"sqlite:///{ENV_CONFIG.owl_db_dir}/main.db", + # https://github.com/bluesky/tiled/issues/663 + poolclass=QueuePool, + pool_pre_ping=True, + pool_size=ENV_CONFIG.owl_max_concurrency, + max_overflow=ENV_CONFIG.owl_max_concurrency, + pool_timeout=30, + pool_recycle=300, +) + + +class UserSQLModel(SQLModel): + metadata = MetaData() diff --git a/services/api/src/owl/db/file.py b/services/api/src/owl/db/file.py deleted file mode 100644 index 6952408..0000000 --- a/services/api/src/owl/db/file.py +++ /dev/null @@ -1,162 +0,0 @@ -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any - -import lancedb -import pyarrow as pa -from filelock import FileLock -from lancedb.table import LanceTable -from loguru import logger -from typing_extensions import Self -from uuid_extensions import uuid7str - -from owl.protocol import ColName, TableName - - -class FileTable: - """ - File Table class. - - Note that by default, this class assumes that each method uses a new LanceDB connection. - Otherwise, consider passing in `read_consistency_interval=timedelta(seconds=0)` during init. - """ - - def __init__( - self, - vector_db_url: str, - table_name: TableName, - read_consistency_interval: timedelta | None = None, - ) -> None: - self.lance_db = lancedb.connect( - vector_db_url, read_consistency_interval=read_consistency_interval - ) - self.read_consistency_interval = read_consistency_interval - self.vector_db_url = Path(vector_db_url) - self.table_name = table_name - self.lock_name_prefix = vector_db_url - self.locks = {} - # Maybe create table - try: - self.lance_db.open_table(table_name) - except FileNotFoundError: - pa_schema = pa.schema( - [ - pa.field("ID", pa.utf8()), - pa.field("Updated at", pa.timestamp("us", tz="UTC")), - pa.field("File Name", pa.utf8()), - pa.field("Content", pa.binary()), - pa.field("File Size", pa.int64()), - pa.field("BLAKE2b Checksum", pa.utf8()), - ] - ) - self.lance_db.create_table(table_name, schema=pa_schema) - - def lock(self, name: str, timeout: int = 60): - name = f"{self.lock_name_prefix}/{name}.lock" - self.locks[name] = self.locks.get(name, FileLock(name, timeout=timeout)) - return self.locks[name] - - def open_table(self) -> LanceTable: - return self.lance_db.open_table(self.table_name) - - def add_file(self, file_name: str, content: bytes, blake2b_checksum: str) -> dict[str, Any]: - if not isinstance(file_name, str): - raise TypeError("`file_name` must be str.") - if not isinstance(content, bytes): - raise TypeError("`content` must be bytes.") - table = self.open_table() - with self.lock(self.table_name): - # Validate data - # rows = table.search().where(where=f"`File Name` = '{file_name}'", prefilter=True).to_list() - # if len(rows) > 0: - # raise FileExistsError("File exists, please choose another name.") - data = [ - { - "ID": uuid7str(), - "Updated at": datetime.now(timezone.utc), - "File Name": file_name, - "Content": content, - "File Size": len(content), - "BLAKE2b Checksum": blake2b_checksum, - } - ] - table.add(data) - return data[0] - - def rename_file(self, file_id: str, file_name: str) -> Self: - if not isinstance(file_id, str): - raise TypeError("`file_id` must be str.") - if not isinstance(file_name, str): - raise TypeError("`file_name` must be str.") - table = self.open_table() - with self.lock(self.table_name): - table.update(where=f"`ID` = '{file_id}'", values={"File Name": file_name}) - return self - - def delete_file(self, file_id: str | None = None, file_name: str | None = None) -> Self: - if file_id == "" or file_name == "": - raise ValueError("`file_id` or `file_name` cannot be empty string.") - if file_id is not None and file_name is not None: - raise ValueError("Cannot specify both `file_id` and `file_name`.") - table = self.open_table() - with self.lock(self.table_name): - if file_id: - table.delete(f"`ID` = '{file_id}'") - elif file_name: - table.delete(f"`File Name` = '{file_name}'") - else: - raise ValueError("Must specify either `file_id` or `file_name`.") - return self - - def get_file( - self, - file_id: str | None = None, - file_name: str | None = None, - columns: list[str] | None = None, - ) -> dict[ColName, Any]: - if file_id == "" or file_name == "": - raise ValueError("`file_id` or `file_name` cannot be empty string.") - if file_id is not None and file_name is not None: - raise ValueError("Cannot specify both `file_id` and `file_name`.") - table = self.open_table() - if file_id: - rows = table.search().where(where=f"`ID` = '{file_id}'", prefilter=True).to_list() - elif file_name: - rows = ( - table.search() - .where(where=f"`File Name` = '{file_name}'", prefilter=True) - .to_list() - ) - else: - raise ValueError("Must specify either `file_id` or `file_name`.") - if len(rows) == 0: - # logger.info(f"File not found: file_id={file_id} file_name={file_name}") - raise FileNotFoundError("File not found.") - elif len(rows) > 1: - logger.warning(f"More than one row in table {self.table_name} with ID {file_id}") - row = rows[0] - if columns is not None: - row = {k: v for k, v in row.items() if k in columns} - return row - - def compact_files(self, *args, **kwargs) -> bool: - with self.lock(self.table_name): - table = self.open_table() - num_rows = table.count_rows() - if num_rows < 10: - return False - table.compact_files(*args, **kwargs) - return True - - def cleanup_old_versions( - self, - older_than: timedelta | None = None, - delete_unverified: bool = False, - ) -> bool: - with self.lock(self.table_name): - table = self.open_table() - num_rows = table.count_rows() - if num_rows < 3: - return False - table.cleanup_old_versions(older_than=older_than, delete_unverified=delete_unverified) - return True diff --git a/services/api/src/owl/db/gen_executor.py b/services/api/src/owl/db/gen_executor.py index 13c3836..466ebda 100644 --- a/services/api/src/owl/db/gen_executor.py +++ b/services/api/src/owl/db/gen_executor.py @@ -1,64 +1,69 @@ import asyncio +import base64 import re -from copy import deepcopy from dataclasses import dataclass +from os.path import splitext from time import time -from typing import Any, AsyncGenerator +from typing import Any, AsyncGenerator, Literal import numpy as np from fastapi import Request +from fastapi.exceptions import RequestValidationError from loguru import logger -from uuid_extensions import uuid7str -from owl.db.gen_table import ChatTable, GenerativeTable +from jamaibase.exceptions import BadInputError, JamaiException, ResourceNotFoundError +from owl.db.gen_table import GenerativeTable from owl.llm import LLMEngine from owl.models import CloudEmbedder from owl.protocol import ( GEN_CONFIG_VAR_PATTERN, ChatCompletionChoiceDelta, + ChatCompletionChunk, ChatEntry, + ChatRequest, + EmbedGenConfig, + ExternalKeys, GenTableChatCompletionChunks, GenTableRowsChatCompletionChunks, GenTableStreamChatCompletionChunk, GenTableStreamReferences, + LLMGenConfig, + RegenStrategy, RowAdd, RowAddRequest, RowRegen, RowRegenRequest, TableMeta, ) -from owl.utils import mask_string -from owl.utils.exceptions import ResourceNotFoundError +from owl.utils import mask_string, uuid7_draft2_str +from owl.utils.io import open_uri_async @dataclass(slots=True) class Task: + type: Literal["embed", "chat"] output_column_name: str - body: dict - is_embed: bool + body: ChatRequest | EmbedGenConfig dtype: str class MultiRowsGenExecutor: def __init__( self, + *, table: GenerativeTable, + meta: TableMeta, request: Request, body: RowAddRequest | RowRegenRequest, rows_batch_size: int, cols_batch_size: int, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", + max_write_batch_size: int, ) -> None: self.table = table + self.meta = meta self.request = request self.body = body + self.is_regen = isinstance(body, RowRegenRequest) self.bodies = ( [ RowAdd( @@ -74,48 +79,46 @@ def __init__( RowRegen( table_id=body.table_id, row_id=row_id, + regen_strategy=body.regen_strategy, + output_column_id=body.output_column_id, stream=body.stream, - concurrent=body.concurrent, + concurrent=self.body.concurrent, ) for row_id in body.row_ids ] ) self.rows_batch_size = rows_batch_size self.cols_batch_size = cols_batch_size - self.openai_api_key = openai_api_key - self.anthropic_api_key = anthropic_api_key - self.gemini_api_key = gemini_api_key - self.cohere_api_key = cohere_api_key - self.groq_api_key = groq_api_key - self.together_api_key = together_api_key - self.jina_api_key = jina_api_key - self.voyage_api_key = voyage_api_key + self.max_write_batch_size = max_write_batch_size + self.external_keys: ExternalKeys = request.state.external_keys + + # Accumulated rows for batch write + self.batch_rows = [] + self.write_batch_size = self.optimal_write_batch_size() + + def _log_exception(self, exc: Exception, error_message: str): + if not isinstance(exc, (JamaiException, RequestValidationError)): + logger.exception(f"{self.request.state.id} - {error_message}") def _create_executor(self, body_: RowAdd | RowRegen): self.executor = GenExecutor( table=self.table, + meta=self.meta, request=self.request, body=body_, cols_batch_size=self.cols_batch_size, - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - cohere_api_key=self.cohere_api_key, - groq_api_key=self.groq_api_key, - together_api_key=self.together_api_key, - jina_api_key=self.jina_api_key, - voyage_api_key=self.voyage_api_key, ) - # logger.debug(body_) - async def _execute(self, body_, tmp_id=None) -> Any | GenTableChatCompletionChunks: + async def _execute( + self, body_, tmp_id=None + ) -> Any | tuple[GenTableChatCompletionChunks, dict]: self._create_executor(body_) if self.body.stream: try: async for chunk in await self.executor.gen_row(): await self.queue.put(chunk) - except Exception: - logger.exception(f"Error executing task {tmp_id}: {body_}") + except Exception as e: + self._log_exception(e, f'Error executing task "{tmp_id}" with body: {body_}') await self.queue.put("data: [DONE]\n\n") else: return await self.executor.gen_row() @@ -125,23 +128,129 @@ async def _gen_stream_rows(self): self.queue = asyncio.Queue() for i in range(0, len(self.bodies), self.rows_batch_size): batch_bodies = self.bodies[i : i + self.rows_batch_size] - for i, body_ in enumerate(batch_bodies): - asyncio.create_task(self._execute(body_, i)) + # Accumulate rows within the row batch + for j, body_ in enumerate(batch_bodies): + asyncio.create_task(self._execute(body_, j)) done_row_count = 0 while done_row_count < len(batch_bodies): chunk = await self.queue.get() - if chunk == "data: [DONE]\n\n": - done_row_count += 1 - content_length += len(chunk.encode("utf-8")) - yield chunk - self.request.state.billing_manager.create_egress_events(content_length / (1024**3)) + if isinstance(chunk, dict) or isinstance(chunk, tuple): + # Accumulate complete row + self.batch_rows.append(chunk) + if len(self.batch_rows) >= self.write_batch_size: + await self._write_rows_to_table() + else: + if chunk == "data: [DONE]\n\n": + done_row_count += 1 + else: + content_length += len(chunk.encode("utf-8")) + yield chunk + + # Write the remaining rows to table + if len(self.batch_rows) > 0: + await self._write_rows_to_table() + # Final yield after writing is done + chunk = "data: [DONE]\n\n" + yield chunk + content_length += len(chunk.encode("utf-8")) + + self.request.state.billing.create_egress_events(content_length / (1024**3)) + + @staticmethod + def _log_item(x: Any) -> str: + if isinstance(x, np.ndarray): + return f"array(shape={x.shape}, dtype={x.dtype})" + elif isinstance(x, str): + return mask_string(x) + else: + return f"type={type(x)}" + + def optimal_write_batch_size(self): + """ + Dynamically adjust batch size for progress updates, capped at `max_write_batch_size`. + """ + total_rows = len(self.bodies) + + # Aim for 5-10 batches, but ensure at least 10 rows per batch + target_batches = min(max(5, total_rows // 10), 10) + write_batch_size = max(total_rows // target_batches, 10) + + # Cap at max_write_batch_size + write_batch_size = min(write_batch_size, self.max_write_batch_size) + + # Handle edge cases for small datasets + if total_rows <= self.max_write_batch_size: + write_batch_size = total_rows + logger.info(f"Write to table: {total_rows} row(s) at once.") + else: + full_batches = total_rows // write_batch_size + remainder = total_rows % write_batch_size + + logger.info( + f"Write to table: {full_batches} batches with {write_batch_size} row(s) each." + ) + + if remainder: + logger.info(f"Write to table: 1 additional batch with {remainder} row(s).") + + return write_batch_size + + async def _write_rows_to_table(self): + """ + Writes accumulated rows to the table in batches. + """ + with self.table.create_session() as session: + if not self.is_regen: + logger.info( + f"{self.request.state.id} - Writing {len(self.batch_rows)} rows to table '{self.body.table_id}'" + ) + try: + await self.table.add_rows(session, self.body.table_id, self.batch_rows) + except Exception as e: + _data = [ + {k: self._log_item(v) for k, v in row.items()} for row in self.batch_rows + ] + self._log_exception(e, f"Error adding {len(self.batch_rows)} rows: {_data}") + else: + # Updating existing rows + for row_id, row in self.batch_rows: + _data = {k: self._log_item(v) for k, v in row.items()} + logger.info( + f"{self.request.state.id} - Updating row with ID '{row_id}' in table '{self.body.table_id}': " + f"{_data}" + ) + try: + self.table.update_rows( + session, self.body.table_id, where=f"`ID` = '{row_id}'", values=row + ) + except Exception as e: + self._log_exception(e, f'Error updating row "{row_id}" with values: {row}') + self.batch_rows.clear() async def _gen_nonstream_rows(self): - rows = [] + rows: list[GenTableChatCompletionChunks] = [] for i in range(0, len(self.bodies), self.rows_batch_size): batched_bodies = self.bodies[i : i + self.rows_batch_size] - rows += await asyncio.gather(*[self._execute(body_) for body_ in batched_bodies]) + rows_and_column_dicts = await asyncio.gather( + *[self._execute(body_) for body_ in batched_bodies] + ) + # Accumulate generated rows + for rows_, column_dict in rows_and_column_dicts: + rows.append(rows_) + + if self.is_regen: + self.batch_rows.append((rows_.row_id, column_dict)) + else: + self.batch_rows.append(column_dict) + + if len(self.batch_rows) >= self.write_batch_size: + await self._write_rows_to_table() + + # Write the reminding rows to table + if len(self.batch_rows) > 0: + await self._write_rows_to_table() + return GenTableRowsChatCompletionChunks(rows=rows) async def gen_rows(self) -> Any | GenTableChatCompletionChunks: @@ -154,20 +263,15 @@ async def gen_rows(self) -> Any | GenTableChatCompletionChunks: class GenExecutor: def __init__( self, + *, table: GenerativeTable, + meta: TableMeta, request: Request, body: RowAdd | RowRegen, cols_batch_size: int, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", ) -> None: self.table = table + self.meta = meta self.body = body self.is_row_add = isinstance(self.body, RowAdd) self.column_dict = {} @@ -176,257 +280,280 @@ def __init__( self.table_id = body.table_id self.request = request if isinstance(body, RowAdd): - body.data["ID"] = body.data.get("ID", uuid7str()) + body.data["ID"] = body.data.get("ID", uuid7_draft2_str()) self.row_id = body.data["ID"] else: self.row_id = body.row_id - self.is_chat = isinstance(self.table, ChatTable) - self.cols_batch_size = cols_batch_size - self.llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - self.openai_api_key = openai_api_key - self.anthropic_api_key = anthropic_api_key - self.gemini_api_key = gemini_api_key - self.cohere_api_key = cohere_api_key - self.groq_api_key = groq_api_key - self.together_api_key = together_api_key - self.jina_api_key = jina_api_key - self.voyage_api_key = voyage_api_key + self.cols_batch_size = cols_batch_size if self.body.concurrent else 1 + self.external_keys: ExternalKeys = request.state.external_keys + self.llm = LLMEngine(request=request) self.error_columns = [] - - async def gen_row(self) -> Any | GenTableChatCompletionChunks: - with self.table.create_session() as session: - meta = session.get(TableMeta, self.table_id) - - col_ids = set(c["id"] for c in meta.cols) + self.tag_regen_columns = [] + self.skip_regen_columns = [] + self.file_columns = [] + self.img_column_dict = {} + self.doc_column_dict = {} + + def _log_exception(self, exc: Exception, error_message: str): + if not isinstance(exc, (JamaiException, RequestValidationError)): + logger.exception(f"{self.request.state.id} - {error_message}") + + async def _get_file_binary(self, uri: str) -> bytes: + async with open_uri_async(uri) as file_handle: + return await file_handle.read() + + async def gen_row(self) -> Any | tuple[GenTableChatCompletionChunks, dict]: + cols = self.meta.cols_schema + col_ids = set(c.id for c in cols) if self.is_row_add: self.column_dict = {k: v for k, v in self.body.data.items() if k in col_ids} else: self.column_dict = self.table.get_row(self.table_id, self.row_id) self.tasks = [] - for col in meta.cols: - gen_config = deepcopy(col["gen_config"]) - col_id = col["id"] + for col in cols: # Skip info columns - if col_id.lower() in ("id", "updated at"): + if col.id.lower() in ("id", "updated at"): continue # Skip state column - if col_id.endswith("_"): + if col.id.endswith("_"): continue # If user provides value, skip - if self.is_row_add and col_id in self.column_dict: + if self.is_row_add and col.id in self.column_dict: continue # If gen_config not defined, set None and skip - if gen_config is None: + if col.gen_config is None: if self.is_row_add: - self.column_dict[col_id] = None + self.column_dict[col.id] = None continue - if self.is_chat and col_id.lower() == "ai": - messages = self.table.get_conversation_thread( - table_id=self.table_id, - row_id="" if self.is_row_add else self.row_id, - include=False, - ).thread - user_message = self.column_dict["User"] - messages.append(ChatEntry.user(content=user_message if user_message else ".")) - if len(messages) == 0: - continue - gen_config["messages"] = [m.model_dump() for m in messages] + if isinstance(col.gen_config, EmbedGenConfig): + task_type = "embed" + if col.vlen <= 0: + raise ValueError( + f'"gen_config" is EmbedGenConfig but `col.vlen` is {col.vlen}' + ) + gen_config = col.gen_config + elif isinstance(col.gen_config, LLMGenConfig): + task_type = "chat" + if col.gen_config.multi_turn: + messages = self.table.get_conversation_thread( + table_id=self.table_id, + column_id=col.id, + row_id="" if self.is_row_add else self.row_id, + include=False, + ).thread + user_message = col.gen_config.prompt + messages.append(ChatEntry.user(content=user_message if user_message else ".")) + if len(messages) == 0: + continue + else: + messages = [ + ChatEntry.system(col.gen_config.system_prompt), + ChatEntry.user(col.gen_config.prompt), + ] + gen_config = ChatRequest( + id=self.request.state.id, messages=messages, **col.gen_config.model_dump() + ) + else: + raise ValueError(f'Unexpected "gen_config" type: {type(col.gen_config)}') self.tasks.append( - Task(col_id, gen_config, is_embed=col["vlen"] > 0, dtype=col["dtype"]) + 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: + 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']." + ) + column_dict_keys = set(self.column_dict.keys()) if len(column_dict_keys - col_ids) > 0: raise ValueError(f"There are unexpected columns: {column_dict_keys - col_ids}") if self.body.stream: - if self.body.concurrent: - return self._stream_concurrent_execution() - else: - return self._stream_sequent_execution() + return self._stream_concurrent_execution() else: - if self.body.concurrent: - return await self._nonstream_concurrent_execution() - else: - return await self._nonstream_sequent_execution() + return await self._nonstream_concurrent_execution() - def _run_embed_tasks(self): + async def _run_embed_tasks(self): """ Executes embedding tasks sequentially. """ - embed_tasks = [task for task in self.tasks if task.is_embed is True] + embed_tasks = [task for task in self.tasks if task.type == "embed"] for task in embed_tasks: output_column_name = task.output_column_name - body = task.body - embedding_model = body["embedding_model"] - embedder = CloudEmbedder( - embedder_name=embedding_model, - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - cohere_api_key=self.cohere_api_key, - groq_api_key=self.groq_api_key, - together_api_key=self.together_api_key, - jina_api_key=self.jina_api_key, - voyage_api_key=self.voyage_api_key, + body: EmbedGenConfig = task.body + embedding_model = body.embedding_model + embedder = CloudEmbedder(request=self.request) + source = self.column_dict[body.source_column] + embedding = await embedder.embed_documents( + embedding_model, texts=["." if source is None else source] ) - source = self.column_dict[body["source_column"]] - embedding = embedder.embed_documents(texts=["." if source is None else source]) embedding = np.asarray(embedding.data[0].embedding, dtype=task.dtype) embedding = embedding / np.linalg.norm(embedding) self.column_dict[output_column_name] = embedding self.regen_column_dict[output_column_name] = embedding - @staticmethod - def _log_item(x: Any) -> str: - if isinstance(x, np.ndarray): - return f"array(shape={x.shape}, dtype={x.dtype})" - elif isinstance(x, str): - return mask_string(x) - else: - return f"type={type(x)}" - - def _write_to_table(self): - """ - Writes the generated data to the table. - """ - with self.table.create_session() as session: - if self.is_row_add: - _data = {k: self._log_item(v) for k, v in self.column_dict.items()} - logger.info( - ( - f"{self.request.state.id} - Writing row to table '{self.table_id}': " - f"{_data}" - ) - ) - try: - self.table.add_rows(session, self.table_id, [self.column_dict]) - except Exception: - _data = [ - { - k: ( - {"type": type(v), "shape": v.shape, "dtype": v.dtype} - if isinstance(v, np.ndarray) - else v - ) - } - for k, v in self.column_dict.items() - ] - logger.exception((f"{self.request.state.id} - Error adding rows {[_data]}")) - else: - _data = {k: self._log_item(v) for k, v in self.regen_column_dict.items()} - logger.info( - ( - f"{self.request.state.id} - Updating row of table '{self.table_id}': " - f"{_data}" - ) - ) - try: - self.table.update_rows( - session, - self.table_id, - where=f"`ID` = '{self.body.row_id}'", - values=self.regen_column_dict, - ) - except Exception: - logger.exception( - f"{self.request.state.id} - Error update rows, where `ID` = '{self.body.row_id}', " - f"values: {self.regen_column_dict}" - ) - def _extract_upstream_columns(self, text: str) -> list[str]: matches = re.findall(GEN_CONFIG_VAR_PATTERN, text) # return the content inside ${...} return matches - def _substitute_data(self, content: str) -> dict[str, Any]: + def _extract_upstream_image_columns(self, text: str) -> list[str]: + matches = re.findall(GEN_CONFIG_VAR_PATTERN, text) + # return the content inside ${...} + return [match for match in matches if self.llm_tasks[matches].dtype == "img"] + + 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]: """ - Substitutes placeholders in the content with actual column values. + Replaces / interpolates column references in the prompt with their contents. Args: - content (str): The content with placeholders. + prompt (str): The original prompt with zero or more column references. Returns: - dict[str, Any]: The content with placeholders replaced. + new_prompt (str | dict[str, Any]): The prompt with column references replaced. """ + image_column_names = [] + def replace_match(match): - key = match.group(1) # Extract the key from the match + column_name = match.group(1) # Extract the column_name from the match try: - return str(self.column_dict[key]) # Data can be non-string - except KeyError: - raise KeyError(f"Requested column '{key}' not found.") - - return re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, content) + if column_name in self.img_column_dict: + image_column_names.append(column_name) + return "" + elif column_name in self.doc_column_dict: + return self.doc_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 + + content_ = re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, prompt) + content = [{"type": "text", "text": content_}] + + if len(image_column_names) > 0: + if len(image_column_names) > 1: + raise BadInputError("Only one image is supported per completion.") + + content.append( + { + "type": "image_url", + "image_url": {"url": self.img_column_dict[image_column_names[0]]}, + } + ) + return content + else: + return content_ - def _check_upstream_error_chunk(self, content: str) -> Exception: + def _check_upstream_error_chunk(self, content: str) -> None: matches = re.findall(GEN_CONFIG_VAR_PATTERN, content) - if any([match in self.error_columns for match in matches]): raise Exception - else: - pass async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: """ - Executes a single task in a streaming manner, returning an asynchronous generator. + Executes a single task in a streaming manner, returning an asynchronous generator of chunks. """ output_column_name = task.output_column_name - body = task.body - body["id"] = self.request.state.id + body: ChatRequest = task.body try: logger.debug(f"Processing column: {output_column_name}") - self._check_upstream_error_chunk(body["messages"][-1]["content"]) - - body["messages"][-1]["content"] = self._substitute_data( - body["messages"][-1]["content"] - ) - new_column_value = "" - messages, references = await self.llm.retrieve_references( - request=self.request, - messages=body.pop("messages"), - rag_params=body.pop("rag_params", None), - **body, - ) - if references is not None: - ref = GenTableStreamReferences( - **references.model_dump(exclude=["object"]), - output_column_name=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}" ) - yield f"data: {ref.model_dump_json()}\n\n" - async for chunk in self.llm.generate_stream( - request=self.request, - messages=messages, - **body, - ): - new_column_value += chunk.text + 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}" + ) + 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) + logger.info( + ( + f"{self.request.state.id} - Streamed completion for " + f"{output_column_name}: <{mask_string(new_column_value)}>" + ) ) - ) - except Exception as exc: + except Exception as e: error_chunk = GenTableStreamChatCompletionChunk( id=self.request.state.id, object="gen_table.completion.chunk", @@ -435,7 +562,7 @@ async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: usage=None, choices=[ ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {exc}"), + message=ChatEntry.assistant(f"[ERROR] {e}"), index=0, finish_reason="error", ) @@ -446,9 +573,11 @@ async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: yield f"data: {error_chunk.model_dump_json()}\n\n" new_column_value = error_chunk.text self.error_columns.append(output_column_name) - logger.exception(f"{self.request.state.id} - LLM generation failed for column: {task}") + self._log_exception( + e, f'Error generating completion for column "{output_column_name}": {e}' + ) finally: - # Append new column data for subsequence tasks + # 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 @@ -457,18 +586,57 @@ async def _execute_task_nonstream(self, task: Task): Executes a single task in a non-streaming manner. """ output_column_name = task.output_column_name - body = task.body - body["id"] = self.request.state.id + body: ChatRequest = task.body try: - body["messages"][-1]["content"] = self._substitute_data( - body["messages"][-1]["content"] - ) - except (IndexError, KeyError): + body.messages[-1].content = self._interpolate_column(body.messages[-1].content) + except IndexError: pass try: - response = await self.llm.rag(request=self.request, **body) - new_column_value = response.text + 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( + 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"Skipped regen for `{output_column_name}`, value: {new_column_value}" + ) + elif output_column_name in self.file_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.info( + f"Identified output column `{output_column_name}` as file type, set value to {new_column_value}" + ) + else: + response = await self.llm.rag(**body.model_dump()) + new_column_value = response.text # append new column data for subsequence tasks self.column_dict[output_column_name] = new_column_value @@ -481,35 +649,34 @@ async def _execute_task_nonstream(self, task: Task): ) return response - except Exception: - logger.exception(f"LLM generation failed for column: {task}") - raise - - async def _stream_sequent_execution(self) -> AsyncGenerator[str, None]: - """ - Executes tasks sequentially in a streaming manner. - """ - llm_tasks = [task for task in self.tasks if not task.is_embed] - for task in llm_tasks: - async for chunk in self._execute_task_stream(task): - yield chunk - yield "data: [DONE]\n\n" - self._run_embed_tasks() - self._write_to_table() - - async def _nonstream_sequent_execution(self) -> GenTableChatCompletionChunks: - """ - Executes tasks sequentially in a non-streaming manner. - """ - responses = {} - llm_tasks = [task for task in self.tasks if not task.is_embed] - - for task in llm_tasks: - responses[task.output_column_name] = await self._execute_task_nonstream(task) - - self._run_embed_tasks() - self._write_to_table() - return GenTableChatCompletionChunks(columns=responses, row_id=self.row_id) + except Exception as e: + error_chunk = ChatCompletionChunk( + id=self.request.state.id, + object="gen_table.completion.chunk", + created=int(time()), + model="", + usage=None, + choices=[ + ChatCompletionChoiceDelta( + message=ChatEntry.assistant( + f'[ERROR] 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.", + ) + if isinstance(e, ResourceNotFoundError) + else ChatEntry.assistant(f"[ERROR] {e}"), + index=0, + finish_reason="error", + ) + ], + ) + new_column_value = error_chunk.text + self.column_dict[output_column_name] = new_column_value + self.regen_column_dict[output_column_name] = new_column_value + self._log_exception( + e, f'Error generating completion for column "{output_column_name}": {e}' + ) + return error_chunk def _setup_dependencies(self) -> None: """ @@ -536,12 +703,10 @@ def _setup_dependencies(self) -> None: ``` """ self.llm_tasks = { - task.output_column_name: task for task in self.tasks if not task.is_embed + task.output_column_name: task for task in self.tasks if task.type == "chat" } self.dependencies = { - task.output_column_name: self._extract_upstream_columns( - task.body["messages"][-1]["content"] - ) + task.output_column_name: self._extract_upstream_columns(task.body.messages[-1].content) for task in self.llm_tasks.values() } logger.debug(f"Initial dependencies: {self.dependencies}") @@ -550,11 +715,51 @@ def _setup_dependencies(self) -> None: key for key in self.column_dict.keys() if key not in self.llm_tasks.keys() ] - async def _nonstream_concurrent_execution(self) -> GenTableChatCompletionChunks: + def _mark_regen_columns(self) -> None: + """ + Tag columns to regenerate based on the chosen regeneration strategy. + """ + if self.is_row_add: + return + + if self.body.regen_strategy == RegenStrategy.RUN_ALL: + self.tag_regen_columns = self.llm_tasks.keys() + + elif self.body.regen_strategy == RegenStrategy.RUN_SELECTED: + self.tag_regen_columns.append(self.body.output_column_id) + + elif self.body.regen_strategy in ( + RegenStrategy.RUN_BEFORE, + RegenStrategy.RUN_AFTER, + ): + if self.body.regen_strategy == RegenStrategy.RUN_BEFORE: + for column_name in self.column_dict.keys(): + 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(): + if column_name == self.body.output_column_id: + reached_column = True + if reached_column: + self.tag_regen_columns.append(column_name) + + else: + 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 + ] + + async def _nonstream_concurrent_execution(self) -> tuple[GenTableChatCompletionChunks, dict]: """ Executes tasks in concurrent in a non-streaming manner, respecting dependencies. """ self._setup_dependencies() + self._mark_regen_columns() completed = set(self.input_column_names) tasks_in_progress = set() @@ -564,8 +769,8 @@ async def execute_task(task_name): task = self.llm_tasks[task_name] try: responses[task_name] = await self._execute_task_nonstream(task) - except Exception: - logger.exception(f"Error executing task: {task}") + except Exception as e: + self._log_exception(e, f'Error executing task "{task_name}": {e}') finally: completed.add(task_name) tasks_in_progress.remove(task_name) @@ -582,22 +787,26 @@ async def execute_task(task_name): # Process tasks in batches for i in range(0, len(ready_tasks), self.cols_batch_size): batched_tasks = ready_tasks[i : i + self.cols_batch_size] - exetasks = [execute_task(task) for task in batched_tasks] + exe_tasks = [execute_task(task) for task in batched_tasks] tasks_in_progress.update(batched_tasks) - await asyncio.gather(*exetasks) + await asyncio.gather(*exe_tasks) completed.update(batched_tasks) tasks_in_progress.difference_update(batched_tasks) # Post-execution steps - self._run_embed_tasks() - self._write_to_table() - return GenTableChatCompletionChunks(columns=responses, row_id=self.row_id) + await self._run_embed_tasks() + + return ( + GenTableChatCompletionChunks(columns=responses, row_id=self.row_id), + self.column_dict if self.is_row_add else self.regen_column_dict, + ) async def _stream_concurrent_execution(self) -> AsyncGenerator[str, None]: """ - Executes tasks in concurrent in a streaming manner, respecting dependencies. + Executes tasks concurrently in a streaming manner, yielding individual chunks. """ self._setup_dependencies() + self._mark_regen_columns() completed = set(self.input_column_names) queue = asyncio.Queue() @@ -608,8 +817,8 @@ async def execute_task(task_name): try: async for chunk in self._execute_task_stream(task): await queue.put((task_name, chunk)) - except Exception: - logger.exception(f"Error executing task: {task}") + except Exception as e: + self._log_exception(e, f'Error executing task "{task_name}": {e}') finally: completed.add(task_name) await queue.put((task_name, None)) @@ -639,8 +848,11 @@ async def execute_task(task_name): continue yield chunk - yield "data: [DONE]\n\n" - # Post-execution steps - self._run_embed_tasks() - self._write_to_table() + await self._run_embed_tasks() + + # Return the complete row for accumulation in MultiRowsGenExecutor + yield self.column_dict if self.is_row_add else (self.body.row_id, self.regen_column_dict) + + # Signal the end of stream for a row + yield "data: [DONE]\n\n" diff --git a/services/api/src/owl/db/gen_table.py b/services/api/src/owl/db/gen_table.py index 8f2938c..223540f 100644 --- a/services/api/src/owl/db/gen_table.py +++ b/services/api/src/owl/db/gen_table.py @@ -1,14 +1,16 @@ +import os import re from collections import defaultdict from copy import deepcopy -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from os import listdir -from os.path import exists +from os.path import exists, isdir, join from pathlib import Path -from shutil import copytree, move +from shutil import copytree, ignore_patterns, move, rmtree from time import perf_counter, sleep -from typing import Any, Type +from typing import Any, BinaryIO, Literal, override +import filetype import lancedb import numpy as np import pandas as pd @@ -16,18 +18,53 @@ from filelock import FileLock from lancedb.table import LanceTable from loguru import logger -from sqlalchemy import desc -from sqlmodel import Session, SQLModel, select +from sqlmodel import Session, select from tenacity import retry, stop_after_attempt, wait_exponential from typing_extensions import Self -from uuid_extensions import uuid7str +from jamaibase.exceptions import ( + BadInputError, + ResourceExistsError, + ResourceNotFoundError, + TableSchemaFixedError, + make_validation_error, +) from jamaibase.utils.io import df_to_csv, json_loads -from owl import protocol as p -from owl.configs.manager import CONFIG -from owl.db import create_sql_tables, create_sqlite_engine +from owl.configs.manager import ENV_CONFIG +from owl.db import cached_text, create_sql_tables, create_sqlite_engine from owl.models import CloudEmbedder, CloudReranker -from owl.utils.exceptions import ResourceExistsError, ResourceNotFoundError, TableSchemaFixedError +from owl.protocol import ( + COL_NAME_PATTERN, + GEN_CONFIG_VAR_PATTERN, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + ChatEntry, + ChatTableSchemaCreate, + ChatThread, + Chunk, + ColName, + ColumnDtype, + ColumnSchema, + CSVDelimiter, + EmbedGenConfig, + GenConfig, + GenConfigUpdateRequest, + GenTableOrderBy, + KnowledgeTableSchemaCreate, + ModelListConfig, + PositiveInt, + RowAddData, + RowUpdateData, + TableMeta, + TableMetaResponse, + TableName, + TableSchema, + TableSchemaCreate, + TableSQLModel, + TableType, +) +from owl.utils import datetime_now_iso, uuid7_draft2_str +from owl.utils.io import open_uri_sync, upload_file_to_s3 # Lance only support null values in string column _py_type_default = { @@ -38,11 +75,11 @@ "float16": 0.0, "bool": False, "str": "''", + "file": "''", } class GenerativeTable: - model_class: Type[SQLModel] = p.TableSQLModel """ Smart Table class. @@ -50,28 +87,68 @@ class GenerativeTable: Otherwise, consider passing in `read_consistency_interval=timedelta(seconds=0)` during init. """ + FIXED_COLUMN_IDS = [] + def __init__( self, db_url: str, vector_db_url: str, + *, read_consistency_interval: timedelta | None = None, create_sqlite_tables: bool = True, ) -> None: - self.lance_db = lancedb.connect( - vector_db_url, read_consistency_interval=read_consistency_interval - ) + self.db_url = Path(db_url) + self.vector_db_url = Path(vector_db_url) + self.read_consistency_interval = read_consistency_interval self.sqlite_engine = create_sqlite_engine(db_url) + if create_sqlite_tables: + create_sql_tables(TableSQLModel, self.sqlite_engine) + self._lance_db = None + self.organization_id = db_url.split(os.sep)[-3] + self.project_id = db_url.split(os.sep)[-2] # Thread and process safe lock self.lock_name_prefix = vector_db_url self.locks = {} - self.read_consistency_interval = read_consistency_interval - if create_sqlite_tables: - create_sql_tables(p.TableSQLModel, self.sqlite_engine) - self.db_url = Path(db_url) - self.vector_db_url = Path(vector_db_url) - def lock(self, name: str, timeout: int = 5): - name = f"{self.lock_name_prefix}/{name}.lock" + @classmethod + def from_ids( + cls, + org_id: str, + project_id: str, + table_type: str | TableType, + ) -> Self: + lance_path = join(ENV_CONFIG.owl_db_dir, org_id, project_id, table_type) + sqlite_path = f"sqlite:///{lance_path}.db" + read_consistency_interval = timedelta(seconds=0) + if table_type == TableType.ACTION: + return ActionTable( + sqlite_path, + lance_path, + read_consistency_interval=read_consistency_interval, + ) + elif table_type == TableType.KNOWLEDGE: + return KnowledgeTable( + sqlite_path, + lance_path, + read_consistency_interval=read_consistency_interval, + ) + else: + return ChatTable( + sqlite_path, + lance_path, + read_consistency_interval=read_consistency_interval, + ) + + @property + def lance_db(self): + if self._lance_db is None: + self._lance_db = lancedb.connect( + self.vector_db_url, read_consistency_interval=self.read_consistency_interval + ) + return self._lance_db + + def lock(self, name: str, timeout: int = ENV_CONFIG.owl_table_lock_timeout_sec): + name = join(self.lock_name_prefix, f"{name}.lock") self.locks[name] = self.locks.get(name, FileLock(name, timeout=timeout)) return self.locks[name] @@ -84,7 +161,7 @@ def has_info_col_names(self, names: list[str]) -> bool: def has_state_col_names(self, names: list[str]) -> bool: return any(n.endswith("_") for n in names) - def num_output_columns(self, meta: p.TableMeta) -> int: + def num_output_columns(self, meta: TableMeta) -> int: return len( [col for col in meta.cols if col["gen_config"] is not None and col["vlen"] == 0] ) @@ -92,41 +169,42 @@ def num_output_columns(self, meta: p.TableMeta) -> int: def _create_table( self, session: Session, - schema: p.TableSchemaCreate, + schema: TableSchemaCreate, remove_state_cols: bool = False, add_info_state_cols: bool = True, - ) -> tuple[LanceTable, p.TableMeta]: + ) -> tuple[LanceTable, TableMeta]: table_id = schema.id - meta = session.get(p.TableMeta, table_id) - if meta is None: - # Add metadata - if add_info_state_cols: - schema = schema.add_info_cols().add_state_cols() - meta = p.TableMeta( - id=table_id, - parent_id=None, - cols=[c.model_dump() for c in schema.cols], - ) - session.add(meta) - session.commit() - session.refresh(meta) - # Create Lance table - table = self.lance_db.create_table(table_id, schema=schema.pyarrow) - else: - raise ResourceExistsError(f"Table '{table_id}' already exists.") - if remove_state_cols: - meta.cols = [c for c in meta.cols if not c["id"].endswith("_")] + with self.lock(table_id): + meta = session.get(TableMeta, table_id) + if meta is None: + # Add metadata + if add_info_state_cols: + schema = schema.add_info_cols().add_state_cols() + meta = TableMeta( + id=table_id, + parent_id=None, + cols=[c.model_dump() for c in schema.cols], + ) + session.add(meta) + session.commit() + session.refresh(meta) + # Create Lance table + table = self.lance_db.create_table(table_id, schema=schema.pyarrow) + else: + raise ResourceExistsError(f'Table "{table_id}" already exists.') + if remove_state_cols: + meta.cols = [c for c in meta.cols if not c["id"].endswith("_")] return table, meta def create_table( self, session: Session, - schema: p.TableSchemaCreate, + schema: TableSchemaCreate, remove_state_cols: bool = False, add_info_state_cols: bool = True, - ) -> tuple[LanceTable, p.TableMeta]: - if not isinstance(schema, p.TableSchema): - raise TypeError("`schema` must be an instance of `p.TableSchema`.") + ) -> tuple[LanceTable, TableMeta]: + if not isinstance(schema, TableSchema): + raise TypeError("`schema` must be an instance of `TableSchema`.") return self._create_table( session=session, schema=schema, @@ -134,22 +212,22 @@ def create_table( add_info_state_cols=add_info_state_cols, ) - def open_table(self, table_id: p.TableName) -> LanceTable: + def open_table(self, table_id: TableName) -> LanceTable: try: table = self.lance_db.open_table(table_id) - except FileNotFoundError: - raise ResourceNotFoundError(f"Table '{table_id}' cannot be found.") + except FileNotFoundError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e return table def open_meta( self, session: Session, - table_id: p.TableName, + table_id: TableName, remove_state_cols: bool = False, - ) -> p.TableMeta: - meta = session.get(p.TableMeta, table_id) + ) -> TableMeta: + meta = session.get(TableMeta, table_id) if meta is None: - raise ResourceNotFoundError(f"Table '{table_id}' cannot be found.") + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') if remove_state_cols: meta.cols = [c for c in meta.cols if not c["id"].endswith("_")] return meta @@ -157,36 +235,53 @@ def open_meta( def open_table_meta( self, session: Session, - table_id: p.TableName, + table_id: TableName, remove_state_cols: bool = False, - ) -> tuple[LanceTable, p.TableMeta]: + ) -> tuple[LanceTable, TableMeta]: meta = self.open_meta(session, table_id, remove_state_cols=remove_state_cols) table = self.open_table(table_id) return table, meta - def _list_meta_selection(self, parent_id: str | None = None): - del parent_id - return select(p.TableMeta) - def list_meta( self, session: Session, + *, offset: int, limit: int, - remove_state_cols: bool = False, parent_id: str | None = None, - ) -> tuple[list[p.TableMetaResponse], int]: - selection = self._list_meta_selection(parent_id) + search_query: str = "", + order_by: str = GenTableOrderBy.UPDATED_AT, + order_descending: bool = True, + count_rows: bool = False, + remove_state_cols: bool = False, + ) -> tuple[list[TableMetaResponse], int]: + t0 = perf_counter() + search_query = search_query.strip() + if parent_id is None: + selection = select(TableMeta) + elif parent_id.lower() == "_agent_": + selection = select(TableMeta).where(TableMeta.parent_id == None) # noqa + elif parent_id.lower() == "_chat_": + selection = select(TableMeta).where(TableMeta.parent_id != None) # noqa + else: + selection = select(TableMeta).where(TableMeta.parent_id == parent_id) + if search_query != "": + selection = selection.where(TableMeta.id.ilike(f"%{search_query}%")) total = len(session.exec(selection).all()) metas = session.exec( - selection.order_by(desc(p.TableMeta.updated_at)).offset(offset).limit(limit) + selection.order_by( + cached_text(f"{order_by} DESC" if order_descending else f"{order_by} ASC") + ) + .offset(offset) + .limit(limit) ).all() + t1 = perf_counter() meta_responses = [] for meta in metas: try: - num_rows = self.count_rows(meta.id) + num_rows = self.count_rows(meta.id) if count_rows else -1 except Exception: - table_path = f"{self.vector_db_url}/{meta.id}.lance" + table_path = self.vector_db_url / f"{meta.id}.lance" if exists(table_path) and len(listdir(table_path)) > 0: logger.error(f"Lance table FAILED to be opened: {meta.id}") else: @@ -194,32 +289,47 @@ def list_meta( session.delete(meta) continue meta_responses.append( - p.TableMetaResponse.model_validate(meta, update={"num_rows": num_rows}) + TableMetaResponse.model_validate(meta, update={"num_rows": num_rows}) ) + t2 = perf_counter() + num_metas = len(metas) + time_per_table = (t2 - t1) * 1000 / num_metas if num_metas > 0 else 0.0 + logger.info( + ( + f"Listing {num_metas:,d} table metas took: {(t2 - t0) * 1000:.2f} ms " + f"SQLite query = {(t1 - t0) * 1000:.2f} ms " + f"Count rows (total) = {(t2 - t1) * 1000:.2f} ms " + f"Count rows (per table) = {time_per_table:.2f} ms" + ) + ) if remove_state_cols: for meta in meta_responses: meta.cols = [c for c in meta.cols if not c.id.endswith("_")] return meta_responses, total - def count_rows(self, table_id: p.TableName, filter: str | None = None) -> int: + def count_rows(self, table_id: TableName, filter: str | None = None) -> int: return self.open_table(table_id).count_rows(filter) def duplicate_table( self, session: Session, - table_id_src: p.TableName, - table_id_dst: p.TableName, + table_id_src: TableName, + table_id_dst: TableName, include_data: bool = True, - deploy: bool = False, - ) -> p.TableMeta: - dst_meta = session.get(p.TableMeta, table_id_dst) + create_as_child: bool = False, + ) -> TableMeta: + dst_meta = session.get(TableMeta, table_id_dst) if dst_meta is not None: - raise ResourceExistsError(f"Table '{table_id_dst}' already exists.") + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') # Duplicate metadata with self.lock(table_id_src): meta = self.open_meta(session, table_id_src) - new_meta = p.TableMeta.model_validate( - meta, update={"id": table_id_dst, "parent_id": table_id_src if deploy else None} + new_meta = TableMeta.model_validate( + meta, + update={ + "id": table_id_dst, + "parent_id": table_id_src if create_as_child else None, + }, ) session.add(new_meta) session.commit() @@ -229,98 +339,105 @@ def duplicate_table( copytree( self.vector_db_url / f"{table_id_src}.lance", self.vector_db_url / f"{table_id_dst}.lance", + ignore=ignore_patterns("_indices"), ) + with self.create_session() as session: + self.create_indexes(session, table_id_dst, force=True) else: - schema = p.TableSchema.model_validate(new_meta) + schema = TableSchema.model_validate(new_meta) self.lance_db.create_table(table_id_dst, schema=schema.pyarrow) return new_meta def rename_table( self, session: Session, - table_id_src: p.TableName, - table_id_dst: p.TableName, - ) -> p.TableMeta: + table_id_src: TableName, + table_id_dst: TableName, + ) -> TableMeta: # Check - dst_meta = session.get(p.TableMeta, table_id_dst) + dst_meta = session.get(TableMeta, table_id_dst) if dst_meta is not None: - raise ResourceExistsError(f"Table '{table_id_dst}' already exists.") + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') # Rename metadata with self.lock(table_id_src): meta = self.open_meta(session, table_id_src) meta.id = table_id_dst - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) + # Rename all parent IDs + session.exec( + cached_text( + f"UPDATE TableMeta SET parent_id = '{table_id_dst}' WHERE parent_id = '{table_id_src}'" + ) + ) session.commit() session.refresh(meta) # Rename LanceTable - # self.lance_db.rename_table(table_id_src, table_id_dst) + # self.lance_db.rename_table(table_id_src, table_id_dst) # Seems like not implemented move( self.vector_db_url / f"{table_id_src}.lance", self.vector_db_url / f"{table_id_dst}.lance", ) return meta - def delete_table(self, session: Session, table_id: p.TableName) -> None: + def delete_table(self, session: Session, table_id: TableName) -> None: with self.lock(table_id): - # Delete metadata - meta = session.get(p.TableMeta, table_id) - if meta is not None: - session.delete(meta) - session.commit() # Delete LanceTable - delete_ok = False - while not delete_ok: + for _ in range(10): + # Try 10 times try: - self.lance_db.drop_table(table_id, ignore_missing=True) - except OSError: + rmtree(self.vector_db_url / f"{table_id}.lance") + except FileNotFoundError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e + except Exception: # There might be ongoing operations - sleep(1) + sleep(0.5) else: - delete_ok = True - # try: - # rmtree(self.vector_db_url / f"{table_id}.lance") - # except FileNotFoundError: - # pass + break + # Delete metadata + meta = session.get(TableMeta, table_id) + if meta is None: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') + session.delete(meta) + session.commit() return - def update_gen_config( - self, session: Session, updates: p.GenConfigUpdateRequest - ) -> p.TableMeta: + def update_gen_config(self, session: Session, updates: GenConfigUpdateRequest) -> TableMeta: table_id = updates.table_id - meta = session.get(p.TableMeta, table_id) - meta_col_ids = set(c.id for c in meta.cols_schema) + meta = session.get(TableMeta, table_id) + if meta is None: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') + meta_col_ids = set(c["id"] for c in meta.cols) update_col_ids = set(updates.column_map.keys()) if len(update_col_ids - meta_col_ids) > 0: - raise ValueError( - f"Some columns are not found in the table: {update_col_ids - meta_col_ids}" + raise make_validation_error( + ValueError( + f"Some columns are not found in the table: {update_col_ids - meta_col_ids}" + ), + loc=("body", "column_map"), ) cols = deepcopy(meta.cols) for c in cols: # Validate and update gen_config = updates.column_map.get(c["id"], c["gen_config"]) - if gen_config is not None: - if "embedding_model" in gen_config: - gen_config = p.EmbedGenConfig.model_validate(gen_config).model_dump() - else: - gen_config = p.ChatRequest.model_validate(gen_config).model_dump() - c["gen_config"] = gen_config - meta.cols = cols - p.TableSchema.model_validate(meta) + c["gen_config"] = ( + gen_config.model_dump() if isinstance(gen_config, GenConfig) else gen_config + ) + meta.cols = [c.model_dump() for c in TableSchema(id=meta.id, cols=cols).cols] session.add(meta) session.commit() session.refresh(meta) return meta def add_columns( - self, session: Session, schema: p.AddColumnSchema - ) -> tuple[LanceTable, p.TableMeta]: + self, session: Session, schema: TableSchemaCreate + ) -> tuple[LanceTable, TableMeta]: """ Adds one or more input or output column. Args: session (Session): SQLAlchemy session. - schema (AddColumnSchema): Schema of the columns to be added. + schema (TableSchemaCreate): Schema of the columns to be added. Raises: ResourceNotFoundError: If the table is not found. @@ -330,17 +447,22 @@ def add_columns( table (LanceTable): Lance table. meta (TableMeta): Table metadata. """ - if not isinstance(schema, p.TableSchema): - raise TypeError("`schema` must be an instance of `p.TableSchema`.") + if not isinstance(schema, TableSchema): + raise TypeError("`schema` must be an instance of `TableSchema`.") table_id = schema.id # Check meta = self.open_meta(session, table_id) schema = schema.add_state_cols() cols = meta.cols_schema + schema.cols if len(set(c.id for c in cols)) != len(cols): - raise ValueError("Schema and table contain overlapping column names.") - meta.cols = [c.model_dump() for c in cols] - p.TableSchema.model_validate(meta) + raise make_validation_error( + ValueError("Schema and table contain overlapping column names."), + loc=("body", "cols"), + ) + meta.cols = [ + c.model_dump() + for c in TableSchema(id=meta.id, cols=[c.model_dump() for c in cols]).cols + ] with self.lock(table_id): # Add columns to LanceDB @@ -365,7 +487,7 @@ def add_columns( table.merge(pa_table, left_on="ID") # Add Table Metadata - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() session.refresh(meta) @@ -374,9 +496,9 @@ def add_columns( def _drop_columns( self, session: Session, - table_id: p.TableName, - col_names: list[p.ColName], - ) -> tuple[LanceTable, p.TableMeta]: + table_id: TableName, + col_names: list[ColName], + ) -> tuple[LanceTable, TableMeta]: """ NOTE: This is broken until lance issue is resolved https://github.com/lancedb/lancedb/pull/1227 @@ -400,9 +522,15 @@ def _drop_columns( if not isinstance(col_names, list): raise TypeError("`col_names` must be a list.") if self.has_state_col_names(col_names): - raise ValueError("Cannot drop state columns.") + raise make_validation_error( + ValueError("Cannot drop state columns."), + loc=("body", "column_names"), + ) if self.has_info_col_names(col_names): - raise ValueError("Cannot drop 'ID' or 'Updated at'.") + raise make_validation_error( + ValueError('Cannot drop "ID" or "Updated at".'), + loc=("body", "column_names"), + ) with self.lock(table_id): meta = self.open_meta(session, table_id) col_names += [f"{n}_" for n in col_names] @@ -410,27 +538,31 @@ def _drop_columns( try: table.drop_columns(col_names) except ValueError as e: - raise ResourceNotFoundError(e) + raise ResourceNotFoundError(e) from e meta.cols = [c.model_dump() for c in meta.cols_schema if c.id not in col_names] - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() session.refresh(meta) return table, meta + # Look at this instead !! def drop_columns( - self, session: Session, table_id: p.TableName, col_names: list[p.ColName] - ) -> tuple[LanceTable, p.TableMeta]: + self, + session: Session, + table_id: TableName, + column_names: list[ColName], + ) -> tuple[LanceTable, TableMeta]: """ Drops one or more input or output column. Args: session (Session): SQLAlchemy session. table_id (str): Table ID. - col_names (list[str]): List of column ID to drop. + column_names (list[str]): List of column ID to drop. Raises: - TypeError: If `col_names` is not a list. + TypeError: If `column_names` is not a list. ResourceNotFoundError: If the table is not found. ResourceNotFoundError: If any of the columns is not found. @@ -438,22 +570,28 @@ def drop_columns( table (LanceTable): Lance table. meta (TableMeta): Table metadata. """ - if not isinstance(col_names, list): - raise TypeError("`col_names` must be a list.") - if self.has_state_col_names(col_names): - raise ValueError("Cannot drop state columns.") - if self.has_info_col_names(col_names): - raise ValueError("Cannot drop 'ID' or 'Updated at'.") + if not isinstance(column_names, list): + raise TypeError("`column_names` must be a list.") + if self.has_state_col_names(column_names): + raise make_validation_error( + ValueError("Cannot drop state columns."), + loc=("body", "column_names"), + ) + if self.has_info_col_names(column_names): + raise make_validation_error( + ValueError('Cannot drop "ID" or "Updated at".'), + loc=("body", "column_names"), + ) with self.lock(table_id): # Get table metadata meta = self.open_meta(session, table_id) # Create new table with dropped columns - new_table_id = f"{table_id}_dropped_{uuid7str()}" - col_names += [f"{col_name}_" for col_name in col_names] - new_schema = p.TableSchema( + new_table_id = f"{table_id}_dropped_{uuid7_draft2_str()}" + column_names += [f"{col_name}_" for col_name in column_names] + new_schema = TableSchema( id=new_table_id, - cols=[c for c in meta.cols_schema if c.id not in col_names], + cols=[c for c in meta.cols_schema if c.id not in column_names], ) new_table, new_meta = self._create_table( session, new_schema, add_info_state_cols=False @@ -474,57 +612,95 @@ def drop_columns( return new_table, new_meta def rename_columns( - self, session: Session, table_id: p.TableName, name_map: dict[p.ColName, p.ColName] - ) -> p.TableMeta: - if self.has_state_col_names(name_map.keys()): - raise ValueError("Cannot rename state columns.") - if self.has_info_col_names(name_map.keys()): - raise ValueError("Cannot rename 'ID' or 'Updated at'.") - if not all(re.match(p.COL_NAME_PATTERN, v) for v in name_map.values()): - raise ValueError("`name_map` contains invalid new column names.") + self, + session: Session, + table_id: TableName, + column_map: dict[ColName, ColName], + ) -> TableMeta: + new_col_names = set(column_map.values()) + if self.has_state_col_names(column_map.keys()): + raise make_validation_error( + ValueError("Cannot rename state columns."), + loc=("body", "column_map"), + ) + if self.has_info_col_names(column_map.keys()): + raise make_validation_error( + ValueError('Cannot rename "ID" or "Updated at".'), + loc=("body", "column_map"), + ) + if len(new_col_names) != len(column_map): + raise make_validation_error( + ValueError("`column_map` contains repeated new column names."), + loc=("body", "column_map"), + ) + if not all(re.match(COL_NAME_PATTERN, v) for v in column_map.values()): + raise make_validation_error( + ValueError("`column_map` contains invalid new column names."), + loc=("body", "column_map"), + ) meta = self.open_meta(session, table_id) - table = self.open_table(table_id) - col_names = set(table.schema.names) - not_found = set(name_map.keys()) - col_names + col_names = set(c.id for c in meta.cols_schema) + overlap_col_names = col_names.intersection(new_col_names) + if len(overlap_col_names) > 0: + raise make_validation_error( + ValueError( + ( + "`column_map` contains new column names that " + f"overlap with existing column names: {overlap_col_names}" + ) + ), + loc=("body", "column_map"), + ) + not_found = set(column_map.keys()) - col_names if len(not_found) > 0: raise ResourceNotFoundError(f"Some columns are not found: {list(not_found)}.") # Add state columns - for k in list(name_map.keys()): - name_map[f"{k}_"] = f"{name_map[k]}_" + for k in list(column_map.keys()): + column_map[f"{k}_"] = f"{column_map[k]}_" # Modify metadata cols = [] for col in meta.cols: col = deepcopy(col) _id = col["id"] - col["id"] = name_map.get(_id, _id) - if col["gen_config"] is not None and col["vlen"] == 0: - for message in col["gen_config"]["messages"]: - message["content"] = re.sub( - p.GEN_CONFIG_VAR_PATTERN, - lambda m: f"${{{name_map.get(m.group(1), m.group(1))}}}", - message["content"], + col["id"] = column_map.get(_id, _id) + if ( + col["gen_config"] is not None + and col["gen_config"].get("object", "") == "gen_config.llm" + ): + for k in ("system_prompt", "prompt"): + col["gen_config"][k] = re.sub( + GEN_CONFIG_VAR_PATTERN, + lambda m: f"${{{column_map.get(m.group(1), m.group(1))}}}", + col["gen_config"][k], ) cols.append(col) with self.lock(table_id): meta.cols = cols - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() session.refresh(meta) # Modify LanceTable - alterations = [{"path": k, "name": v} for k, v in name_map.items()] + alterations = [{"path": k, "name": v} for k, v in column_map.items()] + table = self.open_table(table_id) table.alter_columns(*alterations) return meta def reorder_columns( - self, session: Session, table_id: p.TableName, columns: list[p.ColName] - ) -> p.TableMeta: - if self.has_state_col_names(columns): - raise ValueError("Cannot reorder state columns.") - if self.has_info_col_names(columns): - raise ValueError("Cannot reorder 'ID' or 'Updated at'.") + self, + session: Session, + table_id: TableName, + column_names: list[ColName], + ) -> TableMeta: + column_names_low = [n.lower() for n in column_names] + if len(set(column_names_low)) != len(column_names): + raise BadInputError("Column names must be unique (case-insensitive).") + if self.has_state_col_names(column_names): + raise BadInputError("Cannot reorder state columns.") + if self.has_info_col_names(column_names) and column_names_low[:2] != ["id", "updated at"]: + raise BadInputError('Cannot reorder "ID" or "Updated at".') order = ["ID", "Updated at"] - for c in columns: + for c in column_names: order += [c, f"{c}_"] meta = self.open_meta(session, table_id) try: @@ -532,54 +708,48 @@ def reorder_columns( c.model_dump() for c in sorted(meta.cols_schema, key=lambda x: order.index(x.id)) ] except ValueError as e: - raise ResourceNotFoundError(e) + raise ResourceNotFoundError(e) from e + meta.updated_at = datetime_now_iso() # Validate changes - p.TableSchemaCreate.model_validate( - dict( - id=meta.id, - cols=[ - c - for c in meta.cols - if not ( - c["id"].endswith("_") - or c["id"].lower() in ("id", "updated at") - or c["dtype"].startswith("float") - ) - ], - ) - ) - meta.updated_at = datetime.now(timezone.utc).isoformat() + TableSchema.model_validate(meta.model_dump()) session.add(meta) session.commit() session.refresh(meta) return meta - def add_rows( + async def add_rows( self, session: Session, - table_id: p.TableName, - data: list[dict[p.ColName, Any]], + table_id: TableName, + data: list[dict[ColName, Any]], errors: list[list[str]] | None = None, ) -> Self: if not isinstance(data, list): raise TypeError("`data` must be a list.") with self.lock(table_id): - table = self.open_table(table_id) - meta = self.open_meta(session, table_id) - # Validate data and generate ID & timestamp under write lock - data = p.RowAddData(table_meta=meta, data=data, errors=errors).set_id().data - # Add to Lance Table - table.add(data) - # Update metadata - meta.updated_at = datetime.now(timezone.utc).isoformat() - session.add(meta) - session.commit() + with await lancedb.connect_async( + uri=self.vector_db_url, + read_consistency_interval=self.read_consistency_interval, + ) as db: + try: + with await db.open_table(table_id) as table: + meta = self.open_meta(session, table_id) + # Validate data and generate ID & timestamp under write lock + data = RowAddData(table_meta=meta, data=data, errors=errors).set_id().data + # Add to Lance Table + await table.add(data) + # Update metadata + meta.updated_at = datetime_now_iso() + session.add(meta) + session.commit() + except FileNotFoundError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e return self def update_rows( self, session: Session, - table_id: p.TableName, + table_id: TableName, *, where: str | None, values: dict[str, Any], @@ -589,7 +759,7 @@ def update_rows( table = self.open_table(table_id) meta = self.open_meta(session, table_id) # Validate data and generate ID & timestamp under write lock - values = p.RowUpdateData( + values = RowUpdateData( table_meta=meta, data=[values], errors=None if errors is None else [errors], @@ -599,13 +769,13 @@ def update_rows( values = {k: v for k, v in values.items() if not isinstance(v, np.ndarray)} table.update(where=where, values=values) # Update metadata - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() return self + @staticmethod def _filter_col( - self, col_id: str, columns: list[str] | None = None, remove_state_cols: bool = False, @@ -637,7 +807,7 @@ def _process_cell( # Process precision if float_decimals > 0 and isinstance(data, float): data = round(data, float_decimals) - elif vec_decimals > 0 and isinstance(data, list) and isinstance(data[0], float): + elif vec_decimals > 0 and isinstance(data, list): data = np.asarray(data).round(vec_decimals).tolist() state = row[state_id] if state == "" or state is None: @@ -655,9 +825,10 @@ def _process_cell( else: return data + @staticmethod def _post_process_rows( - self, rows: list[dict[str, Any]], + *, columns: list[str] | None = None, convert_null: bool = True, remove_state_cols: bool = False, @@ -673,7 +844,7 @@ def _post_process_rows( ] rows = [ { - k: self._process_cell( + k: GenerativeTable._process_cell( row, k, convert_null=convert_null, @@ -682,6 +853,7 @@ def _post_process_rows( vec_decimals=vec_decimals, ) for k in row + if not (vec_decimals < 0 and isinstance(row[k], list)) } for row in rows ] @@ -689,16 +861,95 @@ def _post_process_rows( { k: v for k, v in row.items() - if self._filter_col(k, columns=columns, remove_state_cols=remove_state_cols) + if GenerativeTable._filter_col( + k, columns=columns, remove_state_cols=remove_state_cols + ) } for row in rows ] return rows + @staticmethod + def _post_process_rows_df( + df: pd.DataFrame, + *, + columns: list[str] | None = None, + convert_null: bool = True, + remove_state_cols: bool = False, + json_safe: bool = False, + include_original: bool = False, + float_decimals: int = 0, + vec_decimals: int = 0, + ): + dt_columns = set(df.select_dtypes(include="datetimetz").columns.to_list()) + float_columns = set(df.select_dtypes(include="float").columns.to_list()) + + def _process_row(row: pd.Series): + for col_id in row.index.to_list(): + state_id = f"{col_id}_" + try: + data = row[col_id] + except KeyError: + # The column is dropped + continue + if json_safe and col_id in dt_columns: + row[col_id] = data.isoformat() + if state_id not in row: + # Some columns like "ID", "Updated at" do not have state cols + # State cols also do not have their state cols + continue + state = row[state_id] + # Process precision + if isinstance(data, np.ndarray): + if vec_decimals < 0: + row.drop([col_id, state_id], inplace=True) + continue + elif vec_decimals == 0: + if json_safe: + data = data.tolist() + elif vec_decimals > 0: + if json_safe: + data = [round(d, vec_decimals) for d in data.tolist()] + else: + data = data.round(vec_decimals) + elif float_decimals > 0 and col_id in float_columns: + row[col_id] = round(data, float_decimals) + # Convert null + if state == "" or state is None: + data = None if convert_null else data + row[col_id] = {"value": data} if include_original else data + continue + state = json_loads(state) + data = None if convert_null and state["is_null"] else data + if include_original: + ret = {"value": data} + if "original" in state: + ret["original"] = state["original"] + # if "error" in state: + # ret["error"] = state["error"] + row[col_id] = ret + else: + row[col_id] = data + return row + + df = df.apply(_process_row, axis=1) + # Remove hybrid search distance and match score columns + keep_cols = [c for c in df.columns.to_list() if not c.startswith("_")] + # Remove state columns + if remove_state_cols: + keep_cols = [c for c in keep_cols if not c.endswith("_")] + # Column selection + if columns is not None: + columns = {"id", "updated at"} | {c.lower() for c in columns} + keep_cols = [c for c in keep_cols if c.lower() in columns] + df = df[keep_cols] + return df + def get_row( self, - table_id: p.TableName, + table_id: TableName, row_id: str, + *, columns: list[str] | None = None, convert_null: bool = True, remove_state_cols: bool = False, @@ -710,7 +961,7 @@ def get_row( table = self.open_table(table_id) rows = table.search().where(where=f"`ID` = '{row_id}'", prefilter=True).to_list() if len(rows) == 0: - raise ResourceNotFoundError("Row with the specified ID cannot be found.") + raise ResourceNotFoundError(f'Row "{row_id}" is not found.') elif len(rows) > 1: logger.warning(f"More than one row in table {table_id} with ID {row_id}") rows = self._post_process_rows( @@ -725,33 +976,79 @@ def get_row( ) return rows[0] + @staticmethod + def _count_rows_query(table_name: str) -> str: + return f"SELECT COUNT(*) FROM '{table_name}'" + + @staticmethod + def _list_rows_query( + table_name: str, + *, + sort_by: str, + sort_order: Literal["ASC", "DESC"] = "ASC", + starting_after: str | int | None = None, + id_column: str = "ID", + offset: int = 0, + limit: int = 100, + ) -> str: + if starting_after is None: + query = ( + f"""SELECT * FROM '{table_name}' ORDER BY "{sort_by}" {sort_order} LIMIT {limit}""" + ) + else: + query = f""" + WITH sorted_rows AS ( + SELECT + *, + ROW_NUMBER() OVER ( + ORDER BY "{sort_by}" {sort_order} + ) AS _row_num + FROM '{table_name}' + ), + cursor_position AS ( + SELECT _row_num + FROM sorted_rows + WHERE "{id_column}" = '{starting_after}' + ) + SELECT sr.* + FROM sorted_rows sr, cursor_position cp + WHERE sr._row_num > cp._row_num OR cp._row_num IS NULL + ORDER BY sr._row_num + OFFSET {offset} + LIMIT {limit} + """ + return query + def list_rows( self, - table_id: p.TableName, + table_id: TableName, *, offset: int = 0, limit: int = 1_000, - columns: list[p.ColName] | None = None, + columns: list[ColName] | None = None, convert_null: bool = True, remove_state_cols: bool = False, json_safe: bool = False, - sort_descending: bool = True, include_original: bool = False, float_decimals: int = 0, vec_decimals: int = 0, + order_descending: bool = True, ) -> tuple[list[dict[str, Any]], int]: - table = self.open_table(table_id) - total = self.count_rows(table_id) + try: + table = self.open_table(table_id) + total = self.count_rows(table_id) + except ValueError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e offset, limit = max(0, offset), max(1, limit) if offset >= total: rows = [] else: if offset + limit > total: limit = total - offset - if sort_descending: + if order_descending: offset = max(0, total - limit - offset) rows = table._dataset.to_table(offset=offset, limit=limit).to_pylist() - rows = sorted(rows, reverse=sort_descending, key=lambda r: r["ID"]) + rows = sorted(rows, reverse=order_descending, key=lambda r: r["ID"]) rows = self._post_process_rows( rows, columns=columns, @@ -764,13 +1061,13 @@ def list_rows( ) return rows, total - def delete_row(self, session: Session, table_id: p.TableName, row_id: str) -> Self: + def delete_row(self, session: Session, table_id: TableName, row_id: str) -> Self: with self.lock(table_id): table = self.open_table(table_id) table.delete(f"`ID` = '{row_id}'") # Update metadata meta = self.open_meta(session, table_id) - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() return self @@ -778,7 +1075,7 @@ def delete_row(self, session: Session, table_id: p.TableName, row_id: str) -> Se def delete_rows( self, session: Session, - table_id: p.TableName, + table_id: TableName, row_ids: list[str] | None = None, where: str | None = "", ) -> Self: @@ -792,23 +1089,108 @@ def delete_rows( table.delete(where) # Update metadata meta = self.open_meta(session, table_id) - meta.updated_at = datetime.now(timezone.utc).isoformat() + meta.updated_at = datetime_now_iso() session.add(meta) session.commit() return self + @staticmethod + def _interpolate_column( + prompt: str, + column_dtypes: dict[str, str], + column_contents: dict[str, Any], + ) -> str: + """ + Replaces / interpolates column references in the prompt with their contents. + + Args: + prompt (str): The original prompt with zero or more column references. + + Returns: + new_prompt (str): The prompt with column references replaced. + """ + + def replace_match(match): + col_id = match.group(1) + try: + if column_dtypes[col_id] == "file": + return "" + return str(column_contents[col_id]) + except KeyError as e: + raise KeyError(f'Referenced column "{col_id}" is not found.') from e + + return re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, prompt) + + def get_conversation_thread( + self, + table_id: TableName, + column_id: str, + row_id: str = "", + include: bool = True, + ) -> ChatThread: + with self.create_session() as session: + meta = self.open_meta(session, table_id) + cols = {c.id: c for c in meta.cols_schema} + chat_cols = {c.id: c for c in cols.values() if getattr(c.gen_config, "multi_turn", False)} + try: + gen_config = chat_cols[column_id].gen_config + except KeyError as e: + raise ResourceNotFoundError( + f'Column "{column_id}" is not found. Available chat columns: {list(chat_cols.keys())}' + ) from e + ref_col_ids = re.findall(GEN_CONFIG_VAR_PATTERN, gen_config.prompt) + rows, _ = self.list_rows( + table_id=table_id, + offset=0, + limit=1_000_000, + columns=ref_col_ids + [column_id], + convert_null=True, + remove_state_cols=True, + json_safe=True, + float_decimals=0, + vec_decimals=0, + order_descending=False, + ) + if row_id: + row_ids = [r["ID"] for r in rows] + try: + rows = rows[: row_ids.index(row_id) + (1 if include else 0)] + except ValueError as e: + raise make_validation_error( + ValueError(f'Row ID "{row_id}" is not found in table "{table_id}".'), + loc=("body", "row_id"), + ) from e + thread = [] + if gen_config.system_prompt: + thread.append(ChatEntry.system(gen_config.system_prompt)) + for row in rows: + thread.append( + ChatEntry.user( + self._interpolate_column( + gen_config.prompt, + {c.id: c.dtype for c in cols.values()}, + row, + ) + ) + ) + thread.append(ChatEntry.assistant(row[column_id])) + return ChatThread(thread=thread) + def export_csv( self, - table_id: p.TableName, - columns: list[p.ColName] | None = None, + table_id: TableName, + columns: list[ColName] | None = None, file_path: str = "", - delimiter: p.CSVDelimiter | str = p.CSVDelimiter.comma, + delimiter: CSVDelimiter | str = ",", ) -> pd.DataFrame: if isinstance(delimiter, str): try: - delimiter = p.CSVDelimiter[delimiter] - except KeyError: - raise ValueError(f'Delimiter can only be "," or "\\t", received: {delimiter}') + delimiter = CSVDelimiter[delimiter] + except KeyError as e: + raise make_validation_error( + ValueError(f'Delimiter can only be "," or "\\t", received: {delimiter}'), + loc=("body", "delimiter"), + ) from e rows, total = self.list_rows( table_id=table_id, offset=0, @@ -817,10 +1199,10 @@ def export_csv( convert_null=True, remove_state_cols=True, json_safe=True, - sort_descending=False, include_original=False, float_decimals=0, vec_decimals=0, + order_descending=False, ) df = pd.DataFrame.from_dict(rows, orient="columns", dtype=None, columns=None) if len(df) != total: @@ -829,9 +1211,9 @@ def export_csv( ) if file_path == "": return df - if delimiter == p.CSVDelimiter.comma and not file_path.endswith(".csv"): + if delimiter == CSVDelimiter.COMMA and not file_path.endswith(".csv"): file_path = f"{file_path}.csv" - elif delimiter == p.CSVDelimiter.tab and not file_path.endswith(".tsv"): + elif delimiter == CSVDelimiter.TAB and not file_path.endswith(".tsv"): file_path = f"{file_path}.tsv" df_to_csv(df, file_path, sep=delimiter.value) return df @@ -839,53 +1221,131 @@ def export_csv( def dump_parquet( self, session: Session, - table_id: p.TableName, - file_path: str, + table_id: TableName, + dest: str | BinaryIO, + *, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", ) -> None: from pyarrow.parquet import write_table with self.lock(table_id): meta = self.open_meta(session, table_id) table = self.open_table(table_id) + # Convert into Arrow Table pa_table = table._dataset.to_table(offset=None, limit=None) - pa_meta = {} if pa_table.schema.metadata is None else pa_table.schema.metadata + # Add file data into Arrow Table + file_col_ids = [col.id for col in meta.cols_schema if col.dtype == "file"] + for col_id in file_col_ids: + file_bytes = [] + for uri in pa_table.column(col_id).to_pylist(): + if not uri: + file_bytes.append(b"") + continue + with open_uri_sync(uri) as f: + file_bytes.append(f.read()) + # Append byte column + pa_table = pa_table.append_column( + pa.field(f"{col_id}__", pa.binary()), [file_bytes] + ) + # Add Generative Table metadata + pa_meta = pa_table.schema.metadata or {} pa_table = pa_table.replace_schema_metadata( {"gen_table_meta": meta.model_dump_json(), **pa_meta} ) - if not file_path.endswith(".parquet"): - file_path = f"{file_path}.parquet" - write_table(pa_table, file_path) - - def import_parquet( + if isinstance(dest, str): + if isdir(dest): + dest = join(dest, f"{table_id}.parquet") + elif not dest.endswith(".parquet"): + dest = f"{dest}.parquet" + write_table(pa_table, dest, compression=compression) + + async def import_parquet( self, session: Session, - file_path: str, - table_id_dst: str, - ) -> tuple[LanceTable, p.TableMeta]: + source: str | BinaryIO, + table_id_dst: str | None, + ) -> tuple[LanceTable, TableMeta]: from pyarrow.parquet import read_table - pa_table = read_table(file_path) - meta = p.TableMeta.model_validate_json(pa_table.schema.metadata[b"gen_table_meta"]) - meta.id = table_id_dst - session.add(meta) - session.commit() - session.refresh(meta) - table = self.lance_db.create_table(meta.id, data=pa_table, schema=pa_table.schema) + # Check metadata + pa_table = read_table(source, columns=None, use_threads=False, memory_map=True) + try: + meta = TableMeta.model_validate_json(pa_table.schema.metadata[b"gen_table_meta"]) + except KeyError as e: + raise BadInputError("Missing table metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid table metadata in the Parquet file.") from e + # Check for required columns + required_columns = set(self.FIXED_COLUMN_IDS) + meta_cols = {c.id for c in meta.cols_schema} + if len(required_columns - meta_cols) > 0: + raise BadInputError( + f"Missing columns in table metadata: {list(required_columns - meta_cols)}." + ) + # Table ID must not exist + if table_id_dst is None: + table_id_dst = meta.id + with self.lock(table_id_dst): + 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"] + for col_id in file_col_ids: + new_uris = [] + for old_uri, content in zip( + pa_table.column(col_id).to_pylist(), + pa_table.column(f"{col_id}__").to_pylist(), + strict=True, + ): + if len(content) == 0: + new_uris.append(None) + continue + mime_type = filetype.guess(content).mime + if mime_type is None: + mime_type = "application/octet-stream" + uri = await upload_file_to_s3( + self.organization_id, + self.project_id, + content, + mime_type, + old_uri.split("/")[-1], + ) + new_uris.append(uri) + # Drop old columns + pa_table = pa_table.drop_columns([col_id, f"{col_id}__"]) + # Append new column + pa_table = pa_table.append_column(pa.field(col_id, pa.utf8()), [new_uris]) + # Import Generative Table + meta.id = table_id_dst + session.add(meta) + session.commit() + session.refresh(meta) + table = self.lance_db.create_table(meta.id, data=pa_table, schema=pa_table.schema) + self.create_indexes( + session=session, + table_id=meta.id, + force=True, + ) + session.refresh(meta) return table, meta - @retry(wait=wait_exponential(multiplier=1, min=2, max=10), stop=stop_after_attempt(4)) + @retry( + wait=wait_exponential(multiplier=1, min=2, max=10), + stop=stop_after_attempt(4), + reraise=True, + ) def _run_query( self, session: Session, - table_id: p.TableName, + table_id: TableName, table: LanceTable, query: np.ndarray | list | str | None = None, column_name: str | None = None, where: str | None = None, - limit: p.PositiveInt = 10_000, + limit: PositiveInt = 10_000, metric: str = "cosine", - nprobes: p.PositiveInt = 50, - refine_factor: p.PositiveInt = 20, + nprobes: PositiveInt = 50, + refine_factor: PositiveInt = 20, ) -> list[dict[str, Any]]: is_vector = isinstance(query, (list, np.ndarray)) if query is None: @@ -894,6 +1354,7 @@ def _run_query( elif is_vector: query_type = "vector" elif isinstance(query, str): + query = re.sub(r"[\W\s]", " ", query.lower()) query_type = "fts" else: raise TypeError("`query` must be one of [np.ndarray | list | str | None].") @@ -902,9 +1363,6 @@ def _run_query( vector_column_name=column_name, query_type=query_type, ) - if query_type == "fts": - # Prevent term query - query_builder = query_builder.phrase_query() if is_vector: query_builder = ( query_builder.metric(metric).nprobes(nprobes).refine_factor(refine_factor) @@ -914,10 +1372,16 @@ def _run_query( try: results = query_builder.limit(limit).to_list() except ValueError: - logger.exception("Failed to perform search !!! Attempting index rebuild") + logger.exception( + f'Failed to perform search on table "{table_id}" !!! Attempting index rebuild ...' + ) index_ok = self.create_indexes(session, table_id, force=True) - if not index_ok: - logger.error("Failed to reindex !!!") + if index_ok: + logger.warning(f'Reindex table "{table_id}" OK, retrying search ...') + else: + logger.error( + f'Failed to reindex table "{table_id}" !!! Retrying search anyway ...' + ) results = query_builder.limit(limit).to_list() return results @@ -943,20 +1407,20 @@ def _reciprocal_rank_fusion( sorted_rrf = sorted(rrf_scores.values(), key=lambda x: x["rrf_score"], reverse=True) return sorted_rrf - def fts_search( + def regex_search( self, session: Session, - table_id: p.TableName, + table_id: TableName, query: str | None, *, - where: str | None = None, - columns: list[p.ColName] | None = None, + columns: list[ColName] | None = None, convert_null: bool = True, remove_state_cols: bool = False, json_safe: bool = False, include_original: bool = False, float_decimals: int = 0, vec_decimals: int = 0, + order_descending: bool = True, ) -> list[dict[str, Any]]: table, meta = self.open_table_meta(session, table_id) if self.count_rows(table_id) == 0: @@ -964,19 +1428,14 @@ def fts_search( if not isinstance(query, str): raise TypeError(f"`query` must be string, received: {type(query)}") rows = [] - # 2024-06 (BUG?): lance fts works on all indexed cols at once (can't specify the col to be searched) - # Thus no need to loop through indexed col one by one - if len(self.fts_cols(meta)) > 0: - t1 = perf_counter() - rows = self._run_query( - session=session, - table_id=table_id, - table=table, - query=re.sub(r"[^\w\s]", "", query).replace("\n", " "), - where=where, - limit=1_000_000, - ) - logger.info(f"FTS search timings: {perf_counter() - t1:,.3f}") + t0 = perf_counter() + cols = self.fts_cols(meta) + for col in cols: + rows += table.search().where(f"regexp_match(`{col.id}`, '{query}')").to_list() + logger.info(f"Regex search timings ({len(cols)} cols): {perf_counter() - t0:,.3f}") + # De-duplicate and sort + rows = {r["ID"]: r for r in rows}.values() + rows = sorted(rows, reverse=order_descending, key=lambda r: r["ID"]) rows = self._post_process_rows( rows, columns=columns, @@ -989,18 +1448,20 @@ def fts_search( ) return rows - def hybrid_search( + async def hybrid_search( self, session: Session, - table_id: p.TableName, + table_id: TableName, query: str | None, *, where: str | None = None, - limit: p.PositiveInt = 100, - columns: list[p.ColName] | None = None, + limit: PositiveInt = 100, + columns: list[ColName] | None = None, metric: str = "cosine", - nprobes: p.PositiveInt = 50, - refine_factor: p.PositiveInt = 20, + nprobes: PositiveInt = 50, + refine_factor: PositiveInt = 20, + embedder: CloudEmbedder | None = None, + reranker: CloudReranker | None = None, reranking_model: str | None = None, convert_null: bool = True, remove_state_cols: bool = False, @@ -1008,14 +1469,6 @@ def hybrid_search( include_original: bool = False, float_decimals: int = 0, vec_decimals: int = 0, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", ) -> list[dict[str, Any]]: if not (isinstance(limit, int) and limit > 0): # TODO: Currently LanceDB is bugged, limit in theory can be None or 0 or negative @@ -1050,7 +1503,7 @@ def hybrid_search( session=session, table_id=table_id, table=table, - query=re.sub(r"[^\w\s]", "", query).replace("\n", " "), + query=query, # column_name=c.id, where=where, limit=limit, @@ -1058,29 +1511,19 @@ def hybrid_search( nprobes=nprobes, refine_factor=refine_factor, ) - timings[f"FTS:"] = perf_counter() - t1 + timings["FTS:"] = perf_counter() - t1 search_results.append(fts_result) for c in self.embedding_cols(meta): t1 = perf_counter() - gen_config = p.EmbedGenConfig.model_validate(c.gen_config) - embedder = CloudEmbedder( - embedder_name=gen_config.embedding_model, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, + embedding = await embedder.embed_queries( + c.gen_config.embedding_model, texts=[query] ) - embedding = embedder.embed_queries(texts=[query]) # TODO: Benchmark this # Searching using float16 seems to be faster on float32 and float16 indexes # 2024-05-21, lance 0.6.13, pylance 0.10.12 embedding = np.asarray(embedding.data[0].embedding, dtype=np.float16) embedding = embedding / np.linalg.norm(embedding) - timings[f"Embed ({gen_config.embedding_model}): {c.id}"] = perf_counter() - t1 + timings[f"Embed ({c.gen_config.embedding_model}): {c.id}"] = perf_counter() - t1 t1 = perf_counter() sub_rows = self._run_query( session=session, @@ -1099,26 +1542,16 @@ def hybrid_search( timings[f"VS: {c.id}"] = perf_counter() - t1 # list of search results with rrf_score rows = self._reciprocal_rank_fusion(search_results) - if reranking_model is None: + if reranker is None: # No longer do a linear combination for hybrid scores, use RRF score instead. _scores = [(f'(RRF_score={r["rrf_score"]:.1f}, ') for r in rows] logger.info(f"Hybrid search scores: {_scores}") else: t1 = perf_counter() - reranker = CloudReranker( - reranker_name=reranking_model, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - chunks = reranker.rerank_chunks( + chunks = await reranker.rerank_chunks( + reranking_model, chunks=[ - p.Chunk( + Chunk( text="" if row["Text"] is None else row["Text"], title="" if row["Title"] is None else row["Title"], ) @@ -1145,63 +1578,65 @@ def hybrid_search( logger.info(f"Hybrid search timings: {timings}") return rows - def scalar_cols(self, meta: p.TableMeta) -> list[p.ColumnSchema]: + def scalar_cols(self, meta: TableMeta) -> list[ColumnSchema]: return [c for c in meta.cols_schema if c.id.lower() in ("id", "updated at")] - def embedding_cols(self, meta: p.TableMeta) -> list[p.ColumnSchema]: + def embedding_cols(self, meta: TableMeta) -> list[ColumnSchema]: return [c for c in meta.cols_schema if c.vlen > 0] - def fts_cols(self, meta: p.TableMeta) -> list[p.ColumnSchema]: - return [ - c for c in meta.cols_schema if c.dtype == p.DtypeEnum.str_ and c.id.lower() != "id" - ] + def fts_cols(self, meta: TableMeta) -> list[ColumnSchema]: + return [c for c in meta.cols_schema if c.dtype == ColumnDtype.STR and c.id.lower() != "id"] def create_fts_index( self, session: Session, - table_id: p.TableName, + table_id: TableName, *, force: bool = False, ) -> bool: table, meta = self.open_table_meta(session, table_id) fts_cols = [c.id for c in self.fts_cols(meta)] - if not force: - # Maybe can skip reindexing - if meta.indexed_at_fts is not None and meta.indexed_at_fts > meta.updated_at: - return False - num_rows = table.count_rows() - if num_rows == 0: - return False - if len(fts_cols) == 0: - return False - index_datetime = datetime.now(timezone.utc).isoformat() - with self.lock(table_id): - table.create_fts_index(fts_cols, replace=True) - # Update metadata - meta.indexed_at_fts = index_datetime - session.add(meta) - session.commit() + # Maybe can skip reindexing + if ( + (not force) + and meta.indexed_at_fts is not None + and meta.indexed_at_fts > meta.updated_at + ): + return False + num_rows = table.count_rows() + if num_rows == 0: + return False + if len(fts_cols) == 0: + return False + index_datetime = datetime_now_iso() + table.create_fts_index(fts_cols, replace=True) + # Update metadata + meta.indexed_at_fts = index_datetime + session.add(meta) + session.commit() return True def create_scalar_index( self, session: Session, - table_id: p.TableName, + table_id: TableName, *, force: bool = False, ) -> bool: table, meta = self.open_table_meta(session, table_id) - if not force: - # Maybe can skip reindexing - if meta.indexed_at_sca is not None and meta.indexed_at_sca > meta.updated_at: - return False - num_rows = table.count_rows() - if num_rows == 0: - return False - index_datetime = datetime.now(timezone.utc).isoformat() + # Maybe can skip reindexing + if ( + (not force) + and meta.indexed_at_sca is not None + and meta.indexed_at_sca > meta.updated_at + ): + return False + num_rows = table.count_rows() + if num_rows == 0: + return False + index_datetime = datetime_now_iso() for c in self.scalar_cols(meta): - with self.lock(table_id): - table.create_scalar_index(c.id, replace=True) + table.create_scalar_index(c.id, replace=True) # Update metadata meta.indexed_at_sca = index_datetime session.add(meta) @@ -1211,7 +1646,7 @@ def create_scalar_index( def create_vector_index( self, session: Session, - table_id: p.TableName, + table_id: TableName, force: bool = False, *, metric: str = "cosine", @@ -1254,14 +1689,17 @@ def create_vector_index( reindexed (bool): Whether the reindex operation is performed. """ table, meta = self.open_table_meta(session, table_id) - if not force: - # Maybe can skip reindexing - if meta.indexed_at_vec is not None and meta.indexed_at_vec > meta.updated_at: - return False - num_rows = table.count_rows() - if num_rows < 10_000: - return False - index_datetime = datetime.now(timezone.utc).isoformat() + # Maybe can skip reindexing + if ( + (not force) + and meta.indexed_at_vec is not None + and meta.indexed_at_vec > meta.updated_at + ): + return False + num_rows = table.count_rows() + if num_rows < 10_000: + return False + index_datetime = datetime_now_iso() num_partitions = num_partitions or max(1, int(np.sqrt(num_rows))) for c in self.embedding_cols(meta): if num_sub_vectors is None: @@ -1271,16 +1709,15 @@ def create_vector_index( num_sub_vectors = c.vlen // 8 else: num_sub_vectors = 1 - with self.lock(table_id): - table.create_index( - vector_column_name=c.id, - replace=True, - metric=metric, - num_partitions=num_partitions, - num_sub_vectors=num_sub_vectors, - accelerator=accelerator, - index_cache_size=index_cache_size, - ) + table.create_index( + vector_column_name=c.id, + replace=True, + metric=metric, + num_partitions=num_partitions, + num_sub_vectors=num_sub_vectors, + accelerator=accelerator, + index_cache_size=index_cache_size, + ) # Update metadata meta.indexed_at_vec = index_datetime session.add(meta) @@ -1290,11 +1727,11 @@ def create_vector_index( def create_indexes( self, session: Session, - table_id: p.TableName, + table_id: TableName, *, force: bool = False, ) -> bool: - """_summary_ + """Creates scalar, vector, FTS indexes. Args: session (Session): SQLAlchemy session. @@ -1323,13 +1760,13 @@ def create_indexes( num_rows = self.open_table(table_id).count_rows() logger.info( ( - f"Index creation for table '{table_id}' with {num_rows:,d} rows took {t3-t0:,.2f} s " + f'Index creation for table "{table_id}" with {num_rows:,d} rows took {t3-t0:,.2f} s ' f"({timings})." ) ) return len(timings) > 0 - def compact_files(self, table_id: p.TableName, *args, **kwargs) -> bool: + def compact_files(self, table_id: TableName, *args, **kwargs) -> bool: with self.lock(table_id): table = self.open_table(table_id) num_rows = table.count_rows() @@ -1340,7 +1777,7 @@ def compact_files(self, table_id: p.TableName, *args, **kwargs) -> bool: def cleanup_old_versions( self, - table_id: p.TableName, + table_id: TableName, older_than: timedelta | None = None, delete_unverified: bool = False, ) -> bool: @@ -1352,58 +1789,71 @@ def cleanup_old_versions( table.cleanup_old_versions(older_than=older_than, delete_unverified=delete_unverified) return True + def update_title(self, session: Session, table_id: TableName, title: str): + meta = self.open_meta(session, table_id) + meta.title = title + session.add(meta) + session.commit() + class ActionTable(GenerativeTable): pass class KnowledgeTable(GenerativeTable): + FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID"] + + @override def create_table( self, session: Session, - schema: p.KnowledgeTableSchemaCreate, + schema: KnowledgeTableSchemaCreate, + model_list: ModelListConfig, remove_state_cols: bool = False, add_info_state_cols: bool = True, - ) -> tuple[LanceTable, p.TableMeta]: - if not isinstance(schema, p.KnowledgeTableSchemaCreate): + ) -> tuple[LanceTable, TableMeta]: + if not isinstance(schema, KnowledgeTableSchemaCreate): raise TypeError("`schema` must be an instance of `KnowledgeTableSchemaCreate`.") - schema = p.TableSchema( + schema = TableSchema( id=schema.id, cols=[ - p.ColumnSchema(id="Title", dtype=p.DtypeEnum.str_), - p.ColumnSchema( + ColumnSchema(id="Title", dtype=ColumnDtype.STR), + ColumnSchema( id="Title Embed", # TODO: Benchmark this # float32 index creation is 2x faster than float16 # float32 vector search is 10% to 50% faster than float16 # 2024-05-21, lance 0.6.13, pylance 0.10.12 # https://github.com/lancedb/lancedb/issues/1312 - dtype=p.DtypeEnum.float32, - vlen=CONFIG.get_embed_model_info(schema.embedding_model).embedding_size, - gen_config={ - "embedding_model": schema.embedding_model, - "source_column": "Title", - }, + dtype=ColumnDtype.FLOAT32, + vlen=model_list.get_embed_model_info(schema.embedding_model).embedding_size, + gen_config=EmbedGenConfig( + embedding_model=schema.embedding_model, + source_column="Title", + ), ), - p.ColumnSchema(id="Text", dtype=p.DtypeEnum.str_), - p.ColumnSchema( + ColumnSchema(id="Text", dtype=ColumnDtype.STR), + ColumnSchema( id="Text Embed", - dtype=p.DtypeEnum.float32, - vlen=CONFIG.get_embed_model_info(schema.embedding_model).embedding_size, - gen_config={ - "embedding_model": schema.embedding_model, - "source_column": "Text", - }, + dtype=ColumnDtype.FLOAT32, + vlen=model_list.get_embed_model_info(schema.embedding_model).embedding_size, + gen_config=EmbedGenConfig( + embedding_model=schema.embedding_model, + source_column="Text", + ), ), - p.ColumnSchema(id="File ID", dtype=p.DtypeEnum.str_), + ColumnSchema(id="File ID", dtype=ColumnDtype.STR), ] + schema.cols, ) return super().create_table(session, schema, remove_state_cols, add_info_state_cols) + @override def update_gen_config( - self, session: Session, updates: p.GenConfigUpdateRequest - ) -> p.TableMeta: + self, + session: Session, + updates: GenConfigUpdateRequest, + ) -> TableMeta: with self.create_session() as session: table, meta = self.open_table_meta(session, updates.table_id) num_rows = table.count_rows() @@ -1415,9 +1865,12 @@ def update_gen_config( ) return super().update_gen_config(session, updates) + @override def add_columns( - self, session: Session, schema: p.AddKnowledgeColumnSchema - ) -> tuple[LanceTable, p.TableMeta]: + self, + session: Session, + schema: AddKnowledgeColumnSchema, + ) -> tuple[LanceTable, TableMeta]: """ Adds one or more input or output column. @@ -1433,15 +1886,19 @@ def add_columns( table (LanceTable): Lance table. meta (TableMeta): Table metadata. """ - if not isinstance(schema, p.AddKnowledgeColumnSchema): + if not isinstance(schema, AddKnowledgeColumnSchema): raise TypeError("`schema` must be an instance of `AddKnowledgeColumnSchema`.") # if self.open_table(schema.id).count_rows() > 0: # raise TableSchemaFixedError("Knowledge Table contains data, cannot add columns.") return super().add_columns(session, schema) + @override def drop_columns( - self, session: Session, table_id: p.TableName, col_names: list[p.ColName] - ) -> tuple[LanceTable, p.TableMeta]: + self, + session: Session, + table_id: TableName, + col_names: list[ColName], + ) -> tuple[LanceTable, TableMeta]: """ Drops one or more input or output column. @@ -1459,29 +1916,30 @@ def drop_columns( table (LanceTable): Lance table. meta (TableMeta): Table metadata. """ - if sum(n.lower() in ("text", "text embed", "title", "title embed") for n in col_names) > 0: - raise TableSchemaFixedError( - "Cannot drop 'Text', 'Text Embed', 'Title' or 'Title Embed'." - ) - # if self.open_table(table_id).count_rows() > 0: - # raise TableSchemaFixedError("Knowledge Table contains data, cannot drop columns.") + fixed_col_ids = [i.lower() for i in self.FIXED_COLUMN_IDS] + if sum(n.lower() in fixed_col_ids for n in col_names) > 0: + cols = ", ".join(f'"{c}"' for c in self.FIXED_COLUMN_IDS) + raise TableSchemaFixedError(f"Cannot drop {cols}.") return super().drop_columns(session, table_id, col_names) + @override def rename_columns( - self, session: Session, table_id: p.TableName, name_map: dict[p.ColName, p.ColName] - ) -> p.TableMeta: - if sum(n.lower() in ("text", "text embed", "title", "title embed") for n in name_map) > 0: - raise TableSchemaFixedError( - "Cannot rename 'Text', 'Text Embed', 'Title' or 'Title Embed'." - ) - # if self.open_table(table_id).count_rows() > 0: - # raise TableSchemaFixedError("Knowledge Table contains data, cannot rename columns.") - return super().rename_columns(session, table_id, name_map) - + self, + session: Session, + table_id: TableName, + column_map: dict[ColName, ColName], + ) -> TableMeta: + fixed_col_ids = [i.lower() for i in self.FIXED_COLUMN_IDS] + if sum(n.lower() in fixed_col_ids for n in column_map) > 0: + cols = ", ".join(f'"{c}"' for c in self.FIXED_COLUMN_IDS) + raise TableSchemaFixedError(f"Cannot rename {cols}.") + return super().rename_columns(session, table_id, column_map) + + @override def update_rows( self, session: Session, - table_id: p.TableName, + table_id: TableName, where: str | None = None, *, values: dict | None = None, @@ -1496,26 +1954,24 @@ def update_rows( class ChatTable(GenerativeTable): + FIXED_COLUMN_IDS = ["User", "AI"] + + @override def create_table( self, session: Session, - schema: p.ChatTableSchemaCreate, + schema: ChatTableSchemaCreate, remove_state_cols: bool = False, add_info_state_cols: bool = True, - ) -> tuple[LanceTable, p.TableMeta]: - if not isinstance(schema, p.ChatTableSchemaCreate): + ) -> tuple[LanceTable, TableMeta]: + if not isinstance(schema, ChatTableSchemaCreate): raise TypeError("`schema` must be an instance of `ChatTableSchemaCreate`.") return super().create_table(session, schema, remove_state_cols, add_info_state_cols) - def update_title(self, session: Session, table_id: p.TableName, title: str): - meta = self.open_meta(session, table_id) - meta.title = title - session.add(meta) - session.commit() - + @override def add_columns( - self, session: Session, schema: p.AddChatColumnSchema - ) -> tuple[LanceTable, p.TableMeta]: + self, session: Session, schema: AddChatColumnSchema + ) -> tuple[LanceTable, TableMeta]: """ Adds one or more input or output column. @@ -1531,17 +1987,21 @@ def add_columns( table (LanceTable): Lance table. meta (TableMeta): Table metadata. """ - if not isinstance(schema, p.AddChatColumnSchema): - raise TypeError("`schema` must be an instance of `p.AddChatColumnSchema`.") + if not isinstance(schema, AddChatColumnSchema): + raise TypeError("`schema` must be an instance of `AddChatColumnSchema`.") with self.create_session() as session: meta = self.open_meta(session, schema.id) if meta.parent_id is not None: raise TableSchemaFixedError("Unable to add columns to a conversation table.") return super().add_columns(session, schema) + @override def drop_columns( - self, session: Session, table_id: p.TableName, col_names: list[p.ColName] - ) -> tuple[LanceTable, p.TableMeta]: + self, + session: Session, + table_id: TableName, + col_names: list[ColName], + ) -> tuple[LanceTable, TableMeta]: """ Drops one or more input or output column. @@ -1560,81 +2020,24 @@ def drop_columns( meta (TableMeta): Table metadata. """ if sum(n.lower() in ("user", "ai") for n in col_names) > 0: - raise ValueError("Cannot drop 'User' or 'AI'.") + raise make_validation_error( + ValueError('Cannot drop "User" or "AI".'), + loc=("body", "column_names"), + ) with self.create_session() as session: meta = self.open_meta(session, table_id) if meta.parent_id is not None: raise TableSchemaFixedError("Unable to drop columns from a conversation table.") return super().drop_columns(session, table_id, col_names) - def update_gen_config( - self, session: Session, updates: p.GenConfigUpdateRequest - ) -> p.TableMeta: - # with self.create_session() as session: - # meta = self.open_meta(session, updates.table_id) - # if meta.parent_id is not None: - # raise TableSchemaFixedError( - # "Unable to update generation config of a conversation table." - # ) - return super().update_gen_config(session, updates) - + @override def rename_columns( - self, session: Session, table_id: p.TableName, name_map: dict[p.ColName, p.ColName] - ) -> p.TableMeta: - if sum(n.lower() in ("user", "ai") for n in name_map) > 0: - raise TableSchemaFixedError("Cannot rename 'User' or 'AI'.") + self, session: Session, table_id: TableName, column_map: dict[ColName, ColName] + ) -> TableMeta: + if sum(n.lower() in ("user", "ai") for n in column_map) > 0: + raise TableSchemaFixedError('Cannot rename "User" or "AI".') with self.create_session() as session: meta = self.open_meta(session, table_id) if meta.parent_id is not None: raise TableSchemaFixedError("Unable to rename columns of a conversation table.") - return super().rename_columns(session, table_id, name_map) - - def _list_meta_selection(self, parent_id: str | None = None): - if parent_id is None: - selection = select(p.TableMeta) - elif parent_id.lower() == "_agent_": - selection = select(p.TableMeta).where(p.TableMeta.parent_id == None) # noqa - elif parent_id.lower() == "_chat_": - selection = select(p.TableMeta).where(p.TableMeta.parent_id != None) # noqa - else: - selection = select(p.TableMeta).where(p.TableMeta.parent_id == parent_id) - return selection - - def get_conversation_thread( - self, - table_id: p.TableName, - row_id: str = "", - include: bool = True, - ) -> p.ChatThread: - rows, _ = self.list_rows( - table_id=table_id, - offset=0, - limit=1_000_000, - columns=None, - convert_null=True, - remove_state_cols=True, - json_safe=True, - sort_descending=False, - float_decimals=0, - vec_decimals=0, - ) - if row_id: - row_ids = [r["ID"] for r in rows] - try: - rows = rows[: row_ids.index(row_id) + (1 if include else 0)] - except ValueError: - raise ValueError(f'Row ID "{row_id}" not found in table "{table_id}".') - with self.create_session() as session: - meta = self.open_meta(session, table_id) - ai_col = [c for c in meta.cols if c["id"] == "AI"][0] - gen_config = p.ChatRequest.model_validate(ai_col["gen_config"]) - - thread = [] - if gen_config.messages[0].role in (p.ChatRole.SYSTEM.value, p.ChatRole.SYSTEM): - thread.append(gen_config.messages[0]) - for row in rows: - if row["User"]: - thread.append(p.ChatEntry.user(content=row["User"])) - if row["AI"]: - thread.append(p.ChatEntry.assistant(content=row["AI"])) - return p.ChatThread(thread=thread) + return super().rename_columns(session, table_id, column_map) diff --git a/services/api/src/owl/db/oss_admin.py b/services/api/src/owl/db/oss_admin.py new file mode 100644 index 0000000..6e6ed84 --- /dev/null +++ b/services/api/src/owl/db/oss_admin.py @@ -0,0 +1,171 @@ +from typing import Any + +from pydantic import BaseModel, Field, model_validator +from sqlmodel import JSON, Column, Relationship +from sqlmodel import Field as sql_Field +from typing_extensions import Self + +from owl.configs.manager import ENV_CONFIG +from owl.db import UserSQLModel +from owl.protocol import ExternalKeys, Name +from owl.utils import datetime_now_iso +from owl.utils.crypt import decrypt, generate_key + + +class _ProjectBase(UserSQLModel): + name: str = sql_Field( + description="Project name.", + ) + organization_id: str = sql_Field( + default="default", + foreign_key="organization.id", + index=True, + description="Organization ID.", + ) + + +class ProjectCreate(_ProjectBase): + name: Name = sql_Field( + description="Project name.", + ) + + +class ProjectUpdate(BaseModel): + id: str + """Project ID.""" + name: Name | None = sql_Field( + default=None, + description="Project name.", + ) + updated_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Project update datetime (ISO 8601 UTC).", + ) + + +class Project(_ProjectBase, table=True): + id: str = sql_Field( + primary_key=True, + default_factory=lambda: generate_key(24, "proj_"), + description="Project ID.", + ) + created_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Project creation datetime (ISO 8601 UTC).", + ) + updated_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Project update datetime (ISO 8601 UTC).", + ) + organization: "Organization" = Relationship(back_populates="projects") + """Organization that this project is associated with.""" + + +class ProjectRead(_ProjectBase): + id: str = sql_Field( + description="Project ID.", + ) + created_at: str = sql_Field( + description="Project creation datetime (ISO 8601 UTC).", + ) + updated_at: str = sql_Field( + description="Project update datetime (ISO 8601 UTC).", + ) + organization: "OrganizationRead" = sql_Field( + description="Organization that this project is associated with.", + ) + + +class _OrganizationBase(UserSQLModel): + id: str = sql_Field( + default=ENV_CONFIG.default_org_id, + primary_key=True, + description="Organization ID.", + ) + name: str = sql_Field( + default="Personal", + description="Organization name.", + ) + external_keys: dict[str, str] = sql_Field( + default={}, + sa_column=Column(JSON), + description="Mapping of service provider to its API key.", + ) + timezone: str | None = sql_Field( + default=None, + description="Timezone specifier.", + ) + models: dict[str, Any] = sql_Field( + default={}, + sa_column=Column(JSON), + description="The organization's custom model list, in addition to the provided default list.", + ) + + @property + def members(self) -> list: + # OSS does not support user accounts + return [] + + +class OrganizationCreate(_OrganizationBase): + name: str = sql_Field( + default="Personal", + description="Organization name.", + ) + + @model_validator(mode="after") + def check_external_keys(self) -> Self: + self.external_keys = ExternalKeys.model_validate(self.external_keys).model_dump() + return self + + +class OrganizationRead(_OrganizationBase): + created_at: str = sql_Field( + description="Organization creation datetime (ISO 8601 UTC).", + ) + updated_at: str = sql_Field( + description="Organization update datetime (ISO 8601 UTC).", + ) + projects: list[Project] | None = sql_Field( + default=None, + description="List of projects.", + ) + + def decrypt(self, key: str) -> Self: + if self.external_keys is not None: + self.external_keys = {k: decrypt(v, key) for k, v in self.external_keys.items()} + return self + + +class Organization(_OrganizationBase, table=True): + created_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Organization creation datetime (ISO 8601 UTC).", + ) + updated_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Organization update datetime (ISO 8601 UTC).", + ) + projects: list[Project] = Relationship(back_populates="organization") + """List of projects.""" + + +class OrganizationUpdate(BaseModel): + id: str + """Organization ID.""" + name: str | None = None + """Organization name.""" + external_keys: dict[str, str] | None = Field( + default=None, + description="Mapping of service provider to its API key.", + ) + timezone: str | None = Field(default=None) + """ + Timezone specifier. + """ + + @model_validator(mode="after") + def check_external_keys(self) -> Self: + if self.external_keys is not None: + self.external_keys = ExternalKeys.model_validate(self.external_keys).model_dump() + return self diff --git a/services/api/src/owl/db/template.py b/services/api/src/owl/db/template.py new file mode 100644 index 0000000..5a3f738 --- /dev/null +++ b/services/api/src/owl/db/template.py @@ -0,0 +1,55 @@ +from sqlmodel import Field as sql_Field +from sqlmodel import MetaData, Relationship, SQLModel + +from owl.protocol import Name +from owl.utils import datetime_now_iso + + +class TemplateSQLModel(SQLModel): + metadata = MetaData() + + +class TagTemplateLink(TemplateSQLModel, table=True): + tag_id: str = sql_Field( + primary_key=True, + foreign_key="tag.id", + description="Tag ID.", + ) + template_id: str = sql_Field( + primary_key=True, + foreign_key="template.id", + description="Template ID.", + ) + + +class Tag(TemplateSQLModel, table=True): + id: str = sql_Field( + primary_key=True, + description="Tag ID.", + ) + templates: list["Template"] = Relationship(back_populates="tags", link_model=TagTemplateLink) + + +class _TemplateBase(TemplateSQLModel): + id: str = sql_Field( + primary_key=True, + description="Template ID.", + ) + name: Name = sql_Field( + description="Template name.", + ) + created_at: str = sql_Field( + default_factory=datetime_now_iso, + description="Template creation datetime (ISO 8601 UTC).", + ) + + +class Template(_TemplateBase, table=True): + tags: list[Tag] = Relationship( + back_populates="templates", + link_model=TagTemplateLink, + ) + + +class TemplateRead(_TemplateBase): + tags: list[Tag] diff --git a/services/api/src/owl/docio.py b/services/api/src/owl/docio.py index d419cd1..af95b61 100644 --- a/services/api/src/owl/docio.py +++ b/services/api/src/owl/docio.py @@ -3,9 +3,6 @@ import httpx from httpx import Timeout from langchain.docstore.document import Document -from loguru import logger - -from owl import protocol HTTP_CLIENT = httpx.Client(transport=httpx.HTTPTransport(retries=3), timeout=Timeout(5 * 60)) diff --git a/services/api/src/owl/entrypoints/api.py b/services/api/src/owl/entrypoints/api.py index 859dda1..72b3e0e 100644 --- a/services/api/src/owl/entrypoints/api.py +++ b/services/api/src/owl/entrypoints/api.py @@ -1,125 +1,116 @@ """ API server. - -```shell -$ python -m owl.entrypoints.api -$ JAMAI_API_BASE=http://localhost:6969/api TZ=Asia/Singapore python -m owl.entrypoints.api -``` """ import os -from asyncio import sleep -from collections import defaultdict -from time import perf_counter +from typing import Any -from fastapi import FastAPI, Request, Response, status -from fastapi.exceptions import RequestValidationError +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse -from filelock import FileLock, Timeout +from filelock import Timeout from loguru import logger -from starlette.background import BackgroundTasks -from uuid_utils import uuid7 - -from owl.configs.manager import CONFIG, ENV_CONFIG -from owl.routers import gen_table, llm -from owl.utils.exceptions import ( +from pydantic import BaseModel +from starlette.exceptions import HTTPException +from starlette.middleware.sessions import SessionMiddleware + +from jamaibase import JamAIAsync +from jamaibase.exceptions import ( + AuthorizationError, + BadInputError, ContextOverflowError, + ExternalAuthError, + ForbiddenError, InsufficientCreditsError, ResourceExistsError, ResourceNotFoundError, + ServerBusyError, TableSchemaFixedError, + UnexpectedError, + UnsupportedMediaTypeError, UpgradeTierError, ) -from owl.utils.logging import ( - replace_logging_handlers, - setup_logger_sinks, - suppress_logging_handlers, +from owl.billing import BillingManager +from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.protocol import COL_NAME_PATTERN, TABLE_NAME_PATTERN, UserAgent +from owl.routers import file, gen_table, llm, org_admin, template +from owl.utils import uuid7_str +from owl.utils.logging import setup_logger_sinks, suppress_logging_handlers +from owl.utils.responses import ( + bad_input_response, + forbidden_response, + internal_server_error_response, + make_request_log_str, + make_response, + resource_exists_response, + resource_not_found_response, + server_busy_response, + unauthorized_response, ) -from owl.utils.openapi import custom_generate_unique_id -from owl.utils.tasks import repeat_every - -try: - from owl.cloud_client import OwlAsync - from owl.routers import cloud_admin -except ImportError as e: - logger.warning( - ( - "Failed to import cloud modules. Ignore this warning if you are using OSS mode. " - f"Exception: {e}" - ) - ) - OwlAsync = None - cloud_admin = None -try: - from owl.cloud_billing import BillingManager -except ImportError as e: - logger.warning( - ( - "Failed to import cloud modules. Ignore this warning if you are using OSS mode. " - f"Exception: {e}" - ) - ) - from owl.billing import BillingManager +if ENV_CONFIG.is_oss: + from owl.routers import oss_admin as admin + + cloud_auth = None +else: + from owl.routers import cloud_admin as admin + from owl.routers import cloud_auth + + +NO_AUTH_ROUTES = {"health", "public", "favicon.ico"} +client = JamAIAsync(token=ENV_CONFIG.service_key_plain, timeout=60.0) +logger.enable("owl") setup_logger_sinks() # We purposely don't intercept uvicorn logs since it is typically not useful # We also don't intercept transformers logs -replace_logging_handlers(["uvicorn.access"], False) -suppress_logging_handlers(["litellm", "openmeter", "azure"], True) +# replace_logging_handlers(["uvicorn.access"], False) +suppress_logging_handlers(["uvicorn", "litellm", "openmeter", "azure"], True) -# Maybe purge Redis data -if ENV_CONFIG.owl_cache_purge: - CONFIG.purge() - -# Cloud client -if OwlAsync is None or ENV_CONFIG.service_key_plain == "": - owl_client = None -else: - owl_client = OwlAsync(api_key=ENV_CONFIG.service_key_plain) app = FastAPI( logger=logger, default_response_class=ORJSONResponse, # Should be faster - openapi_url="/api/openapi.json", - docs_url="/api/docs", - redoc_url="/api/redoc", + openapi_url="/api/public/openapi.json", + docs_url="/api/public/docs", + redoc_url="/api/public/redoc", license_info={ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html", }, servers=[dict(url="https://api.jamaibase.com")], ) -services = { - "llm": (llm.router, ["Large Language Model"], "/api"), - "gen_table": (gen_table.router, ["Generative Table"], "/api"), -} -if cloud_admin is not None: - services["cloud_admin"] = (cloud_admin.router, ["Cloud Admin"], "/api/admin") -if ENV_CONFIG.owl_service != "": - try: - router, tags, prefix = services[ENV_CONFIG.owl_service] - except KeyError: - logger.error( - f"Invalid service '{ENV_CONFIG.owl_service}', choose from: {list(services.keys())}" - ) - raise +services = [ + (admin.router, ["Backend Admin"], "/api"), + (admin.public_router, ["Backend Admin"], "/api"), + (org_admin.router, ["Organization Admin"], "/api/admin/org"), + (template.router, ["Templates"], "/api"), + (template.public_router, ["Templates (Public)"], "/api"), + (llm.router, ["Large Language Model"], "/api"), + (gen_table.router, ["Generative Table"], "/api"), + (file.router, ["File"], "/api"), +] + +# Mount +for router, tags, prefix in services: app.include_router( router, prefix=prefix, tags=tags, - generate_unique_id_function=custom_generate_unique_id, ) -else: - # Mount everything - for service, (router, tags, prefix) in services.items(): - app.include_router( - router, - prefix=prefix, - tags=tags, - generate_unique_id_function=custom_generate_unique_id, - ) +if cloud_auth is not None: + app.include_router( + cloud_auth.router, + prefix="/api", + tags=["OAuth"], + ) + app.add_middleware( + SessionMiddleware, + secret_key=ENV_CONFIG.owl_session_secret_plain, + max_age=60 * 60 * 24 * 7, + https_only=ENV_CONFIG.owl_is_prod, + ) # Permissive CORS app.add_middleware( @@ -136,503 +127,186 @@ async def startup(): # Router lifespan is broken as of fastapi==0.109.0 and starlette==0.35.1 # https://github.com/tiangolo/fastapi/discussions/9664 logger.info(f"Using configuration: {ENV_CONFIG}") - - -@router.on_event("startup") -@repeat_every(seconds=ENV_CONFIG.owl_compute_storage_period_min * 60, wait_first=True) -async def periodic_storage_update(): - if OwlAsync is None: - return - # Only let one worker perform this task - lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_storage_update.lock", blocking=False) - try: - t0 = perf_counter() - with lock: - usages = BillingManager.get_storage_usage() - if usages is None: - return - db_usages, file_usages = usages - num_ok = num_failed = 0 - for org_id in db_usages: - if org_id == "default": - continue - try: - org_info = await owl_client.get_organization(org_id) - manager = BillingManager( - request=None, - openmeter_id=org_info.openmeter_id, - quotas=org_info.quotas, - quota_reset_at=org_info.quota_reset_at, - organization_tier=org_info.tier, - organization_id=org_id, - project_id="", - api_key="", - ) - await manager.process_storage_usage( - db_usage=db_usages[org_id], - file_usage=file_usages[org_id], - last_db_usage=org_info.db_storage_gb, - last_file_usage=org_info.file_storage_gb, - min_wait_mins=max(5.0, ENV_CONFIG.owl_compute_storage_period_min), - ) - num_ok += 1 - except Exception as e: - logger.warning(f"Storage usage update failed for {org_id}: {e}") - num_failed += 1 - t = perf_counter() - t0 - # Hold the lock for a while to block other workers - await sleep(max(0.0, (ENV_CONFIG.owl_compute_storage_period_min * 60 - t) * 0.5)) - logger.info( - ( - f"Periodic storage usage update completed (t={t:,.3f} s, " - f"{num_ok:,d} OK, {num_failed:,d} failed)." + # Maybe purge Redis data + if ENV_CONFIG.owl_cache_purge: + CONFIG.purge() + if ENV_CONFIG.is_oss: + logger.opt(colors=True).info("Launching in OSS mode.") + from sqlalchemy import func + from sqlmodel import Session, select + + from owl.db import MAIN_ENGINE + from owl.db.oss_admin import Organization, Project + + with Session(MAIN_ENGINE) as session: + org = session.get(Organization, ENV_CONFIG.default_org_id) + if org is None: + org = Organization() + session.add(org) + session.commit() + session.refresh(org) + logger.info(f"Default organization created: {org}") + else: + logger.info(f"Default organization found: {org}") + # Default project could have been deleted + # As long as there is at least one project it's ok + project_count = session.exec(select(func.count(Project.id))).one() + if project_count == 0: + project = Project( + id=ENV_CONFIG.default_project_id, + name="Default", + organization_id=org.id, ) - ) - except Timeout: - pass - except Exception: - logger.exception("Periodic storage usage update encountered an error.") - - -NO_AUTH_ROUTES = {"health", "docs", "openapi.json", "favicon.ico"} -ERROR_MESSAGE_URL = "https://cloud.jamaibase.com" -INTERNAL_ERROR_MESSAGE = "Opss sorry we ran into an unexpected error. Please try again later." + session.add(project) + session.commit() + session.refresh(project) + logger.info(f"Default project created: {project}") + else: + logger.info(f"{project_count:,d} projects found.") + else: + logger.opt(colors=True).info("Launching in Cloud mode.") @app.middleware("http") -async def authenticate(request: Request, call_next): +async def log_request(request: Request, call_next): """ - Implement HTTP Bearer Auth. - - Note that despite reports of issues such as: - - https://github.com/encode/starlette/issues/1438 - - https://github.com/encode/starlette/pull/1640 - - The usage of this auth middleware seems to work well with FastAPI BackgroundTasks. - - References: - - https://fastapi.tiangolo.com/tutorial/middleware/ - - https://stackoverflow.com/a/76583417 - - https://stackoverflow.com/a/70052350 - Args: - request (Request): The request. + request (Request): Starlette request object. call_next (Callable): A function that will receive the request, pass it to the path operation, and returns the response generated. Returns: - response (Response): Response of the path operation if auth is successful, otherwise a 401. + response (Response): Response of the path operation. """ - request.state.id = str(uuid7()) + # Set request state + request.state.id = uuid7_str() + request.state.user_agent = UserAgent.from_user_agent_string( + request.headers.get("user-agent", "") + ) + request.state.billing = BillingManager(request=request) + + # OPTIONS are always allowed for CORS preflight: + if request.method == "OPTIONS": + return await call_next(request) # The following paths are always allowed: - if request.method == "GET" and request.url.path.split("/")[-1] in NO_AUTH_ROUTES: + path_components = [p for p in request.url.path.split("/") if p][:2] + if request.method in ("GET", "HEAD") and ( + len(path_components) == 0 or path_components[-1] in NO_AUTH_ROUTES + ): return await call_next(request) - t0 = perf_counter() - # Defaults - project_id, org_id, org_tier = ENV_CONFIG.default_project, ENV_CONFIG.default_org, "free" - token, openmeter_id = "", "default" - quotas, quota_reset_at = defaultdict(lambda: 1.0), "" - - # --- OSS Mode --- # - if ENV_CONFIG.service_key_plain == "": - openai_api_key = ENV_CONFIG.openai_api_key_plain - anthropic_api_key = ENV_CONFIG.anthropic_api_key_plain - gemini_api_key = ENV_CONFIG.gemini_api_key_plain - cohere_api_key = ENV_CONFIG.cohere_api_key_plain - groq_api_key = ENV_CONFIG.groq_api_key_plain - together_api_key = ENV_CONFIG.together_api_key_plain - jina_api_key = ENV_CONFIG.jina_api_key_plain - voyage_api_key = ENV_CONFIG.voyage_api_key_plain - - # --- Cloud Mode --- # - else: - if owl_client is None: - raise SystemError("Cloud-only module is missing.") - # Parse auth header, check scheme and token - auth = request.headers.get("Authorization", "").split("Bearer ") - if len(auth) < 2 or auth[1] == "": - return ORJSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "object": "error", - "error": "unauthorized", - "message": ( - "You didn't provide an API key. " - "You need to provide your API key in an Authorization header using Bearer auth " - "(i.e. Authorization: Bearer API_KEY. " - f"You can obtain an API key from {ERROR_MESSAGE_URL}" - ), - "detail": f"No API key provided. Header: {request.headers}.", - "request_id": request.state.id, - "exception": "PermissionError", - }, - ) - token = auth[1] - # Admin API only accepts service key - if "api/admin" in request.url.path: - if token != ENV_CONFIG.service_key_plain: - _err_mssg = f"Incorrect service key provided: {token}." - return ORJSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "object": "error", - "error": "unauthorized", - "message": _err_mssg, - "detail": _err_mssg, - "request_id": request.state.id, - "exception": "PermissionError", - }, - ) - # Admin API does not require project ID - external_keys = {} - # Access non-admin APIs - else: - project_id = request.headers.get("X-PROJECT-ID", "").strip() - if project_id == "": - _err_mssg = f"Project not found: {project_id}." - return ORJSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - "object": "error", - "error": "resource_not_found", - "message": _err_mssg, - "detail": _err_mssg, - "request_id": request.state.id, - "exception": "ResourceNotFoundError", - }, - ) - if token == ENV_CONFIG.service_key_plain: - # Service key auth - try: - project_info = await owl_client.get_project(project_id) - org_info = project_info.organization - org_id, external_keys = org_info.id, org_info.external_keys - except (RuntimeError, ResourceNotFoundError): - _err_mssg = f"Project not found: {project_id}." - return ORJSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - "object": "error", - "error": "resource_not_found", - "message": _err_mssg, - "detail": _err_mssg, - "request_id": request.state.id, - "exception": "ResourceNotFoundError", - }, - ) - except Exception as e: - logger.exception(f"Encountered an error while fetching project: {project_id}") - return ORJSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "object": "error", - "error": "unexpected_error", - "message": INTERNAL_ERROR_MESSAGE, - "detail": str(e), - "request_id": request.state.id, - "exception": e.__class__.__name__, - }, - ) - else: - # API key auth - try: - org_info = await owl_client.get_organization(token) - except (RuntimeError, ResourceNotFoundError): - _err_mssg = f"Incorrect API key provided: {token}." - return ORJSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "object": "error", - "error": "unauthorized", - "message": f"{_err_mssg} You can find your API key at {ERROR_MESSAGE_URL}.", - "detail": _err_mssg, - "request_id": request.state.id, - "exception": "PermissionError", - }, - ) - except Exception as e: - logger.exception( - f"Encountered an error while fetching organization using API key: {token}" - ) - return ORJSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "object": "error", - "error": "unexpected_error", - "message": INTERNAL_ERROR_MESSAGE, - "detail": str(e), - "request_id": request.state.id, - "exception": e.__class__.__name__, - }, - ) - if not org_info.active: - return ORJSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "object": "error", - "error": "upgrade_tier", - "message": "Please activate your organization.", - "detail": f"Organization not active. Org data: {org_info}", - "request_id": request.state.id, - "exception": "UpgradeTierError", - }, - ) - org_project_ids = set(p.id for p in org_info.projects) - if project_id not in org_project_ids: - _err_mssg = f"Project not found: {project_id}." - return ORJSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - "object": "error", - "error": "resource_not_found", - "message": _err_mssg, - "detail": f"{_err_mssg} Org data: {org_info}", - "request_id": request.state.id, - "exception": "ResourceNotFoundError", - }, - ) - org_id, external_keys = org_info.id, org_info.external_keys - openmeter_id = org_info.openmeter_id - org_tier = org_info.tier - quotas = org_info.quotas - quota_reset_at = org_info.quota_reset_at - if openmeter_id is None: - logger.warning( - f"{request.state.id} - Organization {org_id} does not have OpenMeter ID." - ) - openai_api_key = external_keys.get("openai_api_key", "") - anthropic_api_key = external_keys.get("anthropic_api_key", "") - gemini_api_key = external_keys.get("gemini_api_key", "") - cohere_api_key = external_keys.get("cohere_api_key", "") - groq_api_key = external_keys.get("groq_api_key", "") - together_api_key = external_keys.get("together_api_key", "") - jina_api_key = external_keys.get("jina_api_key", "") - voyage_api_key = external_keys.get("voyage_api_key", "") - - # --- Set request state and headers --- # - request.state.org_id = org_id - request.state.project_id = project_id - request.state.billing_manager = BillingManager( - request=request, - openmeter_id=openmeter_id, - quotas=quotas, - quota_reset_at=quota_reset_at, - organization_tier=org_tier, - organization_id=org_id, - project_id=project_id, - api_key=token, - ) - # Add API keys into header - headers = dict(request.scope["headers"]) - if openai_api_key: - headers[b"openai-api-key"] = openai_api_key.encode() - if anthropic_api_key: - headers[b"anthropic-api-key"] = anthropic_api_key.encode() - if gemini_api_key: - headers[b"gemini-api-key"] = gemini_api_key.encode() - if cohere_api_key: - headers[b"cohere-api-key"] = cohere_api_key.encode() - if groq_api_key: - headers[b"groq-api-key"] = groq_api_key.encode() - if together_api_key: - headers[b"together-api-key"] = together_api_key.encode() - if jina_api_key: - headers[b"jina-api-key"] = jina_api_key.encode() - if voyage_api_key: - headers[b"voyage-api-key"] = voyage_api_key.encode() - request.scope["headers"] = [(k, v) for k, v in headers.items()] # --- Call request --- # - t1 = perf_counter() response = await call_next(request) - t2 = perf_counter() - - # --- Send events --- # - tasks = BackgroundTasks() - tasks.add_task( - request.state.billing_manager.process_all, - auth_latency_ms=(t1 - t0) * 1e3, - request_latency_ms=(t2 - t1) * 1e3, - content_length_gb=float(response.headers.get("content-length", 0)) / (1024**3), - ) - response.background = tasks + logger.info(make_request_log_str(request, response.status_code)) return response -@app.get("/api/health", tags=["api"]) -async def health() -> Response: +@app.get("/api/health", tags=["Health"]) +async def health() -> ORJSONResponse: """Health check.""" - return Response(status_code=200) + return ORJSONResponse( + status_code=200, + content={"is_oss": ENV_CONFIG.is_oss}, + ) # --- Order of handlers does not matter --- # -@app.exception_handler(Timeout) -async def write_lock_timeout_exc_handler(request: Request, exc: Timeout): - logger.warning(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - content={ - "object": "error", - "error": "write_lock_timeout", - "message": "This table is currently busy. Please try again later.", - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - headers={"Retry-After": 10}, +@app.exception_handler(AuthorizationError) +async def authorization_exc_handler(request: Request, exc: ForbiddenError): + return unauthorized_response(request, str(exc), exception=exc) + + +@app.exception_handler(ExternalAuthError) +async def external_auth_exc_handler(request: Request, exc: ExternalAuthError): + return unauthorized_response( + request, str(exc), error="external_authentication_failed", exception=exc ) +@app.exception_handler(PermissionError) +async def permission_error_exc_handler(request: Request, exc: PermissionError): + return forbidden_response(request, str(exc), error="resource_protected", exception=exc) + + +@app.exception_handler(ForbiddenError) +async def forbidden_exc_handler(request: Request, exc: ForbiddenError): + return forbidden_response(request, str(exc), exception=exc) + + @app.exception_handler(UpgradeTierError) async def upgrade_tier_exc_handler(request: Request, exc: UpgradeTierError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "object": "error", - "error": "upgrade_tier", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return forbidden_response(request, str(exc), error="upgrade_tier", exception=exc) @app.exception_handler(InsufficientCreditsError) async def insufficient_credits_exc_handler(request: Request, exc: InsufficientCreditsError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "object": "error", - "error": "insufficient_credits", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return forbidden_response(request, str(exc), error="insufficient_credits", exception=exc) -@app.exception_handler(PermissionError) -async def permission_error_exc_handler(request: Request, exc: PermissionError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "object": "error", - "error": "resource_protected", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) +@app.exception_handler(FileNotFoundError) +async def file_not_found_exc_handler(request: Request, exc: FileNotFoundError): + return resource_not_found_response(request, str(exc), exception=exc) + + +@app.exception_handler(ResourceNotFoundError) +async def resource_not_found_exc_handler(request: Request, exc: ResourceNotFoundError): + return resource_not_found_response(request, str(exc), exception=exc) @app.exception_handler(FileExistsError) async def file_exists_exc_handler(request: Request, exc: FileExistsError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={ - "object": "error", - "error": "resource_exists", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return resource_exists_response(request, str(exc), exception=exc) @app.exception_handler(ResourceExistsError) async def resource_exists_exc_handler(request: Request, exc: ResourceExistsError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={ - "object": "error", - "error": "resource_exists", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return resource_exists_response(request, str(exc), exception=exc) -@app.exception_handler(FileNotFoundError) -async def file_not_found_exc_handler(request: Request, exc: FileNotFoundError): - logger.info(f"{request.state.id} - {exc}") +@app.exception_handler(UnsupportedMediaTypeError) +async def unsupported_media_type_exc_handler(request: Request, exc: UnsupportedMediaTypeError): + logger.warning(f"{make_request_log_str(request, 415)} - {exc.__class__.__name__}: {exc}") return ORJSONResponse( - status_code=status.HTTP_404_NOT_FOUND, + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, content={ "object": "error", - "error": "resource_not_found", + "error": "unsupported_media_type", "message": str(exc), "detail": str(exc), "request_id": request.state.id, - "exception": exc.__class__.__name__, + "exception": "", }, ) -@app.exception_handler(ResourceNotFoundError) -async def resource_not_found_exc_handler(request: Request, exc: ResourceNotFoundError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - "object": "error", - "error": "resource_not_found", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) +@app.exception_handler(BadInputError) +async def bad_input_exc_handler(request: Request, exc: BadInputError): + return bad_input_response(request, str(exc), exception=exc) @app.exception_handler(TableSchemaFixedError) async def table_fixed_exc_handler(request: Request, exc: TableSchemaFixedError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "object": "error", - "error": "table_schema_fixed", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return bad_input_response(request, str(exc), error="table_schema_fixed", exception=exc) @app.exception_handler(ContextOverflowError) async def context_overflow_exc_handler(request: Request, exc: ContextOverflowError): - logger.info(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "object": "error", - "error": "context_overflow", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, - ) + return bad_input_response(request, str(exc), error="context_overflow", exception=exc) + + +class Wrapper(BaseModel): + body: Any @app.exception_handler(RequestValidationError) async def request_validation_exc_handler(request: Request, exc: RequestValidationError): + content = None try: - logger.info(f"{request.state.id} - RequestValidationError: {exc.errors()}") + logger.info( + f"{make_request_log_str(request, 422)} - RequestValidationError: {exc.errors()}" + ) errors, messages = [], [] for i, e in enumerate(exc.errors()): try: @@ -641,38 +315,67 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio msg = e["msg"].strip() if not msg.endswith("."): msg = f"{msg}." - loc = "" - if len(e["loc"]) > 0: - loc = ".".join(str(ll) for ll in e["loc"]) + " : " - messages.append(f"{i + 1}. {loc}{msg}") + # Intercept Table and Column ID regex error message + if TABLE_NAME_PATTERN in msg: + msg = ( + "Table name or ID must be unique with at least 1 character and up to 100 characters. " + "Must start and end with an alphabet or number. " + "Characters in the middle can include `_` (underscore), `-` (dash), `.` (dot)." + ) + elif COL_NAME_PATTERN in msg: + msg = ( + "Column name or ID must be unique with at least 1 character and up to 100 characters. " + "Must start and end with an alphabet or number. " + "Characters in the middle can include `_` (underscore), `-` (dash), ` ` (space). " + 'Cannot be called "ID" or "Updated at" (case-insensitive).' + ) + + path = "" + for j, x in enumerate(e.get("loc", [])): + if isinstance(x, str): + if j > 0: + path += "." + path += x + elif isinstance(x, int): + path += f"[{x}]" + else: + raise TypeError("Unexpected type") + if path: + path += " : " + messages.append(f"{i + 1}. {path}{msg}") error = {k: v for k, v in e.items() if k != "ctx"} if "ctx" in e: error["ctx"] = {k: repr(v) if k == "error" else v for k, v in e["ctx"].items()} + if "input" in e: + error["input"] = repr(e["input"]) errors.append(error) message = "\n".join(messages) message = f"Your request contains errors:\n{message}" - body = exc.body if isinstance(exc.body, dict) else str(exc.body) + content = { + "object": "error", + "error": "validation_error", + "message": message, + "detail": errors, + "request_id": request.state.id, + "exception": "", + **Wrapper(body=exc.body).model_dump(), + } return ORJSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "object": "error", - "error": "validation_error", - "message": message, - "detail": errors, - "request_id": request.state.id, - "body": body, - "exception": exc.__class__.__name__, - }, + content=content, ) except Exception: - logger.exception("Failed to parse error data !!!") + if content is None: + content = repr(exc) + logger.exception(f"{request.state.id} - Failed to parse error data: {content}") + message = str(exc) return ORJSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "object": "error", "error": "validation_error", - "message": str(exc), - "detail": str(exc), + "message": message, + "detail": message, "request_id": request.state.id, "exception": exc.__class__.__name__, }, @@ -681,36 +384,64 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio @app.exception_handler(Exception) async def exception_handler(request: Request, exc: Exception): - logger.warning(f"{request.state.id} - {exc}") - return ORJSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "object": "error", - "error": "unexpected_error", - "message": INTERNAL_ERROR_MESSAGE, - "detail": str(exc), - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, + return internal_server_error_response(request, exception=exc) + + +@app.exception_handler(UnexpectedError) +async def unexpected_error_handler(request: Request, exc: UnexpectedError): + return internal_server_error_response(request, exception=exc) + + +@app.exception_handler(ResponseValidationError) +async def response_validation_error_handler(request: Request, exc: ResponseValidationError): + return internal_server_error_response(request, exception=exc) + + +@app.exception_handler(Timeout) +async def write_lock_timeout_exc_handler(request: Request, exc: Timeout): + return server_busy_response( + request, + "This table is currently busy. Please try again later.", + exception=exc, + headers={"Retry-After": "10"}, ) -openapi_schema = app.openapi() -# Add security schemes -openapi_schema["components"]["securitySchemes"] = { - "Authentication": { - "type": "http", - "scheme": "bearer", - }, - "X-PROJECT-ID": { - "type": "apiKey", - "name": "X-PROJECT-ID", - "in": "header", - }, -} -openapi_schema["security"] = [{"Authentication": [], "X-PROJECT-ID": []}] -openapi_schema["info"]["x-logo"] = {"url": "https://www.jamaibase.com/favicon.svg"} -app.openapi_schema = openapi_schema +@app.exception_handler(ServerBusyError) +async def busy_exc_handler(request: Request, exc: ServerBusyError): + return server_busy_response( + request, + "The server is currently busy. Please try again later.", + exception=exc, + headers={"Retry-After": "30"}, + ) + + +@app.exception_handler(HTTPException) +async def http_exc_handler(request: Request, exc: HTTPException): + return make_response( + request=request, + message=str(exc), + error="http_error", + status_code=exc.status_code, + detail=None, + exception=exc, + log=exc.status_code != 404, + ) + + +if not ENV_CONFIG.is_oss: + openapi_schema = app.openapi() + # Add security schemes + openapi_schema["components"]["securitySchemes"] = { + "Authentication": { + "type": "http", + "scheme": "bearer", + }, + } + openapi_schema["security"] = [{"Authentication": []}] + openapi_schema["info"]["x-logo"] = {"url": "https://www.jamaibase.com/favicon.svg"} + app.openapi_schema = openapi_schema if __name__ == "__main__": @@ -730,4 +461,5 @@ async def exception_handler(request: Request, exc: Exception): host=ENV_CONFIG.owl_host, port=ENV_CONFIG.owl_port, workers=ENV_CONFIG.owl_workers, + limit_concurrency=ENV_CONFIG.owl_max_concurrency, ) diff --git a/services/api/src/owl/entrypoints/chat_echo.py b/services/api/src/owl/entrypoints/chat_echo.py new file mode 100644 index 0000000..71cce87 --- /dev/null +++ b/services/api/src/owl/entrypoints/chat_echo.py @@ -0,0 +1,121 @@ +from time import time + +from fastapi import FastAPI, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from jamaibase.protocol import ( + ChatCompletionChoice, + ChatEntry, + ChatRequest, + CompletionUsage, +) +from owl.configs.manager import ENV_CONFIG + + +class ChatCompletionRequest(ChatRequest): + stream: bool = False + + +class ChatCompletionChoiceDelta(BaseModel): + delta: dict[str, str] = 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, + description=( + "The reason the model stopped generating tokens. " + "This will be stop if the model hit a natural stop point or a provided stop sequence, " + "length if the maximum number of tokens specified in the request was reached." + ), + ) + + +class ChatCompletionResponse(BaseModel): + id: str = Field( + description="A unique identifier for the chat completion. Each chunk has the same ID." + ) + object: str = Field( + default="chat.completion", + description="Type of API response object.", + examples=["chat.completion"], + ) + created: int = Field( + default_factory=lambda: int(time()), + description="The Unix timestamp (in seconds) of when the chat completion was created.", + ) + model: str = Field(description="The model used for the chat completion.") + choices: list[ChatCompletionChoice | ChatCompletionChoiceDelta] = Field( + description="A list of chat completion choices. Can be more than one if `n` is greater than 1." + ) + usage: CompletionUsage | None = Field( + description="Number of tokens consumed for the completion request.", + examples=[CompletionUsage(), None], + ) + + +app = FastAPI() + + +@app.post("/v1/chat/completions") +async def chat_completion(body: ChatCompletionRequest): + output = body.model_dump_json() + + if body.stream: + + async def stream_response(): + for i, char in enumerate(output): + chunk = ChatCompletionResponse( + id=body.id, + object="chat.completion.chunk", + model=body.model, + choices=[ + ChatCompletionChoiceDelta( + index=0, + delta=dict(content=char), + finish_reason=None if i < len(output) - 1 else "stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=len(output), + completion_tokens=i + 1, + total_tokens=len(output) + i + 1, + ), + ) + yield f"data: {chunk.model_dump()}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(stream_response(), media_type="text/event-stream") + + return ChatCompletionResponse( + id=body.id, + model=body.model, + choices=[ + ChatCompletionChoice( + index=0, message=ChatEntry.assistant(output), finish_reason="stop" + ) + ], + usage=CompletionUsage( + prompt_tokens=len(output), + completion_tokens=len(output), + total_tokens=len(output) + len(output), + ), + ) + + +@app.get("/health", tags=["Health"]) +async def health() -> Response: + """Health check.""" + return Response(status_code=200) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "owl.entrypoints.chat_echo:app", + reload=False, + host=ENV_CONFIG.owl_host, + port=6868, + workers=1, + limit_concurrency=10, + ) diff --git a/services/api/src/owl/entrypoints/chat_python.py b/services/api/src/owl/entrypoints/chat_python.py new file mode 100644 index 0000000..66788c3 --- /dev/null +++ b/services/api/src/owl/entrypoints/chat_python.py @@ -0,0 +1,137 @@ +import io +from contextlib import redirect_stdout +from time import time + +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from jamaibase.protocol import ( + ChatCompletionChoice, + ChatEntry, + ChatRequest, + CompletionUsage, +) +from owl.configs.manager import ENV_CONFIG + + +class ChatCompletionRequest(ChatRequest): + stream: bool = False + + +class ChatCompletionChoiceDelta(BaseModel): + delta: dict[str, str] = 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, + description=( + "The reason the model stopped generating tokens. " + "This will be stop if the model hit a natural stop point or a provided stop sequence, " + "length if the maximum number of tokens specified in the request was reached." + ), + ) + + +class ChatCompletionResponse(BaseModel): + id: str = Field( + description="A unique identifier for the chat completion. Each chunk has the same ID." + ) + object: str = Field( + default="chat.completion", + description="Type of API response object.", + examples=["chat.completion"], + ) + created: int = Field( + default_factory=lambda: int(time()), + description="The Unix timestamp (in seconds) of when the chat completion was created.", + ) + model: str = Field(description="The model used for the chat completion.") + choices: list[ChatCompletionChoice | ChatCompletionChoiceDelta] = Field( + description="A list of chat completion choices. Can be more than one if `n` is greater than 1." + ) + usage: CompletionUsage | None = Field( + description="Number of tokens consumed for the completion request.", + examples=[CompletionUsage(), None], + ) + + +app = FastAPI() + + +def assemble_script(body: ChatCompletionRequest): + messages = [ + message.content + if isinstance(message.content, str) + else "\n".join(d["text"] for d in message if d["type"] == "text") + for message in body.messages + if message.role == "user" + ] + script = "\n".join(messages) + return script + + +def execute_script(script): + with redirect_stdout(io.StringIO(newline="\n")) as f: + exec(script) + output = f.getvalue().strip() + return output.split("\n")[-1] + + +@app.post("/v1/chat/completions") +async def chat_completion(body: ChatCompletionRequest): + script = assemble_script(body) + output = execute_script(script) + + if body.stream: + + async def stream_response(): + for i, char in enumerate(output): + chunk = ChatCompletionResponse( + id=body.id, + object="chat.completion.chunk", + model=body.model, + choices=[ + ChatCompletionChoiceDelta( + index=0, + delta=dict(content=char), + finish_reason=None if i < len(output) - 1 else "stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=len(script), + completion_tokens=i + 1, + total_tokens=len(script) + i + 1, + ), + ) + yield f"data: {chunk.model_dump()}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(stream_response(), media_type="text/event-stream") + + return ChatCompletionResponse( + id=body.id, + model=body.model, + choices=[ + ChatCompletionChoice( + index=0, message=ChatEntry.assistant(output), finish_reason="stop" + ) + ], + usage=CompletionUsage( + prompt_tokens=len(script), + completion_tokens=len(output), + total_tokens=len(script) + len(output), + ), + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "owl.entrypoints.chat_python:app", + reload=False, + host=ENV_CONFIG.owl_host, + port=6869, + workers=1, + limit_concurrency=10, + ) diff --git a/services/api/src/owl/entrypoints/starling.py b/services/api/src/owl/entrypoints/starling.py new file mode 100644 index 0000000..f36efef --- /dev/null +++ b/services/api/src/owl/entrypoints/starling.py @@ -0,0 +1,114 @@ +""" +Starling server. + +```shell +$ celery -A owl.entrypoints.starling worker --loglevel=info --max-memory-per-child 976562 --autoscale=4,2 +$ celery -A owl.entrypoints.starling beat --loglevel=info +(Optional) $ celery -A owl.entrypoints.starling flower --loglevel=info +``` +""" + +import os + +from celery import Celery +from celery.schedules import crontab +from loguru import logger + +from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.utils.logging import ( + replace_logging_handlers, + setup_logger_sinks, + suppress_logging_handlers, +) + +# Maybe purge Redis data +if ENV_CONFIG.owl_cache_purge: + CONFIG.purge() + +SCHEDULER_DB = f"{ENV_CONFIG.owl_db_dir}/_scheduler" +logger.enable("") +setup_logger_sinks(f"{ENV_CONFIG.owl_log_dir}/starling.log") +replace_logging_handlers(["uvicorn.access"], False) +suppress_logging_handlers(["litellm", "openmeter", "azure"], True) + + +try: + if not os.path.exists(SCHEDULER_DB): + os.makedirs(SCHEDULER_DB, exist_ok=True) + logger.info(f"Created scheduler directory at {SCHEDULER_DB}") + else: + logger.info(f"Scheduler directory already exists at {SCHEDULER_DB}") +except Exception as e: + logger.error(f"Error creating scheduler directory: {e}") + + +# Set up Celery +app = Celery("tasks", broker=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0") + +# Configure Celery +app.conf.update( + result_backend=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0", + task_serializer="json", + accept_content=["json"], + result_serializer="json", + result_expires=36000, + timezone="UTC", + enable_utc=True, + beat_schedule_filename=os.path.join(SCHEDULER_DB, "celerybeat-schedule"), +) + +# Load task modules +app.conf.imports = [ + "owl.tasks.genitor", + "owl.tasks.storage", +] + +# Configure the scheduler +app.conf.beat_schedule = {} + +# Add periodic storage update task if service_key_plain is not empty +if ENV_CONFIG.service_key_plain != "": + app.conf.beat_schedule["periodic-storage-update"] = { + "task": "owl.tasks.storage.periodic_storage_update", + "schedule": crontab(minute=f"*/{ENV_CONFIG.owl_compute_storage_period_min}"), + } + +# Add Lance-related tasks +app.conf.beat_schedule.update( + { + "lance-periodic-reindex": { + "task": "owl.tasks.storage.lance_periodic_reindex", + "schedule": crontab(minute=f"*/{max(1,ENV_CONFIG.owl_reindex_period_sec//60)}"), + }, + "lance-periodic-optimize": { + "task": "owl.tasks.storage.lance_periodic_optimize", + "schedule": crontab(minute=f"*/{max(1,ENV_CONFIG.owl_optimize_period_sec//60)}"), + }, + } +) + +# Check if S3-related environment variables are present and non-empty +if all( + getattr(ENV_CONFIG, attr, "") # Use getattr to safely access attributes + for attr in [ + "s3_endpoint", + "s3_access_key_id", + "s3_secret_access_key", + "s3_backup_bucket_name", + ] +): + logger.info("S3 Backup tasks has been configured.") + app.conf.beat_schedule.update( + { + "backup-to-s3": { + "task": "owl.tasks.genitor.backup_to_s3", + "schedule": crontab(minute="0", hour="*"), + }, + "s3-cleanup": { + "task": "owl.tasks.genitor.s3_cleanup", + "schedule": crontab(minute="0", hour="*/24"), + }, + } + ) +else: + logger.info("S3 Backup tasks is not configured.") diff --git a/services/api/src/owl/llm.py b/services/api/src/owl/llm.py index 9db1655..43881a4 100644 --- a/services/api/src/owl/llm.py +++ b/services/api/src/owl/llm.py @@ -1,110 +1,113 @@ from copy import deepcopy from datetime import datetime, timezone from functools import lru_cache +from os.path import join from time import time from typing import AsyncGenerator import litellm import openai -import tiktoken from fastapi import Request -from fastapi.exceptions import RequestValidationError from litellm import Router +from litellm.router import RetryPolicy from loguru import logger -from owl.configs.manager import CONFIG, ENV_CONFIG +from jamaibase.exceptions import ( + BadInputError, + ContextOverflowError, + ExternalAuthError, + JamaiException, + ResourceNotFoundError, + ServerBusyError, + UnexpectedError, +) +from owl.billing import BillingManager +from owl.configs.manager import ENV_CONFIG from owl.db.gen_table import KnowledgeTable +from owl.models import CloudEmbedder, CloudReranker from owl.protocol import ( - DEFAULT_CHAT_MODEL, ChatCompletionChoiceDelta, ChatCompletionChunk, ChatEntry, ChatRole, Chunk, CompletionUsage, + EmbeddingModelConfig, + ExternalKeys, + LLMModelConfig, ModelInfo, ModelInfoResponse, ModelListConfig, RAGParams, References, + RerankingModelConfig, ) -from owl.utils import filter_external_api_key, mask_string -from owl.utils.exceptions import ContextOverflowError, ResourceNotFoundError +from owl.utils import mask_content, mask_string, select_external_api_key litellm.drop_params = True litellm.set_verbose = False +litellm.suppress_debug_info = True -@lru_cache(maxsize=1) -def _get_llm_router(model_json: str): +@lru_cache(maxsize=64) +def _get_llm_router(model_json: str, external_api_keys: str): models = ModelListConfig.model_validate_json(model_json).llm_models + ExternalApiKeys = ExternalKeys.model_validate_json(external_api_keys) # refer to https://docs.litellm.ai/docs/routing for more details - # current fixed strategy to 'simple-shuffle' (no need extra redis, or setting of RPM/TPM) return Router( model_list=[ { "model_name": m.id, "litellm_params": { - "model": m.litellm_id if m.litellm_id != "" else m.id, - "api_key": "null", - "api_base": None if m.api_base == "" else m.api_base, + "model": deployment.litellm_id if deployment.litellm_id.strip() else m.id, + "api_key": select_external_api_key(ExternalApiKeys, deployment.provider), + "api_base": deployment.api_base if deployment.api_base.strip() else None, }, } for m in models + for deployment in m.deployments ], - routing_strategy="simple-shuffle", + routing_strategy="latency-based-routing", num_retries=3, - retry_after=0.5, - timeout=60.0, - allowed_fails=2, - cooldown_time=0, - debug_level="INFO", + retry_policy=RetryPolicy( + TimeoutErrorRetries=3, + RateLimitErrorRetries=3, + ContentPolicyViolationErrorRetries=3, + AuthenticationErrorRetries=0, + BadRequestErrorRetries=0, + ContextWindowExceededErrorRetries=0, + ), + retry_after=5.0, + timeout=ENV_CONFIG.owl_llm_timeout_sec, + allowed_fails=3, + cooldown_time=0.0, + debug_level="DEBUG", + redis_host=ENV_CONFIG.owl_redis_host, + redis_port=ENV_CONFIG.owl_redis_port, ) -def message_len(messages: list[ChatEntry]) -> int: - try: - openai_tokenizer = tiktoken.encoding_for_model("gpt-4") - except KeyError: - openai_tokenizer = tiktoken.get_encoding("cl100k_base") - total_len = 0 - for message in messages: - mlen = 5 # ChatML = 4, role = 1 - if message.content: - mlen += len(openai_tokenizer.encode(message.content)) - if message.name: - mlen += len(openai_tokenizer.encode(message.name)) - # if message.function_call: - # mlen += len(openai_tokenizer.encode(message.function_call.name)) - # mlen += len(openai_tokenizer.encode(message.function_call.arguments)) - total_len += mlen - return total_len - - class LLMEngine: def __init__( self, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", + *, + request: Request, ) -> None: - self.openai_api_key = openai_api_key - self.anthropic_api_key = anthropic_api_key - self.gemini_api_key = gemini_api_key - self.cohere_api_key = cohere_api_key - self.groq_api_key = groq_api_key - self.together_api_key = together_api_key - self.jina_api_key = jina_api_key - self.voyage_api_key = voyage_api_key + self.request = request + self.id: str = request.state.id + self.organization_id: str = request.state.org_id + self.project_id: str = request.state.project_id + self.org_models: ModelListConfig = request.state.org_models + self.external_keys: ExternalKeys = request.state.external_keys + self.is_browser: bool = request.state.user_agent.is_browser + self._billing: BillingManager = request.state.billing @property def router(self): - return _get_llm_router(CONFIG.get_model_json()) + return _get_llm_router( + model_json=self.request.state.all_models.model_dump_json(), + external_api_keys=self.external_keys.model_dump_json(), + ) @staticmethod def _prepare_hyperparams(model: str, hyperparams: dict, **kwargs) -> dict: @@ -144,25 +147,23 @@ def _prepare_messages(messages: list[ChatEntry | dict]) -> list[ChatEntry]: messages = [ChatEntry.system(content="."), ChatEntry.user(content=".")] + messages return messages - @staticmethod def _log_completion_masked( - request: Request, + self, model: str, messages: list[ChatEntry], - api_key: str, **hyperparams, ): body = dict( model=model, - messages=[{"role": m["role"], "content": mask_string(m["content"])} for m in messages], - api_key=mask_string(api_key), + messages=[ + {"role": m["role"], "content": mask_content(m["content"])} for m in messages + ], **hyperparams, ) - logger.info(f"{request.state.id} - Generating chat completions: {body}") + logger.info(f"{self.id} - Generating chat completions: {body}") - @staticmethod def _log_exception( - request: Request, + self, model: str, messages: list[ChatEntry], api_key: str = "", @@ -174,35 +175,86 @@ def _log_exception( api_key=mask_string(api_key), **hyperparams, ) - logger.exception(f"{request.state.id} - Chat completion errored !!! {body}") + logger.exception(f"{self.id} - Chat completion got unexpected error !!! {body}") + + def _map_and_log_exception( + self, + e: Exception, + model: str, + messages: list[ChatEntry], + api_key: str = "", + **hyperparams, + ) -> Exception: + request_id = hyperparams.get("id", None) + err_mssg = getattr(e, "message", str(e)) + log_mssg = f"{request_id} - LiteLLM {e.__class__.__name__}: {err_mssg}" + if isinstance(e, JamaiException): + logger.info(log_mssg) + return e + elif isinstance(e, openai.BadRequestError): + logger.info(log_mssg) + return BadInputError(err_mssg) + elif isinstance(e, openai.AuthenticationError): + logger.info(log_mssg) + return ExternalAuthError(err_mssg) + elif isinstance(e, (openai.RateLimitError, openai.APITimeoutError)): + logger.info(log_mssg) + return ServerBusyError(err_mssg) + elif isinstance(e, openai.OpenAIError): + logger.warning(log_mssg) + return UnexpectedError(err_mssg) + else: + self._log_exception(model, messages, api_key, **hyperparams) + return UnexpectedError(err_mssg) + + def _get_valid_deployments( + self, + model: LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig, + valid_providers: list[str], + ): + valid_deployments = [] + for deployment in model.deployments: + if deployment.provider in valid_providers: + valid_deployments.append(deployment) + return valid_deployments def model_info( self, model: str = "", capabilities: list[str] | None = None, ) -> ModelInfoResponse: - all_models = ModelListConfig.model_validate_json(CONFIG.get_model_json()) - # Chat models - models = [m for m in all_models.llm_models if m.owned_by == "ellm"] - if self.openai_api_key != "": - models += [m for m in all_models.llm_models if m.owned_by == "openai"] - if self.anthropic_api_key != "": - models += [m for m in all_models.llm_models if m.owned_by == "anthropic"] - if self.together_api_key != "": - models += [m for m in all_models.llm_models if m.owned_by == "together_ai"] - # Embedding models - models += [m for m in all_models.embed_models if m.owned_by == "ellm"] - if self.openai_api_key != "": - models += [m for m in all_models.embed_models if m.owned_by == "openai"] - if self.cohere_api_key != "": - models += [m for m in all_models.embed_models if m.owned_by == "cohere"] - # Reranking models - models += [m for m in all_models.rerank_models if m.owned_by == "ellm"] - if self.cohere_api_key != "": - models += [m for m in all_models.rerank_models if m.owned_by == "cohere"] - # Get unique models - unique_models = {m.id: m for m in models} - models = list(unique_models.values()) + all_models: ModelListConfig = self.request.state.all_models + # define all possible api providers + available_providers = [ + "openai", + "anthropic", + "together_ai", + "cohere", + "sambanova", + "cerebras", + "hyperbolic", + ] + # remove providers without credentials + available_providers = [ + provider + for provider in available_providers + if getattr(self.external_keys, provider) != "" + ] + + # add custom and ellm providers as allow no credentials + available_providers.extend( + [ + "custom", + "ellm", + ] + ) + models = [] + # Iterate over the llm, embed, rerank list + for m in all_models.models: + valid_deployments = self._get_valid_deployments(m, available_providers) + if len(valid_deployments) > 0: + m.deployments = valid_deployments + models.append(m) # Filter by name if model != "": models = [m for m in models if m.id == model] @@ -211,9 +263,7 @@ def model_info( for capability in capabilities: models = [m for m in models if capability in m.capabilities] if len(models) == 0: - raise ResourceNotFoundError( - f"No suitable model found with capabilities: {capabilities}" - ) + raise ResourceNotFoundError(f"No model found with capabilities: {capabilities}") response = ModelInfoResponse( data=[ModelInfo.model_validate(m.model_dump()) for m in models] ) @@ -234,61 +284,91 @@ def model_names( names.insert(0, prefer) return names + def get_model_name(self, model: str, capabilities: list[str] | None = None) -> str: + capabilities = ["chat"] if capabilities is None else capabilities + models = self.model_info( + model="", + capabilities=capabilities, + ) + return [m.name for m in models.data if m.id == model][0] + + def validate_model_id( + self, + model: str = "", + capabilities: list[str] | None = None, + ) -> str: + capabilities = ["chat"] if capabilities is None else capabilities + if model == "": + models: ModelListConfig = self.request.state.all_models + model = models.get_default_model(capabilities) + logger.info(f'{self.id} - Empty model changed to "{model}"') + else: + models = self.model_info( + model="", + capabilities=capabilities, + ) + model_ids = [m.id for m in models.data] + if model not in model_ids: + err_mssg = ( + f'Model "{model}" is not available among models with capabilities {capabilities}. ' + f"Choose from: {model_ids}" + ) + logger.info(f"{self.id} - {err_mssg}") + # Return different error message depending if request came from browser + if self.is_browser: + model_names = ", ".join(m.name for m in models.data) + err_mssg = ( + f'Model "{model}" is not available among models with capabilities: {', '.join(capabilities)}. ' + f'Choose from: {model_names}' + ) + raise ResourceNotFoundError(err_mssg) + return model + async def generate_stream( self, - request: Request, model: str, messages: list[ChatEntry | dict], + capabilities: list[str] | None = None, **hyperparams, ) -> AsyncGenerator[ChatCompletionChunk, None]: + api_key = "" try: + model = model.strip() hyperparams = self._prepare_hyperparams(model, hyperparams, stream=True) messages = self._prepare_messages(messages) - input_len = message_len(messages) - output_len = 0 messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] - if not model: - models = self.model_names( - prefer=DEFAULT_CHAT_MODEL, - capabilities=["chat"], - ) - model = models[0] - logger.info(f"{request.state.id} - Empty model changed to: {model}") - - api_key = filter_external_api_key( - model, - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - cohere_api_key=self.cohere_api_key, - groq_api_key=self.groq_api_key, - together_api_key=self.together_api_key, - jina_api_key=self.jina_api_key, - voyage_api_key=self.voyage_api_key, + model = self.validate_model_id( + model=model, + capabilities=capabilities, ) - self._log_completion_masked(request, model, messages, api_key=api_key, **hyperparams) + self._log_completion_masked(model, messages, **hyperparams) response = await self.router.acompletion( model=model, messages=messages, - api_key=api_key, + # Fixes discrepancy between stream and non-stream token usage + stream_options={"include_usage": True}, **hyperparams, ) + chunks = [] + completion = None output_text = "" + usage = CompletionUsage() async for chunk in response: - chunk_message = chunk.choices[0].delta - if not chunk_message.content: - continue - output_len += 1 + 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, + ) yield ChatCompletionChunk( - id=request.state.id, + id=self.id, object="chat.completion.chunk", created=int(time()), model=model, - usage=CompletionUsage( - prompt_tokens=input_len, - completion_tokens=output_len, - total_tokens=input_len + output_len, - ), + usage=usage, choices=[ ChatCompletionChoiceDelta( message=ChatEntry.assistant(choice.delta.content), @@ -300,24 +380,28 @@ async def generate_stream( for choice in chunk.choices ], ) - output_text += chunk.choices[0].delta.content - logger.info(f"{request.state.id} - Streamed completion: <{mask_string(output_text)}>") - request.state.billing_manager.create_llm_events( + 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=input_len, - output_tokens=output_len, + input_tokens=completion.usage.prompt_tokens, + output_tokens=completion.usage.completion_tokens, ) - except Exception as exc: - self._log_exception(request, model, messages, **hyperparams) + except Exception as e: + self._map_and_log_exception(e, model, messages, api_key, **hyperparams) yield ChatCompletionChunk( - id=request.state.id, + id=self.id, object="chat.completion.chunk", created=int(time()), model=model, usage=None, choices=[ ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {repr(exc)}"), + message=ChatEntry.assistant(f"[ERROR] {e!r}"), index=0, finish_reason="error", ) @@ -326,104 +410,47 @@ async def generate_stream( async def generate( self, - request: Request, model: str, messages: list[ChatEntry | dict], + capabilities: list[str] | None = None, **hyperparams, ) -> ChatCompletionChunk: + api_key = "" try: + model = model.strip() + hyperparams = self._prepare_hyperparams(model, hyperparams, stream=False) messages = self._prepare_messages(messages) - hyperparams.pop("stream", False) - hyperparams = self._prepare_hyperparams(model, hyperparams) messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] - if len(model) == 0: - models = self.model_names( - prefer=DEFAULT_CHAT_MODEL, - capabilities=["chat"], - ) - model = models[0] - logger.info(f"{request.state.id} - Empty model changed to: {model}") - api_key = filter_external_api_key( - model, - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - cohere_api_key=self.cohere_api_key, - groq_api_key=self.groq_api_key, - together_api_key=self.together_api_key, - jina_api_key=self.jina_api_key, - voyage_api_key=self.voyage_api_key, + model = self.validate_model_id( + model=model, + capabilities=capabilities, ) - self._log_completion_masked(request, model, messages, api_key=api_key, **hyperparams) - response = await self.router.acompletion( + self._log_completion_masked(model, messages, **hyperparams) + completion = await self.router.acompletion( model=model, messages=messages, - api_key=api_key, - stream=False, **hyperparams, ) - usage = response.usage.model_dump() - completion = ChatCompletionChunk( - id=request.state.id, - object="chat.completion", - created=response.created, + self._billing.create_llm_events( model=model, - usage=usage, - choices=[choice.model_dump() for choice in response.choices], + input_tokens=completion.usage.prompt_tokens, + output_tokens=completion.usage.completion_tokens, ) - input_len = usage.get("prompt_tokens", 0) - output_len = usage.get("completion_tokens", 0) - if input_len == 0 or output_len == 0: - logger.warning(f"LiteLLM '{model}' completion usage: {usage}") - logger.info( - f"{request.state.id} - Generated completion: <{mask_string(completion.text)}>" - ) - request.state.billing_manager.create_llm_events( + completion = ChatCompletionChunk( + id=self.id, + object="chat.completion", + created=completion.created, model=model, - input_tokens=input_len, - output_tokens=output_len, + usage=completion.usage.model_dump(), + choices=[choice.model_dump() for choice in completion.choices], ) + logger.info(f"{self.id} - Generated completion: <{mask_string(completion.text)}>") return completion - except openai.BadRequestError as e: - err_mssg = e.message - err_code = e.code if e.code else None - - logger.warning(f"{request.state.id} - LiteLLM error: {err_mssg}") - if e.status_code == 400: - raise RequestValidationError( - errors=[ - { - "msg": err_mssg, - "model": model, - "code": err_code, - } - ] - ) - else: - raise RuntimeError( - f"LLM server error: model={model} code={err_code} error={err_mssg}" - ) - except Exception: - self._log_exception(request, model, messages, **hyperparams) - # return ChatCompletionChunk( - # id=request.state.id, - # object="chat.completion", - # created=int(time()), - # model=model, - # usage=None, - # choices=[ - # ChatCompletionChoiceDelta( - # message=ChatEntry.assistant(f"[ERROR] {repr(exc)}"), - # index=0, - # finish_reason="error", - # ) - # ], - # ) - raise + except Exception as e: + raise self._map_and_log_exception(e, model, messages, api_key, **hyperparams) from e async def retrieve_references( self, - request: Request, model: str, messages: list[ChatEntry | dict], rag_params: RAGParams | dict | None, @@ -434,6 +461,7 @@ async def retrieve_references( hyperparams = self._prepare_hyperparams(model, hyperparams) messages = self._prepare_messages(messages) + has_file_input = True if isinstance(messages[-1].content, list) else False rag_params = RAGParams.model_validate(rag_params) search_query = rag_params.search_query # Reformulate query if not provided @@ -441,9 +469,12 @@ async def retrieve_references( hyperparams.update(temperature=0.01, top_p=0.01, max_tokens=512) rewriter_messages = deepcopy(messages) if rewriter_messages[0].role not in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): - logger.warning(f"{request.state.id} - `messages[0].role` is not `system` !!!") + logger.warning(f"{self.id} - `messages[0].role` is not `system` !!!") rewriter_messages.insert(0, ChatEntry.system("You are a concise assistant.")) - query_ori = rewriter_messages[-1].content + if has_file_input: + query_ori = rewriter_messages[-1].content[0]["text"] + else: + query_ori = rewriter_messages[-1].content # Search query rewriter now = datetime.now(timezone.utc) @@ -462,7 +493,6 @@ async def retrieve_references( ) ) completion = await self.generate( - request=request, model=model, messages=rewriter_messages, **hyperparams, @@ -471,54 +501,54 @@ async def retrieve_references( if search_query.startswith('"') and search_query.endswith('"'): search_query = search_query[1:-1] logger.info( - f"{request.state.id} - Rewritten query: `{query_ori}` -> `{search_query}` using {model}" + ( + f'{self.id} - Rewritten query using "{model}": ' + f"<{mask_string(query_ori)}> -> <{mask_string(search_query)}>" + ) ) # Query rag_params.search_query = search_query - logger.info(f"{request.state.id} - Querying table: {rag_params}") - lance_path = ( - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/knowledge" + if rag_params.reranking_model is not None: + reranker = CloudReranker(request=self.request) + else: + reranker = None + embedder = CloudEmbedder(request=self.request) + logger.info(f"{self.id} - Querying table: {rag_params}") + lance_path = join( + ENV_CONFIG.owl_db_dir, self.organization_id, self.project_id, "knowledge" ) sqlite_path = f"sqlite:///{lance_path}.db" table = KnowledgeTable(sqlite_path, lance_path) with table.create_session() as session: - rows = table.hybrid_search( - session, + rows = await table.hybrid_search( + session=session, table_id=rag_params.table_id, + embedder=embedder, + reranker=reranker, reranking_model=rag_params.reranking_model, query=search_query, limit=rag_params.k, remove_state_cols=True, float_decimals=0, vec_decimals=0, - openai_api_key=self.openai_api_key, - anthropic_api_key=self.anthropic_api_key, - gemini_api_key=self.gemini_api_key, - cohere_api_key=self.cohere_api_key, - groq_api_key=self.groq_api_key, - together_api_key=self.together_api_key, - jina_api_key=self.jina_api_key, - voyage_api_key=self.voyage_api_key, ) if len(rows) > 1: logger.info( ( - f"{request.state.id} - Retrieved {len(rows):,d} rows from hybrid search: " + f"{self.id} - Retrieved {len(rows):,d} rows from hybrid search: " f"[{self._mask_retrieved_row(rows[0])}, ..., {self._mask_retrieved_row(rows[-1])}]" ) ) elif len(rows) == 1: logger.info( ( - f"{request.state.id} - Retrieved 1 row from hybrid search: " + f"{self.id} - Retrieved 1 row from hybrid search: " f"[{self._mask_retrieved_row(rows[0])}]" ) ) else: - logger.warning( - f"{request.state.id} - Failed to retrieve any rows from hybrid search !" - ) + logger.warning(f"{self.id} - Failed to retrieve any rows from hybrid search !") chunks = [ Chunk( text="" if row["Text"] is None else row["Text"], @@ -532,7 +562,7 @@ async def retrieve_references( # Generate # https://github.com/langchain-ai/langchain/blob/master/libs/langchain/langchain/chains/retrieval_qa/prompt.py - new_prompt = f"""UP-TO-DATE CONTEXT:\n\n""" + new_prompt = """UP-TO-DATE CONTEXT:\n\n""" for chunk in chunks: new_prompt += f""" # Document: {chunk.title} @@ -547,16 +577,20 @@ async def retrieve_references( # Answer the question with citation of relevant documents in the form of `\cite{{Document ID}}`. # """ # noqa: W605 new_prompt += f""" -QUESTION:\n{messages[-1].content.strip()} +QUESTION:\n{messages[-1].content[0]["text"].strip() if has_file_input else messages[-1].content.strip()} Answer the question. """ # noqa: W605 logger.debug( "{id} - Constructed new user prompt: {prompt}", - id=request.state.id, + id=self.id, prompt=new_prompt, ) - messages[-1] = ChatEntry.user(content=new_prompt) + if has_file_input: + new_content = [{"type": "text", "text": new_prompt}, messages[-1].content[1]] + else: + new_content = new_prompt + messages[-1] = ChatEntry.user(content=new_content) return messages, references @staticmethod @@ -570,7 +604,6 @@ def _mask_retrieved_row(row: dict[str, str | None]): async def rag_stream( self, - request: Request, model: str, messages: list[ChatEntry | dict], rag_params: RAGParams | None = None, @@ -579,7 +612,6 @@ async def rag_stream( try: hyperparams = self._prepare_hyperparams(model, hyperparams) messages, references = await self.retrieve_references( - request=request, model=model, messages=messages, rag_params=rag_params, @@ -588,23 +620,22 @@ async def rag_stream( if references is not None: yield references async for chunk in self.generate_stream( - request=request, model=model, messages=messages, **hyperparams, ): yield chunk - except Exception as exc: - self._log_exception(request, model, messages, **hyperparams) + except Exception as e: + self._log_exception(model, messages, **hyperparams) yield ChatCompletionChunk( - id=request.state.id, + id=self.id, object="chat.completion.chunk", created=int(time()), model=model, usage=None, choices=[ ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {repr(exc)}"), + message=ChatEntry.assistant(f"[ERROR] {e!r}"), index=0, finish_reason="error", ) @@ -613,15 +644,14 @@ async def rag_stream( async def rag( self, - request: Request, model: str, messages: list[ChatEntry | dict], + capabilities: list[str] | None = None, rag_params: RAGParams | dict | None = None, **hyperparams, ) -> ChatCompletionChunk: hyperparams = self._prepare_hyperparams(model, hyperparams) messages, references = await self.retrieve_references( - request=request, model=model, messages=messages, rag_params=rag_params, @@ -629,16 +659,16 @@ async def rag( ) try: response = await self.generate( - request=request, model=model, messages=messages, + capabilities=capabilities, **hyperparams, ) response.references = references except ContextOverflowError: - logger.warning(f"{request.state.id} - Chat is too long, returning references only.") + logger.warning(f"{self.id} - Chat is too long, returning references only.") response = ChatCompletionChunk( - id=request.state.id, + id=self.id, object="chat.completion", created=int(time()), model=model, diff --git a/services/api/src/owl/loaders.py b/services/api/src/owl/loaders.py index aee0761..2ddb7ba 100644 --- a/services/api/src/owl/loaders.py +++ b/services/api/src/owl/loaders.py @@ -7,6 +7,7 @@ from langchain_core.documents.base import Document from loguru import logger +from jamaibase.exceptions import BadInputError from owl.configs.manager import ENV_CONFIG from owl.docio import DocIOAPIFileLoader from owl.protocol import Chunk, SplitChunksParams, SplitChunksRequest @@ -50,7 +51,10 @@ def format_chunks(documents: list[Document], file_name: str) -> list[Chunk]: async def load_file( - file_name: str, content: bytes, chunk_size: int, chunk_overlap: int + file_name: str, + content: bytes, + chunk_size: int, + chunk_overlap: int, ) -> list[Chunk]: """ Asynchronously loads and processes a file, converting its content into a list of Chunk objects. @@ -79,10 +83,8 @@ async def load_file( if ext in (".csv", ".tsv", ".json", ".jsonl"): loader = DocIOAPIFileLoader(tmp_path, ENV_CONFIG.docio_url) documents = loader.load() - logger.debug("File '{file_name}' loaded: {docs}", file_name=file_name, docs=documents) - + logger.debug('File "{file_name}" loaded: {docs}', file_name=file_name, docs=documents) chunks = format_chunks(documents, file_name) - if ext == ".json": chunks = split_chunks( SplitChunksRequest( @@ -103,10 +105,8 @@ async def load_file( xml_keep_tags=True, ) documents = await loader.aload() - logger.debug("File '{file_name}' loaded: {docs}", file_name=file_name, docs=documents) - + logger.debug('File "{file_name}" loaded: {docs}', file_name=file_name, docs=documents) chunks = format_chunks(documents, file_name) - chunks = split_chunks( SplitChunksRequest( chunks=chunks, @@ -128,35 +128,56 @@ async def load_file( overlap=chunk_overlap, ) documents = await loader.aload() - logger.debug("File '{file_name}' loaded: {docs}", file_name=file_name, docs=documents) - + logger.debug('File "{file_name}" loaded: {docs}', file_name=file_name, docs=documents) chunks = format_chunks(documents, file_name) elif ext == ".pdf": - logger.info(f"pdf file: {file_name}") - loader = UnstructuredAPIFileLoader( - tmp_path, - url=ENV_CONFIG.unstructuredio_url, - api_key=ENV_CONFIG.unstructuredio_api_key_plain, - mode="elements", - strategy="hi_res", - chunking_strategy="by_title", - max_characters=chunk_size, - overlap=chunk_overlap, - multipage_sections=False, # respect page boundaries - include_page_breaks=True, - ) - documents = await loader.aload() - logger.debug("File '{file_name}' loaded: {docs}", file_name=file_name, docs=documents) - logger.info(f"Load documents: {documents}") - chunks = format_chunks(documents, file_name) + def unstructured_api_file_loader( + strategy: str, split_pdf_page: bool + ) -> UnstructuredAPIFileLoader: + return UnstructuredAPIFileLoader( + tmp_path, + url=ENV_CONFIG.unstructuredio_url, + api_key=ENV_CONFIG.unstructuredio_api_key_plain, + mode="elements", + strategy=strategy, + chunking_strategy="by_title", + max_characters=chunk_size, + overlap=chunk_overlap, + multipage_sections=False, # respect page boundaries + include_page_breaks=True, + split_pdf_page=split_pdf_page, + ) - chunks = combine_table_chunks(chunks=chunks) + if ENV_CONFIG.owl_fast_pdf_parsing: + strategy, split_pdf_page = "fast", False + documents = await unstructured_api_file_loader( + strategy=strategy, split_pdf_page=split_pdf_page + ).aload() + if len(documents) == 0: + strategy = "ocr_only" + logger.info( + "[Scan PDF Detected]: No text or content is found, running `ocr` mode." + ) + else: + strategy, split_pdf_page = "hi_res", True + + documents = await unstructured_api_file_loader( + strategy=strategy, split_pdf_page=split_pdf_page + ).aload() + logger.info( + f"File '{file_name}' parsed in `{strategy}` mode {'with' if split_pdf_page else 'without'} partitioning." + ) + logger.debug(f"File '{file_name}' content: {documents}") + chunks = format_chunks(documents, file_name) + if strategy == "hi_res": + chunks = combine_table_chunks(chunks=chunks) else: - raise ValueError(f"Unsupported file type: {ext}") + raise BadInputError(f'File type "{ext}" is not supported at the moment.') + logger.info(f'File "{file_name}" loaded and split into {len(chunks):,d} chunks.') return chunks diff --git a/services/api/src/owl/models.py b/services/api/src/owl/models.py index 61fd755..6efd67f 100644 --- a/services/api/src/owl/models.py +++ b/services/api/src/owl/models.py @@ -1,3 +1,4 @@ +import asyncio import base64 import imghdr import io @@ -5,59 +6,77 @@ from functools import lru_cache import httpx +import litellm import orjson +from fastapi import Request from langchain.schema.embeddings import Embeddings from litellm import Router +from litellm.router import RetryPolicy from loguru import logger from jamaibase.utils.io import json_loads -from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.configs.manager import ENV_CONFIG from owl.protocol import ( Chunk, ClipInputData, CompletionUsage, + EmbeddingModelConfig, EmbeddingResponse, EmbeddingResponseData, + ExternalKeys, ModelListConfig, + RerankingModelConfig, ) -from owl.utils import filter_external_api_key +from owl.utils import select_external_api_key +litellm.drop_params = True +litellm.set_verbose = False +litellm.suppress_debug_info = True -def _resolve_provider_model_name(id: str) -> str: - split_names = id.split("/") - if len(split_names) < 2: - raise ValueError("`id` needs to be in the form of provider/model_name") - # this assume using huggingface model (usually org/model_name) - return split_names[0], "/".join(split_names[1:]) +HTTP_CLIENT = httpx.AsyncClient(timeout=60.0, transport=httpx.AsyncHTTPTransport(retries=3)) -HTTP_CLIENT = httpx.Client(timeout=60.0, transport=httpx.HTTPTransport(retries=3)) - - -@lru_cache(maxsize=1) -def _get_embedding_router(model_json: str): +@lru_cache(maxsize=32) +def _get_embedding_router(model_json: str, external_api_keys: str): models = ModelListConfig.model_validate_json(model_json).embed_models + ExternalApiKeys = ExternalKeys.model_validate_json(external_api_keys) # refer to https://docs.litellm.ai/docs/routing for more details - # current fixed strategy to 'simple-shuffle' (no need extra redis, or setting of RPM/TPM) return Router( model_list=[ { - "model_name": m.litellm_id, + "model_name": m.id, "litellm_params": { - "model": m.litellm_id, - "api_key": "null", - "api_base": None if m.api_base == "" else m.api_base, + "model": deployment.litellm_id if deployment.litellm_id.strip() else m.id, + "api_key": select_external_api_key(ExternalApiKeys, deployment.provider), + "api_base": deployment.api_base if deployment.api_base.strip() else None, }, } for m in models + for deployment in m.deployments ], - routing_strategy="simple-shuffle", + routing_strategy="latency-based-routing", + num_retries=3, + retry_policy=RetryPolicy( + TimeoutErrorRetries=3, + RateLimitErrorRetries=3, + ContentPolicyViolationErrorRetries=3, + AuthenticationErrorRetries=0, + BadRequestErrorRetries=0, + ContextWindowExceededErrorRetries=0, + ), + retry_after=5.0, + timeout=ENV_CONFIG.owl_embed_timeout_sec, + allowed_fails=3, + cooldown_time=0.0, ) # Cached function -def get_embedding_router(): - return _get_embedding_router(CONFIG.get_model_json()) +def get_embedding_router(all_models: ModelListConfig, external_keys: ExternalKeys) -> Router: + return _get_embedding_router( + model_json=all_models.model_dump_json(), + external_api_keys=external_keys.model_dump_json(), + ) class CloudBase: @@ -68,6 +87,14 @@ def batch(seq, n): for i in range(0, len(seq), n): yield seq[i : i + n] + @staticmethod + def _resolve_provider_model_name(id: str) -> str: + split_names = id.split("/") + if len(split_names) < 2: + raise ValueError("`id` needs to be in the form of provider/model_name") + # this assume using huggingface model (usually org/model_name) + return split_names[0], "/".join(split_names[1:]) + class CloudReranker(CloudBase): API_MAP = { @@ -76,69 +103,51 @@ class CloudReranker(CloudBase): "jina": ENV_CONFIG.jina_api_base, } - def __init__( - self, - reranker_name: str, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", - ): + def __init__(self, request: Request): """Reranker router. Args: - reranker_name (str): Model name in the form (`provider/name`). - openai_api_key (str, optional): OpenAI API key. Defaults to "". - anthropic_api_key (str, optional): Anthropic API key. Defaults to "". - gemini_api_key (str, optional): Gemini API key. Defaults to "". - cohere_api_key (str, optional): Cohere API key. Defaults to "". - groq_api_key (str, optional): Groq API key. Defaults to "". - together_api_key (str, optional): Together API key. Defaults to "". - jina_api_key (str, optional): Jina API key. Defaults to "". - voyage_api_key (str, optional): Voyage API key. Defaults to "". + request (Request): Starlette request object. Raises: ValueError: If provider is not supported. """ + from owl.billing import BillingManager + + self.request = request + self.external_keys: ExternalKeys = request.state.external_keys + self._billing: BillingManager = request.state.billing + + def set_rerank_model(self, reranker_name): # Get embedder_config - reranker_config = CONFIG.get_rerank_model_info(reranker_name) + reranker_config: RerankingModelConfig = ( + self.request.state.all_models.get_rerank_model_info(reranker_name) + ) reranker_config = reranker_config.model_dump(exclude_none=True) - provider_name, model_name = _resolve_provider_model_name(reranker_config["id"]) - self.provider_name = provider_name - self.model_name = model_name + _, model_name = self._resolve_provider_model_name(reranker_config["id"]) self.reranker_config = reranker_config - if provider_name not in ["ellm", "cohere", "voyage", "jina"]: + # 2024-10-03: reranker only support single deployment now. + deployment = reranker_config["deployments"][0] + self.provider_name = deployment["provider"] + if deployment["provider"] not in ["ellm", "cohere", "voyage", "jina"]: raise ValueError( - f"reranker `provider`: {provider_name} not supported please use only following provider: ellm/cohere/voyage/jina" + f"reranker `provider`: {deployment['provider']} not supported please use only following provider: ellm/cohere/voyage/jina" ) api_url = ( - reranker_config["api_base"] + "/rerank" - if provider_name == "ellm" - else self.API_MAP[provider_name] + "/rerank" - ) - api_key = filter_external_api_key( - reranker_name, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, + deployment["api_base"] + "/rerank" + if self.provider_name == "ellm" + else self.API_MAP[self.provider_name] + "/rerank" ) + api_key = select_external_api_key(self.external_keys, self.provider_name) self.reranking_args = { - "model": self.model_name, + "model": model_name, "api_key": api_key, "api_url": api_url, } - def rerank_chunks( + async def rerank_chunks( self, + reranker_name: str, chunks: list[Chunk], query: str, batch_size: int = 256, @@ -146,37 +155,43 @@ def rerank_chunks( content_weight: float = 0.4, use_concat: bool = False, ) -> list[tuple[Chunk, float, int]]: + self.set_rerank_model(reranker_name) # configure the reranker to be used if self.provider_name == "voyage": batch_size = 32 # voyage has a limit on token lengths 100,000 all_contents = [d.text for d in chunks] all_titles = [d.title for d in chunks] + self._billing.check_reranker_quota(model_id=self.reranker_config["id"]) if use_concat: all_concats = [ "Title: " + _title + "\nContent: " + _content - for _title, _content in zip(all_titles, all_contents) + for _title, _content in zip(all_titles, all_contents, strict=True) ] - concat_scores = self._rerank_by_batch(query, all_concats, batch_size) + concat_scores = await self._rerank_by_batch(query, all_concats, batch_size) scores = [x["relevance_score"] for x in concat_scores] else: - content_scores = self._rerank_by_batch(query, all_contents, batch_size) - title_scores = self._rerank_by_batch(query, all_titles, batch_size) + content_scores = await self._rerank_by_batch(query, all_contents, batch_size) + title_scores = await self._rerank_by_batch(query, all_titles, batch_size) scores = [ ( c["relevance_score"] * content_weight + t["relevance_score"] * title_weight if chunks[idx].title != "" else 0.0 ) - for idx, (c, t) in enumerate(zip(content_scores, title_scores)) + for idx, (c, t) in enumerate(zip(content_scores, title_scores, strict=True)) ] + self._billing.create_reranker_events( + self.reranker_config["id"], + len(all_titles) // 100, + ) reranked_chunks = sorted( - ((d, s, i) for i, (d, s) in enumerate(zip(chunks, scores))), + ((d, s, i) for i, (d, s) in enumerate(zip(chunks, scores, strict=True))), key=lambda x: x[1], reverse=True, ) logger.info(f"Reranked order: {[r[2] for r in reranked_chunks]}") return reranked_chunks - def _rerank(self, query, documents: list[str]) -> list[dict]: + async def _rerank(self, query, documents: list[str]) -> list[dict]: headers = { "Content-Type": "application/json", "Authorization": ( @@ -192,7 +207,9 @@ def _rerank(self, query, documents: list[str]) -> list[dict]: "return_documents": False, } - response = HTTP_CLIENT.post(self.reranking_args["api_url"], headers=headers, json=data) + response = await HTTP_CLIENT.post( + self.reranking_args["api_url"], headers=headers, json=data + ) if response.status_code != 200: raise RuntimeError(response.text) response = json_loads(response.text) @@ -201,10 +218,10 @@ def _rerank(self, query, documents: list[str]) -> list[dict]: else: return response["results"] - def _rerank_by_batch(self, query, documents: list[str], batch_size: int) -> list[dict]: + async def _rerank_by_batch(self, query, documents: list[str], batch_size: int) -> list[dict]: all_data = [] for document in self.batch(documents, batch_size): - _tmp = self._rerank( + _tmp = await self._rerank( query, document ) # this scores might not be sorted by input index. some provider will sort result by relevance score _tmp = sorted(_tmp, key=lambda x: x["index"], reverse=False) # sort by index @@ -213,73 +230,49 @@ def _rerank_by_batch(self, query, documents: list[str], batch_size: int) -> list class CloudEmbedder(CloudBase): - def __init__( - self, - embedder_name: str, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", - ): + def __init__(self, request: Request): """Embedder router. Args: - embedder_name (str): Model name in the form (`provider/name`). - openai_api_key (str, optional): OpenAI API key. Defaults to "". - anthropic_api_key (str, optional): Anthropic API key. Defaults to "". - gemini_api_key (str, optional): Gemini API key. Defaults to "". - cohere_api_key (str, optional): Cohere API key. Defaults to "". - groq_api_key (str, optional): Groq API key. Defaults to "". - together_api_key (str, optional): Together API key. Defaults to "". - jina_api_key (str, optional): Jina API key. Defaults to "". - voyage_api_key (str, optional): Voyage API key. Defaults to "". - - Raises: - ValueError: If provider is not supported. + request (Request): Starlette request object. """ + from owl.billing import BillingManager + + self.request = request + self.external_keys: ExternalKeys = request.state.external_keys + self._billing: BillingManager = request.state.billing + + def set_embed_model(self, embedder_name): # Get embedder_config - embedder_config = CONFIG.get_embed_model_info(embedder_name) + embedder_config: EmbeddingModelConfig = self.request.state.all_models.get_embed_model_info( + embedder_name + ) embedder_config = embedder_config.model_dump(exclude_none=True) - provider_name, model_name = _resolve_provider_model_name(embedder_config["id"]) - self.provider_name = provider_name - self.model_name = "voyage/" + model_name if provider_name == "voyage" else model_name self.embedder_config = embedder_config - if provider_name not in ["ellm", "openai", "cohere", "voyage", "jina"]: - raise ValueError( - ( - f"Embedder provider {provider_name} not supported, " - "please use only following provider: ellm/openai/cohere/voyage/jina" - ) - ) - api_key = filter_external_api_key( - embedder_name, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, + self.embedder_router = get_embedding_router( + self.request.state.all_models, self.external_keys ) + for deployment in embedder_config["deployments"]: + if deployment["provider"] not in ["ellm", "openai", "cohere", "voyage", "jina"]: + raise ValueError( + ( + f"Embedder provider {deployment['provider']} not supported, " + "please use only following provider: ellm/openai/cohere/voyage/jina" + ) + ) self.embedding_args = { - "model": embedder_config["litellm_id"], - "api_key": api_key, + "model": embedder_config["id"], "dimensions": self.embedder_config.get("dimensions"), } - def embed_texts(self, texts: list[str]) -> EmbeddingResponse: - if self.provider_name == "jina": + async def embed_texts(self, texts: list[str]) -> EmbeddingResponse: + if self.embedder_config["owned_by"] == "jina": headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {self.embedding_args['api_key']}", + "Authorization": f"Bearer {self.external_keys.jina}", } data = {"input": texts, "model": self.embedding_args["model"]} - response = HTTP_CLIENT.post( + response = await HTTP_CLIENT.post( ENV_CONFIG.jina_api_base + "/embeddings", headers=headers, json=data, @@ -288,22 +281,34 @@ def embed_texts(self, texts: list[str]) -> EmbeddingResponse: raise RuntimeError(response.text) response = EmbeddingResponse.model_validate_json(response.text) else: - response = get_embedding_router().embedding(**self.embedding_args, input=texts) + response = await self.embedder_router.aembedding(**self.embedding_args, input=texts) response = EmbeddingResponse.model_validate(response.model_dump()) + return response - def embed_documents(self, texts: list[str], batch_size: int = 2048) -> EmbeddingResponse: + async def embed_documents( + self, + embedder_name: str, + texts: list[str], + batch_size: int = 2048, + ) -> EmbeddingResponse: + self.set_embed_model(embedder_name) """Embed search docs.""" if not isinstance(texts, list): raise TypeError("`texts` must be a list.") - if self.provider_name == "cohere": + if self.embedder_config["owned_by"] == "cohere": self.embedding_args["input_type"] = "search_document" batch_size = 96 # limit on cohere server - if self.provider_name == "jina": + if self.embedder_config["owned_by"] == "jina": batch_size = 128 # don't know limit, but too large will timeout - if self.provider_name == "voyage": + if self.embedder_config["owned_by"] == "voyage": batch_size = 128 # limit on voyage server - responses = [self.embed_texts(txt) for txt in self.batch(texts, batch_size)] + if self.embedder_config["owned_by"] == "openai": + batch_size = 256 # limited by token per min (10,000,000) + self._billing.check_embedding_quota(model_id=self.embedder_config["id"]) + responses = await asyncio.gather( + *[self.embed_texts(txt) for txt in self.batch(texts, batch_size)] + ) embeddings = [e.embedding for e in itertools.chain(*[r.data for r in responses])] usages = CompletionUsage( prompt_tokens=sum(r.usage.prompt_tokens for r in responses), @@ -314,17 +319,28 @@ def embed_documents(self, texts: list[str], batch_size: int = 2048) -> Embedding model=responses[0].model, usage=usages, ) + self._billing.create_embedding_events( + model=self.embedder_config["id"], + token_usage=usages.total_tokens, + ) return embeddings - def embed_queries(self, texts: list[str]) -> EmbeddingResponse: + async def embed_queries(self, embedder_name: str, texts: list[str]) -> EmbeddingResponse: + self.set_embed_model(embedder_name) """Embed query text.""" if not isinstance(texts, list): raise TypeError("`texts` must be a list.") if self.embedding_args.get("transform_query"): texts = [self.embedding_args.get("transform_query") + text for text in texts] - if self.provider_name == "cohere": + if self.embedder_config["owned_by"] == "cohere": self.embedding_args["input_type"] = "search_query" - return self.embed_texts(texts) + self._billing.check_embedding_quota(model_id=self.embedder_config["id"]) + response = await self.embed_texts(texts) + self._billing.create_embedding_events( + model=self.embedder_config["id"], + token_usage=response.usage.total_tokens, + ) + return response class CloudImageEmbedder(CloudBase, Embeddings): @@ -345,11 +361,11 @@ def __init__(self): "api_url": api_url, } - def _embed(self, objects: list[ClipInputData]) -> list[list[float]]: + async def _embed(self, objects: list[ClipInputData]) -> list[list[float]]: parsed_data = self._parse_data(objects) headers = {"Content-Type": "application/json"} data = {"data": parsed_data, "execEndpoint": "/"} - response = HTTP_CLIENT.post( + response = await HTTP_CLIENT.post( self.embedding_args["api_url"], headers=headers, data=orjson.dumps(data), @@ -373,24 +389,28 @@ def _get_blob_from_data(self, data: ClipInputData): # Get the image format try: img_format = imghdr.what(f).lower() - except Exception: - raise OSError(f"object {data.image_filename} is not a valid image format.") + except Exception as e: + raise ValueError( + f"object {data.image_filename} is not a valid image format." + ) from e # Read the image file img_data = f.read() img_base64 = base64.b64encode(img_data) data_uri = f"data:image/{img_format};base64," + img_base64.decode("utf-8") return data_uri - def embed_documents( + async def embed_documents( self, objects: list[ClipInputData], batch_size: int = 64 ) -> list[list[float]]: """Embed search objects (image).""" if not isinstance(objects, list): raise TypeError("`objects` must be a list.") - embeddings = [self._embed(obj) for obj in self.batch(objects, batch_size)] + embeddings = await asyncio.gather( + *[self._embed(obj) for obj in self.batch(objects, batch_size)] + ) return list(itertools.chain(*embeddings)) - def embed_query(self, data: ClipInputData) -> list[float]: + async def embed_query(self, data: ClipInputData) -> list[float]: """Embed query text/image.""" - embeddings = self._embed([data]) + embeddings = await self._embed([data]) return embeddings[0] # should just have 1 elements diff --git a/services/api/src/owl/protocol.py b/services/api/src/owl/protocol.py index 64a8047..9d4e8c2 100644 --- a/services/api/src/owl/protocol.py +++ b/services/api/src/owl/protocol.py @@ -11,33 +11,40 @@ from __future__ import annotations import re +from copy import deepcopy from datetime import datetime, timezone from enum import Enum, EnumMeta -from functools import reduce -from typing import Annotated, Any, Generic, Literal, Sequence, Type, TypeVar +from functools import cached_property, reduce +from os.path import splitext +from typing import Annotated, Any, Generic, Literal, Sequence, Type, TypeVar, Union import numpy as np import pyarrow as pa from loguru import logger +from natsort import natsorted from pydantic import ( + AfterValidator, BaseModel, BeforeValidator, ConfigDict, + Discriminator, Field, + Tag, ValidationError, computed_field, create_model, field_validator, model_validator, ) -from pydantic.functional_validators import AfterValidator -from sqlmodel import JSON, Column +from sqlmodel import JSON, Column, MetaData, SQLModel from sqlmodel import Field as sql_Field -from sqlmodel import MetaData, SQLModel from typing_extensions import Self -from uuid_extensions import uuid7str +from jamaibase import protocol as p +from jamaibase.exceptions import ResourceNotFoundError from jamaibase.utils.io import json_dumps +from owl.utils import datetime_now_iso, uuid7_draft2_str +from owl.version import __version__ as owl_version PositiveInt = Annotated[int, Field(ge=0, description="Positive integer.")] PositiveNonZeroInt = Annotated[int, Field(gt=0, description="Positive non-zero integer.")] @@ -56,51 +63,555 @@ 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" + +# 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" + +# 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" + +IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] +DOCUMENT_FILE_EXTENSIONS = [ + ".pdf", + ".txt", + ".md", + ".docx", + ".xml", + ".html", + ".json", + ".csv", + ".tsv", + ".jsonl", + ".xlsx", + ".xls", +] + +Name = Annotated[ + str, + BeforeValidator(lambda v, _: v.strip() if isinstance(v, str) else v), + Field( + pattern=r"\w+", + max_length=100, + description=( + "Name or ID. Must be unique with at least 1 non-symbol character and up to 100 characters." + ), + ), +] + + +class UserAgent(BaseModel): + is_browser: bool = Field( + default=True, + description="Whether the request originates from a browser or an app.", + examples=[True, False], + ) + agent: str = Field( + description="The agent, such as 'SDK', 'Chrome', 'Firefox', 'Edge', or an empty string if it cannot be determined.", + examples=["", "SDK", "Chrome", "Firefox", "Edge"], + ) + agent_version: str = Field( + default="", + description="The agent version, or an empty string if it cannot be determined.", + examples=["", "5.0", "0.3.0"], + ) + os: str = Field( + default="", + description="The system/OS name and release, such as 'Windows NT 10.0', 'Linux 5.15.0-113-generic', or an empty string if it cannot be determined.", + examples=["", "Windows NT 10.0", "Linux 5.15.0-113-generic"], + ) + architecture: str = Field( + default="", + description="The machine type, such as 'AMD64', 'x86_64', or an empty string if it cannot be determined.", + examples=["", "AMD64", "x86_64"], + ) + language: str = Field( + default="", + description="The SDK language, such as 'TypeScript', 'Python', or an empty string if it is not applicable.", + examples=["", "TypeScript", "Python"], + ) + language_version: str = Field( + default="", + description="The SDK language version, such as '4.9', '3.10.14', or an empty string if it is not applicable.", + examples=["", "4.9", "3.10.14"], + ) + + @computed_field( + description="The system/OS name, such as 'Linux', 'Darwin', 'Java', 'Windows', or an empty string if it cannot be determined.", + examples=["", "Windows NT", "Linux"], + ) + @property + def system(self) -> str: + return self._split_os_string()[0] + + @computed_field( + description="The system's release, such as '2.2.0', 'NT', or an empty string if it cannot be determined.", + examples=["", "10", "5.15.0-113-generic"], + ) + @property + def system_version(self) -> str: + return self._split_os_string()[1] + + def _split_os_string(self) -> tuple[str, str]: + match = re.match(r"([^\d]+) ([\d.]+).*$", self.os) + if match: + os_name = match.group(1).strip() + os_version = match.group(2).strip() + return os_name, os_version + else: + return "", "" + + @classmethod + def from_user_agent_string(cls, ua_string: str) -> Self: + if not ua_string: + return cls(is_browser=False, agent="") + + # SDK pattern + sdk_match = re.match(r"SDK/(\S+) \((\w+)/(\S+); ([^;]+); (\w+)\)", ua_string) + if sdk_match: + return cls( + is_browser=False, + agent="SDK", + agent_version=sdk_match.group(1), + os=sdk_match.group(4), + architecture=sdk_match.group(5), + language=sdk_match.group(2), + language_version=sdk_match.group(3), + ) + + # Browser pattern + browser_match = re.match(r"Mozilla/5.0 \(([^)]+)\).*", ua_string) + if browser_match: + os_info = browser_match.group(1).split(";") + # Microsoft Edge + match = re.match(r".+(Edg/.+)$", ua_string) + if match: + return cls( + agent="Edge", + agent_version=match.group(1).split("/")[-1].strip(), + os=os_info[0].strip(), + architecture=os_info[-1].strip() if len(os_info) == 3 else "", + language="", + language_version="", + ) + # Firefox + match = re.match(r".+(Firefox/.+)$", ua_string) + if match: + return cls( + agent="Firefox", + agent_version=match.group(1).split("/")[-1].strip(), + os=os_info[0].strip(), + architecture=os_info[-1].strip() if len(os_info) == 3 else "", + language="", + language_version="", + ) + # Chrome + match = re.match(r".+(Chrome/.+)$", ua_string) + if match: + return cls( + agent="Chrome", + agent_version=match.group(1).split("/")[-1].strip(), + os=os_info[0].strip(), + architecture=os_info[-1].strip() if len(os_info) == 3 else "", + language="", + language_version="", + ) + return cls(is_browser="mozilla" in ua_string.lower(), agent="") + + +class ExternalKeys(BaseModel): + model_config = ConfigDict(extra="forbid") + custom: str = "" + openai: str = "" + anthropic: str = "" + gemini: str = "" + cohere: str = "" + groq: str = "" + together_ai: str = "" + jina: str = "" + voyage: str = "" + hyperbolic: str = "" + cerebras: str = "" + sambanova: str = "" + class OkResponse(BaseModel): ok: bool = True -class Document(BaseModel): - """Document class for use in DocIO.""" +class StringResponse(BaseModel): + object: Literal["string"] = Field( + default="string", + description='The object type, which is always "string".', + examples=["string"], + ) + data: str = Field( + description="The string data.", + examples=["text"], + ) + + +class AdminOrderBy(str, Enum): + ID = "id" + """Sort by `id` column.""" + NAME = "name" + """Sort by `name` column.""" + CREATED_AT = "created_at" + """Sort by `created_at` column.""" + UPDATED_AT = "updated_at" + """Sort by `updated_at` column.""" + + def __str__(self) -> str: + return self.value + + +class GenTableOrderBy(str, Enum): + ID = "id" + """Sort by `id` column.""" + UPDATED_AT = "updated_at" + """Sort by `updated_at` column.""" + + def __str__(self) -> str: + return self.value + + +class TemplateMeta(BaseModel): + """Template metadata.""" + + name: Name + description: str + tags: list[str] + created_at: str = Field( + default_factory=datetime_now_iso, + description="Creation datetime (ISO 8601 UTC).", + ) + + +class ModelCapability(str, Enum): + COMPLETION = "completion" + CHAT = "chat" + IMAGE = "image" + EMBED = "embed" + RERANK = "rerank" + + def __str__(self) -> str: + return self.value + + +class ModelInfo(BaseModel): + id: str = Field( + description=( + 'Unique identifier in the form of "{provider}/{model_id}". ' + "Users will specify this to select a model." + ), + examples=[EXAMPLE_CHAT_MODEL], + ) + object: str = Field( + default="model", + description="Type of API response object.", + examples=["model"], + ) + name: str = Field( + description="Name of the model.", + examples=["OpenAI GPT-4o Mini"], + ) + context_length: int = Field( + description="Context length of model.", + examples=[16384], + ) + languages: list[str] = Field( + description="List of languages which the model is well-versed in.", + examples=[["en"]], + ) + owned_by: str = Field( + default="", + description="The organization that owns the model. Defaults to the provider in model ID.", + examples=["openai"], + ) + capabilities: list[ModelCapability] = Field( + description="List of capabilities of model.", + examples=[[ModelCapability.CHAT]], + ) + + @model_validator(mode="after") + def check_owned_by(self) -> Self: + if self.owned_by.strip() == "": + self.owned_by = self.id.split("/")[0] + return self + + +class ModelInfoResponse(BaseModel): + object: str = Field( + default="chat.model_info", + description="Type of API response object.", + examples=["chat.model_info"], + ) + data: list[ModelInfo] = Field( + description="List of model information.", + ) - page_content: str - metadata: dict = {} +class ModelDeploymentConfig(BaseModel): + litellm_id: str = Field( + default="", + description=( + "LiteLLM routing / mapping ID. " + '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], + ) + api_base: str = Field( + default="", + description="Hosting url for the model.", + ) + provider: str = Field( + default="", + description="Provider of the model.", + ) -class Chunk(BaseModel): - """Class for storing a piece of text and associated metadata.""" - text: str = Field(description="Document chunk text.") - title: str = Field(default="", description='Document title. Defaults to "".') - page: int = Field(default=0, description="Page number. Defaults to 0.") - file_name: str = Field(default="", description="Document file name.") - file_path: str = Field(default="", description="Document file path.") - document_id: str = Field(default="", description="Document ID.") - chunk_id: str = Field(default="", description="Chunk ID.") - metadata: dict = Field( - default_factory=dict, - description="Arbitrary metadata about the page content (e.g., source, relationships to other documents, etc.).", +class ModelConfig(ModelInfo): + priority: int = Field( + default=0, + ge=0, + description="Priority when assigning default model. Larger number means higher priority.", + ) + deployments: list[ModelDeploymentConfig] = Field( + [], + description="List of model deployment configs.", + ) + litellm_id: str = Field( + default="", + deprecated=True, + description=( + "Deprecated. Retained for compatibility. " + "LiteLLM routing / mapping ID. " + '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], + ) + api_base: str = Field( + default="", + deprecated=True, + description="Deprecated. Retained for compatibility. Hosting url for the model.", + ) + + @model_validator(mode="after") + def compat_deployments(self) -> Self: + if len(self.deployments) > 0: + return self + self.deployments = [ + ModelDeploymentConfig( + litellm_id=self.litellm_id, + api_base=self.api_base, + provider=self.id.split("/")[0], + ) + ] + return self + + +class LLMModelConfig(ModelConfig): + input_cost_per_mtoken: float = Field( + default=-1.0, + description="Cost in USD per million (mega) input / prompt token.", + ) + output_cost_per_mtoken: float = Field( + default=-1.0, + description="Cost in USD per million (mega) output / completion token.", + ) + capabilities: list[ModelCapability] = Field( + default=[ModelCapability.CHAT], + description="List of capabilities of model.", + examples=[[ModelCapability.CHAT]], + ) + + @model_validator(mode="after") + def check_cost_per_mtoken(self) -> Self: + # GPT-4o-mini pricing (2024-08-10) + if self.input_cost_per_mtoken <= 0: + self.input_cost_per_mtoken = 0.150 + if self.output_cost_per_mtoken <= 0: + self.output_cost_per_mtoken = 0.600 + return self + + +class EmbeddingModelConfig(ModelConfig): + id: str = Field( + description=( + 'Unique identifier in the form of "{provider}/{model_id}". ' + '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], + ) + embedding_size: int = Field( + description="Embedding size of the model", + ) + # Currently only useful for openai + dimensions: int | None = Field( + default=None, + description="Dimensions, a reduced embedding size (openai specs).", + ) + # Most likely only useful for hf models + transform_query: str | None = Field( + default=None, + description="Transform query that might be needed, esp. for hf models", + ) + capabilities: list[ModelCapability] = Field( + default=[ModelCapability.EMBED], + description="List of capabilities of model.", + examples=[[ModelCapability.EMBED]], + ) + cost_per_mtoken: float = Field( + default=-1, + description="Cost in USD per million embedding tokens.", ) + @model_validator(mode="after") + def check_cost_per_mtoken(self) -> Self: + # OpenAI text-embedding-3-small pricing (2024-09-09) + if self.cost_per_mtoken < 0: + self.cost_per_mtoken = 0.022 + return self -class SplitChunksParams(BaseModel): - method: str = Field( - default="RecursiveCharacterTextSplitter", - description="Name of the splitter.", - examples=["RecursiveCharacterTextSplitter"], + +class RerankingModelConfig(ModelConfig): + id: str = Field( + description=( + 'Unique identifier in the form of "{provider}/{model_id}". ' + '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], ) - chunk_size: PositiveNonZeroInt = Field( - default=1000, - description="Maximum chunk size (number of characters). Must be > 0.", - examples=[1000], + capabilities: list[ModelCapability] = Field( + default=[ModelCapability.RERANK], + description="List of capabilities of model.", + examples=[[ModelCapability.RERANK]], ) - chunk_overlap: PositiveInt = Field( - default=200, - description="Overlap in characters between chunks. Must be >= 0.", - examples=[200], + cost_per_ksearch: float = Field( + default=-1, + description="Cost in USD for a thousand searches.", ) + @model_validator(mode="after") + def check_cost_per_ksearch(self) -> Self: + # Cohere rerank-multilingual-v3.0 pricing (2024-09-09) + if self.cost_per_ksearch < 0: + self.cost_per_ksearch = 2.0 + return self + + +class ModelListConfig(BaseModel): + object: str = Field( + default="configs.models", + description="Type of API response object.", + examples=["configs.models"], + ) + llm_models: list[LLMModelConfig] = [] + embed_models: list[EmbeddingModelConfig] = [] + rerank_models: list[RerankingModelConfig] = [] + + @cached_property + def models(self) -> list[LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig]: + """A list of all the models.""" + return self.llm_models + self.embed_models + self.rerank_models + + @cached_property + def model_map(self) -> dict[str, LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig]: + """A map of all the models.""" + return {m.id: m for m in self.models} + + def get_model_info( + self, model_id: str + ) -> LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig: + try: + return self.model_map[model_id] + except KeyError: + raise ValueError( + f"Invalid model ID: {model_id}. Available models: {[m.id for m in self.models]}" + ) from None + + def get_llm_model_info(self, model_id: str) -> LLMModelConfig: + return self.get_model_info(model_id) + + def get_embed_model_info(self, model_id: str) -> EmbeddingModelConfig: + return self.get_model_info(model_id) + + def get_rerank_model_info(self, model_id: str) -> RerankingModelConfig: + return self.get_model_info(model_id) + + def get_default_model(self, capabilities: list[str] | None = None) -> str: + models = self.models + if capabilities is not None: + for capability in capabilities: + models = [m for m in models if capability 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] + return model.id + + @staticmethod + def _sort_key_with_priority( + x: LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig, + ) -> str: + return (int(not x.id.startswith("ellm")), -x.priority, x.name) + + @model_validator(mode="after") + def sort_models(self) -> Self: + self.llm_models = list(natsorted(self.llm_models, key=self._sort_key)) + self.embed_models = list(natsorted(self.embed_models, key=self._sort_key)) + self.rerank_models = list(natsorted(self.rerank_models, key=self._sort_key)) + return self + + @model_validator(mode="after") + def unique_model_ids(self) -> Self: + if len(set(m.id for m in self.models)) != len(self.models): + raise ValueError("There are repeated model IDs in the config.") + return self + + @staticmethod + def _sort_key( + x: LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig, + ) -> str: + return (int(not x.id.startswith("ellm")), x.name) + + def __add__(self, other: ModelListConfig) -> ModelListConfig: + if isinstance(other, ModelListConfig): + self_ids = set(m.id for m in self.models) + other_ids = set(m.id for m in other.models) + repeated_ids = self_ids.intersection(other_ids) + if len(repeated_ids) != 0: + raise ValueError( + f"There are repeated model IDs among the two configs: {list(repeated_ids)}" + ) + return ModelListConfig( + llm_models=self.llm_models + other.llm_models, + embed_models=self.embed_models + other.embed_models, + rerank_models=self.rerank_models + other.rerank_models, + ) + else: + raise TypeError( + f"Unsupported operand type(s) for +: 'ModelListConfig' and '{type(other)}'" + ) + + +class Chunk(p.Chunk): + pass + + +class SplitChunksParams(p.SplitChunksParams): + pass + class SplitChunksRequest(BaseModel): id: str = Field( @@ -145,9 +656,11 @@ def str_trunc(self) -> str: class RAGParams(BaseModel): table_id: str = Field(description="Knowledge Table ID", examples=["my-dataset"], min_length=2) - reranking_model: Annotated[ - str | None, Field(description="Reranking model to use for hybrid search.") - ] = None + reranking_model: str | None = Field( + default=None, + description="Reranking model to use for hybrid search.", + examples=[EXAMPLE_RERANKING_MODEL, None], + ) search_query: str = Field( default="", description="Query used to retrieve items from the KB database. If not provided (default), it will be generated using LLM.", @@ -214,164 +727,6 @@ class VectorSearchResponse(BaseModel): ) -class ModelCapability(str, Enum): - completion = "completion" - chat = "chat" - image = "image" - embed = "embed" - rerank = "rerank" - - -DEFAULT_CHAT_MODEL = "openai/gpt-3.5-turbo" - -# 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} -DEFAULT_EMBEDDING_MODEL = "openai/text-embedding-3-small-512" - -# 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} -DEFAULT_RERANKING_MODEL = "cohere/rerank-multilingual-v3.0" - - -class ModelInfo(BaseModel): - id: str = Field( - description="Unique identifier of the model.", - examples=[DEFAULT_CHAT_MODEL], - ) - object: str = Field( - default="model", - description="Type of API response object.", - examples=["model"], - ) - name: str = Field( - default=DEFAULT_CHAT_MODEL, - description="Name of model.", - examples=[DEFAULT_CHAT_MODEL], - ) - context_length: int = Field( - description="Context length of model.", - examples=[16384], - ) - languages: list[str] = Field( - description="List of languages which the model is well-versed in.", - examples=[["en"]], - ) - capabilities: list[Literal["completion", "chat", "image", "embed", "rerank"]] = Field( - description="List of capabilities of model.", - examples=[["chat"]], - ) - owned_by: str = Field( - description="The organization that owns the model.", - examples=["openai"], - ) - - -class ModelInfoResponse(BaseModel): - object: str = Field( - default="chat.model_info", - description="Type of API response object.", - examples=["chat.model_info"], - ) - data: list[ModelInfo] = Field( - description="List of model information.", - ) - - -class LLMModelConfig(ModelInfo): - litellm_id: str = Field( - default="", - description="LiteLLM routing name for self-hosted models.", - # exclude=True, - ) - api_base: str = Field( - default="", - description="Hosting url for the model.", - ) - - -class EmbeddingModelConfig(BaseModel): - id: str = Field( - description=( - "Provider and model name in this format {provider}/{model}, " - "for self-host model with infinity do ellm/{org}/{model}" - ) - ) - litellm_id: str = Field( - description="LiteLLM compatible model ID.", - ) - owned_by: str = Field( - description="The organization that owns the model.", - examples=["openai"], - ) - context_length: int = Field( - description="Max context length of the model.", - ) - embedding_size: int = Field( - description="Embedding size of the model", - ) - dimensions: int | None = Field( - default=None, - description="Dimensions, a reduced embedding size (openai specs).", - ) # currently only useful for openai - languages: list[str] | None = Field( - default=["en"], - description="Supported language", - ) - transform_query: str | None = Field( - default=None, - description="Transform query that might be needed, esp. for hf models", - ) # most likely only useful for hf models - api_base: str = Field( - default="", - description="Hosting url for the model.", - ) - capabilities: list[Literal["embed"]] = Field( - default=["embed"], - description="List of capabilities of model.", - examples=[["embed"]], - ) - - -class RerankingModelConfig(BaseModel): - id: str = Field( - description=( - "Provider and model name in this format {provider}/{model}, " - "for self-host model with infinity do ellm/{org}/{model}" - ) - ) - owned_by: str = Field( - description="The organization that owns the model.", - examples=["openai"], - ) - context_length: int = Field( - description="Max context length of the model.", - ) - languages: list[str] | None = Field( - default=["en"], - description="Supported language.", - ) - api_base: str = Field( - default="", - description="Hosting url for the model.", - ) - capabilities: list[Literal["rerank"]] = Field( - default=["rerank"], - description="List of capabilities of model.", - examples=[["rerank"]], - ) - - -class ModelListConfig(BaseModel): - llm_models: list[LLMModelConfig] - embed_models: list[EmbeddingModelConfig] - rerank_models: list[RerankingModelConfig] - - class ChatRole(str, Enum): """Represents who said a chat message.""" @@ -384,8 +739,8 @@ class ChatRole(str, Enum): # FUNCTION = "function" # """The message is the result of a function call.""" - -pat = re.compile(r"[^a-zA-Z0-9_-]") + def __str__(self) -> str: + return self.value def sanitise_name(v: str) -> str: @@ -397,7 +752,7 @@ def sanitise_name(v: str) -> str: Returns: out (str): Sanitised name string that is safe for OpenAI. """ - return re.sub(pat, "_", v).strip() + return re.sub(r"[^a-zA-Z0-9_-]", "_", v).strip() MessageName = Annotated[str, AfterValidator(sanitise_name)] @@ -406,14 +761,11 @@ def sanitise_name(v: str) -> str: class ChatEntry(BaseModel): """Represents a message in the chat context.""" - model_config = ConfigDict( - use_enum_values=True, - frozen=True, - ) + model_config = ConfigDict(use_enum_values=True) role: ChatRole """Who said the message?""" - content: str + content: str | list[dict[str, str | dict[str, str]]] """The content of the message.""" name: MessageName | None = None """The name of the user who sent the message, if set (user messages only).""" @@ -429,14 +781,22 @@ def user(cls, content: str, **kwargs): return cls(role=ChatRole.USER, content=content, **kwargs) @classmethod - def assistant(cls, content: str | None, **kwargs): + def assistant(cls, content: str | list[dict[str, str]] | None, **kwargs): """Create a new assistant message.""" return cls(role=ChatRole.ASSISTANT, content=content, **kwargs) @field_validator("content", mode="before") @classmethod - def handle_null_content(cls, v: Any) -> Any: - return "" if v is None else v + def coerce_input(cls, value: Any) -> str | list[dict[str, str | dict[str, str]]]: + if isinstance(value, list): + return [cls.coerce_input(v) for v in value] + if isinstance(value, dict): + return {k: cls.coerce_input(v) for k, v in value.items()} + if isinstance(value, str): + return value + if value is None: + return "" + return str(value) class ChatThread(BaseModel): @@ -495,7 +855,7 @@ def delta(self) -> ChatEntry: class References(BaseModel): object: str = Field( default="chat.references", - description="The object type, which is always `chat.references`.", + description="Type of API response object.", examples=["chat.references"], ) chunks: list[Chunk] = Field( @@ -542,41 +902,13 @@ def remove_contents(self): return copy -class GenTableStreamReferences(References): - object: str = Field( - default="gen_table.references", - description="The object type, which is always `gen_table.references`.", - examples=["gen_table.references"], - ) - output_column_name: str - - -class GenTableChatCompletionChunks(BaseModel): - object: str = Field( - default="gen_table.completion.chunks", - description="The object type, which is always `gen_table.completion.chunks`.", - examples=["gen_table.completion.chunks"], - ) - columns: dict[str, ChatCompletionChunk] - row_id: str - - -class GenTableRowsChatCompletionChunks(BaseModel): - object: str = Field( - default="gen_table.completion.rows", - description="The object type, which is always `gen_table.completion.rows`.", - examples=["gen_table.completion.rows"], - ) - rows: list[GenTableChatCompletionChunks] - - class ChatCompletionChunk(BaseModel): id: str = Field( description="A unique identifier for the chat completion. Each chunk has the same ID." ) object: str = Field( default="chat.completion.chunk", - description="The object type, which is always `chat.completion.chunk`.", + description="Type of API response object.", examples=["chat.completion.chunk"], ) created: int = Field( @@ -608,19 +940,47 @@ def completion_tokens(self) -> int: return self.usage.completion_tokens @property - def text(self) -> str | None: + def text(self) -> str: """The text of the most recent chat completion.""" - return self.message.content if len(self.choices) > 0 else None + return self.message.content if len(self.choices) > 0 else "" @property def finish_reason(self) -> str | None: return self.choices[0].finish_reason if len(self.choices) > 0 else None +class GenTableStreamReferences(References): + object: str = Field( + default="gen_table.references", + description="Type of API response object.", + examples=["gen_table.references"], + ) + output_column_name: str + + +class GenTableChatCompletionChunks(BaseModel): + object: str = Field( + default="gen_table.completion.chunks", + description="Type of API response object.", + examples=["gen_table.completion.chunks"], + ) + columns: dict[str, ChatCompletionChunk] + row_id: str + + +class GenTableRowsChatCompletionChunks(BaseModel): + object: str = Field( + default="gen_table.completion.rows", + description="Type of API response object.", + examples=["gen_table.completion.rows"], + ) + rows: list[GenTableChatCompletionChunks] + + class GenTableStreamChatCompletionChunk(ChatCompletionChunk): object: str = Field( default="gen_table.completion.chunk", - description="The object type, which is always `gen_table.completion.chunk`.", + description="Type of API response object.", examples=["gen_table.completion.chunk"], ) output_column_name: str @@ -633,7 +993,7 @@ class ChatRequest(BaseModel): description="Chat ID. Must be unique against document ID for it to be embeddable. Defaults to ''.", ) model: str = Field( - default=DEFAULT_CHAT_MODEL, + default="", description="ID of the model to use. See the model endpoint compatibility table for details on which models work with the Chat API.", ) messages: list[ChatEntry] = Field( @@ -646,23 +1006,23 @@ class ChatRequest(BaseModel): examples=[None], ) temperature: Annotated[float, Field(ge=0.001, le=2.0)] = Field( - default=1.0, + default=0.2, description=""" What sampling temperature to use, in [0.001, 2.0]. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. """, - examples=[1.0], + examples=[0.2], ) top_p: Annotated[float, Field(ge=0.001, le=1.0)] = Field( - default=1.0, + default=0.6, description=""" An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. Must be in [0.001, 1.0]. """, - examples=[1.0], + examples=[0.6], ) n: int = Field( default=1, @@ -726,14 +1086,12 @@ class ChatRequest(BaseModel): examples=[""], ) - @model_validator(mode="after") - def convert_stop(self) -> Self: - # TODO: Introduce this in v0.3 - # if isinstance(self.stop, list) and len(self.stop) == 0: - # self.stop = None - if self.stop is None: - self.stop = [] - return self + @field_validator("stop", mode="after") + @classmethod + def convert_stop(cls, v: list[str] | None) -> list[str] | None: + if isinstance(v, list) and len(v) == 0: + v = None + return v class EmbeddingRequest(BaseModel): @@ -750,7 +1108,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=["openai/text-embedding-3-small-512"], + examples=[EXAMPLE_EMBEDDING_MODEL], ) type: Literal["query", "document"] = Field( default="document", @@ -773,7 +1131,7 @@ class EmbeddingRequest(BaseModel): class EmbeddingResponseData(BaseModel): object: str = Field( default="embedding", - description="The object type, which is always `embedding`.", + description="Type of API response object.", examples=["embedding"], ) embedding: list[float] | str = Field( @@ -793,7 +1151,7 @@ class EmbeddingResponseData(BaseModel): class EmbeddingResponse(BaseModel): object: str = Field( default="list", - description="The object type, which is always `list`.", + description="Type of API response object.", examples=["list"], ) data: list[EmbeddingResponseData] = Field( @@ -830,6 +1188,9 @@ class Page(BaseModel, Generic[T]): offset: Annotated[int, Field(description="Number of skipped items.", examples=[0])] = 0 limit: Annotated[int, Field(description="Number of items per page.", examples=[0])] = 0 total: Annotated[int, Field(description="Total number of items.", examples=[0])] = 0 + starting_after: Annotated[ + str | int | None, Field(description="Pagination cursor.", examples=["31a0552", 0, None]) + ] = None def nd_array_before_validator(x): @@ -840,8 +1201,8 @@ def datetime_str_before_validator(x): return x.isoformat() if isinstance(x, datetime) else str(x) -COL_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_ \-]{0,98}[a-zA-Z0-9]$" -TABLE_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,98}[a-zA-Z0-9]$" +COL_NAME_PATTERN = r"^[A-Za-z0-9]([A-Za-z0-9 _-]{0,98}[A-Za-z0-9])?$" +TABLE_NAME_PATTERN = r"^[A-Za-z0-9]([A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$" ODD_SINGLE_QUOTE = r"(? str: + return self.value + -class DtypeEnum(str, Enum, metaclass=MetaEnum): - int_ = "int" - int8 = "int8" - float_ = "float" - float32 = "float32" - float16 = "float16" - bool_ = "bool" - str_ = "str" - date_time = "date-time" +class ColumnDtype(str, Enum, metaclass=MetaEnum): + INT = "int" + INT8 = "int8" + FLOAT = "float" + FLOAT32 = "float32" + FLOAT16 = "float16" + BOOL = "bool" + STR = "str" + DATE_TIME = "date-time" + FILE = "file" + def __str__(self) -> str: + return self.value -class DtypeCreateEnum(str, Enum, metaclass=MetaEnum): - int_ = "int" - float_ = "float" - bool_ = "bool" - str_ = "str" + +class ColumnDtypeCreate(str, Enum, metaclass=MetaEnum): + INT = "int" + FLOAT = "float" + BOOL = "bool" + STR = "str" + FILE = "file" + + def __str__(self) -> str: + return self.value class TableType(str, Enum, metaclass=MetaEnum): - action = "action" + ACTION = "action" """Action table.""" - knowledge = "knowledge" + KNOWLEDGE = "knowledge" """Knowledge table.""" - chat = "chat" + CHAT = "chat" """Chat table.""" + def __str__(self) -> str: + return self.value + + +class LLMGenConfig(BaseModel): + object: Literal["gen_config.llm"] = Field( + default="gen_config.llm", + description='The object type, which is always "gen_config.llm".', + examples=["gen_config.llm"], + ) + model: str = Field( + default="", + description="ID of the model to use. See the model endpoint compatibility table for details on which models work with the Chat API.", + ) + system_prompt: str = Field( + default="", + description="System prompt for the LLM.", + ) + prompt: str = Field( + default="", + description="Prompt for the LLM.", + ) + multi_turn: bool = Field( + default=False, + description="Whether this column is a multi-turn chat with history along the entire column.", + ) + rag_params: RAGParams | None = Field( + default=None, + description="Retrieval Augmented Generation search params. Defaults to None (disabled).", + examples=[None], + ) + temperature: Annotated[float, Field(ge=0.001, le=2.0)] = Field( + default=0.2, + description=""" +What sampling temperature to use, in [0.001, 2.0]. +Higher values like 0.8 will make the output more random, +while lower values like 0.2 will make it more focused and deterministic. +""", + examples=[0.2], + ) + top_p: Annotated[float, Field(ge=0.001, le=1.0)] = Field( + default=0.6, + description=""" +An alternative to sampling with temperature, called nucleus sampling, +where the model considers the results of the tokens with top_p probability mass. +So 0.1 means only the tokens comprising the top 10% probability mass are considered. +Must be in [0.001, 1.0]. +""", + examples=[0.6], + ) + stop: list[str] | None = Field( + default=None, + description="Up to 4 sequences where the API will stop generating further tokens.", + examples=[None], + ) + max_tokens: PositiveNonZeroInt = Field( + default=2048, + description=""" +The maximum number of tokens to generate in the chat completion. +Must be in [1, context_length - 1). Default is 2048. +The total length of input tokens and generated tokens is limited by the model's context length. +""", + examples=[2048], + ) + presence_penalty: float = Field( + default=0.0, + description=""" +Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, +increasing the model's likelihood to talk about new topics. +""", + examples=[0.0], + ) + frequency_penalty: float = Field( + default=0.0, + description=""" +Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, +decreasing the model's likelihood to repeat the same line verbatim. +""", + examples=[0.0], + ) + logit_bias: dict = Field( + default={}, + description=""" +Modify the likelihood of specified tokens appearing in the completion. +Accepts a json object that maps tokens (specified by their token ID in the tokenizer) +to an associated bias value from -100 to 100. +Mathematically, the bias is added to the logits generated by the model prior to sampling. +The exact effect will vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; +values like -100 or 100 should result in a ban or exclusive selection of the relevant token. +""", + examples=[{}], + ) + + @model_validator(mode="before") + @classmethod + def compat(cls, data: Any) -> Any: + if isinstance(data, BaseModel): + data = data.model_dump() + if not isinstance(data, dict): + raise TypeError( + f"Input to `LLMGenConfig` must be a dict or BaseModel, received: {type(data)}" + ) + if data.get("system_prompt", None) or data.get("prompt", None): + return data + messages: list[dict[str, Any]] = data.get("messages", []) + num_prompts = len(messages) + if num_prompts >= 2: + data["system_prompt"] = messages[0]["content"] + data["prompt"] = messages[1]["content"] + elif num_prompts == 1: + if messages[0]["role"] == "system": + data["system_prompt"] = messages[0]["content"] + data["prompt"] = "" + elif messages[0]["role"] == "user": + data["system_prompt"] = "" + data["prompt"] = messages[0]["content"] + else: + raise ValueError( + f'Attribute "messages" cannot contain only assistant messages: {messages}' + ) + data["object"] = "gen_config.llm" + return data + + @field_validator("stop", mode="after") + @classmethod + def convert_stop(cls, v: list[str] | None) -> list[str] | None: + if isinstance(v, list) and len(v) == 0: + v = None + return v + + +class EmbedGenConfig(BaseModel): + object: Literal["gen_config.embed"] = Field( + default="gen_config.embed", + description='The object type, which is always "gen_config.embed".', + examples=["gen_config.embed"], + ) + embedding_model: str = Field( + description="The embedding model to use.", + examples=[EXAMPLE_EMBEDDING_MODEL], + ) + source_column: str = Field( + description="The source column for embedding.", + examples=["text_column"], + ) + + +def _gen_config_discriminator(x: Any) -> str | None: + object_attr = getattr(x, "object", None) + if object_attr: + return object_attr + if isinstance(x, BaseModel): + x = x.model_dump() + if isinstance(x, dict): + if "object" in x: + return x["object"] + if "embedding_model" in x: + return "gen_config.embed" + else: + return "gen_config.llm" + return None + + +GenConfig = LLMGenConfig | EmbedGenConfig +DiscriminatedGenConfig = Annotated[ + Union[ + Annotated[LLMGenConfig, Tag("gen_config.llm")], + Annotated[LLMGenConfig, Tag("gen_config.chat")], + Annotated[EmbedGenConfig, Tag("gen_config.embed")], + ], + Discriminator(_gen_config_discriminator), +] + class ColumnSchema(BaseModel): id: str = Field(description="Column name.") - dtype: DtypeEnum = Field( - default=DtypeEnum.str_, - description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time"]', + dtype: ColumnDtype = Field( + default=ColumnDtype.STR, + description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time", "file"]', ) vlen: PositiveInt = Field( # type: ignore default=0, @@ -971,38 +1518,34 @@ class ColumnSchema(BaseModel): "Only applies to string and vector columns. Defaults to True." ), ) - gen_config: dict[str, Any] | None = Field( + gen_config: DiscriminatedGenConfig | None = Field( default=None, description=( - '_Optional_. Generation config in the form of `ChatRequest`. If provided, then this column will be an "Output Column". ' + '_Optional_. Generation config. If provided, then this column will be an "Output Column". ' "Table columns on its left can be referenced by `${column-name}`." ), ) @model_validator(mode="after") def check_vector_column_dtype(self) -> Self: - if self.vlen > 0 and self.dtype not in (DtypeEnum.float32, DtypeEnum.float16): + if self.vlen > 0 and self.dtype not in (ColumnDtype.FLOAT32, ColumnDtype.FLOAT16): raise ValueError("Vector columns must contain float32 or float16 only.") return self - @model_validator(mode="after") - def validate_gen_config(self) -> Self: - if self.gen_config is not None: - # Validate - if "embedding_model" in self.gen_config: - self.gen_config = EmbedGenConfig.model_validate(self.gen_config).model_dump() - else: - self.gen_config = ChatRequest.model_validate(self.gen_config).model_dump() - return self - class ColumnSchemaCreate(ColumnSchema): id: ColName = Field(description="Column name.") - dtype: DtypeCreateEnum = Field( - default=DtypeCreateEnum.str_, - description='Column data type, one of ["int", "float", "bool", "str"]', + dtype: ColumnDtypeCreate = Field( + default=ColumnDtypeCreate.STR, + description='Column data type, one of ["int", "float", "bool", "str", "file"]', ) + @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.") + return self + class TableSQLModel(SQLModel): metadata = MetaData() @@ -1010,7 +1553,14 @@ class TableSQLModel(SQLModel): class TableBase(TableSQLModel): id: str = sql_Field(primary_key=True, description="Table name.") - # version: int = 0 + version: str = sql_Field( + default=owl_version, description="Table version, following owl version." + ) + meta: dict[str, Any] = sql_Field( + sa_column=Column(JSON), + default={}, + description="Additional metadata about the table.", + ) class TableSchema(TableBase): @@ -1052,7 +1602,7 @@ def add_state_cols(self) -> Self: for c in self.cols: cols.append(c) if c.id.lower() not in ("id", "updated at"): - cols.append(ColumnSchema(id=f"{c.id}_", dtype=DtypeEnum.str_)) + cols.append(ColumnSchema(id=f"{c.id}_", dtype=ColumnDtype.STR)) self.cols = cols return self @@ -1064,73 +1614,89 @@ def add_info_cols(self) -> Self: self (TableSchemaCreate): TableSchemaCreate """ self.cols = [ - ColumnSchema(id="ID", dtype=DtypeEnum.str_), - ColumnSchema(id="Updated at", dtype=DtypeEnum.date_time), + ColumnSchema(id="ID", dtype=ColumnDtype.STR), + ColumnSchema(id="Updated at", dtype=ColumnDtype.DATE_TIME), ] + self.cols return self + @staticmethod + def get_default_prompts( + table_id: str, + curr_column: ColumnSchema, + column_ids: list[str], + ) -> tuple[str, str]: + input_cols = "\n\n".join(c + ": ${" + c + "}" for c in column_ids) + if getattr(curr_column.gen_config, "multi_turn", False): + system_prompt = ( + f'You are an agent named "{table_id}". Be helpful. Provide answers based on the information given. ' + "Ensuring that your reply is easy to understand and is accessible to all users. " + "Be factual and do not hallucinate." + ) + user_prompt = "${User}" + else: + system_prompt = ( + "You are a versatile data generator. " + "Your task is to process information from input data and generate appropriate responses " + "based on the specified column name and input data. " + "Adapt your output format and content according to the column name provided." + ) + user_prompt = ( + f'Table name: "{table_id}"\n\n' + f"{input_cols}\n\n" + f'Based on the available information, provide an appropriate response for the column "{curr_column.id}".\n' + "Remember to act as a cell in a spreadsheet and provide concise, " + "relevant information without explanations unless specifically requested." + ) + return system_prompt, user_prompt + @model_validator(mode="after") def check_gen_configs(self) -> Self: for i, col in enumerate(self.cols): gen_config = col.gen_config if gen_config is None: continue - col_ids = set(col.id for col in self.cols[:i] if not col.id.endswith("_")) - if col.vlen > 0: - gen_config = EmbedGenConfig.model_validate(gen_config) - if gen_config.source_column not in col_ids: + available_cols = [ + col + for col in self.cols[:i] + if (not col.id.endswith("_")) + and col.id.lower() not in ("id", "updated at") + and col.vlen == 0 + ] + col_ids = [col.id for col in available_cols] + col_ids_set = set(col_ids) + if isinstance(gen_config, EmbedGenConfig): + if gen_config.source_column not in col_ids_set: raise ValueError( ( f"Table '{self.id}': " f"Embedding 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: {list(col_ids)}." - ) - ) - else: - num_prompts = len(gen_config["messages"]) - if num_prompts > 2: - self.cols[i].gen_config["messages"] = self.cols[i].gen_config["messages"][:2] - elif num_prompts == 2: - pass - elif num_prompts == 1: - self.cols[i].gen_config["messages"].append( - ChatEntry.user(content=".").model_dump() - ) - else: - raise ValueError( - f"`gen_config.messages` must be a list of at least length 1, received: {num_prompts:,d}" - ) - gen_config = ChatRequest.model_validate(self.cols[i].gen_config) - if gen_config.messages[0].role not in (ChatRole.SYSTEM, ChatRole.SYSTEM.value): - raise ValueError( - ( - f"Table '{self.id}': " - "The first `ChatEntry` in `gen_config.messages` " - f"of column '{col.id}' is not a system prompt. " - f"Saw {gen_config.messages[0].role} message." - ) - ) - if gen_config.messages[1].role not in (ChatRole.USER, ChatRole.USER.value): - raise ValueError( - ( - f"Table '{self.id}': " - "The second `ChatEntry` in `gen_config.messages` " - f"of column '{col.id}' is not a user prompt. " - f"Saw {gen_config.messages[1].role} message." + f"Available columns: {col_ids}." ) ) - for message in gen_config.messages: - for key in re.findall(GEN_CONFIG_VAR_PATTERN, message.content): - if key not in col_ids: + elif isinstance(gen_config, LLMGenConfig): + # Insert default prompts if needed + system_prompt, user_prompt = self.get_default_prompts( + table_id=self.id, + curr_column=col, + column_ids=[col.id for col in available_cols if col.gen_config is None], + ) + if not gen_config.system_prompt.strip(): + gen_config.system_prompt = system_prompt + if not gen_config.prompt.strip(): + gen_config.prompt = user_prompt + # Check references + for message in (gen_config.system_prompt, gen_config.prompt): + for key in re.findall(GEN_CONFIG_VAR_PATTERN, message): + if key not in col_ids_set: raise ValueError( ( f"Table '{self.id}': " f"Generation prompt of column '{col.id}' referenced " f"an invalid source column '{key}'. " "Make sure you only reference columns on its left. " - f"Available columns: {list(col_ids)}." + f"Available columns: {col_ids}." ) ) return self @@ -1151,13 +1717,6 @@ def check_cols(self) -> Self: return self -class AddColumnSchema(TableSchemaCreate): - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self - - class ActionTableSchemaCreate(TableSchemaCreate): pass @@ -1180,6 +1739,11 @@ def check_cols(self) -> Self: raise ValueError("Schema cannot contain column names: 'Text', 'Title', 'File ID'.") return self + @staticmethod + def get_default_prompts(*args, **kwargs) -> tuple[str, str]: + # This should act as if its AddKnowledgeColumnSchema + return "", "" + class AddKnowledgeColumnSchema(TableSchemaCreate): @model_validator(mode="after") @@ -1203,6 +1767,9 @@ def check_cols(self) -> Self: num_text_cols = sum(c.id.lower() in ("user", "ai") for c in self.cols) if num_text_cols != 2: raise ValueError("Schema must contain column names: 'User' and 'AI'.") + for c in self.cols: + if c.id.lower() == "ai": + c.gen_config.multi_turn = True return self @@ -1231,7 +1798,7 @@ class TableMeta(TableBase, table=True): description="Chat title. Defaults to ''.", ) updated_at: str = sql_Field( - default_factory=lambda: datetime.now(timezone.utc).isoformat(), + default_factory=datetime_now_iso, description="Table last update timestamp (ISO 8601 UTC).", ) # SQLite does not support TZ indexed_at_fts: str | None = sql_Field( @@ -1246,7 +1813,7 @@ class TableMeta(TableBase, table=True): @property def cols_schema(self) -> list[ColumnSchema]: - return [ColumnSchema.model_validate(c) for c in self.cols] + return [ColumnSchema.model_validate(c) for c in deepcopy(self.cols)] @property def regular_cols(self) -> list[ColumnSchema]: @@ -1270,7 +1837,10 @@ class TableMetaResponse(TableSchema): indexed_at_sca: str | None = Field( description="Table last scalar index timestamp (ISO 8601 UTC)." ) - num_rows: int = Field(description="Number of rows in the table.") + num_rows: int = Field( + default=-1, + description="Number of rows in the table. Defaults to -1 (not counted).", + ) @model_validator(mode="after") def check_gen_configs(self) -> Self: @@ -1334,7 +1904,7 @@ def _handle_nulls_and_validate(self, check_missing_cols: bool = True) -> Self: self.errors = [[] for _ in self.data] # Validate - for d, err in zip(self.data, self.errors): + for d, err in zip(self.data, self.errors, strict=True): # Fill in missing cols if check_missing_cols: for k in cols: @@ -1360,13 +1930,13 @@ def _handle_nulls_and_validate(self, check_missing_cols: bool = True) -> Self: d[k] = None # state["error"] = True if d[k] is None: - if col.dtype == DtypeEnum.int_: + if col.dtype == ColumnDtype.INT: d[k] = 0 - elif col.dtype == DtypeEnum.float_: + elif col.dtype == ColumnDtype.FLOAT: d[k] = 0.0 - elif col.dtype == DtypeEnum.bool_: + elif col.dtype == ColumnDtype.BOOL: d[k] = False - elif col.dtype == DtypeEnum.str_: + elif col.dtype in (ColumnDtype.STR, ColumnDtype.FILE): # Store null string as "" # https://github.com/lancedb/lancedb/issues/1160 d[k] = "" @@ -1393,14 +1963,14 @@ def set_id(self) -> Self: """ for d in self.data: if "ID" not in d: - d["ID"] = uuid7str() + d["ID"] = uuid7_draft2_str() return self def sql_escape(self) -> Self: cols = {c.id: c for c in self.table_meta.cols_schema} for d in self.data: for k in list(d.keys()): - if cols[k].dtype == DtypeEnum.str_: + if cols[k].dtype == ColumnDtype.STR: d[k] = re.sub(ODD_SINGLE_QUOTE, "''", d[k]) return self @@ -1417,16 +1987,11 @@ def handle_nulls_and_validate(self) -> Self: return self._handle_nulls_and_validate(check_missing_cols=False) -class EmbedGenConfig(BaseModel): - embedding_model: str - source_column: str - - class GenConfigUpdateRequest(BaseModel): table_id: TableName = Field(description="Table name or ID.") - column_map: dict[ColName, dict | None] = Field( + column_map: dict[ColName, DiscriminatedGenConfig | None] = Field( description=( - "Mapping of column ID to generation config JSON in the form of `ChatRequest`. " + "Mapping of column ID to generation config JSON in the form of `GenConfig`. " "Table columns on its left can be referenced by `${column-name}`." ) ) @@ -1434,7 +1999,7 @@ class GenConfigUpdateRequest(BaseModel): @model_validator(mode="after") def check_column_map(self) -> Self: if sum(n.lower() in ("id", "updated at") for n in self.column_map) > 0: - raise ValueError("`column_map` cannot contain keys: 'ID' or 'Updated at'.") + raise ValueError("column_map cannot contain keys: 'ID' or 'Updated at'.") return self @@ -1455,6 +2020,13 @@ class ColumnReorderRequest(BaseModel): table_id: TableName = Field(description="Table name or ID.") column_names: list[ColName] = Field(description="List of column ID in the desired order.") + @field_validator("column_names", mode="after") + @classmethod + def check_unique_column_names(cls, value: list[ColName]) -> list[ColName]: + if len(set(n.lower() for n in value)) != len(value): + raise ValueError("Column names must be unique (case-insensitive).") + return value + class ColumnDropRequest(BaseModel): table_id: TableName = Field(description="Table name or ID.") @@ -1469,7 +2041,7 @@ def check_column_names(self) -> Self: class Task(BaseModel): output_column_name: str - body: ChatRequest + body: LLMGenConfig class RowAdd(BaseModel): @@ -1533,7 +2105,8 @@ def __repr__(self): else v ) } - for k, v in self.data.items() + for row in self.data + for k, v in row.items() ] return ( f"{self.__class__.__name__}(" @@ -1542,6 +2115,22 @@ def __repr__(self): ")" ) + @model_validator(mode="after") + def check_data(self) -> Self: + for row in self.data: + for value in row.values(): + if isinstance(value, str) and ( + value.startswith("s3://") or value.startswith("file://") + ): + extension = splitext(value)[1].lower() + if extension not in IMAGE_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}" + ) + return self + class RowAddRequestWithLimit(RowAddRequest): data: list[dict[ColName, Any]] = Field( @@ -1573,6 +2162,33 @@ class RowUpdateRequest(BaseModel): ), ) + @model_validator(mode="after") + def check_data(self) -> Self: + for value in self.data.values(): + if isinstance(value, str) and ( + value.startswith("s3://") or value.startswith("file://") + ): + extension = splitext(value)[1].lower() + if extension not in IMAGE_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}" + ) + return self + + +class RegenStrategy(str, Enum): + """Strategies for selecting columns during row regeneration.""" + + RUN_ALL = "run_all" + RUN_BEFORE = "run_before" + RUN_SELECTED = "run_selected" + RUN_AFTER = "run_after" + + def __str__(self) -> str: + return self.value + class RowRegen(BaseModel): table_id: TableName = Field( @@ -1581,6 +2197,27 @@ class RowRegen(BaseModel): row_id: str = Field( description="ID of the row to regenerate.", ) + regen_strategy: RegenStrategy = Field( + default=RegenStrategy.RUN_ALL, + description=( + "_Optional_. Strategy for selecting columns to regenerate." + "Choose `run_all` to regenerate all columns in the specified row; " + "Choose `run_before` to regenerate columns up to the specified column_id; " + "Choose `run_selected` to regenerate only the specified column_id; " + "Choose `run_after` to regenerate columns starting from the specified column_id; " + ), + ) + output_column_id: str | None = Field( + default=None, + description=( + "_Optional_. Output column name to indicate the starting or ending point of regen for `run_before`, " + "`run_selected` and `run_after` strategies. Required if `regen_strategy` is not 'run_all'. " + "Given columns are 'C1', 'C2', 'C3' and 'C4', if column_id is 'C3': " + "`run_before` regenerate columns 'C1', 'C2' and 'C3'; " + "`run_selected` regenerate only column 'C3'; " + "`run_after` regenerate columns 'C3' and 'C4'; " + ), + ) stream: bool = Field( description="Whether or not to stream the LLM generation.", ) @@ -1606,6 +2243,27 @@ class RowRegenRequest(BaseModel): max_length=100, description="List of ID of the row to regenerate. Minimum 1 row, maximum 100 rows.", ) + regen_strategy: RegenStrategy = Field( + default=RegenStrategy.RUN_ALL, + description=( + "_Optional_. Strategy for selecting columns to regenerate." + "Choose `run_all` to regenerate all columns in the specified row; " + "Choose `run_before` to regenerate columns up to the specified column_id; " + "Choose `run_selected` to regenerate only the specified column_id; " + "Choose `run_after` to regenerate columns starting from the specified column_id; " + ), + ) + output_column_id: str | None = Field( + default=None, + description=( + "_Optional_. Output column name to indicate the starting or ending point of regen for `run_before`, " + "`run_selected` and `run_after` strategies. Required if `regen_strategy` is not 'run_all'. " + "Given columns are 'C1', 'C2', 'C3' and 'C4', if column_id is 'C3': " + "`run_before` regenerate columns 'C1', 'C2' and 'C3'; " + "`run_selected` regenerate only column 'C3'; " + "`run_after` regenerate columns 'C3' and 'C4'; " + ), + ) stream: bool = Field( description="Whether or not to stream the LLM generation.", ) @@ -1621,6 +2279,19 @@ class RowRegenRequest(BaseModel): description="_Optional_. Whether or not to concurrently generate the output rows and columns.", ) + @model_validator(mode="after") + def check_output_column_id_provided(self) -> Self: + if self.regen_strategy != RegenStrategy.RUN_ALL and self.output_column_id is None: + raise ValueError( + "`output_column_id` is required for regen_strategy other than 'run_all'." + ) + return self + + @model_validator(mode="after") + def sort_row_ids(self) -> Self: + self.row_ids = sorted(self.row_ids) + return self + class RowDeleteRequest(BaseModel): table_id: TableName = Field(description="Table name or ID.") @@ -1707,7 +2378,7 @@ class SearchRequest(BaseModel): ) vec_decimals: int = Field( default=0, - description="_Optional_. Number of decimals for vectors. Defaults to 0 (no rounding).", + description="_Optional_. Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", ) reranking_model: Annotated[ str | None, Field(description="Reranking model to use for hybrid search.") @@ -1751,5 +2422,18 @@ class TableDataImportRequest(BaseModel): # ), # ] = None delimiter: Annotated[ - str, Field(description='The delimiter of the file: can be "," or "\\t". Defaults to ",".') + str, + Field(description='The delimiter of the file: can be "," or "\\t". Defaults to ",".'), ] = "," + + +class FileUploadResponse(p.FileUploadResponse): + pass + + +class GetURLRequest(p.GetURLRequest): + pass + + +class GetURLResponse(p.GetURLResponse): + pass diff --git a/services/api/src/owl/routers/file.py b/services/api/src/owl/routers/file.py new file mode 100644 index 0000000..495ce00 --- /dev/null +++ b/services/api/src/owl/routers/file.py @@ -0,0 +1,193 @@ +import mimetypes +import os +from typing import Annotated +from urllib.parse import quote, urlparse, urlunparse + +import httpx +from fastapi import APIRouter, Depends, Request, Response, UploadFile +from fastapi.responses import FileResponse, JSONResponse +from loguru import logger + +from jamaibase.exceptions import ResourceNotFoundError +from owl.configs.manager import ENV_CONFIG +from owl.protocol import FileUploadResponse, GetURLRequest, GetURLResponse +from owl.utils.auth import ProjectRead, auth_user_project +from owl.utils.exceptions import handle_exception +from owl.utils.io import ( + LOCAL_FILE_DIR, + S3_CLIENT, + UPLOAD_WHITE_LIST_MIME, + get_s3_aclient, + upload_file_to_s3, +) + +HTTP_ACLIENT = httpx.AsyncClient() if S3_CLIENT else None +router = APIRouter() + + +async def _generate_presigned_url(s3_client, bucket_name: str, key: str) -> str: + response = await s3_client.list_objects_v2(Bucket=bucket_name, Prefix=key, MaxKeys=1) + if "Contents" not in response: + return "" + presigned_url = await s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket_name, "Key": key}, + ExpiresIn=3600, + ) + parsed_url = urlparse(presigned_url) + return urlunparse( + ( + parsed_url.scheme, + ENV_CONFIG.owl_file_proxy_url, + "/api/v1/files" + parsed_url.path, + parsed_url.params, + parsed_url.query, + parsed_url.fragment, + ) + ) + + +@router.get("/v1/files/{path:path}") +@handle_exception +async def proxy_file(request: Request, path: str) -> Response: + if HTTP_ACLIENT: + # S3 file handling + encoded_path = quote(path) + original_url = f"{ENV_CONFIG.s3_endpoint}/{encoded_path}?{request.query_params}" + response = await HTTP_ACLIENT.get(original_url) + # Determine the MIME type + mime_type, _ = mimetypes.guess_type(original_url) + if mime_type is None: + mime_type = "application/octet-stream" + # Set the Content-Disposition header + headers = dict(response.headers) + headers["Content-Disposition"] = "inline" + headers["Content-Type"] = mime_type + return Response( + content=response.content, + status_code=response.status_code, + headers=headers, + ) + + elif os.path.exists(LOCAL_FILE_DIR): + # Local file handling + file_path = os.path.join(LOCAL_FILE_DIR, path) + if not os.path.exists(file_path) or not os.path.isfile(file_path): + raise ResourceNotFoundError( + "Requested resource in not found in configured local file store." + ) + # Determine the MIME type + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + return FileResponse( + path=file_path, + media_type=mime_type, + filename=os.path.basename(file_path), + content_disposition_type="inline", + ) + + else: + raise ResourceNotFoundError("Neither S3 nor local file store is configured") + + +@router.options("/v1/files/upload/") +@handle_exception +async def upload_file_options(): + headers = { + "Allow": "POST, OPTIONS", + "Accept": ", ".join(UPLOAD_WHITE_LIST_MIME), + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + } + return JSONResponse(content={"accepted_types": list(UPLOAD_WHITE_LIST_MIME)}, headers=headers) + + +@router.post("/v1/files/upload/") +@handle_exception +async def upload_file( + project: Annotated[ProjectRead, Depends(auth_user_project)], + file: UploadFile, +) -> FileUploadResponse: + content = await file.read() + uri = await upload_file_to_s3( + project.organization.id, project.id, content, file.content_type, file.filename + ) + return FileUploadResponse(uri=uri) + + +@router.post("/v1/files/url/raw", response_model=GetURLResponse) +@handle_exception +async def get_raw_file_urls(body: GetURLRequest, request: Request) -> GetURLResponse: + results = [] + if S3_CLIENT: + # S3 file store + async with get_s3_aclient() as aclient: + for uri in body.uris: + file_url = "" + if uri.startswith("s3://"): + try: + bucket_name, key = uri[5:].split("/", 1) + file_url = await _generate_presigned_url(aclient, bucket_name, key) + except Exception as e: + logger.exception( + f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' + ) + results.append(file_url) + else: + # Local file store + for uri in body.uris: + file_url = "" + if uri.startswith("file://"): + try: + local_path = os.path.abspath(uri[7:]) + if os.path.exists(local_path): + # Generate a URL for the local file + relative_path = os.path.relpath(local_path, LOCAL_FILE_DIR) + file_url = str(request.url_for("proxy_file", path=relative_path)) + except Exception as e: + logger.exception( + f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' + ) + results.append(file_url) + return GetURLResponse(urls=results) + + +@router.post("/v1/files/url/thumb", response_model=GetURLResponse) +@handle_exception +async def get_thumbnail_urls(body: GetURLRequest, request: Request) -> GetURLResponse: + results = [] + if S3_CLIENT: + # S3 file store + async with get_s3_aclient() as aclient: + for uri in body.uris: + file_url = "" + if uri.startswith("s3://"): + try: + bucket_name, key = uri[5:].split("/", 1) + thumb_key = key.replace("raw", "thumb") + thumb_key = f"{os.path.splitext(thumb_key)[0]}.webp" + file_url = await _generate_presigned_url(aclient, bucket_name, thumb_key) + except Exception as e: + logger.exception( + f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' + ) + results.append(file_url) + else: + # Local file store + for uri in body.uris: + file_url = "" + if uri.startswith("file://"): + try: + local_path = os.path.abspath(uri[7:]) + thumb_path = local_path.replace("raw", "thumb") + thumb_path = f"{os.path.splitext(thumb_path)[0]}.webp" + 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)) + except Exception as e: + logger.exception( + f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' + ) + results.append(file_url) + return GetURLResponse(urls=results) diff --git a/services/api/src/owl/routers/gen_table.py b/services/api/src/owl/routers/gen_table.py index 99ab527..c7a6296 100644 --- a/services/api/src/owl/routers/gen_table.py +++ b/services/api/src/owl/routers/gen_table.py @@ -1,390 +1,296 @@ -import pathlib -from asyncio import sleep -from datetime import timedelta -from hashlib import blake2b -from os import remove -from os.path import basename -from tempfile import NamedTemporaryFile -from time import perf_counter +import re +from io import BytesIO +from os import listdir, makedirs +from os.path import isdir, join, splitext +from shutil import copy2, copytree +from tempfile import TemporaryDirectory from typing import Annotated, Any import numpy as np +import pandas as pd +import tiktoken from fastapi import ( APIRouter, BackgroundTasks, + Depends, File, Form, - Header, Path, Query, Request, + Response, UploadFile, ) -from fastapi.exceptions import RequestValidationError from fastapi.responses import FileResponse, StreamingResponse -from filelock import FileLock, Timeout from loguru import logger -from pydantic import ValidationError -from pydantic_core import InitErrorDetails -from jamaibase.utils.io import csv_to_df -from owl import protocol as p +from jamaibase.exceptions import ( + ResourceNotFoundError, + TableSchemaFixedError, + UnsupportedMediaTypeError, + make_validation_error, +) +from jamaibase.utils.io import csv_to_df, json_loads from owl.configs.manager import ENV_CONFIG -from owl.db.file import FileTable from owl.db.gen_executor import MultiRowsGenExecutor -from owl.db.gen_table import ActionTable, ChatTable, GenerativeTable, KnowledgeTable +from owl.db.gen_table import GenerativeTable from owl.llm import LLMEngine from owl.loaders import load_file -from owl.models import CloudEmbedder -from owl.utils.exceptions import OwlException, ResourceNotFoundError, TableSchemaFixedError -from owl.utils.tasks import repeat_every +from owl.models import CloudEmbedder, CloudReranker +from owl.protocol import ( + GEN_CONFIG_VAR_PATTERN, + TABLE_NAME_PATTERN, + ActionTableSchemaCreate, + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + ChatEntry, + ChatTableSchemaCreate, + ChatThread, + ColName, + ColumnDropRequest, + ColumnDtype, + ColumnRenameRequest, + ColumnReorderRequest, + CSVDelimiter, + EmbedGenConfig, + GenConfig, + GenConfigUpdateRequest, + GenTableOrderBy, + KnowledgeTableSchemaCreate, + LLMGenConfig, + OkResponse, + Page, + RowAddRequest, + RowAddRequestWithLimit, + RowDeleteRequest, + RowRegenRequest, + RowUpdateRequest, + SearchRequest, + TableMetaResponse, + TableSchema, + TableSchemaCreate, + TableType, +) +from owl.utils import uuid7_str +from owl.utils.auth import ProjectRead, auth_user_project +from owl.utils.exceptions import handle_exception +from owl.utils.io import EMBED_WHITE_LIST_MIME, upload_file_to_s3 router = APIRouter() -def _get_gen_table( - org_id: str, - project_id: str, - table_type: p.TableType, -) -> GenerativeTable: - lance_path = f"{ENV_CONFIG.owl_db_dir}/{org_id}/{project_id}/{table_type.value}" - sqlite_path = f"sqlite:///{lance_path}.db" - read_consistency_interval = timedelta(seconds=0) - if table_type == table_type.action: - return ActionTable( - sqlite_path, lance_path, read_consistency_interval=read_consistency_interval - ) - elif table_type == table_type.knowledge: - return KnowledgeTable( - sqlite_path, lance_path, read_consistency_interval=read_consistency_interval - ) - else: - return ChatTable( - sqlite_path, lance_path, read_consistency_interval=read_consistency_interval - ) - - -def _get_file_table( - org_id: str, - project_id: str, -) -> FileTable: - return FileTable( - f"{ENV_CONFIG.owl_db_dir}/{org_id}/{project_id}/file", - table_name="file", - read_consistency_interval=timedelta(seconds=0), - ) - - -def _iter_all_tables(batch_size: int = 200): - table_types = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] - db_dir = pathlib.Path(ENV_CONFIG.owl_db_dir) - for org_dir in db_dir.iterdir(): - if not org_dir.is_dir(): - continue - for project_dir in org_dir.iterdir(): - if not project_dir.is_dir(): - continue - for table_type in table_types: - table = _get_gen_table(org_dir.name, project_dir.name, table_type) - with table.create_session() as session: - offset, total = 0, 1 - while offset < total: - metas, total = table.list_meta( - session, - offset=offset, - limit=batch_size, - remove_state_cols=True, - parent_id=None, - ) - offset += batch_size - for meta in metas: - yield session, table, meta, f"{project_dir}/{table_type.value}/{meta.id}" - table = _get_file_table(org_dir.name, project_dir.name) - yield None, table, None, f"{project_dir}/file/file" - - -@router.on_event("startup") -@repeat_every(seconds=ENV_CONFIG.owl_reindex_period_sec, wait_first=True) -async def periodic_reindex(): - lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_reindex.lock", blocking=False) - try: - with lock: - t0 = perf_counter() - num_ok = num_skipped = num_failed = 0 - for session, table, meta, table_path in _iter_all_tables(): - if session is None: - continue - try: - reindexed = table.create_indexes(session, meta.id) - if reindexed: - num_ok += 1 - else: - num_skipped += 1 - except Timeout: - logger.warning(f"Periodic Lance re-indexing skipped for table: {table_path}") - num_skipped += 1 - except Exception: - logger.exception(f"Periodic Lance re-indexing failed for table: {table_path}") - num_failed += 1 - t = perf_counter() - t0 - # Hold the lock for a while to block other workers - await sleep(max(0.0, (ENV_CONFIG.owl_reindex_period_sec - t) * 0.5)) - logger.info( - ( - f"Periodic Lance re-indexing completed (t={t:,.3f} s, " - f"{num_ok:,d} OK, {num_skipped:,d} skipped, {num_failed:,d} failed)." +def _validate_gen_config( + llm: LLMEngine, + gen_config: GenConfig | None, + table_type: TableType, + column_id: str, + file_column_ids: list[str], +) -> GenConfig | None: + if gen_config is None: + return gen_config + if isinstance(gen_config, LLMGenConfig): + # Set multi-turn for Chat Table + if table_type == TableType.CHAT and column_id.lower() == "ai": + gen_config.multi_turn = True + # Assign a LLM model if not specified + try: + 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: + capabilities = ["image"] + break + gen_config.model = llm.validate_model_id( + model=gen_config.model, + capabilities=capabilities, ) - ) - except Timeout: - pass - except Exception: - logger.exception("Periodic Lance re-indexing encountered an error.") - - -@router.on_event("startup") -@repeat_every(seconds=ENV_CONFIG.owl_optimize_period_sec, wait_first=True) -async def periodic_optimize(): - lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_optimization.lock", blocking=False) - try: - with lock: - t0 = perf_counter() - num_ok = num_skipped = num_failed = 0 - for _, table, meta, table_path in _iter_all_tables(): - done = True - try: - if meta is None: - done = done and table.compact_files() - done = done and table.cleanup_old_versions( - older_than=timedelta( - minutes=ENV_CONFIG.owl_remove_version_older_than_mins - ), - ) - else: - done = done and table.compact_files(meta.id) - done = done and table.cleanup_old_versions( - meta.id, - older_than=timedelta( - minutes=ENV_CONFIG.owl_remove_version_older_than_mins - ), - ) - if done: - num_ok += 1 - else: - num_skipped += 1 - except Timeout: - logger.warning(f"Periodic Lance optimization skipped for table: {table_path}") - num_skipped += 1 - except Exception: - logger.exception(f"Periodic Lance optimization failed for table: {table_path}") - num_failed += 1 - t = perf_counter() - t0 - # Hold the lock for a while to block other workers - await sleep(max(0.0, (ENV_CONFIG.owl_reindex_period_sec - t) * 0.5)) - logger.info( - ( - f"Periodic Lance optimization completed (t={t:,.3f} s, " - f"{num_ok:,d} OK, {num_skipped:,d} skipped, {num_failed:,d} failed)." + except ValueError as e: + raise ResourceNotFoundError("There is no chat model available.") from e + except ResourceNotFoundError as e: + raise ResourceNotFoundError( + f'Column {column_id} used a chat model "{gen_config.model}" that is not available.' + ) from e + # Check Knowledge Table existence + if gen_config.rag_params is None: + return gen_config + ref_table_id = gen_config.rag_params.table_id + kt_table_dir = join( + ENV_CONFIG.owl_db_dir, + llm.organization_id, + llm.project_id, + TableType.KNOWLEDGE, + f"{ref_table_id}.lance", + ) + if not (isdir(kt_table_dir) and len(listdir(kt_table_dir)) > 0): + raise ResourceNotFoundError( + f"Column {column_id} referred to a Knowledge Table '{ref_table_id}' that does not exist." ) - ) - except Timeout: + # Validate Reranking Model + reranking_model = gen_config.rag_params.reranking_model + if reranking_model is None: + return gen_config + try: + gen_config.rag_params.reranking_model = llm.validate_model_id( + model=reranking_model, + capabilities=["rerank"], + ) + except ValueError as e: + raise ResourceNotFoundError("There is no reranking model available.") from e + except ResourceNotFoundError as e: + raise ResourceNotFoundError( + f'Column {column_id} used a reranking model "{reranking_model}" that is not available.' + ) from e + elif isinstance(gen_config, EmbedGenConfig): pass - except Exception: - logger.exception("Periodic Lance optimization encountered an error.") + return gen_config def _create_table( request: Request, - table_type: p.TableType, - schema: p.TableSchemaCreate, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Creating table: " - f"table_type={table_type} table_id={schema.id} cols={[c.id for c in schema.cols]}" - ) - ) + organization_id: str, + project_id: str, + table_type: TableType, + schema: TableSchemaCreate, +) -> 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("_") + ] for col in schema.cols: - if col.gen_config is None: - continue - if "embedding_model" in col.gen_config: - pass - else: - # Assign a LLM model if not specified - gen_config = p.ChatRequest.model_validate(col.gen_config) - if gen_config.model == "": - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - models = llm.model_names(capabilities=["chat"]) - if len(models) > 0: - col.gen_config["model"] = models[0] - - # Check Knowledge Table existence - rag_params = col.gen_config.get("rag_params", None) - if rag_params is None: - continue - ref_table_id = rag_params["table_id"] + col.gen_config = _validate_gen_config( + llm=llm, + gen_config=col.gen_config, + table_type=table_type, + column_id=col.id, + file_column_ids=file_column_ids, + ) + if table_type == TableType.KNOWLEDGE: try: - get_table(request, p.TableType.knowledge, ref_table_id) - except ResourceNotFoundError: - raise ResourceNotFoundError( - f"Column {col.id} referred to a Knowledge Table '{ref_table_id}' that does not exist." + embedding_model = schema.embedding_model + schema.embedding_model = llm.validate_model_id( + model=embedding_model, + capabilities=["embed"], ) - - # Validate Reranking Model - reranking_model = rag_params["reranking_model"] - if reranking_model is None: - continue - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - reranking_models = llm.model_names(capabilities=["rerank"]) - if reranking_model not in reranking_models: + except ValueError as e: + raise ResourceNotFoundError("There is no embedding model available.") from e + except ResourceNotFoundError as e: raise ResourceNotFoundError( - f"Column {col.id} used a reranking model '{reranking_model}' that does not exist." - ) - - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_db_storage_quota() - # Create - with table.create_session() as session: - _, meta = table.create_table(session, schema) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(schema.id)} - ) - return meta - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to create table: table_type={table_type} " - f"schema={schema}" - ) + f'Column used a embedding model "{embedding_model}" that is not available.' + ) from e + table = GenerativeTable.from_ids(organization_id, project_id, table_type) + # Create + with table.create_session() as session: + _, meta = ( + table.create_table(session, schema, request.state.all_models) + if table_type == TableType.KNOWLEDGE + else table.create_table(session, schema) ) - raise + meta = TableMetaResponse(**meta.model_dump(), num_rows=0) + return meta @router.post("/v1/gen_tables/action") +@handle_exception def create_action_table( request: Request, - schema: p.ActionTableSchemaCreate, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _create_table( - request, - p.TableType.action, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: ActionTableSchemaCreate, +) -> TableMetaResponse: + return _create_table(request, project.organization.id, project.id, TableType.ACTION, body) @router.post("/v1/gen_tables/knowledge") +@handle_exception def create_knowledge_table( request: Request, - schema: p.KnowledgeTableSchemaCreate, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _create_table( - request, - p.TableType.knowledge, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: KnowledgeTableSchemaCreate, +) -> TableMetaResponse: + return _create_table(request, project.organization.id, project.id, TableType.KNOWLEDGE, body) @router.post("/v1/gen_tables/chat") +@handle_exception def create_chat_table( request: Request, - schema: p.ChatTableSchemaCreate, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _create_table( - request, - p.TableType.chat, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: ChatTableSchemaCreate, +) -> TableMetaResponse: + return _create_table(request, project.organization.id, project.id, TableType.CHAT, body) + + +def _duplicate_table( + organization_id: str, + project_id: str, + table_type: TableType, + table_id_src: str, + table_id_dst: str, + include_data: bool, + create_as_child: bool, +) -> TableMetaResponse: + # Duplicate + table = GenerativeTable.from_ids(organization_id, project_id, table_type) + with table.create_session() as session: + meta = table.duplicate_table( + session, + table_id_src, + table_id_dst, + include_data, + create_as_child=create_as_child, + ) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta + + +@router.post("/v1/gen_tables/{table_type}/duplicate/{table_id_src}") +@handle_exception +def duplicate_table( + *, + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: TableType, + table_id_src: str = Path(pattern=TABLE_NAME_PATTERN, description="Source table name or ID."), + table_id_dst: str | None = Query( + default=None, pattern=TABLE_NAME_PATTERN, description="Destination table name or ID." + ), + include_data: bool = Query( + default=True, + description="_Optional_. Whether to include the data from the source table in the duplicated table. Defaults to `True`.", + ), + create_as_child: bool = Query( + default=False, + description=( + "_Optional_. Whether the new table is a child table. Defaults to `False`. " + "If this is True, then `include_data` will be set to True." + ), + ), +) -> TableMetaResponse: + if create_as_child: + include_data = True + if not table_id_dst: + table_id_dst = f"{table_id_src}_{uuid7_str()}" + return _duplicate_table( + organization_id=project.organization.id, + project_id=project.id, + table_type=table_type, + table_id_src=table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, ) @router.post("/v1/gen_tables/{table_type}/duplicate/{table_id_src}/{table_id_dst}") -def duplicate_table( +@handle_exception +def duplicate_table_deprecated( *, - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id_src: str = Path(pattern=p.TABLE_NAME_PATTERN, description="Source table name or ID."), + response: Response, + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: TableType, + table_id_src: str = Path(pattern=TABLE_NAME_PATTERN, description="Source table name or ID."), table_id_dst: str = Path( - pattern=p.TABLE_NAME_PATTERN, description="Destination table name or ID." + pattern=TABLE_NAME_PATTERN, description="Destination table name or ID." ), include_data: bool = Query( default=True, @@ -394,644 +300,359 @@ def duplicate_table( default=False, description="_Optional_. Whether to deploy the duplicated table. Defaults to `False`.", ), -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Duplicating table: " - f"table_type={table_type} table_id_src={table_id_src} table_id_dst={table_id_dst}" - ) +) -> TableMetaResponse: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.4. ' + "Use '/v1/gen_tables/{table_type}/duplicate/{table_id_src}' instead." + '"' + ) + return _duplicate_table( + organization_id=project.organization.id, + project_id=project.id, + table_type=table_type, + table_id_src=table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=deploy, ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_db_storage_quota() - # Duplicate - with table.create_session() as session: - meta = table.duplicate_table(session, table_id_src, table_id_dst, include_data, deploy) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(table_id_dst)} - ) - return meta - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to duplicate table: table_type={table_type} " - f"table_id_src={table_id_src} table_id_dst={table_id_dst} include_data={include_data}" - ) - ) - raise @router.post("/v1/gen_tables/{table_type}/rename/{table_id_src}/{table_id_dst}") +@handle_exception def rename_table( - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], table_id_src: Annotated[str, Path(description="Source table name or ID.")], # Don't validate table_id_dst: Annotated[ str, Path( - pattern=p.TABLE_NAME_PATTERN, + pattern=TABLE_NAME_PATTERN, description="Destination table name or ID.", ), ], -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Renaming table: " - f"table_type={table_type} table_id_src={table_id_src} table_id_dst={table_id_dst}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - meta = table.rename_table(session, table_id_src, table_id_dst) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(table_id_dst)} - ) - return meta - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to rename table: table_type={table_type} " - f"table_id_src={table_id_src} table_id_dst={table_id_dst}" - ) - ) - raise +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + meta = table.rename_table(session, table_id_src, table_id_dst) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(table_id_dst)) + return meta @router.delete("/v1/gen_tables/{table_type}/{table_id}") +@handle_exception def delete_table( - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], table_id: Annotated[str, Path(description="The ID of the table to delete.")], # Don't validate -) -> p.OkResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Deleting table: " - f"table_type={table_type} table_id={table_id}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - table.delete_table(session, table_id) - return p.OkResponse() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to delete table: table_type={table_type} " - f"table_id={table_id}" - ) - ) - raise +) -> OkResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + table.delete_table(session, table_id) + return OkResponse() @router.get("/v1/gen_tables/{table_type}") +@handle_exception def list_tables( - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - offset: int = Query( - default=0, - ge=0, - description="_Optional_. Item offset for pagination. Defaults to 0.", - ), - limit: int = Query( - default=100, - gt=0, - le=100, - description="_Optional_. Number of tables to return (min 1, max 100). Defaults to 100.", - ), + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + offset: Annotated[ + int, + Query( + ge=0, + description="_Optional_. Item offset for pagination. Defaults to 0.", + ), + ] = 0, + limit: Annotated[ + int, + Query( + gt=0, + le=100, + description="_Optional_. Number of tables to return (min 1, max 100). Defaults to 100.", + ), + ] = 100, parent_id: Annotated[ str | None, Query( description=( "_Optional_. Parent ID of tables to return. Defaults to None (return all tables). " "Additionally for Chat Table, you can list: " - "(1) the chats of a particular agent by specifying its table name/ID; " - '(2) all chat agents by passing in "_agent_"; or ' - '(3) all chats by passing in "_chat_".' + '(1) all chat agents by passing in "_agent_"; or ' + '(2) all chats by passing in "_chat_".' ), ), ] = None, -) -> p.Page[p.TableMetaResponse]: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Listing tables: " - f"table_type={table_type} offset={offset} limit={limit} parent_id={parent_id}" + search_query: Annotated[ + str, + Query( + max_length=100, + description='_Optional_. A string to search for within table IDs as a filter. Defaults to "" (no filter).', + ), + ] = "", + order_by: Annotated[ + GenTableOrderBy, + Query( + min_length=1, + description='_Optional_. Sort tables by this attribute. Defaults to "updated_at".', + ), + ] = GenTableOrderBy.UPDATED_AT, + order_descending: Annotated[ + bool, + Query(description="_Optional_. Whether to sort by descending order. Defaults to True."), + ] = True, + count_rows: Annotated[ + bool, + Query( + description="_Optional_. Whether to count the rows of the tables. Defaults to False." + ), + ] = False, +) -> Page[TableMetaResponse]: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + metas, total = table.list_meta( + session, + offset=offset, + limit=limit, + remove_state_cols=True, + parent_id=parent_id, + search_query=search_query, + order_by=order_by, + order_descending=order_descending, + count_rows=count_rows, ) - ) - try: - # Check quota - request.state.billing_manager.check_egress_quota() - # List - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - metas, total = table.list_meta( - session, - offset=offset, - limit=limit, - remove_state_cols=True, - parent_id=parent_id, - ) - return p.Page[p.TableMetaResponse]( - items=metas, - offset=offset, - limit=limit, - total=total, - ) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to list tables: table_type={table_type} " - f"offset={offset} limit={limit} parent_id={parent_id}" - ) + return Page[TableMetaResponse]( + items=metas, + offset=offset, + limit=limit, + total=total, ) - raise @router.get("/v1/gen_tables/{table_type}/{table_id}") +@handle_exception def get_table( request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id: str = Path( - pattern=p.TABLE_NAME_PATTERN, description="The ID of the table to fetch." - ), -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Fetch table: " - f"table_type={table_type} table_id={table_id}" - ) - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="The ID of the table to fetch."), +) -> TableMetaResponse: + organization_id = project.organization.id + project_id = project.id try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) + table = GenerativeTable.from_ids(organization_id, project_id, table_type) with table.create_session() as session: meta = table.open_meta(session, table_id, remove_state_cols=True) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(table_id)} + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta + except ResourceNotFoundError: + lance_path = join( + ENV_CONFIG.owl_db_dir, + organization_id, + project_id, + table_type, + f"{table_id}.lance", + ) + if isdir(lance_path): + logger.exception( + f"{request.state.id} - Table cannot be opened but the directory exists !!!" ) - return meta - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to fetch table: table_type={table_type} " - f"table_id={table_id}" + dst_dir = join( + ENV_CONFIG.owl_db_dir, + "problematic", + organization_id, + project_id, + table_type, + ) + makedirs(dst_dir, exist_ok=True) + _uuid = uuid7_str() + copytree(lance_path, join(dst_dir, f"{table_id}_{_uuid}.lance")) + copy2( + join( + ENV_CONFIG.owl_db_dir, + organization_id, + project_id, + f"{table_type}.db", + ), + join( + ENV_CONFIG.owl_db_dir, + "problematic", + organization_id, + project_id, + f"{table_type}_{_uuid}.db", + ), ) - ) raise @router.post("/v1/gen_tables/{table_type}/gen_config/update") +@handle_exception def update_gen_config( request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - updates: p.GenConfigUpdateRequest, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Updating gen config: " - f"table_type={table_type} table_id={updates.table_id} " - f"column_map_keys={list(updates.column_map.keys())}" - ) - ) - # Validate Knowledge Table existence if RAGParams is used - for col_id, gen_config in updates.column_map.items(): - if gen_config is None: - continue - rag_params = gen_config.get("rag_params", None) - if rag_params is None: - continue - ref_table_id = rag_params["table_id"] - try: - get_table(request, p.TableType.knowledge, ref_table_id) - except ResourceNotFoundError: - raise ResourceNotFoundError( - f"Column {col_id} referred to a Knowledge Table '{ref_table_id}' that does not exist." - ) - - # Validate Reranking Model - reranking_model = rag_params["reranking_model"] - if reranking_model is None: - continue - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - reranking_models = llm.model_names(capabilities=["rerank"]) - if reranking_model not in reranking_models: - raise ResourceNotFoundError( - f"Column {col_id} used a reranking model '{reranking_model}' that does not exist." - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + updates: GenConfigUpdateRequest, +) -> TableMetaResponse: + # Validate + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + meta = table.open_meta(session, updates.table_id) + llm = LLMEngine(request=request) + file_column_ids = [ + col["id"] + for col in meta.cols + if col["dtype"] == ColumnDtype.FILE and not col["id"].endswith("_") + ] - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - meta = table.update_gen_config(session, updates) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(updates.table_id)} - ) - return meta - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to update generation config: table_type={table_type} " - f"updates={updates}" + if table_type == TableType.KNOWLEDGE: + # Knowledge Table "Title Embed" and "Text Embed" columns must always have gen config + for c in ["Title Embed", "Text Embed"]: + if c in updates.column_map and updates.column_map[c] is None: + updates.column_map.pop(c) + elif table_type == TableType.CHAT: + # Chat Table AI column must always have gen config + if "AI" in updates.column_map and updates.column_map["AI"] is None: + updates.column_map.pop("AI") + + updates.column_map = { + col_id: _validate_gen_config( + llm=llm, + gen_config=gen_config, + table_type=table_type, + column_id=col_id, + file_column_ids=file_column_ids, ) - ) - raise + for col_id, gen_config in updates.column_map.items() + } + # Update + meta = table.update_gen_config(session, updates) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta def _add_columns( request: Request, - table_type: p.TableType, - schema: p.TableSchemaCreate, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Adding columns: " - f"table_type={table_type} table_id={schema.id} cols={[c.id for c in schema.cols]}" - ) - ) + organization_id: str, + project_id: str, + table_type: TableType, + schema: TableSchemaCreate, +) -> TableMetaResponse: # Validate - for col in schema.cols: - if col.gen_config is None: - continue - if "embedding_model" in col.gen_config: - pass - else: - # Assign a LLM model if not specified - gen_config = p.ChatRequest.model_validate(col.gen_config) - if gen_config.model == "": - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - models = llm.model_names(capabilities=["chat"]) - if len(models) > 0: - col.gen_config["model"] = models[0] - - # Check Knowledge Table existence - rag_params = col.gen_config.get("rag_params", None) - if rag_params is None: - continue - ref_table_id = rag_params["table_id"] - try: - get_table(request, p.TableType.knowledge, ref_table_id) - except ResourceNotFoundError: - raise ResourceNotFoundError( - f"Column {col.id} referred to a Knowledge Table '{ref_table_id}' that does not exist." - ) - - # Validate Reranking Model - reranking_model = rag_params["reranking_model"] - if reranking_model is None: - continue - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - reranking_models = llm.model_names(capabilities=["rerank"]) - if reranking_model not in reranking_models: - raise ResourceNotFoundError( - f"Column {col.id} used a reranking model '{reranking_model}' that does not exist." + table = GenerativeTable.from_ids(organization_id, project_id, table_type) + with table.create_session() as session: + meta = table.open_meta(session, schema.id) + llm = LLMEngine(request=request) + 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("_") + ] + schema.cols = [col for col in cols if col.id in set(c.id for c in schema.cols)] + for col in schema.cols: + col.gen_config = _validate_gen_config( + llm=llm, + gen_config=col.gen_config, + table_type=table_type, + column_id=col.id, + file_column_ids=file_column_ids, ) - - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_db_storage_quota() # Create - with table.create_session() as session: - _, meta = table.add_columns(session, schema) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(schema.id)} - ) - return meta - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to add columns to table: table_type={table_type} " - f"schema={schema}" - ) - ) - raise + _, meta = table.add_columns(session, schema) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta @router.post("/v1/gen_tables/action/columns/add") +@handle_exception def add_action_columns( request: Request, - schema: p.AddActionColumnSchema, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _add_columns( - request, - p.TableType.action, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: AddActionColumnSchema, +) -> TableMetaResponse: + return _add_columns(request, project.organization.id, project.id, TableType.ACTION, body) @router.post("/v1/gen_tables/knowledge/columns/add") +@handle_exception def add_knowledge_columns( request: Request, - schema: p.AddKnowledgeColumnSchema, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _add_columns( - request, - p.TableType.knowledge, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: AddKnowledgeColumnSchema, +) -> TableMetaResponse: + return _add_columns(request, project.organization.id, project.id, TableType.KNOWLEDGE, body) @router.post("/v1/gen_tables/chat/columns/add") +@handle_exception def add_chat_columns( request: Request, - schema: p.AddChatColumnSchema, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.TableMetaResponse: - return _add_columns( - request, - p.TableType.chat, - schema, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + body: AddChatColumnSchema, +) -> TableMetaResponse: + return _add_columns(request, project.organization.id, project.id, TableType.CHAT, body) + + +def _create_indexes( + project: ProjectRead, + table_type: TableType, + table_id: str, +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + table.create_indexes(session, table_id) @router.post("/v1/gen_tables/{table_type}/columns/drop") +@handle_exception def drop_columns( - request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.ColumnDropRequest, -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Dropping columns: " - f"table_type={table_type} body={body}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - _, meta = table.drop_columns(session, body.table_id, body.column_names) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(body.table_id)} - ) - bg_tasks.add_task(table.create_indexes, session, body.table_id) - return meta - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to drop columns from table: table_type={table_type} " - f"body={body}" - ) - ) - raise + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnDropRequest, +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + _, meta = table.drop_columns(session, body.table_id, body.column_names) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + bg_tasks.add_task(_create_indexes, project, table_type, body.table_id) + return meta -@router.post("/v1/gen_tables/{table_type}/columns/rename") -def rename_columns( - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.ColumnRenameRequest, -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Renaming columns: " - f"table_type={table_type} body={body}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - meta = table.rename_columns(session, body.table_id, body.column_map) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(body.table_id)} - ) - return meta - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to rename columns of table: table_type={table_type} " - f"body={body}" - ) - ) - raise +@router.post("/v1/gen_tables/{table_type}/columns/rename") +@handle_exception +def rename_columns( + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnRenameRequest, +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + meta = table.rename_columns(session, body.table_id, body.column_map) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta @router.post("/v1/gen_tables/{table_type}/columns/reorder") +@handle_exception def reorder_columns( - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.ColumnReorderRequest, -) -> p.TableMetaResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Reordering columns: " - f"table_type={table_type} body={body}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - try: - meta = table.reorder_columns(session, body.table_id, body.column_names) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - meta = p.TableMetaResponse.model_validate( - meta, update={"num_rows": table.count_rows(body.table_id)} - ) - return meta - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to reorder columns of table: table_type={table_type} " - f"body={body}" - ) - ) - raise + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnReorderRequest, +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + meta = table.reorder_columns(session, body.table_id, body.column_names) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta @router.get("/v1/gen_tables/{table_type}/{table_id}/rows") +@handle_exception def list_rows( *, - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id: str = Path(pattern=p.TABLE_NAME_PATTERN, description="Table ID or name."), + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name."), offset: int = Query( default=0, ge=0, @@ -1046,37 +667,47 @@ def list_rows( search_query: str = Query( default="", max_length=10_000, - description='FTS query to filter the returned rows. Defaults to "" (no filter).', + description='_Optional_. A string to search for within the rows as a filter. Defaults to "" (no filter).', ), - columns: list[p.ColName] | None = Query( + columns: list[ColName] | None = Query( default=None, description="_Optional_. A list of column names to include in the response. Default is to return all columns.", ), float_decimals: int = Query( default=0, + ge=0, description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", ), vec_decimals: int = Query( default=0, - description="_Optional_. Number of decimals for vectors. Defaults to 0 (no rounding).", + description="_Optional_. Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", ), -) -> p.Page[dict[p.ColName, Any]]: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Listing rows: " - f"table_type={table_type} table_id={table_id} columns={columns} offset={offset} limit={limit}" + order_descending: Annotated[ + bool, + Query(description="_Optional_. Whether to sort by descending order. Defaults to True."), + ] = True, +) -> Page[dict[ColName, Any]]: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + if search_query == "": + rows, total = table.list_rows( + table_id=table_id, + offset=offset, + limit=limit, + columns=columns, + convert_null=True, + remove_state_cols=True, + json_safe=True, + include_original=True, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + order_descending=order_descending, ) - ) - try: - # Check quota - request.state.billing_manager.check_egress_quota() - # List - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - if search_query == "": - rows, total = table.list_rows( + else: + with table.create_session() as session: + rows = table.regex_search( + session=session, table_id=table_id, - offset=offset, - limit=limit, + query=search_query, columns=columns, convert_null=True, remove_state_cols=True, @@ -1084,427 +715,234 @@ def list_rows( include_original=True, float_decimals=float_decimals, vec_decimals=vec_decimals, + order_descending=order_descending, ) - else: - with table.create_session() as session: - rows = table.fts_search( - session=session, - table_id=table_id, - query=search_query, - where=None, - columns=columns, - convert_null=True, - remove_state_cols=True, - json_safe=True, - include_original=True, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ) - total = len(rows) - rows = rows[offset : offset + limit] - return p.Page[dict[p.ColName, Any]](items=rows, offset=offset, limit=limit, total=total) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to list rows of table: table_type={table_type} " - f"table_id={table_id} offset={offset} limit={limit} columns={columns}" - ) - ) - raise + total = len(rows) + rows = rows[offset : offset + limit] + return Page[dict[ColName, Any]](items=rows, offset=offset, limit=limit, total=total) @router.get("/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}") +@handle_exception def get_row( *, - request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id: str = Path(pattern=p.TABLE_NAME_PATTERN, description="Table ID or name."), + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name."), row_id: Annotated[str, Path(description="The ID of the specific row to fetch.")], - columns: list[p.ColName] | None = Query( + columns: list[ColName] | None = Query( default=None, description="_Optional_. A list of column names to include in the response. Default is to return all columns.", ), float_decimals: int = Query( default=0, + ge=0, description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", ), vec_decimals: int = Query( default=0, - description="_Optional_. Number of decimals for vectors. Defaults to 0 (no rounding).", + description="_Optional_. Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", ), -) -> dict[p.ColName, Any]: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Fetch row: " - f"table_type={table_type} table_id={table_id} row_id={row_id} columns={columns}" - ) +) -> dict[ColName, Any]: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + row = table.get_row( + table_id, + row_id, + columns=columns, + convert_null=True, + remove_state_cols=True, + json_safe=True, + include_original=True, + float_decimals=float_decimals, + vec_decimals=vec_decimals, ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - row = table.get_row( - table_id, - row_id, - columns=columns, - convert_null=True, - remove_state_cols=True, - json_safe=True, - include_original=True, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ) - return row - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to fetch row from table: table_type={table_type} " - f"table_id={table_id} row_id={row_id} columns={columns}" - ) - ) - raise + return row @router.post("/v1/gen_tables/{table_type}/rows/add") +@handle_exception async def add_rows( request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.RowAddRequestWithLimit, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: RowAddRequestWithLimit, ): - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Adding rows: " - f"table_type={table_type} table_id={body.table_id} stream={body.stream} " - f"reindex={body.reindex} concurrent={body.concurrent} " - f"num_rows={len(body.data)} " - f"data_keys={[list(d.keys()) for d in body.data[:3]]}" + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + # Check quota + request.state.billing.check_gen_table_llm_quota(table, body.table_id) + # Checks + with table.create_session() as session: + meta = table.open_meta(session, body.table_id) + has_chat_cols = ( + sum( + col["gen_config"] is not None and col["gen_config"].get("multi_turn", False) + for col in meta.cols ) + > 0 ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_gen_table_llm_quota(table, body.table_id) - request.state.billing_manager.check_db_storage_quota() - request.state.billing_manager.check_egress_quota() - # Maybe re-index - if body.reindex or ( - body.reindex is None - and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows - ): - with table.create_session() as session: - bg_tasks.add_task( - table.create_indexes, - session, - body.table_id, - ) - executor = MultiRowsGenExecutor( - table, - request=request, - body=body, - rows_batch_size=ENV_CONFIG.owl_concurrent_rows_batch_size, - cols_batch_size=ENV_CONFIG.owl_concurrent_cols_batch_size, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - if body.stream: - return StreamingResponse( - content=await executor.gen_rows(), - status_code=200, - media_type="text/event-stream", - headers={"X-Accel-Buffering": "no"}, - ) - else: - return await executor.gen_rows() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to add rows to table: table_type={table_type} " - f"body={body}" - ) + # Maybe re-index + if body.reindex or ( + body.reindex is None + and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows + ): + bg_tasks.add_task(_create_indexes, project, table_type, body.table_id) + executor = MultiRowsGenExecutor( + table=table, + meta=meta, + request=request, + body=body, + rows_batch_size=(1 if has_chat_cols else ENV_CONFIG.owl_concurrent_rows_batch_size), + cols_batch_size=ENV_CONFIG.owl_concurrent_cols_batch_size, + max_write_batch_size=(1 if has_chat_cols else ENV_CONFIG.owl_max_write_batch_size), + ) + if body.stream: + return StreamingResponse( + content=await executor.gen_rows(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, ) - raise + else: + return await executor.gen_rows() @router.post("/v1/gen_tables/{table_type}/rows/regen") +@handle_exception async def regen_rows( request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.RowRegenRequest, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: RowRegenRequest, ): - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Regenerating rows: " - f"table_type={table_type} body={body}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_gen_table_llm_quota(table, body.table_id) - request.state.billing_manager.check_db_storage_quota() - request.state.billing_manager.check_egress_quota() - # Maybe re-index - if body.reindex or ( - body.reindex is None - and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows - ): - with table.create_session() as session: - bg_tasks.add_task( - table.create_indexes, - session, - body.table_id, + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + # Check quota + request.state.billing.check_gen_table_llm_quota(table, body.table_id) + # Checks + with table.create_session() as session: + meta = table.open_meta(session, body.table_id) + if body.output_column_id is not None: + output_column_ids = [col["id"] for col in meta.cols if col["gen_config"] is not None] + if len(output_column_ids) > 0 and body.output_column_id not in output_column_ids: + raise ResourceNotFoundError( + ( + f'`output_column_id` "{body.output_column_id}" is not found. ' + f"Available output columns: {output_column_ids}" ) - - executor = MultiRowsGenExecutor( - table, - request=request, - body=body, - rows_batch_size=ENV_CONFIG.owl_concurrent_rows_batch_size, - cols_batch_size=ENV_CONFIG.owl_concurrent_cols_batch_size, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - if body.stream: - return StreamingResponse( - content=await executor.gen_rows(), - status_code=200, - media_type="text/event-stream", - headers={"X-Accel-Buffering": "no"}, - ) - else: - return await executor.gen_rows() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" ) + has_chat_cols = ( + sum( + col["gen_config"] is not None and col["gen_config"].get("multi_turn", False) + for col in meta.cols ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to regen rows of table: table_type={table_type} " - f"body={body}" - ) + > 0 + ) + # Maybe re-index + if body.reindex or ( + body.reindex is None + and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows + ): + bg_tasks.add_task(_create_indexes, project, table_type, body.table_id) + executor = MultiRowsGenExecutor( + table=table, + meta=meta, + request=request, + body=body, + rows_batch_size=(1 if has_chat_cols else ENV_CONFIG.owl_concurrent_rows_batch_size), + cols_batch_size=ENV_CONFIG.owl_concurrent_cols_batch_size, + max_write_batch_size=(1 if has_chat_cols else ENV_CONFIG.owl_max_write_batch_size), + ) + if body.stream: + return StreamingResponse( + content=await executor.gen_rows(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, ) - raise + else: + return await executor.gen_rows() @router.post("/v1/gen_tables/{table_type}/rows/update") +@handle_exception def update_row( - request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.RowUpdateRequest, -) -> p.OkResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Updating row: " - f"table_type={table_type} table_id={body.table_id} row_id={body.row_id} " - f"reindex={body.reindex} data_keys={list(body.data.keys())}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - # Check quota - request.state.billing_manager.check_db_storage_quota() - # Check column type - if table_type == p.TableType.knowledge: - col_names = set(n.lower() for n in body.data.keys()) - if "text embed" in col_names or "title embed" in col_names: - raise TableSchemaFixedError("Cannot update 'Text Embed' or 'Title Embed'.") - # Update - with table.create_session() as session: - table.update_rows( - session, - body.table_id, - where=f"`ID` = '{body.row_id}'", - values=body.data, - ) - if body.reindex or ( - body.reindex is None - and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows - ): - bg_tasks.add_task(table.create_indexes, session, body.table_id) - return p.OkResponse() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to update rows of table: table_type={table_type} " - f"body={body}" - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: RowUpdateRequest, +) -> OkResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + # Check column type + if table_type == TableType.KNOWLEDGE: + col_names = set(n.lower() for n in body.data.keys()) + if "text embed" in col_names or "title embed" in col_names: + raise TableSchemaFixedError("Cannot update 'Text Embed' or 'Title Embed'.") + # Update + with table.create_session() as session: + table.update_rows( + session, + body.table_id, + where=f"`ID` = '{body.row_id}'", + values=body.data, ) - raise + if body.reindex or ( + body.reindex is None + and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows + ): + bg_tasks.add_task(_create_indexes, project, table_type, body.table_id) + return OkResponse() @router.post("/v1/gen_tables/{table_type}/rows/delete") +@handle_exception def delete_rows( - request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.RowDeleteRequest, -) -> p.OkResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Deleting rows: " - f"table_type={table_type} body={body}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - table.delete_rows(session, body.table_id, body.row_ids, body.where) - if body.reindex or ( - body.reindex is None - and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows - ): - bg_tasks.add_task(table.create_indexes, session, body.table_id) - return p.OkResponse() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to delete rows from table: table_type={table_type} " - f"body={body}" - ) - ) - raise + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: RowDeleteRequest, +) -> OkResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + table.delete_rows(session, body.table_id, body.row_ids, body.where) + if body.reindex or ( + body.reindex is None + and table.count_rows(body.table_id) <= ENV_CONFIG.owl_immediate_reindex_max_rows + ): + bg_tasks.add_task(_create_indexes, project, table_type, body.table_id) + return OkResponse() @router.delete("/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}") +@handle_exception def delete_row( - request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id: str = Path(pattern=p.TABLE_NAME_PATTERN, description="Table ID or name."), + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name."), row_id: str = Path(description="The ID of the specific row to delete."), reindex: Annotated[bool, Query(description="Whether to reindex immediately.")] = True, -) -> p.OkResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Deleting row: " - f"table_type={table_type} table_id={table_id} row_id={row_id} reindex={reindex}" - ) - ) - try: - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - table.delete_row(session, table_id, row_id) - if reindex: - bg_tasks.add_task(table.create_indexes, session, table_id) - return p.OkResponse() - except Timeout: - logger.warning( - ( - "Could not acquire lock for table: " - f"{ENV_CONFIG.owl_db_dir}/{request.state.org_id}/{request.state.project_id}/{table_type.value}" - ) - ) - raise - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to delete row from table: table_type={table_type} " - f"table_id={table_id} row_id={row_id} reindex={reindex}" - ) - ) - raise +) -> OkResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + table.delete_row(session, table_id, row_id) + if reindex: + bg_tasks.add_task(_create_indexes, project, table_type, table_id) + return OkResponse() -@router.get("/v1/gen_tables/chat/{table_id}/thread") +@router.get("/v1/gen_tables/{table_type}/{table_id}/thread") +@handle_exception def get_conversation_thread( - request: Request, - table_id: Annotated[str, Path(pattern=p.TABLE_NAME_PATTERN, description="Table ID or name.")], + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name.")], + column_id: Annotated[str, Query(description="ID / name of the column to fetch.")], row_id: Annotated[ str, - Query(description='_Optional_. Row ID for filtering. Defaults to "" (export all rows).'), + Query( + description='_Optional_. ID / name of the last row in the thread. Defaults to "" (export all rows).' + ), ] = "", include: Annotated[ bool, @@ -1512,159 +950,116 @@ def get_conversation_thread( description="_Optional_. Whether to include the row specified by `row_id`. Defaults to True." ), ] = True, -) -> p.ChatThread: - try: - # Check quota - request.state.billing_manager.check_egress_quota() - # Fetch - table: ChatTable = _get_gen_table( - request.state.org_id, - request.state.project_id, - p.TableType.chat, - ) - return table.get_conversation_thread( - table_id=table_id, - row_id=row_id, - include=include, - ) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to fetch conversation thread from table: table_id={table_id}" - ) - ) - raise +) -> ChatThread: + # Fetch + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + return table.get_conversation_thread( + table_id=table_id, + column_id=column_id, + row_id=row_id, + include=include, + ) @router.post("/v1/gen_tables/{table_type}/hybrid_search") -def hybrid_search( +@handle_exception +async def hybrid_search( request: Request, - table_type: Annotated[p.TableType, Path(description="Table type.")], - body: p.SearchRequest, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> list[dict[p.ColName, Any]]: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Hybrid search: " - f"table_type={table_type} body={body}" - ) - ) - try: - # Check quota - request.state.billing_manager.check_egress_quota() - # Search - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - with table.create_session() as session: - rows = table.hybrid_search( - session, - body.table_id, - query=body.query, - where=body.where, - limit=body.limit, - metric=body.metric, - nprobes=body.nprobes, - refine_factor=body.refine_factor, - reranking_model=body.reranking_model, - float_decimals=body.float_decimals, - vec_decimals=body.vec_decimals, - convert_null=True, - remove_state_cols=True, - json_safe=True, - include_original=True, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - return rows - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to search table: table_type={table_type} " - f"body={body}" - ) + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + body: SearchRequest, +) -> list[dict[ColName, Any]]: + # Search + embedder = CloudEmbedder(request=request) + if body.reranking_model is not None: + reranker = CloudReranker(request=request) + else: + reranker = None + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + rows = await table.hybrid_search( + session, + body.table_id, + query=body.query, + where=body.where, + limit=body.limit, + metric=body.metric, + nprobes=body.nprobes, + refine_factor=body.refine_factor, + embedder=embedder, + reranker=reranker, + reranking_model=body.reranking_model, + vec_decimals=body.vec_decimals, + convert_null=True, + remove_state_cols=True, + json_safe=True, + include_original=True, ) - raise + return rows def list_files(): pass -def _embed(embedder: CloudEmbedder, texts: list[str], embed_dtype: str) -> np.ndarray: - embeddings = embedder.embed_documents(texts=texts) +def _truncate_text(text: str, max_context_length: int, encoding_name: str = "cl100k_base") -> str: + """Truncates the text to fit within the max_context_length.""" + + encoding = tiktoken.get_encoding(encoding_name) + encoded_text = encoding.encode(text) + + if len(encoded_text) <= max_context_length: + return text + + truncated_encoded = encoded_text[:max_context_length] + truncated_text = encoding.decode(truncated_encoded) + return truncated_text + + +async def _embed( + embedder_name: str, embedder: CloudEmbedder, texts: list[str], embed_dtype: str +) -> np.ndarray: + if len(texts) == 0: + raise make_validation_error( + ValueError("There is no text or content to embed."), loc=("body", "file") + ) + embeddings = await embedder.embed_documents(embedder_name, texts=texts) embeddings = np.asarray([d.embedding for d in embeddings.data], dtype=embed_dtype) embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True) return embeddings -async def _add_file( +async def _embed_file( request: Request, bg_tasks: BackgroundTasks, - request_id: str, + project: ProjectRead, table_id: str, - file_info: dict, + file_name: str, + file_content: bytes, + file_uri: str, chunk_size: int, chunk_overlap: int, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", -) -> p.OkResponse: - file_name = file_info["File Name"] - chunks = await load_file(file_name, file_info["Content"], chunk_size, chunk_overlap) - logger.debug("Splitting file: {file_name}", file_name=file_name) +) -> OkResponse: + request_id = request.state.id + logger.info(f'{request_id} - Parsing file "{file_name}".') + chunks = await load_file(file_name, file_content, chunk_size, chunk_overlap) + logger.info(f'{request_id} - Embedding file "{file_name}" with {len(chunks):,d} chunks.') # --- Extract title --- # - excerpt = "".join(d.text for d in chunks[:8]) - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - model = llm.model_names( - prefer=p.DEFAULT_CHAT_MODEL, + excerpt = "".join(d.text for d in chunks[:8])[:50000] + llm = LLMEngine(request=request) + model = llm.validate_model_id( + model="", capabilities=["chat"], ) - model = model[0] logger.debug(f"{request_id} - Performing title extraction using: {model}") try: response = await llm.generate( - request=request, + id=request_id, model=model, messages=[ - p.ChatEntry.system("You are an concise assistant."), - p.ChatEntry.user( + ChatEntry.system("You are an concise assistant."), + ChatEntry.user( ( f"CONTEXT:\n{excerpt}\n\n" "From the excerpt, extract the document title or guess a possible title. " @@ -1685,13 +1080,11 @@ async def _add_file( title = "" # --- Add into Knowledge Table --- # - table = _get_gen_table(request.state.org_id, request.state.project_id, p.TableType.knowledge) + organization_id = project.organization.id + project_id = project.id + table = GenerativeTable.from_ids(organization_id, project_id, TableType.KNOWLEDGE) # Check quota - request.state.billing_manager.check_gen_table_llm_quota(table, table_id) - request.state.billing_manager.check_db_storage_quota() - request.state.billing_manager.check_file_storage_quota() - request.state.billing_manager.check_egress_quota() - + request.state.billing.check_gen_table_llm_quota(table, table_id) with table.create_session() as session: meta = table.open_meta(session, table_id) title_embed = None @@ -1699,22 +1092,27 @@ async def _add_file( for col in meta.cols: if col["vlen"] == 0: continue - gen_config = p.EmbedGenConfig.model_validate(col["gen_config"]) - embedder = CloudEmbedder( - embedder_name=gen_config.embedding_model, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) + gen_config = EmbedGenConfig.model_validate(col["gen_config"]) + request.state.billing.check_embedding_quota(model_id=gen_config.embedding_model) + embedder = CloudEmbedder(request=request) if col["id"] == "Title Embed": - title_embed = _embed(embedder, [title], col["dtype"])[0] + title_embed = await _embed( + gen_config.embedding_model, embedder, [title], col["dtype"] + ) + title_embed = title_embed[0] elif col["id"] == "Text Embed": - text_embeds = _embed(embedder, [chunk.text for chunk in chunks], col["dtype"]) + # Truncate based on embedder context length + embedder_context_length = ( + (llm.model_info(gen_config.embedding_model)).data[0].context_length + ) + texts = [_truncate_text(chunk.text, embedder_context_length) for chunk in chunks] + + text_embeds = await _embed( + gen_config.embedding_model, + embedder, + texts, + col["dtype"], + ) else: continue if title_embed is None or len(text_embeds) == 0: @@ -1727,37 +1125,55 @@ async def _add_file( "Text Embed": text_embed, "Title": title, "Title Embed": title_embed, - "File ID": file_info["ID"], + "File ID": file_uri, } - for chunk, text_embed in zip(chunks, text_embeds) + for chunk, text_embed in zip(chunks, text_embeds, strict=True) ] + logger.info( + f'{request_id} - Writing file "{file_name}" with {len(chunks):,d} chunks to DB.' + ) await add_rows( request=request, bg_tasks=bg_tasks, - table_type=p.TableType.knowledge, - body=p.RowAddRequest(table_id=table_id, data=row_add_data, stream=False), - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - table.create_indexes(session, table_id) - return p.OkResponse() - - -@router.post("/v1/gen_tables/knowledge/upload_file") -async def upload_file( + project=project, + table_type=TableType.KNOWLEDGE, + body=RowAddRequest.model_construct(table_id=table_id, data=row_add_data, stream=False), + ) + bg_tasks.add_task(_create_indexes, project, "knowledge", table_id) + return OkResponse() + + +@router.options("/v1/gen_tables/knowledge/embed_file") +@router.options("/v1/gen_tables/knowledge/upload_file", deprecated=True) +@handle_exception +async def embed_file_options(request: Request, response: Response): + headers = { + "Allow": "POST, OPTIONS", + "Accept": ", ".join(EMBED_WHITE_LIST_MIME), + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + } + if "upload_file" in request.url.path: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.4. ' + "Use '/v1/gen_tables/{table_type}/embed_file' instead." + '"' + ) + return Response(content=None, headers=headers) + + +@router.post("/v1/gen_tables/knowledge/embed_file") +@router.post("/v1/gen_tables/knowledge/upload_file", deprecated=True) +@handle_exception +async def embed_file( + *, request: Request, + response: Response, bg_tasks: BackgroundTasks, + project: Annotated[ProjectRead, Depends(auth_user_project)], file: Annotated[UploadFile, File(description="The file.")], - file_name: Annotated[str, Form(description="File name.")], - table_id: Annotated[ - str, Form(pattern=p.TABLE_NAME_PATTERN, description="Knowledge Table ID.") - ], + file_name: Annotated[str, Form(description="File name.", deprecated=True)] = "", + table_id: Annotated[str, Form(pattern=TABLE_NAME_PATTERN, description="Knowledge Table ID.")], # overwrite: Annotated[ # bool, Form(description="Whether to overwrite old file with the same name.") # ] = False, @@ -1770,242 +1186,232 @@ async def upload_file( # stream: Annotated[ # bool, Form(description="Whether or not to stream the LLM generation.") # ] = True, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.OkResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Uploading file: " - f"file_name={file_name} table_id={table_id} " - f"chunk_size={chunk_size} chunk_overlap={chunk_overlap}" - ) +) -> OkResponse: + if "upload_file" in request.url.path: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.4. ' + "Use '/v1/gen_tables/{table_type}/embed_file' instead." + '"' + ) + # Validate the Content-Type of the uploaded file + file_name = file.filename or file_name + if splitext(file_name)[1].lower() == ".jsonl": + file_content_type = "application/jsonl" + else: + file_content_type = file.content_type + if file_content_type not in EMBED_WHITE_LIST_MIME: + raise UnsupportedMediaTypeError( + f"File type '{file_content_type}' is unsupported. Accepted types are: {', '.join(EMBED_WHITE_LIST_MIME)}" + ) + # --- Add into File Table --- # + content = await file.read() + uri = await upload_file_to_s3( + project.organization.id, + project.id, + content, + file_content_type, + file_name, + ) + # if overwrite: + # file_table.delete_file(file_name=file_name) + # --- Add into Knowledge Table --- # + return await _embed_file( + request=request, + bg_tasks=bg_tasks, + project=project, + table_id=table_id, + file_name=file_name, + file_content=content, + file_uri=uri, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, ) - try: - # --- Add into File Table --- # - content = await file.read() - file_table = _get_file_table(request.state.org_id, request.state.project_id) - # if overwrite: - # file_table.delete_file(file_name=file_name) - # Compute checksum - block_size = 2**10 - hasher = blake2b() - for i in range(0, len(content), block_size): - hasher.update(content[i : i + block_size]) - file_info = file_table.add_file( - file_name=file_name, content=content, blake2b_checksum=hasher.hexdigest() - ) - # --- Add into Knowledge Table --- # - return await _add_file( - request=request, - bg_tasks=bg_tasks, - request_id=request.state.id, - table_id=table_id, - file_info=file_info, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to upload file into Knowledge Table: " - f"file_name={file_name} table_id={table_id} " - f"chunk_size={chunk_size} chunk_overlap={chunk_overlap}" - ) - ) - raise @router.post("/v1/gen_tables/{table_type}/import_data") +@handle_exception async def import_table_data( request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - file: Annotated[UploadFile, File(description="The file.")], - file_name: Annotated[str, Form(description="File name.")], + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + file: Annotated[UploadFile, File(description="The CSV or TSV file.")], table_id: Annotated[ - str, Form(pattern=p.TABLE_NAME_PATTERN, description="Knowledge Table ID.") + str, + Form( + pattern=TABLE_NAME_PATTERN, + description="ID or name of the table that the data should be imported into.", + ), ], stream: Annotated[ bool, Form(description="Whether or not to stream the LLM generation.") ] = True, # List of inputs is bugged as of 2024-07-14: https://github.com/tiangolo/fastapi/pull/9928/files # column_names: Annotated[ - # list[p.ColName] | None, + # list[ColName] | None, # Form( # description="_Optional_. A list of columns names if the CSV does not have header row. Defaults to None (read from CSV).", # ), # ] = None, # columns: Annotated[ - # list[p.ColName] | None, + # list[ColName] | None, # Form( # description="_Optional_. A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at').", # ), # ] = None, delimiter: Annotated[ - p.CSVDelimiter, + CSVDelimiter, Form(description='The delimiter, can be "," or "\\t". Defaults to ",".'), - ] = p.CSVDelimiter.comma, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", + ] = CSVDelimiter.COMMA, ): - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Importing data: " - f"file_name={file_name} table_type={table_type} table_id={table_id} " - f"delimiter={delimiter}" - ) - ) + # Get column info + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with table.create_session() as session: + meta = table.open_meta(session, table_id, remove_state_cols=True) + cols = { + c.id.lower(): c for c in meta.cols_schema if c.id.lower() not in ("id", "updated at") + } + cols_dtype = { + c.id: c.dtype + for c in meta.cols_schema + if c.id.lower() not in ("id", "updated at") and c.vlen == 0 + } + # --- Read file as DataFrame --- # + content = await file.read() try: - # --- Read file as DataFrame --- # - content = await file.read() + df = csv_to_df(content.decode("utf-8"), sep=delimiter.value) + # Do not import "ID" and "Updated at" + keep_cols = [c for c in df.columns.tolist() if c.lower() in cols] + df = df.filter(items=keep_cols, axis="columns") + except ValueError as e: + raise make_validation_error(e, loc=("body", "file")) from e + # if isinstance(columns, list) and len(columns) > 0: + # df = df[columns] + if len(df) == 0: + raise make_validation_error( + ValueError("The file provided is empty."), loc=("body", "file") + ) + # Convert vector data + for col_id in df.columns.tolist(): + if cols[col_id.lower()].vlen > 0: + df[col_id] = df[col_id].apply(json_loads) + # Cast data to follow column dtype + for col_id, dtype in cols_dtype.items(): + if col_id not in df.columns: + continue try: - df = csv_to_df(content.decode("utf-8"), sep=delimiter.value) - # Do not import "ID" and "Updated at" - keep_cols = [c for c in df.columns.tolist() if not c.lower() in ("id", "updated at")] - df = df.filter(items=keep_cols, axis="columns") - # Only keep columns with valid names - df = df.filter(regex=p.COL_NAME_PATTERN, axis="columns") - except ValueError: - raise ValidationError.from_exception_data( - "The data provided is invalid.", - line_errors=[ - InitErrorDetails( - type="value_error", - loc=("body", "file"), - input="", - ctx=dict(error=ValueError("The data provided is invalid.")), - ) - ], - ) - # if isinstance(columns, list) and len(columns) > 0: - # df = df[columns] - if len(df) == 0: - raise ValidationError.from_exception_data( - "The data provided is empty.", - line_errors=[ - InitErrorDetails( - type="value_error", - loc=("body", "file"), - input="", - ctx=dict(error=ValueError("The data provided is empty.")), - ) - ], - ) - row_add_data = df.to_dict(orient="records") - return await add_rows( - request=request, - bg_tasks=bg_tasks, - table_type=table_type, - body=p.RowAddRequest(table_id=table_id, data=row_add_data, stream=stream), - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to import data: " - f"file_name={file_name} table_type={table_type} table_id={table_id} " - f"delimiter={delimiter}" - ) - ) - raise + 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: + dtype = "str" + df[col_id] = df[col_id].astype(dtype, errors="raise") + except ValueError as e: + raise make_validation_error(e, loc=("body", "file")) from e + # Convert DF to list of dicts + row_add_data = df.to_dict(orient="records") + return await add_rows( + request=request, + bg_tasks=bg_tasks, + project=project, + table_type=table_type, + body=RowAddRequest(table_id=table_id, data=row_add_data, stream=stream), + ) @router.get("/v1/gen_tables/{table_type}/{table_id}/export_data") +@handle_exception def export_table_data( *, - request: Request, bg_tasks: BackgroundTasks, - table_type: Annotated[p.TableType, Path(description="Table type.")], - table_id: Annotated[str, Path(pattern=p.TABLE_NAME_PATTERN, description="Table ID or name.")], + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[ + str, + Path(pattern=TABLE_NAME_PATTERN, description="ID or name of the table to be exported."), + ], delimiter: Annotated[ - p.CSVDelimiter, Query(description='The delimiter, can be "," or "\\t". Defaults to ",".') - ] = p.CSVDelimiter.comma, + CSVDelimiter, + Query(description='The delimiter, can be "," or "\\t". Defaults to ",".'), + ] = CSVDelimiter.COMMA, columns: Annotated[ - list[p.ColName] | None, + list[ColName] | None, Query( + min_length=1, description="_Optional_. A list of columns to be exported. Defaults to None (export all columns).", ), ] = None, ) -> FileResponse: - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] Exporting data: " - f"table_type={table_type} table_id={table_id} " - f"delimiter={delimiter} columns={columns}" - ) + # Export data + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + ext = ".csv" if delimiter == CSVDelimiter.COMMA else ".tsv" + tmp_dir = TemporaryDirectory() + filename = f"{table_id}{ext}" + filepath = join(tmp_dir.name, filename) + # Keep a reference to the directory and only delete upon completion + bg_tasks.add_task(tmp_dir.cleanup) + table.export_csv( + table_id=table_id, + columns=columns, + file_path=filepath, + delimiter=delimiter, ) - try: - # Check quota - request.state.billing_manager.check_egress_quota() - # Export data - table = _get_gen_table(request.state.org_id, request.state.project_id, table_type) - ext = ".csv" if delimiter == p.CSVDelimiter.comma else ".tsv" - tmp = NamedTemporaryFile(suffix=ext, delete=False) - bg_tasks.add_task(remove, tmp.name) - logger.info( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Exporting to temporary file: {tmp.name}" + return FileResponse( + path=filepath, + filename=filename, + media_type="application/octet-stream", + ) + + +@router.post("/v1/gen_tables/{table_type}/import") +@handle_exception +async def import_table( + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + file: Annotated[UploadFile, File(description="The parquet file.")], + table_id_dst: Annotated[ + str | None, + Form(pattern=TABLE_NAME_PATTERN, description="The ID or name of the new table."), + ] = None, +) -> TableMetaResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + with BytesIO(await file.read()) as source: + with table.create_session() as session: + _, meta = await table.import_parquet( + session=session, + source=source, + table_id_dst=table_id_dst, ) - ) - table.export_csv( + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta + + +@router.get("/v1/gen_tables/{table_type}/{table_id}/export") +@handle_exception +def export_table( + *, + bg_tasks: BackgroundTasks, + project: Annotated[ProjectRead, Depends(auth_user_project)], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[ + str, + Path(pattern=TABLE_NAME_PATTERN, description="ID or name of the table to be exported."), + ], +) -> FileResponse: + table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + tmp_dir = TemporaryDirectory() + filename = f"{table_id}.parquet" + filepath = join(tmp_dir.name, filename) + # Keep a reference to the directory and only delete upon completion + bg_tasks.add_task(tmp_dir.cleanup) + with table.create_session() as session: + table.dump_parquet( + session=session, table_id=table_id, - columns=columns, - file_path=tmp.name, - delimiter=delimiter, - ) - return FileResponse( - path=tmp.name, - filename=basename(tmp.name), - media_type="application/octet-stream", - ) - except ValidationError as e: - raise RequestValidationError(errors=e.errors()) - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to list rows of table: table_type={table_type} " - f"table_id={table_id} delimiter={delimiter} columns={columns}" - ) + dest=filepath, ) - raise + return FileResponse( + path=filepath, + filename=filename, + media_type="application/octet-stream", + ) diff --git a/services/api/src/owl/routers/llm.py b/services/api/src/owl/routers/llm.py index c4201df..a27bd7d 100644 --- a/services/api/src/owl/routers/llm.py +++ b/services/api/src/owl/routers/llm.py @@ -6,16 +6,25 @@ from typing import Annotated import numpy as np -from fastapi import APIRouter, Header, Query, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse -from loguru import logger -from owl import protocol as p +from jamaibase.exceptions import ResourceNotFoundError from owl.llm import LLMEngine from owl.models import CloudEmbedder -from owl.utils.exceptions import OwlException, ResourceNotFoundError +from owl.protocol import ( + EXAMPLE_CHAT_MODEL, + ChatRequest, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingResponseData, + ModelCapability, + ModelInfoResponse, +) +from owl.utils.auth import auth_user_project +from owl.utils.exceptions import handle_exception -router = APIRouter() +router = APIRouter(dependencies=[Depends(auth_user_project)]) @router.get( @@ -23,62 +32,36 @@ summary="List the info of models available.", description="List the info of models available with the specified name and capabilities.", ) +@handle_exception async def get_model_info( request: Request, model: Annotated[ str, Query( description="ID of the requested model.", - examples=[p.DEFAULT_CHAT_MODEL], + examples=[EXAMPLE_CHAT_MODEL], ), ] = "", capabilities: Annotated[ - list[p.ModelCapability] | None, + list[ModelCapability] | None, Query( description=( "Filter the model info by model's capabilities. " "Leave it blank to disable filter." ), - examples=[[p.ModelCapability.chat]], + examples=[[ModelCapability.CHAT]], ), ] = None, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.ModelInfoResponse: - logger.info(f"Listing model info with capabilities: {capabilities}") +) -> ModelInfoResponse: try: if capabilities is not None: capabilities = [c.value for c in capabilities] - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - return llm.model_info( + return LLMEngine(request=request).model_info( model=model, capabilities=capabilities, ) except ResourceNotFoundError: - return p.ModelInfoResponse(data=[]) - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to list model info: model={model} capabilities={capabilities}" - ) - ) - raise + return ModelInfoResponse(data=[]) @router.get( @@ -89,131 +72,78 @@ async def get_model_info( "If the preferred model is not available, then return the first available model." ), ) +@handle_exception async def get_model_names( request: Request, prefer: Annotated[ str, Query( description="ID of the preferred model.", - examples=[p.DEFAULT_CHAT_MODEL], + examples=[EXAMPLE_CHAT_MODEL], ), ] = "", capabilities: Annotated[ - list[p.ModelCapability] | None, + list[ModelCapability] | None, Query( description=( "Filter the model info by model's capabilities. " "Leave it blank to disable filter." ), - examples=[[p.ModelCapability.chat]], + examples=[[ModelCapability.CHAT]], ), ] = None, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", ) -> list[str]: try: if capabilities is not None: capabilities = [c.value for c in capabilities] - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - return llm.model_names( + return LLMEngine(request=request).model_names( prefer=prefer, capabilities=capabilities, ) except ResourceNotFoundError: return [] - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to list model names: prefer={prefer} capabilities={capabilities}" - ) - ) - raise @router.post( "/v1/chat/completions", description="Given a list of messages comprising a conversation, the model will return a response.", ) -async def generate_completions( - request: Request, - body: p.ChatRequest, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -): - try: - # Check quota - request.state.billing_manager.check_llm_quota(body.model) - request.state.billing_manager.check_egress_quota() - # Run LLM - llm = LLMEngine( - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - hyperparams = body.model_dump(exclude_none=True) - if body.stream: +@handle_exception +async def generate_completions(request: Request, body: ChatRequest): + # Check quota + request.state.billing.check_llm_quota(body.model) + request.state.billing.check_egress_quota() + # Run LLM + llm = LLMEngine(request=request) + # object key could cause issue to some LLM provider, ex: Anthropic + body.id = request.state.id + hyperparams = body.model_dump(exclude_none=True, exclude={"object"}) + if body.stream: - async def _generate(): - content_length = 0 - async for chunk in llm.rag_stream(request=request, **hyperparams): - sse = f"data: {chunk.model_dump_json()}\n\n" - content_length += len(sse.encode("utf-8")) - yield sse - sse = "data: [DONE]\n\n" + async def _generate(): + content_length = 0 + async for chunk in llm.rag_stream(**hyperparams): + sse = f"data: {chunk.model_dump_json()}\n\n" content_length += len(sse.encode("utf-8")) yield sse - request.state.billing_manager.create_egress_events(content_length / (1024**3)) + sse = "data: [DONE]\n\n" + content_length += len(sse.encode("utf-8")) + yield sse + request.state.billing.create_egress_events(content_length / (1024**3)) - response = StreamingResponse( - content=_generate(), - status_code=200, - media_type="text/event-stream", - headers={"X-Accel-Buffering": "no"}, - ) + response = StreamingResponse( + content=_generate(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) - else: - response = await llm.rag(request=request, **hyperparams) - request.state.billing_manager.create_egress_events( - len(response.model_dump_json().encode("utf-8")) / (1024**3) - ) - return response - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to generate chat completion: body={body}" - ) + else: + response = await llm.rag(**hyperparams) + request.state.billing.create_egress_events( + len(response.model_dump_json().encode("utf-8")) / (1024**3) ) - raise + return response @router.post( @@ -224,51 +154,20 @@ async def _generate(): "Note that the vectors are NOT normalized." ), ) -async def generate_embeddings( - request: Request, - body: p.EmbeddingRequest, - openai_api_key: Annotated[str, Header(description="OpenAI API key.")] = "", - anthropic_api_key: Annotated[str, Header(description="Anthropic API key.")] = "", - gemini_api_key: Annotated[str, Header(description="Google Gemini API key.")] = "", - cohere_api_key: Annotated[str, Header(description="Cohere API key.")] = "", - groq_api_key: Annotated[str, Header(description="Groq API key.")] = "", - together_api_key: Annotated[str, Header(description="Together AI API key.")] = "", - jina_api_key: Annotated[str, Header(description="Jina API key.")] = "", - voyage_api_key: Annotated[str, Header(description="Voyage API key.")] = "", -) -> p.EmbeddingResponse: - try: - embedder = CloudEmbedder( - embedder_name=body.model, - openai_api_key=openai_api_key, - anthropic_api_key=anthropic_api_key, - gemini_api_key=gemini_api_key, - cohere_api_key=cohere_api_key, - groq_api_key=groq_api_key, - together_api_key=together_api_key, - jina_api_key=jina_api_key, - voyage_api_key=voyage_api_key, - ) - if isinstance(body.input, str): - body.input = [body.input] - if body.type == "document": - embeddings = embedder.embed_documents(texts=body.input) - else: - embeddings = embedder.embed_queries(texts=body.input) - if body.encoding_format == "base64": - embeddings.data = [ - p.EmbeddingResponseData( - embedding=base64.b64encode(np.asarray(e.embedding, dtype=np.float32)), index=i - ) - for i, e in enumerate(embeddings.data) - ] - return embeddings - except OwlException: - raise - except Exception: - logger.exception( - ( - f"{request.state.id} - [{request.state.org_id}/{request.state.project_id}] " - f"Failed to generate embedding: body={body}" +@handle_exception +async def generate_embeddings(request: Request, body: EmbeddingRequest) -> EmbeddingResponse: + embedder = CloudEmbedder(request=request) + if isinstance(body.input, str): + body.input = [body.input] + if body.type == "document": + embeddings = await embedder.embed_documents(embedder_name=body.model, texts=body.input) + else: + embeddings = await embedder.embed_queries(embedder_name=body.model, texts=body.input) + if body.encoding_format == "base64": + embeddings.data = [ + EmbeddingResponseData( + embedding=base64.b64encode(np.asarray(e.embedding, dtype=np.float32)), index=i ) - ) - raise + for i, e in enumerate(embeddings.data) + ] + return embeddings diff --git a/services/api/src/owl/routers/org_admin.py b/services/api/src/owl/routers/org_admin.py new file mode 100644 index 0000000..e1f66e4 --- /dev/null +++ b/services/api/src/owl/routers/org_admin.py @@ -0,0 +1,703 @@ +import pathlib +from datetime import datetime +from io import BytesIO +from os.path import join +from tempfile import TemporaryDirectory +from time import perf_counter +from typing import Annotated, Literal, Mapping + +import pyarrow as pa +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + Form, + Path, + Query, + Request, + UploadFile, +) +from fastapi.responses import FileResponse +from loguru import logger +from pyarrow.parquet import read_table as read_parquet_table +from pyarrow.parquet import write_table as write_parquet_table +from sqlalchemy import func +from sqlmodel import Session, select + +from jamaibase.exceptions import ( + BadInputError, + ForbiddenError, + ResourceExistsError, + ResourceNotFoundError, + UpgradeTierError, + make_validation_error, +) +from jamaibase.utils.io import json_dumps, json_loads, read_json +from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.db import MAIN_ENGINE, UserSQLModel, cached_text, create_sql_tables +from owl.db.gen_table import GenerativeTable +from owl.protocol import ( + AdminOrderBy, + ModelListConfig, + Name, + OkResponse, + Page, + TableMeta, + TableMetaResponse, + TableType, + TemplateMeta, +) +from owl.utils import datetime_now_iso +from owl.utils.auth import WRITE_METHODS, AuthReturn, auth_user +from owl.utils.crypt import generate_key +from owl.utils.exceptions import handle_exception + +if ENV_CONFIG.is_oss: + from owl.db.oss_admin import ( + Organization, + OrganizationRead, + Project, + ProjectCreate, + ProjectRead, + ProjectUpdate, + ) +else: + from owl.db.cloud_admin import ( + Organization, + OrganizationRead, + Project, + ProjectCreate, + ProjectRead, + ProjectUpdate, + ) + + +CURR_DIR = pathlib.Path(__file__).resolve().parent +TEMPLATE_DIR = CURR_DIR.parent / "templates" +router = APIRouter() + + +@router.on_event("startup") +async def startup(): + create_sql_tables(UserSQLModel, MAIN_ENGINE) + + +def _get_session(): + with Session(MAIN_ENGINE) as session: + yield session + + +def _check_access( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: AuthReturn, + org_or_id: str | Organization, +) -> Organization: + if isinstance(org_or_id, str): + if ENV_CONFIG.is_oss: + # OSS only has one default organization + org_or_id = ENV_CONFIG.default_org_id + organization = session.get(Organization, org_or_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{org_or_id}" is not found.') + else: + organization = org_or_id + if ENV_CONFIG.is_oss: + return organization + + user, org = auth_info + if user is not None: + user_roles = {m.organization_id: m.role for m in user.member_of} + user_role = user_roles.get(organization.id, None) + if user_role is None: + raise ForbiddenError(f'You do not have access to organization "{organization.id}".') + if user_role == "guest" and request.method in WRITE_METHODS: + raise ForbiddenError( + f'You do not have write access to organization "{organization.id}".' + ) + if org is not None and org.id != organization.id: + raise ForbiddenError(f'You do not have access to organization "{organization.id}".') + # Non-activated orgs can only perform GET requests + if (not organization.active) and (request.method != "GET"): + raise UpgradeTierError(f'Your organization "{organization.id}" is not activated.') + return organization + + +def _get_organization_from_path( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: Annotated[AuthReturn, Depends(auth_user)], + organization_id: Annotated[str, Path(min_length=1, description='Organization ID "org_xxx".')], +) -> Organization: + return _check_access( + session=session, request=request, auth_info=auth_info, org_or_id=organization_id + ) + + +def _get_organization_from_query( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: Annotated[AuthReturn, Depends(auth_user)], + organization_id: Annotated[str, Query(min_length=1, description='Organization ID "org_xxx".')], +) -> Organization: + return _check_access( + session=session, request=request, auth_info=auth_info, org_or_id=organization_id + ) + + +def _get_project_from_path( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: Annotated[AuthReturn, Depends(auth_user)], + project_id: Annotated[str, Path(min_length=1, description='Project ID "proj_xxx".')], +) -> Project: + proj = session.get(Project, project_id) + if proj is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + _check_access( + session=session, request=request, auth_info=auth_info, org_or_id=proj.organization + ) + return proj + + +@router.get("/v1/models/{organization_id}") +@handle_exception +def get_org_model_config( + organization: Annotated[Organization, Depends(_get_organization_from_path)], +) -> ModelListConfig: + # Get only org models + return ModelListConfig.model_validate(organization.models) + + +@router.patch("/v1/models/{organization_id}") +@handle_exception +def set_org_model_config( + *, + session: Annotated[Session, Depends(_get_session)], + organization: Annotated[Organization, Depends(_get_organization_from_path)], + body: ModelListConfig, +) -> OkResponse: + # Validate + _ = body + CONFIG.get_model_config() + for m in body.models: + m.owned_by = "custom" + organization.models = body.model_dump(mode="json") + organization.updated_at = datetime_now_iso() + session.add(organization) + session.commit() + return OkResponse() + + +@router.post("/v1/projects") +@handle_exception +def create_project( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: Annotated[AuthReturn, Depends(auth_user)], + body: ProjectCreate, +) -> ProjectRead: + if ENV_CONFIG.is_oss: + body.organization_id = ENV_CONFIG.default_org_id + _check_access( + session=session, request=request, auth_info=auth_info, org_or_id=body.organization_id + ) + same_name_count = session.exec( + select( + func.count(Project.id).filter( + Project.organization_id == body.organization_id, Project.name == body.name + ) + ) + ).one() + if same_name_count > 0: + raise ResourceExistsError("Project with the same name exists.") + project_id = generate_key(24, "proj_") + while session.get(Project, project_id) is not None: + project_id = generate_key(24, "proj_") + proj = Project( + id=project_id, + name=body.name, + organization_id=body.organization_id, + ) + session.add(proj) + session.commit() + session.refresh(proj) + logger.info(f"{request.state.id} - Project created: {proj}") + return ProjectRead( + **proj.model_dump(), + organization=OrganizationRead( + **proj.organization.model_dump(), + members=proj.organization.members, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain), + ) + + +@router.patch("/v1/projects") +@handle_exception +def update_project( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + auth_info: Annotated[AuthReturn, Depends(auth_user)], + body: ProjectUpdate, +) -> ProjectRead: + proj = session.get(Project, body.id) + if proj is None: + raise ResourceNotFoundError(f'Project "{body.id}" is not found.') + _check_access( + session=session, request=request, auth_info=auth_info, org_or_id=proj.organization + ) + for key, value in body.model_dump(exclude=["id"], exclude_none=True).items(): + if key == "name": + same_name_count = session.exec( + select( + func.count(Project.id).filter( + Project.organization_id == proj.organization_id, + Project.name == body.name, + ) + ) + ).one() + if same_name_count > 0: + raise ResourceExistsError("Project with the same name exists.") + setattr(proj, key, value) + proj.updated_at = datetime_now_iso() + session.add(proj) + session.commit() + session.refresh(proj) + logger.info(f"{request.state.id} - Project updated: {proj}") + return ProjectRead( + **proj.model_dump(), + organization=OrganizationRead( + **proj.organization.model_dump(), + members=proj.organization.members, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain), + ) + + +@router.patch("/v1/projects/{project_id}") +@handle_exception +def set_project_updated_at( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + project: Annotated[Project, Depends(_get_project_from_path)], + updated_at: Annotated[ + str | None, Query(min_length=1, description="Project update datetime (ISO 8601 UTC).") + ] = None, +) -> OkResponse: + if updated_at is None: + updated_at = datetime_now_iso() + else: + try: + tz = str(datetime.fromisoformat(updated_at).tzinfo) + except Exception as e: + raise BadInputError("`updated_at` must be a ISO 8601 UTC datetime string.") from e + if tz != "UTC": + raise BadInputError(f'`updated_at` must be UTC, but received "{tz}".') + project.updated_at = updated_at + session.add(project) + session.commit() + logger.info(f"{request.state.id} - Project updated_at set to: {updated_at}") + return OkResponse() + + +@router.get("/v1/projects") +@handle_exception +def list_projects( + *, + session: Annotated[Session, Depends(_get_session)], + organization: Annotated[Organization, Depends(_get_organization_from_query)], + search_query: Annotated[ + str, + Query( + max_length=10_000, + description='_Optional_. A string to search for within project names as a filter. Defaults to "" (no filter).', + ), + ] = "", + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(gt=0, le=100)] = 100, + order_by: Annotated[ + AdminOrderBy, + Query( + min_length=1, + description='_Optional_. Sort projects by this attribute. Defaults to "updated_at".', + ), + ] = AdminOrderBy.UPDATED_AT, + order_descending: Annotated[ + bool, + Query(description="_Optional_. Whether to sort by descending order. Defaults to True."), + ] = True, +) -> Page[ProjectRead]: + organization_id = organization.id + org = session.get(Organization, organization_id) + if org is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + search_query = search_query.strip() + selection = select(Project).where(Project.organization_id == organization_id) + count = func.count(Project.id).filter(Project.organization_id == organization_id) + if search_query: + selection = selection.where(Project.name.ilike(f"%{search_query}%")) + count = count.filter(Project.name.ilike(f"%{search_query}%")) + order_by = f"LOWER({order_by})" + selection = selection.order_by( + cached_text(f"{order_by} DESC" if order_descending else f"{order_by} ASC") + ) + projects = session.exec(selection.offset(offset).limit(limit)).all() + total = session.exec(select(count)).one() + return Page[ProjectRead]( + items=projects, + offset=offset, + limit=limit, + total=total, + ) + + +@router.get("/v1/projects/{project_id}") +@handle_exception +def get_project( + project: Annotated[Project, Depends(_get_project_from_path)], +) -> ProjectRead: + proj = ProjectRead( + **project.model_dump(), + organization=OrganizationRead( + **project.organization.model_dump(), + members=project.organization.members, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain), + ) + return proj + + +@router.delete("/v1/projects/{project_id}") +@handle_exception +def delete_project( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + project: Annotated[Project, Depends(_get_project_from_path)], +) -> OkResponse: + project_id = project.id + session.delete(project) + session.commit() + logger.info(f"{request.state.id} - Project deleted: {project_id}") + return OkResponse() + + +def _package_project_tables(project: Project) -> list[tuple[str, TableMetaResponse, bytes]]: + data = [] + table_types = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + for table_type in table_types: + table = GenerativeTable.from_ids(project.organization_id, project.id, table_type) + with table.create_session() as session: + # Lance tables could be on S3 so we use list_meta instead of listdir + batch_size, offset, total = 200, 0, 1 + while offset < total: + metas, total = table.list_meta( + session, + offset=offset, + limit=batch_size, + remove_state_cols=True, + parent_id=None, + ) + offset += batch_size + for meta in metas: + with BytesIO() as f: + table.dump_parquet(session=session, table_id=meta.id, dest=f) + data.append((table_type.value, meta, f.getvalue())) + return data + + +def _export_project( + *, + request: Request, + bg_tasks: BackgroundTasks, + project: Project, + output_file_ext: str, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + extra_metas: Mapping[str, str] | None = None, +) -> FileResponse: + t0 = perf_counter() + # Check quota + request.state.billing.check_egress_quota() + # Check extra metadata + extra_metas = extra_metas or {} + for k, v in extra_metas.items(): + if not isinstance(v, str): + raise BadInputError(f'Invalid extra metadata: value of key "{k}" is not a string.') + # Dump all tables as parquet files + data = _package_project_tables(project) + if len(data) == 0: + metas = [] + pa_table = pa.Table.from_pydict({"table_type": pa.array([]), "data": pa.array([])}) + else: + metas = [] + for table_type, meta, _ in data: + metas.append({"table_type": table_type, "table_meta": meta.model_dump(mode="json")}) + data = list(zip(*data, strict=True)) + pa_table = pa.Table.from_pydict( + {"table_type": pa.array(data[0]), "data": pa.array(data[2])} + ) + pa_meta = pa_table.schema.metadata or {} + pa_table = pa_table.replace_schema_metadata( + { + "project_meta": project.model_dump_json(), + "table_metas": json_dumps(metas), + **extra_metas, + **pa_meta, + } + ) + tmp_dir = TemporaryDirectory() + filename = f"{project.id}{output_file_ext}" + filepath = join(tmp_dir.name, filename) + # Keep a reference to the directory and only delete upon completion + bg_tasks.add_task(tmp_dir.cleanup) + write_parquet_table(pa_table, where=filepath, compression=compression) + logger.info( + f'{request.state.id} - Project "{project.id}" exported in {perf_counter() - t0:,.2f} s.' + ) + return FileResponse( + path=filepath, + filename=filename, + media_type="application/octet-stream", + ) + + +@router.get("/v1/projects/{project_id}/export") +@handle_exception +def export_project( + *, + request: Request, + bg_tasks: BackgroundTasks, + project: Annotated[Project, Depends(_get_project_from_path)], + compression: Annotated[ + Literal["NONE", "ZSTD", "LZ4", "SNAPPY"], + Query(description="Parquet compression codec."), + ] = "ZSTD", +) -> FileResponse: + return _export_project( + request=request, + bg_tasks=bg_tasks, + project=project, + output_file_ext=".parquet", + compression=compression, + ) + + +@router.get("/v1/projects/{project_id}/export/template") +@handle_exception +def export_project_as_template( + *, + request: Request, + bg_tasks: BackgroundTasks, + project: Annotated[Project, Depends(_get_project_from_path)], + name: Annotated[Name, Query(description="Template name.")], + tags: Annotated[list[str], Query(description="Template tags.")], + description: Annotated[str, Query(description="Template description.")], + compression: Annotated[ + Literal["NONE", "ZSTD", "LZ4", "SNAPPY"], + Query(description="Parquet compression codec."), + ] = "ZSTD", +) -> FileResponse: + template_meta = TemplateMeta(name=name, description=description, tags=tags) + return _export_project( + request=request, + bg_tasks=bg_tasks, + project=project, + output_file_ext=".template.parquet", + compression=compression, + extra_metas={"template_meta": template_meta.model_dump_json()}, + ) + + +@router.post("/v1/projects/import/{organization_id}") +@handle_exception +async def import_project( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + organization: Annotated[Organization, Depends(_get_organization_from_path)], + file: Annotated[UploadFile, File(description="Project or Template Parquet file.")], + project_id_dst: Annotated[ + str, + Form( + description=( + "_Optional_. ID of the project to import tables into. " + "Defaults to creating new project." + ), + ), + ] = "", +) -> ProjectRead: + t0 = perf_counter() + organization_id = organization.id + if project_id_dst == "": + proj = None + else: + proj = session.get(Project, project_id_dst) + if proj is None: + raise ResourceNotFoundError(f'Project "{project_id_dst}" is not found.') + if proj.organization_id != organization_id: + raise ForbiddenError( + f'You do not have access to organization "{proj.organization_id}".' + ) + try: + with BytesIO(await file.read()) as source: + # Read metadata + pa_table = read_parquet_table(source, columns=[], use_threads=False, memory_map=True) + metadata = pa_table.schema.metadata + if proj is None: + # Create the project + project_meta = metadata.get(b"template_meta", None) + if project_meta is None: + project_meta = metadata.get(b"project_meta", None) + if project_meta is None: + raise BadInputError("Missing template or table metadata in the Parquet file.") + try: + project_meta = json_loads(project_meta) + except Exception as e: + raise BadInputError( + "Invalid template or table metadata in the Parquet file." + ) from e + proj = Project(name=project_meta["name"], organization_id=organization_id) + session.add(proj) + session.commit() + session.refresh(proj) + project_id_dst = proj.id + else: + # Check if all the table IDs have no conflict + try: + type_metas = json_loads(metadata[b"table_metas"]) + except KeyError as e: + raise BadInputError("Missing table metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid table metadata in the Parquet file.") from e + for type_meta in type_metas: + table = GenerativeTable.from_ids( + organization_id, project_id_dst, type_meta["table_type"] + ) + with table.create_session() as gt_sess: + table_id = type_meta["table_meta"]["id"] + meta = gt_sess.get(TableMeta, table_id) + if meta is not None: + raise ResourceExistsError(f'Table "{table_id}" already exists.') + logger.info( + f'{request.state.id} - Project "{proj.id}" metadata imported in {perf_counter() - t0:,.2f} s.' + ) + # Create the tables + pa_table = read_parquet_table(source, columns=None, use_threads=False, memory_map=True) + for row in pa_table.to_pylist(): + table_type = row["table_type"] + with BytesIO(row["data"]) as pq_source: + table = GenerativeTable.from_ids(organization_id, project_id_dst, table_type) + with table.create_session() as gt_sess: + await table.import_parquet( + session=gt_sess, + source=pq_source, + table_id_dst=None, + ) + logger.info( + f'{request.state.id} - Project "{proj.id}" imported in {perf_counter() - t0:,.2f} s.' + ) + except pa.ArrowInvalid as e: + raise make_validation_error( + e, + loc=("body", "file"), + ) from e + return ProjectRead( + **proj.model_dump(), + organization=OrganizationRead( + **proj.organization.model_dump(), + members=proj.organization.members, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain), + ) + + +@router.post("/v1/projects/import/{organization_id}/templates/{template_id}") +@handle_exception +async def import_project_from_template( + *, + session: Annotated[Session, Depends(_get_session)], + organization: Annotated[Organization, Depends(_get_organization_from_path)], + template_id: Annotated[str, Path(description="ID of the template to import from.")], + project_id_dst: Annotated[ + str, + Query( + description=( + "_Optional_. ID of the project to import tables into. " + "Defaults to creating new project." + ), + ), + ] = "", +) -> ProjectRead: + template_dir = TEMPLATE_DIR / template_id + if not template_dir.is_dir(): + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + organization_id = organization.id + if project_id_dst == "": + proj = None + else: + proj = session.get(Project, project_id_dst) + if proj is None: + raise ResourceNotFoundError(f'Project "{project_id_dst}" is not found.') + if proj.organization_id != organization_id: + raise ForbiddenError( + f'You do not have access to organization "{proj.organization_id}".' + ) + if proj is None: + # Create the project + template_meta = read_json(template_dir / "template_meta.json") + proj = Project(name=template_meta["name"], organization_id=organization_id) + session.add(proj) + session.commit() + session.refresh(proj) + project_id_dst = proj.id + else: + # Check if all the table IDs have no conflict + for table_type in [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT]: + table_dir = template_dir / table_type + if not table_dir.is_dir(): + continue + table = GenerativeTable.from_ids(organization_id, project_id_dst, table_type) + for pq_source in table_dir.iterdir(): + if not pq_source.is_file(): + continue + pa_table = read_parquet_table( + pq_source, columns=[], use_threads=False, memory_map=True + ) + try: + pq_meta = TableMeta.model_validate_json( + pa_table.schema.metadata[b"gen_table_meta"] + ) + except KeyError as e: + raise BadInputError("Missing table metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid table metadata in the Parquet file.") from e + with table.create_session() as gt_sess: + meta = gt_sess.get(TableMeta, pq_meta.id) + if meta is not None: + raise ResourceExistsError(f'Table "{pq_meta.id}" already exists.') + # Create the tables + for table_type in [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT]: + table_dir = template_dir / table_type + if not table_dir.is_dir(): + continue + for pq_source in table_dir.iterdir(): + if not pq_source.is_file(): + continue + table = GenerativeTable.from_ids(organization_id, project_id_dst, table_type) + with table.create_session() as gt_sess: + await table.import_parquet( + session=gt_sess, + source=pq_source, + table_id_dst=None, + ) + return ProjectRead( + **proj.model_dump(), + organization=OrganizationRead( + **proj.organization.model_dump(), + members=proj.organization.members, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain), + ) diff --git a/services/api/src/owl/routers/oss_admin.py b/services/api/src/owl/routers/oss_admin.py new file mode 100644 index 0000000..29a0cca --- /dev/null +++ b/services/api/src/owl/routers/oss_admin.py @@ -0,0 +1,94 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Request +from loguru import logger +from sqlmodel import Session + +from jamaibase.exceptions import ResourceNotFoundError +from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.db import MAIN_ENGINE, UserSQLModel, create_sql_tables +from owl.db.oss_admin import ( + Organization, + OrganizationRead, + OrganizationUpdate, +) +from owl.protocol import ModelListConfig, OkResponse +from owl.utils import datetime_now_iso +from owl.utils.crypt import encrypt_random +from owl.utils.exceptions import handle_exception + +router = APIRouter() +public_router = APIRouter() # Dummy router to be compatible with cloud admin + + +@router.on_event("startup") +async def startup(): + create_sql_tables(UserSQLModel, MAIN_ENGINE) + + +def _get_session(): + with Session(MAIN_ENGINE) as session: + yield session + + +@router.patch("/admin/backend/v1/organizations") +@handle_exception +def update_organization( + *, + session: Annotated[Session, Depends(_get_session)], + request: Request, + body: OrganizationUpdate, +) -> OrganizationRead: + body.id = ENV_CONFIG.default_org_id + org = session.get(Organization, body.id) + if org is None: + raise ResourceNotFoundError(f'Organization "{body.id}" is not found.') + + # --- Perform update --- # + for key, value in body.model_dump(exclude=["id"], exclude_none=True).items(): + if key == "external_keys": + value = { + k: encrypt_random(v, ENV_CONFIG.owl_encryption_key_plain) for k, v in value.items() + } + setattr(org, key, value) + org.updated_at = datetime_now_iso() + session.add(org) + session.commit() + session.refresh(org) + logger.info(f"{request.state.id} - Organization updated: {org}") + org = OrganizationRead( + **org.model_dump(), + projects=org.projects, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain) + return org + + +@router.get("/admin/backend/v1/organizations/{org_id}") +@handle_exception +def get_organization( + *, + session: Annotated[Session, Depends(_get_session)], + org_id: Annotated[str, Path(min_length=1)], +) -> OrganizationRead: + org = session.get(Organization, org_id) + if org is None: + raise ResourceNotFoundError(f'Organization "{org_id}" is not found.') + org = OrganizationRead( + **org.model_dump(), + projects=org.projects, + ).decrypt(ENV_CONFIG.owl_encryption_key_plain) + return org + + +@router.get("/admin/backend/v1/models") +@handle_exception +def get_model_config() -> ModelListConfig: + # Get model config (exclude org models) + return CONFIG.get_model_config() + + +@router.patch("/admin/backend/v1/models") +@handle_exception +def set_model_config(body: ModelListConfig) -> OkResponse: + CONFIG.set_model_config(body) + return OkResponse() diff --git a/services/api/src/owl/routers/template.py b/services/api/src/owl/routers/template.py new file mode 100644 index 0000000..866a7d2 --- /dev/null +++ b/services/api/src/owl/routers/template.py @@ -0,0 +1,417 @@ +import os +import pathlib +from io import BytesIO +from shutil import rmtree +from time import perf_counter +from typing import Annotated, Any + +import duckdb +import pyarrow as pa +from fastapi import ( + APIRouter, + Depends, + File, + Form, + Path, + Query, + Request, + UploadFile, +) +from filelock import FileLock, Timeout +from loguru import logger +from pyarrow.parquet import read_table as read_parquet_table +from sqlmodel import Session, select + +from jamaibase.exceptions import ( + BadInputError, + ResourceExistsError, + ResourceNotFoundError, + UnexpectedError, +) +from jamaibase.utils.io import dump_json, json_loads, read_json +from owl.db import create_sql_tables, create_sqlite_engine +from owl.db.gen_table import GenerativeTable +from owl.db.template import Tag, Template, TemplateRead, TemplateSQLModel +from owl.protocol import ( + TABLE_NAME_PATTERN, + ColName, + GenTableOrderBy, + OkResponse, + Page, + TableMetaResponse, + TableType, + TemplateMeta, +) +from owl.utils.auth import auth_internal +from owl.utils.exceptions import handle_exception + +CURR_DIR = pathlib.Path(__file__).resolve().parent +TEMPLATE_DIR = CURR_DIR.parent / "templates" +DB_PATH = TEMPLATE_DIR / "template.db" +TEMPLATE_ID_PATTERN = r"^[A-Za-z0-9]([A-Za-z0-9_-]{0,98}[A-Za-z0-9])?$" + +router = APIRouter(dependencies=[Depends(auth_internal)]) +public_router = APIRouter() + + +@router.on_event("startup") +async def startup(): + global ENGINE + ENGINE = create_sqlite_engine(f"sqlite:///{DB_PATH}") + _populate_template_db() + + +def _populate_template_db(timeout: float = 0.0): + lock = FileLock(TEMPLATE_DIR / "template.lock", timeout=timeout) + try: + with lock: + t0 = perf_counter() + if DB_PATH.exists(): + os.remove(DB_PATH) + create_sql_tables(TemplateSQLModel, ENGINE) + metas = [] + for template_dir in TEMPLATE_DIR.iterdir(): + if not template_dir.is_dir(): + continue + template_filepath = template_dir / "template_meta.json" + if not template_filepath.is_file(): + logger.warning(f"Missing template metadata JSON in {template_dir}") + continue + metas.append((template_dir.name, read_json(template_dir / "template_meta.json"))) + tags = sum([meta["tags"] for _, meta in metas], []) + tags = {t: t for t in tags} + with Session(ENGINE) as session: + for tag in tags: + tag = Tag(id=tag) + session.add(tag) + tags[tag.id] = tag + session.commit() + for template_id, meta in metas: + meta = TemplateMeta.model_validate(meta) + session.add( + Template( + id=template_id, + name=meta.name, + description=meta.description, + created_at=meta.created_at, + tags=[tags[t] for t in meta.tags], + ) + ) + session.commit() + logger.info(f"Populated template DB in {perf_counter() - t0:,.2f} s") + except Timeout: + pass + except Exception as e: + logger.exception(f"Failed to populate template DB due to {e}") + + +def _get_session(): + with Session(ENGINE) as session: + yield session + + +@router.post("/admin/backend/v1/templates/import") +@handle_exception +async def add_template( + *, + request: Request, + file: Annotated[UploadFile, File(description="Template Parquet file.")], + template_id_dst: Annotated[ + str, Form(pattern=TEMPLATE_ID_PATTERN, description="The ID of the new template.") + ], + exist_ok: Annotated[ + bool, Form(description="_Optional_. Whether to overwrite existing template.") + ] = False, +) -> OkResponse: + t0 = perf_counter() + dst_dir = TEMPLATE_DIR / template_id_dst + if exist_ok: + try: + rmtree(dst_dir) + except (NotADirectoryError, FileNotFoundError): + pass + elif dst_dir.is_dir(): + raise ResourceExistsError(f'Template "{template_id_dst}" already exists.') + os.makedirs(dst_dir, exist_ok=True) + try: + with BytesIO(await file.read()) as source: + # Write the template metadata JSON + pa_table = read_parquet_table(source, columns=None, use_threads=False, memory_map=True) + metadata = pa_table.schema.metadata + try: + template_meta = json_loads(metadata[b"template_meta"]) + except KeyError as e: + raise BadInputError("Missing template metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid template metadata in the Parquet file.") from e + dump_json(template_meta, dst_dir / "template_meta.json") + # Write the table parquet files + try: + type_metas = json_loads(metadata[b"table_metas"]) + except KeyError as e: + raise BadInputError("Missing table metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid table metadata in the Parquet file.") from e + for row, type_meta in zip(pa_table.to_pylist(), type_metas, strict=True): + table_type = type_meta["table_type"] + table_id = type_meta["table_meta"]["id"] + os.makedirs(dst_dir / table_type, exist_ok=True) + with open(dst_dir / table_type / f"{table_id}.parquet", "wb") as f: + f.write(row["data"]) + logger.info( + f'{request.state.id} - Template "{template_id_dst}" imported in {perf_counter() - t0:,.2f} s.' + ) + except pa.ArrowInvalid as e: + raise BadInputError(str(e)) from e + _populate_template_db(30.0) + return OkResponse() + + +@router.post("/admin/backend/v1/templates/populate") +@handle_exception +def populate_templates( + *, + timeout: Annotated[ + float, + Query(ge=0, description="_Optional_. Timeout in seconds, must be >= 0. Defaults to 30.0."), + ] = 30.0, +) -> OkResponse: + _populate_template_db(timeout=timeout) + return OkResponse() + + +@public_router.get("/public/v1/templates") +@handle_exception +def list_templates( + *, + session: Annotated[Session, Depends(_get_session)], + search_query: Annotated[ + str, + Query( + max_length=10_000, + description='_Optional_. A string to search for within template names. Defaults to "" (no filter).', + ), + ] = "", +) -> Page[TemplateRead]: + selection = select(Template) + if search_query != "": + selection = selection.where(Template.name.ilike(f"%{search_query}%")) + items = session.exec(selection).all() + total = len(items) + return Page[TemplateRead](items=items, offset=0, limit=total, total=total) + + +@public_router.get("/public/v1/templates/{template_id}") +@handle_exception +def get_template( + *, + session: Annotated[Session, Depends(_get_session)], + template_id: Annotated[ + str, + Path(max_length=10_000, description="Template ID."), + ], +) -> TemplateRead: + template = session.get(Template, template_id) + if template is None: + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + return template + + +@public_router.get("/public/v1/templates/{template_id}/gen_tables/{table_type}") +@handle_exception +def list_tables( + *, + template_id: Annotated[ + str, + Path(max_length=10_000, description="Template ID."), + ], + table_type: Annotated[TableType, Path(description="Table type.")], + offset: Annotated[ + int, + Query( + ge=0, + description="_Optional_. Item offset for pagination. Defaults to 0.", + ), + ] = 0, + limit: Annotated[ + int, + Query( + gt=0, + le=100, + description="_Optional_. Number of tables to return (min 1, max 100). Defaults to 100.", + ), + ] = 100, + search_query: Annotated[ + str, + Query( + max_length=100, + description='_Optional_. A string to search for within table IDs as a filter. Defaults to "" (no filter).', + ), + ] = "", + order_by: Annotated[ + GenTableOrderBy, + Query( + min_length=1, + description='_Optional_. Sort tables by this attribute. Defaults to "updated_at".', + ), + ] = GenTableOrderBy.UPDATED_AT, + order_descending: Annotated[ + bool, + Query(description="_Optional_. Whether to sort by descending order. Defaults to True."), + ] = True, +) -> Page[TableMetaResponse]: + template_dir = TEMPLATE_DIR / template_id + if not template_dir.is_dir(): + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + table_dir = template_dir / table_type + if not table_dir.is_dir(): + return Page[TableMetaResponse](items=[], offset=0, limit=100, total=0) + metas: list[TableMetaResponse] = [] + for table_path in sorted(table_dir.iterdir()): + table = read_parquet_table(table_path, columns=[], use_threads=False, memory_map=True) + try: + table_meta = table.schema.metadata[b"gen_table_meta"] + except KeyError as e: + raise UnexpectedError( + f'Missing table metadata in "templates/{template_id}/gen_tables/{table_type}/{table_path.name}".' + ) from e + except Exception as e: + raise UnexpectedError( + f'Invalid table metadata in "templates/{template_id}/gen_tables/{table_type}/{table_path.name}".' + ) from e + metas.append(TableMetaResponse.model_validate_json(table_meta)) + metas = [ + m + for m in sorted(metas, key=lambda m: getattr(m, order_by), reverse=order_descending) + if search_query.lower() in m.id.lower() + ] + total = len(metas) + return Page[TableMetaResponse]( + items=metas[offset : offset + limit], offset=offset, limit=limit, total=total + ) + + +@public_router.get("/public/v1/templates/{template_id}/gen_tables/{table_type}/{table_id}") +@handle_exception +def get_table( + *, + template_id: Annotated[ + str, + Path(max_length=10_000, description="Template ID."), + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name."), +) -> TableMetaResponse: + template_dir = TEMPLATE_DIR / template_id + if not template_dir.is_dir(): + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + table_path = template_dir / table_type / f"{table_id}.parquet" + if not table_path.is_file(): + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') + table = read_parquet_table(table_path, columns=[], use_threads=False, memory_map=True) + try: + meta = TableMetaResponse.model_validate_json(table.schema.metadata[b"gen_table_meta"]) + except KeyError as e: + raise UnexpectedError( + f'Missing table metadata in "templates/{template_id}/gen_tables/{table_type}/{table_path.name}".' + ) from e + except Exception as e: + raise UnexpectedError( + f'Invalid table metadata in "templates/{template_id}/gen_tables/{table_type}/{table_path.name}".' + ) from e + return meta + + +@public_router.get("/public/v1/templates/{template_id}/gen_tables/{table_type}/{table_id}/rows") +@handle_exception +def list_table_rows( + *, + template_id: Annotated[ + str, + Path(max_length=10_000, description="Template ID."), + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(pattern=TABLE_NAME_PATTERN, description="Table ID or name."), + starting_after: Annotated[ + str | None, + Query( + min_length=1, + description=( + "_Optional_. A cursor for use in pagination. Only rows with ID > `starting_after` will be returned. " + 'For instance, if your call receives 100 rows ending with ID "x", ' + 'your subsequent call can include `starting_after="x"` in order to fetch the next page of the list.' + ), + ), + ] = None, + offset: Annotated[ + int, + Query( + ge=0, + description="_Optional_. Item offset. Defaults to 0.", + ), + ] = 0, + limit: Annotated[ + int, + Query( + gt=0, + le=100, + description="_Optional_. Number of rows to return (min 1, max 100). Defaults to 100.", + ), + ] = 100, + order_by: Annotated[ + str, + Query( + min_length=1, + description='_Optional_. Sort rows by this column. Defaults to "Updated at".', + ), + ] = "Updated at", + order_descending: Annotated[ + bool, + Query(description="_Optional_. Whether to sort by descending order. Defaults to True."), + ] = True, + float_decimals: int = Query( + default=0, + ge=0, + description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", + ), + vec_decimals: int = Query( + default=0, + description="_Optional_. Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", + ), +) -> Page[dict[ColName, Any]]: + template_dir = TEMPLATE_DIR / template_id + if not template_dir.is_dir(): + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + table_path = template_dir / table_type / f"{table_id}.parquet" + if not table_path.is_file(): + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') + + query = GenerativeTable._list_rows_query( + table_name=table_path, + sort_by=order_by, + sort_order="DESC" if order_descending else "ASC", + starting_after=starting_after, + id_column="ID", + offset=offset, + limit=limit, + ) + df = duckdb.sql(query).df() + df = GenerativeTable._post_process_rows_df( + df, + columns=None, + convert_null=True, + remove_state_cols=True, + json_safe=True, + include_original=True, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ) + rows = df.to_dict("records") + total = duckdb.sql(GenerativeTable._count_rows_query(table_path)).fetchone()[0] + return Page[dict[ColName, Any]]( + items=rows, + offset=offset, + limit=limit, + total=total, + starting_after=starting_after, + ) diff --git a/services/api/src/owl/scripts/backup_db.py b/services/api/src/owl/scripts/backup_db.py new file mode 100644 index 0000000..27fbaaf --- /dev/null +++ b/services/api/src/owl/scripts/backup_db.py @@ -0,0 +1,78 @@ +import os +import sqlite3 +from datetime import datetime, timezone +from os.path import dirname, isdir, join +from shutil import copy2 + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +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): + 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) + + +def restore(db_dir: str): + for org_id in os.listdir(db_dir): + org_dir = join(db_dir, org_id) + if not isdir(org_dir): + continue + for proj_id in os.listdir(org_dir): + proj_dir = join(org_dir, proj_id) + if not isdir(proj_dir): + continue + for table_type in ["action", "knowledge", "chat"]: + bak_files = list( + sorted( + f + for f in os.listdir(proj_dir) + if f.startswith(table_type) and f.endswith(".db_BAK") + ) + ) + src_path = join(proj_dir, bak_files[0]) + dst_path = join(proj_dir, f'{bak_files[0].split("_")[0]}.db') + os.remove(dst_path) + copy2(src_path, dst_path) + + +def find_sqlite_files(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(os.path.join(root, filename)) + return sqlite_files + + +if __name__ == "__main__": + sqlite_files = find_sqlite_files(ENV_CONFIG.owl_db_dir) + backup_dir = f"{ENV_CONFIG.owl_db_dir}_BAK_{NOW}" + print(f'Backing up DB dir "{ENV_CONFIG.owl_db_dir}" to "{backup_dir}"') + os.makedirs(backup_dir, exist_ok=False) + + 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) diff --git a/services/api/src/owl/scripts/create_meters.py b/services/api/src/owl/scripts/create_meters.py index 524f020..f4e4d3d 100644 --- a/services/api/src/owl/scripts/create_meters.py +++ b/services/api/src/owl/scripts/create_meters.py @@ -1,6 +1,5 @@ import openmeter from azure.core.exceptions import ResourceExistsError -from loguru import logger from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -21,7 +20,28 @@ def openmeter_api_key_plain(self): config = Config() meters = [ { - "slug": "spent", + "slug": "request_count_v2", + "eventType": "request_count", + "aggregation": "COUNT", + "windowSize": "MINUTE", + "description": "API request count.", + "groupBy": { + "method": "$.method", + "path": "$.path", + "org_id": "$.org_id", + "project_id": "$.project_id", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", + }, + }, + { + "slug": "spent_v2", "eventType": "spent", "aggregation": "SUM", # "SUM", "COUNT", "UNIQUE_COUNT", "AVG", "MIN", "MAX" "windowSize": "MINUTE", # Aggregation window size: "MINUTE", "HOUR", "DAY" (Only MINUTE accepted for now) @@ -30,12 +50,19 @@ def openmeter_api_key_plain(self): "category": "$.category", "org_id": "$.org_id", "project_id": "$.project_id", - "api_key": "$.api_key", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, "valueProperty": "$.spent_usd", }, { - "slug": "llm_tokens", + "slug": "llm_tokens_v2", "eventType": "llm_tokens", "aggregation": "SUM", "windowSize": "MINUTE", @@ -45,63 +72,98 @@ def openmeter_api_key_plain(self): "type": "$.type", # "system", "input", "output" "org_id": "$.org_id", "project_id": "$.project_id", - "api_key": "$.api_key", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, "valueProperty": "$.tokens", }, { - "slug": "bandwidth", - "eventType": "bandwidth", + "slug": "embedding_tokens_v2", + "eventType": "embedding_tokens", "aggregation": "SUM", "windowSize": "MINUTE", - "description": "Egress usage in GB.", + "description": "Embedding token usage.", "groupBy": { - "type": "$.type", # "egress" + "model": "$.model", "org_id": "$.org_id", "project_id": "$.project_id", - "api_key": "$.api_key", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, - "valueProperty": "$.amount_gb", + "valueProperty": "$.tokens", }, { - "slug": "storage", - "eventType": "storage", - "aggregation": "MAX", + "slug": "reranker_searches_v2", + "eventType": "reranker_searches", + "aggregation": "SUM", "windowSize": "MINUTE", - "description": "Storage usage in GB.", + "description": "Reranker search usage.", "groupBy": { - "type": "$.type", # "db" or "file" + "model": "$.model", "org_id": "$.org_id", + "project_id": "$.project_id", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, - "valueProperty": "$.amount_gb", + "valueProperty": "$.searches", }, { - "slug": "request_count", - "eventType": "request_count", - "aggregation": "COUNT", + "slug": "bandwidth_v2", + "eventType": "bandwidth", + "aggregation": "SUM", "windowSize": "MINUTE", - "description": "API request count.", + "description": "Egress usage in GB.", "groupBy": { - "method": "$.method", - "path": "$.path", + "type": "$.type", # "egress" "org_id": "$.org_id", "project_id": "$.project_id", - "api_key": "$.api_key", + "user_id": "$.user_id", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, + "valueProperty": "$.amount_gb", }, { - "slug": "request_latency", - "eventType": "request_latency", - "aggregation": "AVG", + "slug": "storage_v2", + "eventType": "storage", + "aggregation": "MAX", "windowSize": "MINUTE", - "description": "Request latency in millisecond.", + "description": "Storage usage in GB.", "groupBy": { - "task": "$.task", + "type": "$.type", # "db" or "file" "org_id": "$.org_id", - "project_id": "$.project_id", - "api_key": "$.api_key", + "agent": "$.agent", + "agent_version": "$.agent_version", + "architecture": "$.architecture", + "system": "$.system", + "system_version": "$.system_version", + "language": "$.language", + "language_version": "$.language_version", }, - "valueProperty": "$.latency_ms", + "valueProperty": "$.amount_gb", }, ] diff --git a/services/api/src/owl/scripts/update_db.py b/services/api/src/owl/scripts/update_db.py index 463c124..188d030 100644 --- a/services/api/src/owl/scripts/update_db.py +++ b/services/api/src/owl/scripts/update_db.py @@ -1,12 +1,95 @@ +import os import sqlite3 +from datetime import datetime, timezone +from os.path import join +from pprint import pprint -conn = sqlite3.connect("db/main.db") -c = conn.cursor() -c.execute("ALTER TABLE organization ADD COLUMN db_storage_gb REAL DEFAULT 0.0") -c.execute("ALTER TABLE organization ADD COLUMN file_storage_gb REAL DEFAULT 0.0") -# conn.commit() -# c.execute("PRAGMA table_info(organization)") -# print(c.fetchall()) -c.execute("SELECT COUNT(*) FROM user") -# c.execute("SELECT * FROM organization LIMIT 1") -print(c.fetchall()) +from pydantic_settings import BaseSettings, SettingsConfigDict + +from owl.configs.manager import ENV_CONFIG + + +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" + + +NOW = datetime.now(tz=timezone.utc).isoformat() +backup_dir = f"{ENV_CONFIG.owl_db_dir}_BAK_{NOW}" +os.makedirs(backup_dir, exist_ok=False) + + +def add_columns(): + with sqlite3.connect(join(ENV_CONFIG.owl_db_dir, "main.db")) as src: + c = src.cursor() + # Add OAuth columns to user table + c.execute("ALTER TABLE user ADD COLUMN username TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN refresh_counter INTEGER DEFAULT 0") + c.execute("ALTER TABLE user ADD COLUMN google_id TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN google_name TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN google_username TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN google_email TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN google_picture_url TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN github_id INTEGER DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN github_name TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN github_username TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN github_email TEXT DEFAULT NULL") + c.execute("ALTER TABLE user ADD COLUMN github_picture_url TEXT DEFAULT NULL") + src.commit() + c.execute("CREATE UNIQUE INDEX idx_user_google_id ON user (google_id)") + c.execute("CREATE UNIQUE INDEX idx_user_github_id ON user (github_id)") + # Rename table + c.execute("ALTER TABLE `userorglink` RENAME TO `orgmember`") + # Flatten quota related columns to organization table + c.execute("ALTER TABLE organization ADD COLUMN credit REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN credit_grant REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN llm_tokens_quota_mtok REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN llm_tokens_usage_mtok REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN embedding_tokens_quota_mtok REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN embedding_tokens_usage_mtok REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN reranker_quota_ksearch REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN reranker_usage_ksearch REAL DEFAULT 0") + c.execute("ALTER TABLE organization ADD COLUMN db_quota_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN db_usage_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN file_quota_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN file_usage_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN egress_quota_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN egress_usage_gib REAL DEFAULT 0.0") + c.execute("ALTER TABLE organization ADD COLUMN models JSON DEFAULT '{}'") + # Remove nested quota column + c.execute("ALTER TABLE organization DROP COLUMN quotas") + src.commit() + c.execute("PRAGMA table_info(organization)") + pprint(c.fetchall()) + c.close() + + +def update_oauth_info(): + with sqlite3.connect(join(ENV_CONFIG.owl_db_dir, "main.db")) as src: + c = src.cursor() + c.execute("SELECT id FROM User") + for row in c.fetchall(): + user_id = row[0] + if user_id.startswith("github|"): + c.execute( + "UPDATE User SET github_id = ? WHERE id = ?", + (int(user_id.split("|")[1]), user_id), + ) + src.commit() + elif user_id.startswith("google-oauth2|"): + c.execute( + "UPDATE User SET google_id = ? WHERE id = ?", + (user_id.split("|")[1], user_id), + ) + src.commit() + c.close() + + +if __name__ == "__main__": + with sqlite3.connect(join(ENV_CONFIG.owl_db_dir, "main.db")) as src: + with sqlite3.connect(join(backup_dir, "main.db")) as dst: + src.backup(dst) + add_columns() + update_oauth_info() diff --git a/services/api/src/owl/tasks/genitor.py b/services/api/src/owl/tasks/genitor.py new file mode 100644 index 0000000..8def84b --- /dev/null +++ b/services/api/src/owl/tasks/genitor.py @@ -0,0 +1,194 @@ +# tasks.py +import os +import pathlib +import tempfile +from datetime import datetime, timedelta, timezone + +import boto3 +from botocore.client import Config +from celery import Celery, chord +from loguru import logger + +from owl.configs.manager import ENV_CONFIG +from owl.db.gen_table import GenerativeTable +from owl.protocol import TableType + +# Set up Celery +app = Celery("tasks", broker=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0") + +# Configure Celery +app.conf.update( + result_backend=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0", + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, +) + + +def get_timestamp(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d-%H") + + +def _get_s3_client(): + _session = boto3.session.Session() + return _session.client( + "s3", + endpoint_url=ENV_CONFIG.s3_endpoint, + aws_access_key_id=ENV_CONFIG.s3_access_key_id, + aws_secret_access_key=ENV_CONFIG.s3_secret_access_key_plain, + config=Config(signature_version="s3v4"), + ) + + +@app.task +def s3_cleanup(): + s3_client = _get_s3_client() + current_date = datetime.utcnow().date() + retention_period = timedelta(days=7) + try: + datetime_list = [] + continuation_token = None + + while True: + params = { + "Bucket": ENV_CONFIG.s3_backup_bucket_name, + "MaxKeys": 1000, + "Delimiter": "/", + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + response = s3_client.list_objects_v2(**params) + if "CommonPrefixes" in response: + for prefix in response["CommonPrefixes"]: + date_hour = prefix["Prefix"].rstrip("/") + if date_hour != "main-db": + backup_date = datetime.strptime(date_hour, "%Y-%m-%d-%H").date() + if current_date - backup_date > retention_period: + datetime_list.append(date_hour) + + if not response.get("IsTruncated"): + break + + continuation_token = response.get("NextContinuationToken") + + date_objects = [datetime.strptime(date_str, "%Y-%m-%d-%H") for date_str in datetime_list] + + # Find the latest hour for each day + latest_by_day = {} + for date_obj in date_objects: + date_key = date_obj.date() + if date_key not in latest_by_day or date_obj > latest_by_day[date_key]: + latest_by_day[date_key] = date_obj + + # Create a list of all entries except the latest for each day + datetime_list = [ + date_obj.strftime("%Y-%m-%d-%H") + for date_obj in date_objects + if date_obj != latest_by_day[date_obj.date()] + ] + + for time in datetime_list: + paginator = s3_client.get_paginator("list_objects_v2") + page_iterator = paginator.paginate( + Bucket=ENV_CONFIG.s3_backup_bucket_name, Prefix=time + ) + + for page in page_iterator: + if "Contents" in page: + for obj in page["Contents"]: + s3_client.delete_object( + Bucket=ENV_CONFIG.s3_backup_bucket_name, Key=obj["Key"] + ) + logger.info(f"S3 Cleanup done for {time}!") + + except Exception as e: + logger.error(f"S3 Cleanup failed:\n {e}") + + +@app.task +def backup_to_s3(): + db_dir = pathlib.Path(ENV_CONFIG.owl_db_dir) + logger.info(f"DB PATH: {db_dir}") + all_chains = [] + + for org_dir in db_dir.iterdir(): + if not org_dir.is_dir() or not org_dir.name.startswith("org_"): + continue + for project_dir in org_dir.iterdir(): + if not project_dir.is_dir(): + continue + table_types = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + + lance_chains = [ + backup_gen_table_parquet.s(str(org_dir.name), str(project_dir.name), table_type) + for table_type in table_types + ] + + all_chains.extend(lance_chains) + + if all_chains: + return chord(all_chains)(backup_project_results.s()) + else: + logger.warning("No tasks to execute in the chord.") + return None + + +@app.task +def backup_project_results(results): + failed_project = [] + status_dict = {} + for success, org_id, project_id in results: + status_dict[(org_id, project_id)] = status_dict.get((org_id, project_id), True) and success + + results = [[status, *key] for key, status in status_dict.items()] + true_count = sum(status for status, _, _ in results) + + for success, org_id, project_id in results: + if not success: + failed_project.append(f"{org_id}/{project_id}") + + logger.info( + f"Total number of successful project backup: {true_count} out of {len(results)}. \n {failed_project}" + ) + + +@app.task +def backup_gen_table_parquet(org_id: str, project_id: str, table_type: str): + try: + table = GenerativeTable.from_ids(org_id, project_id, table_type) + table_dir = f"{ENV_CONFIG.owl_db_dir}/{org_id}/{project_id}/{table_type}" + with table.create_session() as session: + offset, total = 0, 1 + while offset < total: + metas, total = table.list_meta( + session, + offset=offset, + limit=50, + remove_state_cols=True, + parent_id=None, + ) + offset += 50 + for meta in metas: + upload_path = ( + f"{get_timestamp()}/db/{org_id}/{project_id}/{table_type}/{meta.id}" + ) + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_file = os.path.join(tmp_dir, f"{meta.id}.parquet") + table.dump_parquet(session=session, table_id=meta.id, dest=tmp_file) + s3_client = _get_s3_client() + s3_client.upload_file( + tmp_file, + ENV_CONFIG.s3_backup_bucket_name, + f"{upload_path}.parquet", + ) + logger.info( + f"Backup to s3://{ENV_CONFIG.s3_backup_bucket_name}/{upload_path}.parquet" + ) + return True, org_id, project_id + + except Exception as e: + logger.error(f"Error backing up Lance table {table_dir}: {e}") + return False, org_id, project_id diff --git a/services/api/src/owl/tasks/restore.py b/services/api/src/owl/tasks/restore.py new file mode 100644 index 0000000..8b50a35 --- /dev/null +++ b/services/api/src/owl/tasks/restore.py @@ -0,0 +1,239 @@ +import multiprocessing +import os +import re +import sqlite3 +import time +from io import BytesIO + +import boto3 +import click +import lance +import pyarrow.parquet as pq +from botocore.client import Config +from loguru import logger +from tqdm import tqdm + +from owl import protocol as p +from owl.configs.manager import ENV_CONFIG +from owl.db.gen_table import GenerativeTable +from owl.protocol import TableMetaResponse +from owl.utils.logging import setup_logger_sinks + +setup_logger_sinks(f"{ENV_CONFIG.owl_log_dir}/restoration.log") +logger.info(f"Using configuration: {ENV_CONFIG}") + + +def _get_s3_client(): + _session = boto3.session.Session() + return _session.client( + "s3", + endpoint_url=ENV_CONFIG.s3_endpoint, + aws_access_key_id=ENV_CONFIG.s3_access_key_id, + aws_secret_access_key=ENV_CONFIG.s3_secret_access_key_plain, + config=Config(signature_version="s3v4"), + ) + + +def _initialize_databases(table_info_list): + initialized_dbs = set() + for item in table_info_list: + org_id = item["org_id"] + project_id = item["project_id"] + table_type = item["table_type"] + + lance_path = os.path.join(ENV_CONFIG.owl_db_dir, org_id, project_id, table_type) + sqlite_path = f"{lance_path}.db" + if table_type != "file": + if sqlite_path not in initialized_dbs: + os.makedirs(os.path.dirname(sqlite_path), exist_ok=True) + with sqlite3.connect(sqlite_path) as conn: + conn.execute("PRAGMA journal_mode=WAL;") + initialized_dbs.add(sqlite_path) + + +def get_default_workers(): + return max(multiprocessing.cpu_count() * 8, 1) + + +def restore(item): + import asyncio + + try: + s3_client = _get_s3_client() + org_id = item["org_id"] + project_id = item["project_id"] + table_type = item["table_type"] + table_parquet = item["table_parquet"] + + if table_type == "file": + file_parquet_key = os.path.join( + item["datetime"], "db", org_id, project_id, "file", "file.parquet" + ) + file_lance_dir = os.path.join( + ENV_CONFIG.owl_db_dir, org_id, project_id, "file", "file.lance" + ) + logger.info(f"Processing {org_id}/{project_id}/{table_type}/{table_parquet}") + + if not os.path.exists(file_lance_dir): + response = s3_client.get_object( + Bucket=ENV_CONFIG.s3_backup_bucket_name, Key=file_parquet_key + ) + logger.info(f"Processing {org_id}/{project_id}/file/file.parquet") + body = response["Body"].read() + parquet_table = pq.read_table(BytesIO(body)) + lance.write_dataset(parquet_table, file_lance_dir) + else: + object_key = ( + f"{item['datetime']}/db/{org_id}/{project_id}/{table_type}/{table_parquet}" + ) + logger.info(f"Processing {org_id}/{project_id}/{table_type}/{table_parquet}") + response = s3_client.get_object( + Bucket=ENV_CONFIG.s3_backup_bucket_name, Key=object_key + ) + table_id = re.sub(r"\.parquet$", "", table_parquet, flags=re.IGNORECASE) + table = GenerativeTable.from_ids(org_id, project_id, p.TableType(table_type)) + + body = response["Body"].read() + with table.create_session() as session: + _, meta = asyncio.run( + table.import_parquet( + session=session, + source=BytesIO(body), + table_id_dst=table_id, + ) + ) + meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) + return meta + except Exception as e: + logger.error(f"Failed to import table from parquet due to {e.__class__.__name__}: {e}") + + +@click.command() +def main(): + s3_client = _get_s3_client() + try: + datetime_set = set() + table_info_list = [] + continuation_token = None + total_objects = 0 + fetch_start_time = time.time() + + # Ask for the number of workers + max_workers = get_default_workers() + workers = click.prompt( + f"Enter the number of worker processes to use (1-{max_workers}). Default:", + type=click.IntRange(1, max_workers), + default=max_workers, + ) + + click.echo("Fetching S3 objects...") + while True: + params = { + "Bucket": ENV_CONFIG.s3_backup_bucket_name, + "MaxKeys": 1000, + "Delimiter": "/", + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + response = s3_client.list_objects_v2(**params) + if "CommonPrefixes" in response: + for prefix in response["CommonPrefixes"]: + datetime = prefix["Prefix"].rstrip("/") + if datetime != "main-db": + datetime_set.add(datetime) + + if not response.get("IsTruncated"): + break + + continuation_token = response.get("NextContinuationToken") + total_objects += response.get("KeyCount") + + fetch_end_time = time.time() + total_fetch_time = fetch_end_time - fetch_start_time + average_fetch_speed = total_objects / total_fetch_time + click.echo( + f"Fetching completed. Total objects: {total_objects}. Average speed: {average_fetch_speed:.2f} objects/second" + ) + + # Display available dates and let user choose + click.echo("Available dates:") + for idx, date in enumerate(sorted(datetime_set), 1): + click.echo(f"{idx}. {date}") + + date_choice = click.prompt("Select a date (enter the number)", type=int, default=1) + specific_date = sorted(datetime_set)[date_choice - 1] + + try: + paginator = s3_client.get_paginator("list_objects_v2") + page_iterator = paginator.paginate( + Bucket=ENV_CONFIG.s3_backup_bucket_name, Prefix=specific_date + ) + + for page in page_iterator: + if "Contents" in page: + for obj in page["Contents"]: + parts = obj["Key"].split("/") + + datetime, org_id, project_id, table_type, table_parquet = ( + parts[0], + parts[2], + parts[3], + parts[4], + parts[5], + ) + + datetime_set.add(datetime) + table_info_list.append( + { + "datetime": datetime, + "org_id": org_id, + "project_id": project_id, + "table_type": table_type, + "table_parquet": table_parquet, + } + ) + + except Exception as e: + logger.error(f"An error occurred: {e}") + + # Check if database files exist and ask for overwrite confirmation + current_files = os.listdir(ENV_CONFIG.owl_db_dir) + if current_files: + click.echo(f"Current database path: {ENV_CONFIG.owl_db_dir}") + if not click.confirm("Do you want to overwrite the existing files?"): + click.echo("Operation cancelled.") + return + else: + click.echo(f"Current database path: {ENV_CONFIG.owl_db_dir}") + if not click.confirm("Confirm restoring to this directory?"): + click.echo("Operation cancelled.") + return + + table_info_list = sorted(table_info_list, key=lambda x: x["org_id"]) + filtered_list = [item for item in table_info_list if item["datetime"] == specific_date] + + # Use this before starting the multiprocessing pool + _initialize_databases(filtered_list) + click.echo(f"Using {workers} worker processes") + tic = time.time() + + with multiprocessing.Pool(workers, maxtasksperchild=2) as pool: + list( + tqdm( + pool.imap_unordered(restore, filtered_list), + total=len(filtered_list), + desc="Importing tables", + unit="table", + ) + ) + + click.echo(f"Import completed successfully! {time.time() - tic:.2f}s") + + except Exception as e: + logger.error(f"Failed to import table from parquet: {e}") + raise + + +if __name__ == "__main__": + main() diff --git a/services/api/src/owl/tasks/storage.py b/services/api/src/owl/tasks/storage.py new file mode 100644 index 0000000..866db6c --- /dev/null +++ b/services/api/src/owl/tasks/storage.py @@ -0,0 +1,192 @@ +import asyncio +import pathlib +from datetime import timedelta +from time import perf_counter + +from celery import Celery +from filelock import FileLock, Timeout +from loguru import logger + +from jamaibase import JamAI +from owl.billing import BillingManager +from owl.configs.manager import ENV_CONFIG +from owl.db.gen_table import GenerativeTable +from owl.protocol import TableType +from owl.utils.io import get_file_usage, get_storage_usage + +logger.info(f"Using configuration: {ENV_CONFIG}") +client = JamAI(token=ENV_CONFIG.service_key_plain, timeout=60.0) + +# Set up Celery +app = Celery("tasks", broker=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0") + +# Configure Celery +app.conf.update( + result_backend=f"redis://{ENV_CONFIG.owl_redis_host}:{ENV_CONFIG.owl_redis_port}/0", + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, +) + +logger.info(f"Using configuration: {ENV_CONFIG}") + + +def _iter_all_tables(batch_size: int = 200): + table_types = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + db_dir = pathlib.Path(ENV_CONFIG.owl_db_dir) + for org_dir in db_dir.iterdir(): + if not org_dir.is_dir() or not org_dir.name.startswith(("org_", "default")): + continue + for project_dir in org_dir.iterdir(): + if not project_dir.is_dir(): + continue + for table_type in table_types: + table = GenerativeTable.from_ids(org_dir.name, project_dir.name, table_type) + with table.create_session() as session: + offset, total = 0, 1 + while offset < total: + metas, total = table.list_meta( + session, + offset=offset, + limit=batch_size, + remove_state_cols=True, + parent_id=None, + ) + offset += batch_size + for meta in metas: + yield ( + session, + table, + meta, + f"{project_dir}/{table_type}/{meta.id}", + ) + + +@app.task +def periodic_storage_update(): + # Cloud client + if ENV_CONFIG.is_oss: + return + + lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_storage_update.lock", blocking=False) + try: + t0 = perf_counter() + with lock: + file_usages = get_file_usage(ENV_CONFIG.owl_db_dir) + db_usages = get_storage_usage(ENV_CONFIG.owl_db_dir) + num_ok = num_skipped = num_failed = 0 + for org_id in db_usages: + if not org_id.startswith("org_"): + continue + db_usage_gib = db_usages[org_id] + file_usage_gib = file_usages[org_id] + try: + org = client.admin.backend.get_organization(org_id) + manager = BillingManager( + organization=org, + project_id="", + user_id="", + request=None, + ) + manager.create_storage_events(db_usage_gib, file_usage_gib) + asyncio.get_event_loop().run_until_complete(manager.process_all()) + num_ok += 1 + except Exception as e: + logger.warning((f"Storage usage update failed for {org_id}: {e}")) + num_failed += 1 + t = perf_counter() - t0 + logger.info( + ( + f"Periodic storage usage update completed (t={t:,.3f} s, " + f"{num_ok:,d} OK, {num_skipped:,d} skipped, {num_failed:,d} failed)." + ) + ) + except Timeout: + pass + except Exception as e: + logger.exception(f"Periodic storage usage update failed due to {e}") + + +@app.task +def lance_periodic_reindex(): + lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_reindex.lock", timeout=0) + try: + with lock: + t0 = perf_counter() + num_ok = num_skipped = num_failed = 0 + for session, table, meta, table_path in _iter_all_tables(): + if session is None: + continue + try: + reindexed = table.create_indexes(session, meta.id) + if reindexed: + num_ok += 1 + else: + num_skipped += 1 + except Timeout: + logger.warning(f"Periodic Lance re-indexing skipped for table: {table_path}") + num_skipped += 1 + except Exception: + logger.exception(f"Periodic Lance re-indexing failed for table: {table_path}") + num_failed += 1 + t = perf_counter() - t0 + logger.info( + ( + f"Periodic Lance re-indexing completed (t={t:,.3f} s, " + f"{num_ok:,d} OK, {num_skipped:,d} skipped, {num_failed:,d} failed)." + ) + ) + except Timeout: + logger.info("Periodic Lance re-indexing skipped due to lock.") + except Exception as e: + logger.exception(f"Periodic Lance re-indexing failed due to {e}") + + +@app.task +def lance_periodic_optimize(): + lock = FileLock(f"{ENV_CONFIG.owl_db_dir}/periodic_optimization.lock", timeout=0) + try: + with lock: + t0 = perf_counter() + num_ok = num_skipped = num_failed = 0 + for _, table, meta, table_path in _iter_all_tables(): + done = True + try: + if meta is None: + done = done and table.compact_files() + done = done and table.cleanup_old_versions( + older_than=timedelta( + minutes=ENV_CONFIG.owl_remove_version_older_than_mins + ), + ) + else: + done = done and table.compact_files(meta.id) + done = done and table.cleanup_old_versions( + meta.id, + older_than=timedelta( + minutes=ENV_CONFIG.owl_remove_version_older_than_mins + ), + ) + if done: + num_ok += 1 + else: + num_skipped += 1 + except Timeout: + logger.warning(f"Periodic Lance optimization skipped for table: {table_path}") + num_skipped += 1 + except Exception: + logger.exception(f"Periodic Lance optimization failed for table: {table_path}") + num_failed += 1 + t = perf_counter() - t0 + logger.info( + ( + f"Periodic Lance optimization completed (t={t:,.3f} s, " + f"{num_ok:,d} OK, {num_skipped:,d} skipped, {num_failed:,d} failed)." + ) + ) + except Timeout: + logger.info("Periodic Lance optimization skipped due to lock.") + except Exception as e: + logger.exception(f"Periodic Lance optimization failed due to {e}") diff --git a/services/api/src/owl/templates/.gitignore b/services/api/src/owl/templates/.gitignore new file mode 100644 index 0000000..2782799 --- /dev/null +++ b/services/api/src/owl/templates/.gitignore @@ -0,0 +1,2 @@ +# Include Parquet files +!*.parquet \ No newline at end of file diff --git a/services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet b/services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet new file mode 100644 index 0000000..541d16b --- /dev/null +++ b/services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06f0f218779d43a88233d8c729b7e5d3701244629fea14eeb52a9088611bdf90 +size 45308 diff --git a/services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet b/services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet new file mode 100644 index 0000000..4062cdd --- /dev/null +++ b/services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e62e7f76f7d254948b560afb91800e396ab04a2124811ef563697cf48e4ed2d +size 8949864 diff --git a/services/api/src/owl/templates/f1_due_diligence/template_meta.json b/services/api/src/owl/templates/f1_due_diligence/template_meta.json new file mode 100644 index 0000000..22973e4 --- /dev/null +++ b/services/api/src/owl/templates/f1_due_diligence/template_meta.json @@ -0,0 +1,6 @@ +{ + "name": "Form F-1 Due Diligence", + "description": "Performs financial due diligence based on Form F-1 filings.", + "tags": ["sector:Finance", "task:Research"], + "created_at": "2024-09-30T15:38:13.747349+00:00" +} diff --git a/services/api/src/owl/unstructuredio.py b/services/api/src/owl/unstructuredio.py index 755880f..bc9951e 100644 --- a/services/api/src/owl/unstructuredio.py +++ b/services/api/src/owl/unstructuredio.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import IO, Any, Callable, Iterator +from typing import Any, Callable, Iterator from langchain_community.document_loaders.base import BaseLoader from langchain_core.documents import Document @@ -74,7 +74,7 @@ def lazy_load(self) -> Iterator[Document]: text_dict: dict[int, str] = {} meta_dict: dict[int, dict] = {} - for idx, element in enumerate(elements): + for element in elements: metadata = element["metadata"] if hasattr(element, "metadata"): metadata.update(element["metadata"]) @@ -180,7 +180,7 @@ def _get_metadata(self) -> dict: if __name__ == "__main__": - filename = "clients/python/tests/docx/Recommendation Letter.docx" + filename = "clients/python/tests/files/docx/Recommendation Letter.docx" doc_loader = UnstructuredAPIFileLoader( filename, mode="single", diff --git a/services/api/src/owl/utils/__init__.py b/services/api/src/owl/utils/__init__.py index 8dadc9f..7bde827 100644 --- a/services/api/src/owl/utils/__init__.py +++ b/services/api/src/owl/utils/__init__.py @@ -1,40 +1,62 @@ -from owl.utils.exceptions import ResourceNotFoundError - - -def filter_external_api_key( - model: str, - openai_api_key: str = "", - anthropic_api_key: str = "", - gemini_api_key: str = "", - cohere_api_key: str = "", - groq_api_key: str = "", - together_api_key: str = "", - jina_api_key: str = "", - voyage_api_key: str = "", -) -> str: - if model.startswith("ellm"): - key = together_api_key if together_api_key else "DUMMY_KEY" - elif model.startswith("openai"): - key = openai_api_key - elif model.startswith("anthropic"): - key = anthropic_api_key - elif model.startswith("gemini"): - key = gemini_api_key - elif model.startswith("cohere"): - key = cohere_api_key - elif model.startswith("groq"): - key = groq_api_key - elif model.startswith("together"): - key = together_api_key - elif model.startswith("jina"): - key = jina_api_key - elif model.startswith("voyage"): - key = voyage_api_key +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from uuid_extensions import uuid7str as _uuid7_draft2_str +from uuid_utils import uuid7 as _uuid7 + +from jamaibase.exceptions import ResourceNotFoundError + + +def get_non_empty(mapping: dict[str, Any], key: str, default: Any): + value = mapping.get(key, None) + return value if value else default + + +def datetime_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def uuid7_draft2_str(prefix: str = "") -> str: + return f"{prefix}{_uuid7_draft2_str()}" + + +def uuid7_str(prefix: str = "") -> str: + return f"{prefix}{_uuid7()}" + + +def datetime_str_from_uuid7(uuid7_str: str) -> str: + # Extract the timestamp (first 48 bits) + timestamp = UUID(uuid7_str).int >> 80 + dt = datetime.fromtimestamp(timestamp / 1000.0, tz=timezone.utc) + return dt.isoformat() + + +def datetime_str_from_uuid7_draft2(uuid7_str: str) -> str: + # https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-02.html#name-uuidv7-layout-and-bit-order + # Parse the UUID string + uuid_obj = UUID(uuid7_str) + # Extract the unix timestamp (first 36 bits) + unix_ts = uuid_obj.int >> 92 + # Extract the fractional seconds (next 24 bits) + frac_secs = (uuid_obj.int >> 68) & 0xFFFFFF + # Combine unix timestamp and fractional seconds + total_secs = unix_ts + (frac_secs / 0x1000000) + # Create a datetime object + dt = datetime.fromtimestamp(total_secs, tz=timezone.utc) + return dt.isoformat() + + +def select_external_api_key(external_api_keys, provider: str) -> str: + if provider == "ellm": + return "DUMMY_KEY" else: - raise ResourceNotFoundError(f"Unsupported model: {model}") - if key == "" or not key: - raise ResourceNotFoundError(f"No suitable API key for model: {model}") - return key + try: + return getattr(external_api_keys, provider) or "DUMMY_KEY" + except AttributeError: + raise ResourceNotFoundError( + f"External API key not found for provider: {provider}" + ) from None def mask_string(x: str | None) -> str | None: @@ -43,3 +65,13 @@ def mask_string(x: str | None) -> str | None: if x.startswith("[ERROR]"): return x return f"len={len(x)} str={x[:5]}***{x[-5:]}" + + +def mask_content(x: str | list[dict[str, str]] | None) -> str | list[dict[str, str]] | None: + if isinstance(x, list): + return [mask_content(v) for v in x] + if isinstance(x, dict): + return {k: mask_content(v) for k, v in x.items()} + if isinstance(x, str): + return mask_string(x) + return None diff --git a/services/api/src/owl/utils/auth.py b/services/api/src/owl/utils/auth.py new file mode 100644 index 0000000..48047a9 --- /dev/null +++ b/services/api/src/owl/utils/auth.py @@ -0,0 +1,384 @@ +from secrets import compare_digest +from typing import Annotated, AsyncGenerator + +from fastapi import 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 + +from jamaibase import JamAIAsync +from jamaibase.exceptions import ( + AuthorizationError, + ForbiddenError, + ResourceNotFoundError, + ServerBusyError, + UnexpectedError, + UpgradeTierError, +) +from jamaibase.protocol import OrganizationRead, PATRead, ProjectRead, UserRead +from owl.billing import BillingManager +from owl.configs.manager import CONFIG, ENV_CONFIG +from owl.protocol import ExternalKeys, ModelListConfig +from owl.utils import datetime_now_iso, get_non_empty + +CLIENT = JamAIAsync(token=ENV_CONFIG.service_key_plain, timeout=60.0) +WRITE_METHODS = {"PUT", "PATCH", "POST", "DELETE", "PURGE"} +JAMAI_CLOUD_URL = "https://cloud.jamaibase.com" +NO_PROJECT_ID_MESSAGE = ( + "You didn't provide a project ID. " + 'You need to provide your project ID in an "X-PROJECT-ID" header ' + "(i.e. X-PROJECT-ID: PROJECT_ID). " + f"You can retrieve your project ID via API or from {JAMAI_CLOUD_URL}" +) +NO_TOKEN_MESSAGE = ( + "You didn't provide an authorization token. " + "You need to provide your either your Personal Access Token or organization API key (deprecated) " + 'in an "Authorization" header using Bearer auth (i.e. "Authorization: Bearer TOKEN"). ' + f"You can obtain your token from {JAMAI_CLOUD_URL}" +) +INVALID_TOKEN_MESSAGE = ( + "You provided an invalid authorization token. " + "You need to provide your either your Personal Access Token or organization API key (deprecated) " + 'in an "Authorization" header using Bearer auth (i.e. "Authorization: Bearer TOKEN"). ' + f"You can obtain your token from {JAMAI_CLOUD_URL}" +) +ORG_API_KEY_DEPRECATE_MESSAGE = ( + "Usage of organization API key is deprecated and will be removed soon. " + "Authenticate using your Personal Access Token instead." +) + + +@retry( + retry=retry_if_exception_type(RequestError), + wait=wait_random_exponential(multiplier=1, min=0.1, max=3), + stop=stop_after_attempt(3), + reraise=True, +) +async def _get_project_with_retries(project_id: str) -> ProjectRead: + return await CLIENT.admin.organization.get_project(project_id) + + +async def _get_project(request: Request, project_id: str) -> ProjectRead: + try: + return await _get_project_with_retries(project_id) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') from e + except RequestError as e: + logger.warning( + f'{request.state.id} - Error fetching project "{project_id}" due to {e.__class__.__name__}: {e}' + ) + raise ServerBusyError(f"{e.__class__.__name__}: {e}") from e + except Exception as e: + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + + +@retry( + retry=retry_if_exception_type(RequestError), + wait=wait_random_exponential(multiplier=1, min=0.1, max=3), + stop=stop_after_attempt(3), + reraise=True, +) +async def _get_organization_with_retries(org_id_or_token: str) -> OrganizationRead: + return await CLIENT.admin.backend.get_organization(org_id_or_token) + + +async def _get_organization(request: Request, org_id_or_token: str) -> OrganizationRead: + try: + return await _get_organization_with_retries(org_id_or_token) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Organization "{org_id_or_token}" is not found.') from e + except RequestError as e: + logger.warning( + f'{request.state.id} - Error fetching organization "{org_id_or_token}" due to {e.__class__.__name__}: {e}' + ) + raise ServerBusyError(f"{e.__class__.__name__}: {e}") from e + except Exception as e: + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + + +@retry( + retry=retry_if_exception_type(RequestError), + wait=wait_random_exponential(multiplier=1, min=0.1, max=3), + stop=stop_after_attempt(3), + reraise=True, +) +async def _get_user_with_retries(user_id_or_token: str) -> UserRead: + return await CLIENT.admin.backend.get_user(user_id_or_token) + + +async def _get_user(request: Request, user_id_or_token: str) -> UserRead: + try: + return await _get_user_with_retries(user_id_or_token) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'User "{user_id_or_token}" is not found.') from e + except RequestError as e: + logger.warning( + f'{request.state.id} - Error fetching user "{user_id_or_token}" due to {e.__class__.__name__}: {e}' + ) + raise ServerBusyError(f"{e.__class__.__name__}: {e}") from e + except Exception as e: + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + + +@retry( + retry=retry_if_exception_type(RequestError), + wait=wait_random_exponential(multiplier=1, min=0.1, max=3), + stop=stop_after_attempt(3), + reraise=True, +) +async def _get_pat_with_retries(token: str) -> PATRead: + return await CLIENT.admin.backend.get_pat(token) + + +async def _get_pat(request: Request, token: str) -> PATRead: + try: + return await _get_pat_with_retries(token) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'PAT "{token}" is not found.') from e + except RequestError as e: + logger.warning( + f'{request.state.id} - Error fetching PAT "{token}" due to {e.__class__.__name__}: {e}' + ) + raise ServerBusyError(f"{e.__class__.__name__}: {e}") from e + except Exception as e: + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + + +def _get_external_keys(organization: OrganizationRead) -> ExternalKeys: + ext_keys = organization.external_keys + return ExternalKeys( + custom=get_non_empty(ext_keys, "custom", ENV_CONFIG.custom_api_key_plain), + openai=get_non_empty(ext_keys, "openai", ENV_CONFIG.openai_api_key_plain), + anthropic=get_non_empty(ext_keys, "anthropic", ENV_CONFIG.anthropic_api_key_plain), + gemini=get_non_empty(ext_keys, "gemini", ENV_CONFIG.gemini_api_key_plain), + cohere=get_non_empty(ext_keys, "cohere", ENV_CONFIG.cohere_api_key_plain), + groq=get_non_empty(ext_keys, "groq", ENV_CONFIG.groq_api_key_plain), + together_ai=get_non_empty(ext_keys, "together_ai", ENV_CONFIG.together_api_key_plain), + jina=get_non_empty(ext_keys, "jina", ENV_CONFIG.jina_api_key_plain), + voyage=get_non_empty(ext_keys, "voyage", ENV_CONFIG.voyage_api_key_plain), + 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), + ) + + +async def auth_internal_oss() -> str: + return "" + + +async def auth_internal_cloud( + bearer_token: Annotated[str, Header(alias="Authorization", description="Service key.")] = "", +) -> str: + bearer_token = bearer_token.strip().split("Bearer ") + if len(bearer_token) < 2 or bearer_token[1].strip() == "": + raise AuthorizationError(NO_TOKEN_MESSAGE) + token = bearer_token[1].strip() + if not ( + compare_digest(token, ENV_CONFIG.service_key_plain) + or compare_digest(token, ENV_CONFIG.service_key_alt_plain) + ): + raise AuthorizationError(INVALID_TOKEN_MESSAGE) + return token + + +auth_internal = auth_internal_oss if ENV_CONFIG.is_oss else auth_internal_cloud + + +AuthReturn = tuple[UserRead | None, OrganizationRead | None] + + +async def auth_user_oss() -> AuthReturn: + return None, None + + +async def auth_user_cloud( + request: Request, + response: Response, + bearer_token: Annotated[ + str, + Header( + alias="Authorization", + description="One of: Service key, user PAT or organization API key.", + ), + ] = "", + user_id: Annotated[str, Header(alias="X-USER-ID", description="User ID.")] = "", +) -> AuthReturn: + bearer_token = bearer_token.strip() + bearer_token = bearer_token.split("Bearer ") + if len(bearer_token) < 2 or bearer_token[1].strip() == "": + raise AuthorizationError(NO_TOKEN_MESSAGE) + + # Authenticate + user = org = None + token = bearer_token[1].strip() + if ( + compare_digest(token, ENV_CONFIG.service_key_plain) + or compare_digest(token, ENV_CONFIG.service_key_alt_plain) + or token.startswith("jamai_sk_") + ): + if token.startswith("jamai_sk_"): + org = await _get_organization(request, token) + response.headers["Warning"] = f'299 - "{ORG_API_KEY_DEPRECATE_MESSAGE}"' + if user_id: + user = await _get_user(request, user_id) + + elif token.startswith("jamai_pat_"): + user = await _get_user(request, token) + + elif user := request.session.get("user", None) is not None: + user = UserRead(**user) + + else: + raise AuthorizationError(INVALID_TOKEN_MESSAGE) + return user, org + + +auth_user = auth_user_oss if ENV_CONFIG.is_oss else auth_user_cloud + + +async def auth_user_project_oss( + request: Request, + project_id: Annotated[ + str, Header(alias="X-PROJECT-ID", description='Project ID "proj_xxx".') + ] = "default", +) -> AsyncGenerator[ProjectRead, None]: + project_id = project_id.strip() + if project_id == "": + raise AuthorizationError(NO_PROJECT_ID_MESSAGE) + + # Fetch project + project = await _get_project(request, project_id) + organization = project.organization + + # Set some state + request.state.org_id = organization.id + request.state.project_id = project.id + request.state.external_keys = _get_external_keys(organization) + request.state.org_models = ModelListConfig.model_validate(organization.models) + request.state.all_models = request.state.org_models + CONFIG.get_model_config() + request.state.billing = BillingManager(request=request) + + yield project + + +async def auth_user_project_cloud( + request: Request, + response: Response, + project_id: Annotated[ + str, Header(alias="X-PROJECT-ID", description='Project ID "proj_xxx".') + ] = "", + bearer_token: Annotated[ + str, + Header( + alias="Authorization", + description="One of: Service key, user PAT or organization API key.", + ), + ] = "", + 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() + if project_id == "": + raise AuthorizationError(NO_PROJECT_ID_MESSAGE) + + # Fetch project + project = await _get_project(request, project_id) + organization = project.organization + + # Set some state + request.state.org_id = organization.id + request.state.project_id = project.id + request.state.external_keys = _get_external_keys(organization) + request.state.org_models = ModelListConfig.model_validate(organization.models) + request.state.all_models = request.state.org_models + CONFIG.get_model_config() + + # Check if token is provided + bearer_token = bearer_token.split("Bearer ") + if len(bearer_token) < 2 or bearer_token[1].strip() == "": + raise AuthorizationError(NO_TOKEN_MESSAGE) + + user_roles = {u.user_id: u.role for u in organization.members} + # Non-activated orgs can only perform GET requests + if (not organization.active) and (request.method != "GET"): + raise UpgradeTierError(f'Your organization "{organization.id}" is not activated.') + + # Authenticate + token = bearer_token[1].strip() + if compare_digest(token, ENV_CONFIG.service_key_plain) or compare_digest( + token, ENV_CONFIG.service_key_alt_plain + ): + pass + elif token.startswith("jamai_sk_"): + _org = await _get_organization(request, token) + if project.organization.id != _org.id: + raise AuthorizationError( + f'Your provided project "{project.id}" does not belong to organization "{_org.id}".' + ) + response.headers["Warning"] = f'299 - "{ORG_API_KEY_DEPRECATE_MESSAGE}"' + + elif token.startswith("jamai_pat_"): + pat = await _get_pat(request, token) + if pat.expiry != "" and datetime_now_iso() > pat.expiry: + raise AuthorizationError( + "Your Personal Access Token has expired. Please generate a new token." + ) + user_id = pat.user_id + + elif logged_in_user := request.session.get("user", None) is not None: + logged_in_user = UserRead(**logged_in_user) + user_id = logged_in_user.id + + else: + raise AuthorizationError(INVALID_TOKEN_MESSAGE) + + # Role-based access control + if user_id: + user_role = user_roles.get(user_id, None) + if user_role is None: + raise ForbiddenError(f'You do not have access to organization "{organization.id}".') + if user_role == "guest" and request.method in WRITE_METHODS: + raise ForbiddenError( + f'You do not have write access to organization "{organization.id}".' + ) + if user_role != "admin" and "api/admin/org" in route: + raise ForbiddenError( + f'You do not have admin access to organization "{organization.id}".' + ) + + # Billing + request.state.billing = BillingManager( + organization=organization, + project_id=project.id, + user_id=user_id, + request=request, + ) + + # If quota ran out then allow read access only + if request.method in WRITE_METHODS: + request.state.billing.check_egress_quota() + request.state.billing.check_db_storage_quota() + request.state.billing.check_file_storage_quota() + + 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() + + # 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}' + ) + + +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/crypt.py b/services/api/src/owl/utils/crypt.py index c42d9b6..bcc48b0 100644 --- a/services/api/src/owl/utils/crypt.py +++ b/services/api/src/owl/utils/crypt.py @@ -11,7 +11,6 @@ from Cryptodome.Cipher import AES from Cryptodome.Random import get_random_bytes -from loguru import logger def _encrypt(message: str, password: str, aes_mode: int) -> str: diff --git a/services/api/src/owl/utils/exceptions.py b/services/api/src/owl/utils/exceptions.py index 546b583..991fedd 100644 --- a/services/api/src/owl/utils/exceptions.py +++ b/services/api/src/owl/utils/exceptions.py @@ -1,23 +1,15 @@ -import functools -from typing import Any, Type +from functools import wraps +from inspect import iscoroutinefunction +from typing import Any, Callable, Type, TypeVar, overload -import numpy as np +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from filelock import Timeout +from loguru import logger +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError - -def docstring_message(cls): - """ - Decorates an exception to make its docstring its default message. - https://stackoverflow.com/a/66491013 - """ - # Must use cls_init name, not cls.__init__ itself, in closure to avoid recursion - cls_init = cls.__init__ - - @functools.wraps(cls.__init__) - def wrapped_init(self, msg=cls.__doc__, *args, **kwargs): - cls_init(self, msg, *args, **kwargs) - - cls.__init__ = wrapped_init - return cls +from jamaibase.exceptions import JamaiException, ResourceExistsError, UnexpectedError def check_type(obj: Any, clss: tuple[Type] | Type, mssg: str) -> None: @@ -25,53 +17,124 @@ def check_type(obj: Any, clss: tuple[Type] | Type, mssg: str) -> None: raise TypeError(f"{mssg} Received: {type(obj)}") -def check_iterable_element_type(obj: Any, clss: tuple[Type] | Type, mssg: str) -> None: - if not all(isinstance(o, clss) for o in obj): - raise TypeError(mssg) - - -def check_xy_arr(obj: Any, name: str): - if not (isinstance(obj, (list, np.ndarray)) and len(obj) == 2): - raise ValueError(f"{name} must be a list or `np.ndarray` with len == 2, received: {obj}") - - -def check_xyz_arr(obj: Any, name: str): - if not (isinstance(obj, (list, np.ndarray)) and len(obj) == 3): - raise ValueError(f"{name} must be a list or `np.ndarray` with len == 3, received: {obj}") - +F = TypeVar("F", bound=Callable[..., Any]) -@docstring_message -class OwlException(Exception): - """Generic owl exception for easy error handling.""" - pass +@overload +def handle_exception( + func: F, + *, + failure_message: str = "", +) -> F: ... -@docstring_message -class TableSchemaFixedError(OwlException): - """Table schema cannot be modified.""" +@overload +def handle_exception( + *, + failure_message: str = "", +) -> Callable[[F], F]: ... -@docstring_message -class ResourceExistsError(OwlException): - """Resource with the specified name already exists.""" - - -@docstring_message -class ResourceNotFoundError(OwlException): - """Resource with the specified name cannot be found.""" - - -@docstring_message -class ContextOverflowError(OwlException): - """Model's context length has been exceeded.""" - - -@docstring_message -class UpgradeTierError(OwlException): - """You have exhausted the allocations of your subscribed tier. Please upgrade.""" - +def handle_exception( + func: F | None = None, + *, + failure_message: str = "", + handler: Callable[..., Any] | None = None, +) -> Callable[[F], F] | F: + # TODO: Add support for callable as "failure_message" + """ + A decorator to handle exceptions for both synchronous and asynchronous functions. + Its main purpose is to: + - Provide more meaningful error messages for logging. + - Transform certain error classes, for example `RequestValidationError` -> `ValidationError`. + + It also allows you to specify a custom exception handler function. + The handler function should accept a single positional argument (the exception instance) + and all keyword arguments passed to the decorated function. + + Note that if a handler is provided, you are responsible to re-raise the exception if desired. + + Args: + func (F | None): The function to be decorated. This can be either a synchronous or + asynchronous function. When used as a decorator, leave this unset. Defaults to `None`. + failure_message (str): Optional message to be logged for timeout and unexpected exceptions. Defaults to "". + handler (Callable[..., None] | None): A custom exception handler function. + The handler function should accept a single positional argument (the exception instance) + and all keyword arguments passed to the decorated function. + + Returns: + func (Callable[[F], F] | F): The decorated function with exception handling applied. + + Raises: + JamaiException: If the exception is of type JamaiException. + RequestValidationError: If the exception is a FastAPI RequestValidationError. + ValidationError: Wraps Pydantic ValidationError as RequestValidationError. + ResourceExistsError: If an IntegrityError indicates a unique constraint violation in the database. + UnexpectedError: For any other unhandled exceptions. + """ -@docstring_message -class InsufficientCreditsError(OwlException): - """Please ensure that you have sufficient credits.""" + def decorator(fn: F) -> F: + def _handle_exception(e: Exception, kwargs): + try: + if handler is not None: + return handler(e, **kwargs) + except e.__class__: + pass + except Exception: + logger.warning(f"Exception handler failed for exception: {e}") + + if isinstance(e, JamaiException): + raise + elif isinstance(e, RequestValidationError): + raise + elif isinstance(e, ValidationError): + # Sometimes ValidationError is raised from additional checking code + raise RequestValidationError(errors=e.errors()) from e + elif isinstance(e, IntegrityError): + err_mssg: str = e.args[0] + err_mssg = err_mssg.split("UNIQUE constraint failed:") + if len(err_mssg) > 1: + constraint = err_mssg[1].strip() + raise ResourceExistsError(f'DB item "{constraint}" already exists.') from e + else: + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + elif isinstance(e, Timeout): + request: Request | None = kwargs.get("request", None) + mssg = failure_message if failure_message else "Could not acquire lock" + mssg = f"{e.__class__.__name__}: {e} - {mssg} - kwargs={kwargs}" + if request: + logger.warning(f"{request.state.id} - {mssg}") + else: + logger.warning(mssg) + raise + else: + request: Request | None = kwargs.get("request", None) + mssg = failure_message if failure_message else f"Failed to run {fn.__name__}" + mssg = f"{e.__class__.__name__}: {e} - {mssg} - kwargs={kwargs}" + if request: + logger.error(f"{request.state.id} - {mssg}") + else: + logger.error(mssg) + raise UnexpectedError(f"{e.__class__.__name__}: {e}") from e + + if iscoroutinefunction(fn): + + @wraps(fn) + async def wrapper(**kwargs): + try: + return await fn(**kwargs) + except Exception as e: + return _handle_exception(e, kwargs) + + else: + + @wraps(fn) + def wrapper(**kwargs): + try: + return fn(**kwargs) + except Exception as e: + return _handle_exception(e, kwargs) + + return wrapper + + return decorator if func is None else decorator(func) diff --git a/services/api/src/owl/utils/io.py b/services/api/src/owl/utils/io.py index e69de29..7541017 100644 --- a/services/api/src/owl/utils/io.py +++ b/services/api/src/owl/utils/io.py @@ -0,0 +1,312 @@ +import asyncio +import contextlib +import os +import pathlib +import zipfile +from io import BytesIO +from os import listdir, walk +from os.path import abspath, dirname, getsize, isdir, islink, join, relpath +from typing import AsyncGenerator, BinaryIO, Generator + +import aioboto3 +import aiofiles +import boto3 +from botocore.exceptions import ClientError +from loguru import logger + +from jamaibase.exceptions import BadInputError, ResourceNotFoundError +from jamaibase.utils.io import generate_thumbnail +from owl.configs.manager import ENV_CONFIG +from owl.utils import uuid7_str + +if ENV_CONFIG.owl_file_dir.startswith("s3://"): + S3_CLIENT = boto3.client( + "s3", + aws_access_key_id=ENV_CONFIG.s3_access_key_id, + aws_secret_access_key=ENV_CONFIG.s3_secret_access_key_plain, + endpoint_url=ENV_CONFIG.s3_endpoint, + ) + S3_BUCKET_NAME = ENV_CONFIG.owl_file_dir.replace("s3://", "") + LOCAL_FILE_DIR = "" + logger.info(f"Starting with S3 File Storage: {S3_BUCKET_NAME}") +else: + S3_CLIENT = None + S3_BUCKET_NAME = "" + LOCAL_FILE_DIR = ENV_CONFIG.owl_file_dir.replace("file://", "") + logger.info(f"Starting with Local File Storage: {LOCAL_FILE_DIR}") + +EMBED_WHITE_LIST = { + "application/pdf": [".pdf"], + "application/xml": [".xml"], + "application/json": [".json"], + "application/jsonl": [".jsonl"], + "application/x-ndjson": [".jsonl"], + "application/json-lines": [".jsonl"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"], + "application/vnd.ms-powerpoint": [".ppt"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], + "application/vnd.ms-excel": [".xls"], + "text/markdown": [".md"], + "text/plain": [".txt"], + "text/html": [".html"], + "text/tab-separated-values": [".tsv"], + "text/csv": [".csv"], + "text/xml": [".xml"], +} +IMAGE_WHITE_LIST = { + "image/jpeg": [".jpg", ".jpeg"], + "image/png": [".png"], + "image/gif": [".gif"], + "image/webp": [".webp"], +} +UPLOAD_WHITE_LIST = {**EMBED_WHITE_LIST, **IMAGE_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) +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) + + +def get_db_usage(db_dir: str) -> float: + """Returns the DB storage used in bytes (B).""" + db_usage = 0.0 + for root, dirs, filenames in walk(abspath(db_dir), topdown=True): + # Don't visit Lance version directories + if root.endswith(".lance") and "_versions" in dirs: + dirs.remove("_versions") + for f in filenames: + fp = join(root, f) + if islink(fp): + continue + db_usage += getsize(fp) + return db_usage + + +def get_storage_usage(db_dir: str) -> dict[str, float]: + """Returns the DB storage used by each organisation in GiB.""" + db_usage = {} + for org_id in listdir(db_dir): + org_dir = join(db_dir, org_id) + if not (isdir(org_dir) and org_id.startswith("org_")): + continue + db_usage[org_id] = get_db_usage(org_dir) + db_usage = {k: v / (1024**3) for k, v in db_usage.items()} + return db_usage + + +def get_file_usage(db_dir: str) -> dict[str, float]: + """Returns the File storage used by each organisation in GiB.""" + file_usage = {} + if S3_CLIENT: + paginator = S3_CLIENT.get_paginator("list_objects_v2") + for org_id in listdir(db_dir): + org_dir = join(db_dir, org_id) + if not (isdir(org_dir) and org_id.startswith("org_")): + continue + + total_size = 0 + for prefix in [f"raw/{org_id}/", f"thumb/{org_id}/"]: + for page in paginator.paginate(Bucket=S3_BUCKET_NAME, Prefix=prefix): + for obj in page.get("Contents", []): + total_size += obj["Size"] + + file_usage[org_id] = total_size / (1024**3) # Convert to GiB + else: + for org_id in listdir(db_dir): + org_dir = join(db_dir, org_id) + print(org_id) + if not (isdir(org_dir) and org_id.startswith(("org_", "default"))): + continue + total_size = 0 + for subdir in ["raw", "thumb"]: + file_dir = join(LOCAL_FILE_DIR, subdir, org_id) + print(LOCAL_FILE_DIR) + if os.path.exists(file_dir): + for root, _, files in os.walk(file_dir): + for file in files: + file_path = join(root, file) + total_size += os.path.getsize(file_path) + + file_usage[org_id] = total_size / (1024**3) # Convert to GiB + + return file_usage + + +def zip_directory_content(root_dir: str, output_filepath: str) -> None: + root_dir = abspath(root_dir) + output_filepath = abspath(output_filepath) + if dirname(output_filepath) == root_dir: + raise ValueError("Output directory cannot be the zipped directory.") + with zipfile.ZipFile(output_filepath, "w", zipfile.ZIP_DEFLATED) as f: + for dir_name, _, filenames in walk(root_dir): + for filename in filenames: + filepath = join(dir_name, filename) + # Create a relative path for the file in the zip archive + arcname = relpath(filepath, root_dir) + f.write(filepath, arcname) + + +@contextlib.asynccontextmanager +async def get_s3_aclient(): + async with aioboto3.Session().client( + "s3", + aws_access_key_id=ENV_CONFIG.s3_access_key_id, + aws_secret_access_key=ENV_CONFIG.s3_secret_access_key_plain, + endpoint_url=ENV_CONFIG.s3_endpoint, + ) as aclient: + yield aclient + + +# Synchronous version +@contextlib.contextmanager +def open_uri_sync(uri: str) -> Generator[BinaryIO | BytesIO, None, None]: + if S3_CLIENT: + if uri.startswith("s3://"): + try: + bucket_name, key = uri[5:].split("/", 1) + response = S3_CLIENT.get_object(Bucket=bucket_name, Key=key) + yield response["Body"] + except ClientError as e: + logger.warning(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + except Exception as e: + logger.exception(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + else: + raise ResourceNotFoundError(f'File "{uri}" is not found.') + else: + if uri.startswith("file://"): + try: + local_path = os.path.abspath(uri[7:]) + with open(local_path, "rb") as file: + yield file + except FileNotFoundError as e: + logger.warning(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + except Exception as e: + logger.exception(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + else: + raise ResourceNotFoundError(f'File "{uri}" is not found.') + + +# Asynchronous version +@contextlib.asynccontextmanager +async def open_uri_async(uri: str) -> AsyncGenerator[BinaryIO | BytesIO, None]: + if S3_CLIENT: + if uri.startswith("s3://"): + try: + bucket_name, key = uri[5:].split("/", 1) + async with get_s3_aclient() as aclient: + response = await aclient.get_object(Bucket=bucket_name, Key=key) + yield response["Body"] + except ClientError as e: + logger.warning(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + except Exception as e: + logger.exception(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + else: + raise ResourceNotFoundError(f'File "{uri}" is not found.') + else: + if uri.startswith("file://"): + try: + local_path = os.path.abspath(uri[7:]) + async with aiofiles.open(local_path, "rb") as file: + yield file + except FileNotFoundError as e: + logger.warning(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + except Exception as e: + logger.exception(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" is not found.') from e + else: + raise ResourceNotFoundError(f'File "{uri}" is not found.') + + +def os_path_to_s3_key(path: pathlib.Path | str) -> str: + # Convert path to string if it's a PathLike object + path_str = str(path) + # Replace backslashes with forward slashes + s3_key = path_str.replace(os.path.sep, "/") + # Remove leading slash if present + return s3_key.lstrip("/") + + +async def upload_file_to_s3( + organization_id: str, + project_id: str, + content: bytes, + content_type: str, + filename: str, +) -> str: + if content_type not in UPLOAD_WHITE_LIST_MIME: + raise BadInputError( + f"Unsupported file MIME type: {content_type}. Allowed types are: {', '.join(UPLOAD_WHITE_LIST_MIME)}" + ) + file_extension = os.path.splitext(filename)[1].lower() + if file_extension not in UPLOAD_WHITE_LIST_EXT: + 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" + ) + + 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_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)) + thumbnail = await thumbnail_task + + if S3_CLIENT: + async with get_s3_aclient() as aclient: + # Upload raw file + await aclient.put_object( + Body=content, + Bucket=S3_BUCKET_NAME, + Key=raw_key, + ContentType=content_type, + ) + if len(thumbnail) > 0: + await aclient.put_object( + Body=thumbnail, + Bucket=S3_BUCKET_NAME, + Key=thumb_key, + ContentType="image/webp", + ) + logger.info( + f"File Uploaded: [{organization_id}/{project_id}] " + f"Location: s3://{S3_BUCKET_NAME}/{raw_key} " + f"File name: {filename}, MIME type: {content_type}. " + ) + return f"s3://{S3_BUCKET_NAME}/{raw_key}" + else: + raw_file_path = os.path.join(LOCAL_FILE_DIR, raw_path) + thumb_file_path = os.path.join(LOCAL_FILE_DIR, thumb_path) + + os.makedirs(os.path.dirname(raw_file_path)) + os.makedirs(os.path.dirname(thumb_file_path)) + + async with aiofiles.open(raw_file_path, "wb") as out_file: + await out_file.write(content) + + if len(thumbnail) > 0: + async with aiofiles.open(thumb_file_path, "wb") as thumb_file: + await thumb_file.write(thumbnail) + + logger.info( + f"File Uploaded: [{organization_id}/{project_id}] " + f"Location: file://{raw_file_path} " + f"File name: {filename}, MIME type: {content_type}. " + ) + return f"file://{raw_file_path}" diff --git a/services/api/src/owl/utils/jwt.py b/services/api/src/owl/utils/jwt.py new file mode 100644 index 0000000..5e2df6e --- /dev/null +++ b/services/api/src/owl/utils/jwt.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone +from typing import Any + +import jwt +from fastapi import Request +from loguru import logger + +from jamaibase.exceptions import AuthorizationError +from owl.configs.manager import ENV_CONFIG + + +def encode_jwt(data: dict[str, Any], expiry: datetime) -> str: + data.update({"iat": datetime.now(tz=timezone.utc), "exp": expiry}) + token = jwt.encode(data, f"{ENV_CONFIG.owl_encryption_key_plain}_secret", algorithm="HS256") + return token + + +def decode_jwt( + token: str, + expired_token_message: str, + invalid_token_message: str, + request: Request | None = None, +) -> dict[str, Any]: + try: + data = jwt.decode( + token, + f"{ENV_CONFIG.owl_encryption_key_plain}_secret", + algorithms=["HS256"], + ) + return data + except jwt.exceptions.ExpiredSignatureError as e: + raise AuthorizationError(expired_token_message) from e + except jwt.exceptions.PyJWTError as e: + raise AuthorizationError(invalid_token_message) from e + except Exception as e: + if request 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}' + ) + raise AuthorizationError(invalid_token_message) from e diff --git a/services/api/src/owl/utils/kb.py b/services/api/src/owl/utils/kb.py index 9506f68..8d342a1 100644 --- a/services/api/src/owl/utils/kb.py +++ b/services/api/src/owl/utils/kb.py @@ -69,9 +69,9 @@ def remove_chunk_overlap( if match is None: continue documents[i].text = text[: match.a] - documents_scores = [(c, s) for c, s in zip(documents, scores) if len(c.text) > 0] + documents_scores = [(c, s) for c, s in zip(documents, scores, strict=True) if len(c.text) > 0] if len(documents_scores) == 0: documents, scores = [], [] else: - documents, scores = zip(*documents_scores) + documents, scores = zip(*documents_scores, strict=True) return documents, scores diff --git a/services/api/src/owl/utils/logging.py b/services/api/src/owl/utils/logging.py index dc84bba..6812766 100644 --- a/services/api/src/owl/utils/logging.py +++ b/services/api/src/owl/utils/logging.py @@ -6,9 +6,12 @@ import inspect import logging +import sys from loguru import logger +from owl.configs.manager import ENV_CONFIG + class InterceptHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: @@ -72,17 +75,31 @@ def suppress_logging_handlers(names: list[str], include_submodules: bool = True) lgg.setLevel("ERROR") -def setup_logger_sinks(): - import sys - from copy import deepcopy - - from owl.configs.manager import LOGS - +def setup_logger_sinks(log_filepath: str = f"{ENV_CONFIG.owl_log_dir}/owl.log"): logger.remove() - log_cfg = deepcopy(LOGS) - stderr_cfg = log_cfg.pop("stderr", None) - if stderr_cfg is not None: - logger.add(sys.stderr, **stderr_cfg) - for path, cfg in log_cfg.items(): - logger.add(sink=path, **cfg) - logger.info(f"Writing logs to: {path}") + logger.level("INFO", color="") + logger.configure( + handlers=[ + { + "sink": sys.stderr, + "level": "INFO", + "serialize": False, + "backtrace": False, + "diagnose": True, + "enqueue": True, + "catch": True, + }, + { + "sink": log_filepath, + "level": "INFO", + "serialize": False, + "backtrace": False, + "diagnose": True, + "enqueue": True, + "catch": True, + "rotation": "50 MB", + "delay": False, + "watch": False, + }, + ], + ) diff --git a/services/api/src/owl/utils/responses.py b/services/api/src/owl/utils/responses.py new file mode 100644 index 0000000..b4a95ca --- /dev/null +++ b/services/api/src/owl/utils/responses.py @@ -0,0 +1,360 @@ +from typing import Mapping + +from fastapi import Request, status +from fastapi.responses import ORJSONResponse +from loguru import logger +from starlette.exceptions import HTTPException + +from jamaibase.exceptions import JamaiException + +INTERNAL_ERROR_MESSAGE = "Opss sorry we ran into an unexpected error. Please try again later." + + +def make_request_log_str(request: Request, status_code: int) -> str: + """ + Generate a string for logging, given a request object and an HTTP status code. + + Args: + request (Request): Starlette request object. + status_code (int): HTTP error code. + + Returns: + str: A string in the format + ' - " " ' + """ + query = request.url.query + query = f"?{query}" if query else "" + org_id = "" + project_id = "" + try: + org_id = request.state.org_id + project_id = request.state.project_id + except Exception: + pass + return ( + f"{request.state.id} - " + f'"{request.method} {request.url.path}{query}" {status_code} - ' + f"org_id={org_id} project_id={project_id}" + ) + + +def make_response( + request: Request, + message: str, + error: str, + status_code: int, + *, + detail: str | None = None, + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, + log: bool = True, +) -> ORJSONResponse: + """ + Create a Response object. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + error (str): Short error name. + status_code (int): HTTP error code. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + log (bool, optional): Whether to log the response. Defaults to True. + + Returns: + response (ORJSONResponse): Response object. + """ + if detail is None: + detail = f"{message}\nException:{repr(exception)}" + request_headers = dict(request.headers) + if "authorization" in request_headers: + request_headers["authorization"] = ( + f'{request_headers["authorization"][:2]}*****{request_headers["authorization"][-1:]}' + ) + response = ORJSONResponse( + status_code=status_code, + content={ + "object": "error", + "error": error, + "message": message, + "detail": detail, + "request_id": request.state.id, + "exception": exception.__class__.__name__ if exception else None, + "headers": request_headers, + }, + headers=headers, + ) + mssg = make_request_log_str(request, response.status_code) + if not log: + return response + if status_code == 500: + log_fn = logger.exception + elif status_code > 500: + log_fn = logger.warning + elif exception is None: + log_fn = logger.info + elif isinstance(exception, (JamaiException, HTTPException)): + log_fn = logger.info + else: + log_fn = logger.warning + if exception: + log_fn(f"{mssg} - {exception.__class__.__name__}: {exception}") + else: + log_fn(mssg) + return response + + +def unauthorized_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "unauthorized", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 401. + The client should provide or correct their authentication information. + Often used when a user is not logged in or their session has expired. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "unauthorized". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + exception=exception, + headers=headers, + ) + + +def forbidden_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "forbidden", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 403. + The client does not have access rights to the content. + Authentication will not help, as the client is not allowed to perform the requested action. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "forbidden". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_403_FORBIDDEN, + detail=detail, + exception=exception, + headers=headers, + ) + + +def resource_not_found_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "resource_not_found", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 404. + The server can not find the requested resource. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "resource_not_found". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + exception=exception, + headers=headers, + ) + + +def resource_exists_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "resource_exists", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 409. + The request cannot be processed because it conflicts with the current state of the resource. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "resource_exists". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_409_CONFLICT, + detail=detail, + exception=exception, + headers=headers, + ) + + +def bad_input_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "bad_input", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 422. + The request contains errors and cannot be processed. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "bad_input". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=detail, + exception=exception, + headers=headers, + ) + + +def internal_server_error_response( + request: Request, + message: str = INTERNAL_ERROR_MESSAGE, + *, + detail: str | None = None, + error: str = "unexpected_error", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 500. + The server encountered an unexpected condition that prevented it from fulfilling the request. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "unexpected_error". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=detail, + exception=exception, + headers=headers, + ) + + +def server_busy_response( + request: Request, + message: str, + *, + detail: str | None = None, + error: str = "busy", + exception: Exception | None = None, + headers: Mapping[str, str] | None = None, +) -> ORJSONResponse: + """ + HTTP 503. + The server is currently unable to handle the request due to a temporary overloading or maintenance. + + Args: + request (Request): Starlette request object. + message (str): User-friendly error message to be displayed by frontend or SDK. + detail (str | None, optional): Error message with potentially more details. + Defaults to None (message + headers). + error (str, optional): Short error name. Defaults to "busy". + exception (Exception | None, optional): Exception that occurred. Defaults to None. + headers (Mapping[str, str] | None, optional): Response headers. Defaults to None. + + Returns: + response (ORJSONResponse): Response object. + """ + return make_response( + request=request, + message=message, + error=error, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=detail, + exception=exception, + headers=headers, + ) diff --git a/services/api/src/owl/utils/tasks.py b/services/api/src/owl/utils/tasks.py index 0e44e52..4df67bc 100644 --- a/services/api/src/owl/utils/tasks.py +++ b/services/api/src/owl/utils/tasks.py @@ -8,8 +8,10 @@ import asyncio import logging +import time from asyncio import ensure_future from functools import wraps +from time import perf_counter from traceback import format_exception from typing import Any, Callable, Coroutine, Union @@ -92,3 +94,24 @@ async def loop() -> None: return wrapped return decorator + + +def repeat_every_blocking( + *, + seconds: float, + wait_first: bool = False, +): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + if wait_first: + time.sleep(seconds) + while True: + t0 = perf_counter() + func(*args, **kwargs) + t = perf_counter() - t0 + time.sleep(max(0, seconds - t)) + + return wrapper + + return decorator diff --git a/services/api/src/owl/version.py b/services/api/src/owl/version.py index d3ec452..493f741 100644 --- a/services/api/src/owl/version.py +++ b/services/api/src/owl/version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/services/api/tests/test_io.py b/services/api/tests/test_io.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/api/tests/test_lance.py b/services/api/tests/test_lance.py new file mode 100644 index 0000000..a766ec9 --- /dev/null +++ b/services/api/tests/test_lance.py @@ -0,0 +1,31 @@ +from datetime import timedelta +from os.path import join +from pathlib import Path +from shutil import copytree +from tempfile import TemporaryDirectory + +import lancedb + +CURR_DIR = Path(__file__).resolve().parent + + +def test_lance(): + table_id = "test_table" + with TemporaryDirectory() as tmp_dir: + copytree(join(CURR_DIR, f"{table_id}.lance"), join(tmp_dir, f"{table_id}.lance")) + lance_db = lancedb.connect(tmp_dir) + # Try opening table + table = lance_db.open_table(table_id) + assert table.count_rows() > 0 + # Try deleting rows + rows = table._dataset.to_table(offset=0, limit=100).to_pylist() + row_ids = [r["ID"] for r in rows] + for row_id in row_ids[3:]: + table.delete(f"`ID` = '{row_id}'") + # Try table optimization + table.cleanup_old_versions(older_than=timedelta(seconds=0), delete_unverified=False) + table.compact_files() + + +if __name__ == "__main__": + test_lance() diff --git a/services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_data.lance b/services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_data.lance new file mode 100644 index 0000000000000000000000000000000000000000..7a54f317d086a1d4d1a7767ab4b449acc778b693 GIT binary patch literal 1214 zcmZ9~OK1~87zgm(JT~c8ZQQo$*0x$>t0vktiK$5NpjM?)D;^ZQNQf~=LXyU2*GBCj zprD|h#1=eRd|niU)@SP@YOSwWAN3KCnrlJuA`}!f{pKH8JD1=5=bLY4Vb(X=lPS~- zT*Hqv8O$9bzgJ%*JM5Q8|5s*wdXVz^sUfnr?3^GA~@eL-|bRE?eJvkBmyg z}($=4_Cb<`zM*o z<#&{)lJChI-VbEcPv*I^6O{Y*eZxGnxGj9&;qTn z42rNE&Vh5`JXis3a6Xh^C9Hzg&<<;$3~S*6SO@E216&9f!A7_kI^Yu61e@VfxC}0b zPUwOw;7Zs66}Sqzp$B^5YUqPAv|@f0k5$ULE&rzazS3e6k6L=`^sJnjmG>4KMW-Yj z492u@s_{>0@GhGgiHB2aFrM^lDgUmWZEk)oFFGVkcUMm!vM(j``rU#SNvLalGkat{ zS|OTa!9*x532w#XRXn}LTdZRi$-uSBYT(z9gAA-e8nZSd`K)&KbCE8@9Lo1v@YQ+W-In literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_lookup.lance b/services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_lookup.lance new file mode 100644 index 0000000000000000000000000000000000000000..651a4ad92a979d1df6e596fce1b76e63277a28d4 GIT binary patch literal 675 zcmXpxR^ISXhK-@r<`$S{Km&|WJ_nSB@deQMB4~UlmuM;iO@9I!KLw2s<-(~9H2DHF zehC_XEs)QpCd8bZnWyj{1{k#jN-}d(i%Sx73#>|utwKYb4HzvLxio<45-TJa39D2V z;>s({$%#+SFU>2FU_#cwR+^btVr&F7PEm-XATd2PJ~O34f*Gq43C1jbF4mmHyyR3N zHUm9FBR#`sKnHXDU=(6vV3d&1y2Z#PCd6ojqPe)Fs5H5ROB}@sj9RQfYt2l6)`|;p z0(~1_mY7qTD#4_Ma1Tf#u>vZ=j3gl;#AqzRg3S_16iZkYSP<4oB3r`-ltEYol7d^r djw~g_!NADG%)-jXE>W=(mc|&E7UuKN1841fn4oh>9lV_k<d3WaH=ljprZt|UFTnWa`HM$3Y8*~^?4dOTB&OoA;%o)#$- zm!bADTyXN)cCm9S3*)SEqX#mTd=3&aI%gNWlSYPtEs-7N$JExJY?!t>X(m+fvzMW% zS4F)_h&&6fy|uizr7XpsbZ+&SYHTUiikt>AS~YprzLhD;_{5ToImlj*;Z%3u)k{;U zc(m2)oCY#g4P>$qn*M6ctgnT)OGe2jFI!|+Q_4>fF4bxVK6^{G zkO}!IIWVb0BnL&WC&)CnHKND3q@t>1xX`heiPY{CmqP5K>Xah)RSjwyOPE`eQ71}08b0H#ThdW_%jkVf1@TW+M-D%{X?j#&9#R zRdSqL3KTxGM5867p&a$LYLi1|kVETDv{@@BM4#utY%@CM8UN?DtKwR zcFL2B-Hr;%H$CroD`RNaWLK4lrQbh6H*qC{97^Uq&9Rhn&||?U>eUz!SCuRz#S|~9 z@A+UU#a1)O*yN;>)Mk*o4T{p(+e(6Fux9vU zy*5qVUCjfZY1~LpL_b%fvS7-2HSC|9NKZ`InJ&=4JHw?dgWf2<`7T)GCj^#hSZywS z&xfihKc>?|4Pj|N7j21M-6P{72E~e)Am17IYMei2z3P4M{s2X)uT?uI+Pp+C*Wh?5%6*F3GgU*415xt zf=_|R!4u#~@Dw-$PlHc`XTY=IGvKq}IdB7f4m=M&4_*LY0AB=O0xyCugPY(h;3aSl zUIt$U8OT8aO0XHKu3j3ST|lzCXM8+b?2JeJ=6!$y%}}7lokz^|MaW+1irN_*a54OY z&+fri*IS* z-e+UCG&w(AyLI!E8(aDIY{#rwHsdA3Rm8E=gt2B(_MCp literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_lookup.lance b/services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_lookup.lance new file mode 100644 index 0000000000000000000000000000000000000000..fe902202788378d2cb2451296bf7a7984968f359 GIT binary patch literal 1326 zcma))F;Ck-6vusbY@8zybOT&ORk~>irRd^1G{|+M5~GZK1V)e; z7#SEcvho4=27Cm*13lDxA$KB4Z+ZIr|Ni%T?_}0)$7w4L{4g0b{V45lKW2>iJZPqa zL7yj4Co<&Ola)cHA2uRCiaN=^OgrSQDDFp$wZc&L9zj^U18F|!pYl8COw&>tvl*7m zeJ`7zWc{Dav+Ui10rc#WoxjLTSJoMCN#?ukJ=b^=yRuAW2gG4l(69$+{X$O~;KS^o zVJ^^m{zA)NYWiB!dj3Z9Z#8|V=__#o{y?l>!`yp;gX+%d_|GsuW+*oCN8$1bMT`8; z1m{JT^^)I3KBlJ9Rt)oTGl&uXMBEWSPJ6>26KcKNLeWVT+ZoT3lsIg^5$rb}#jx(w zCZ>VtsQzH#ZGxB*7GoC481E<}pt>!v?NDHAq%1b;o$;(lsi~~>YVnD*EXAS(HE8KQ zP)z~ZUg-_BJCAaNGyjIK<_xx`nF1G3cQvl;xoxv~%OGA=VuKJy5l zQ#9I<62(<7Q+SzK@}iS*Dio%fPM-JvxRUhDW|lgc7%dCVWiNAH=D=it)!0(36*&!Lv}$?QzLP1+_{5ToImlj*;Z%1&ua~A$ z@o1~pISpj08pvcJH2u_=SzilpTU;{3=Paew*-*h!>Ge2jFI(iirj(x|T&mR!eD;=V zArtaba$r)0NDhi#PmpP5YebK6Nkvu3aG_%_6RF)PE```d)hR{ps~XfamN2s>qh3k` z!DY3outB|2AX_#-50%_dcX>tQ)7#3H=0Zfw4tK)j8f&$2;7^ehyVJ;zW89=5jp1fs ztK>Md6exUViAGCELpkbg)s{nMkVETDv{@@BM4#utY@3}7J(~NHc<{+*w$Kx}qS$3Z z|2UVpWiD4rSuZ1K)NN6iQ|eOBLRFdbfh|!eKiR<+;yNL@tPXGCRz&vJuXwP@+dl=GtaFRtE3gv$K?aX zZZl?(_Ac{|)?*5TS_E3;*3WyMD&ZZkrG}xcs9SH)BHznlqLoitgQ7I{wvwP3tQr1T zuT87Ft9jrvjT`BS=;vxw7EC#>hW(Qh>4^zD(*+uMXSlRw&>O|K?}9~sLSUJO)#lRo ze5ji8W9E9OAuR3ZqAhV=_sBTeqRrjKMg2vKn5xfhU-w8mpL&cMZ7+5nd8Mq<0P#MD zQE-|IPmb@&C{CI7UKv~aUX7wqZ{&>c3+A=wu~L)Ez7L~n-<71O(_<7houmE2v|7S* z0el1)(1b+wr0oO;}hqu5Fz%Ricz~8{X!L!TVz1P7EegS?D{t7+-rw6)wuYvD@ zpM&3lcftGM@s;kL1FwUhf!~6^fcL;-tKB^XUIRY`zX5*+{{oM!b@v$fE_et08vF_T z6FhXVyLSn^3hsdIRMSsOV`EE8V+*sfx!u^@Xl!a5+x5mn;9+nKJ_a5E9|xZRkAla* zC&3B$6nGpw0iFa;fm84__%wJ1JPSSpJ`0`$*TLt&^WgK~1@Hy%Merr?BKR`60lorW z0%zc5@Kun392B4g+d#EE&+FAbx937jI6d?C?rmvlu`?d=KktK9BzmT;Nc1f3JYudd zLiP&S-RyohALf1z|K2{|gDs5d!sh&au5bSS@crwsH$Tr0y>C7AevK52&rQ}o4qLnB z_WHl`XSA_)d*{~X_WNw)mdEEO2XEc{j)LPff<#yIWf~(#_rNo#|*}bx*Au zjZaSwf3&%MW6!(k@;;y1QMiMn1N)AqlM$b;{GXxI`wXqFuk@zQ3{9=k$yy)WxE(Hx h9o&C7IkYrdKCrU7c5wRO~>irRd^1G{|+M5~GZK1V)e; z7#SEcvho4=27Cm*13lDxA$KB4Z+ZIr|Ni%T?_}0)$7w4L{4g0b{V45lKW2>iJZPqa zL7yj4Co<&Ola)cHA2uRCiaN=^OgrSQDDFp$wZc&L9zj^U18F|!pYl8COw&>tvl*7m zeJ`7zWc{Dav+Ui10rc#WoxjLTSJoMCN#?ukJ=b^=yRuAW2gG4l(69$+{X$O~;KS^o zVJ^^m{zA)NYWiB!dj3Z9Z#8|V=__#o{y?l>!`yp;gX+%d_|GsuW+*oCN8$1bMT`8; z1m{JT^^)I3KBlJ9Rt)oTGl&uXMBEWSPJ6>26KcKNLeWVT+ZoT3lsIg^5$rb}#jx(w zCZ>VtsQzH#ZGxB*7GoC481E<}pt>!v?NDHAq%1b;o$;(lsi~~>YVnD*EXAS(HE8KQ zP)z~ZUg-_BJCAaNGyjIK<_xx`nF1G3cQvl;xoxv~%O1T>?^bEeKwOf`ZO`?;nWyVCKjFeQz0f;~VeK6dDDt z>1Ub@<_?iRYA=%A_DiJy8#6vTLV5knO>$uH7TI9CLrzUIFI>M%`Ap^>8{c}Lj7p>A zraW_Kc#QR<56C}V56R8qBhr{-j+}l>*||GQ{%(Cj?)c6;KKzujY2!1pZI)R%I!^i2 z(dXn?_XK&R`UUCEGYjWmQue8@$dg-MlUeH<@<4&P=UR@ka^x-9(=|yRu6aieO*7q> z-&3ARejsnOeVG~>k7r|z@7&_n**aBPOQn(B* zhfe5%E8t4l2HkKK^gu6chpV9vYEbESrPo#c!n7)8me^Y#D_>XaC-sZf3d^~4S{EKbMkRsMeVnU6@ z0-;_tp~$?;OuK65)=L%fNFqR=DH~iR$5AVoM$1^*l1vGH}~AZ14OB Do&pO` literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_lookup.lance b/services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_lookup.lance new file mode 100644 index 0000000000000000000000000000000000000000..651a4ad92a979d1df6e596fce1b76e63277a28d4 GIT binary patch literal 675 zcmXpxR^ISXhK-@r<`$S{Km&|WJ_nSB@deQMB4~UlmuM;iO@9I!KLw2s<-(~9H2DHF zehC_XEs)QpCd8bZnWyj{1{k#jN-}d(i%Sx73#>|utwKYb4HzvLxio<45-TJa39D2V z;>s({$%#+SFU>2FU_#cwR+^btVr&F7PEm-XATd2PJ~O34f*Gq43C1jbF4mmHyyR3N zHUm9FBR#`sKnHXDU=(6vV3d&1y2Z#PCd6ojqPe)Fs5H5ROB}@sj9RQfYt2l6)`|;p z0(~1_mY7qTD#4_Ma1Tf#u>vZ=j3gl;#AqzRg3S_16iZkYSP<4oB3r`-ltEYol7d^r djw~g_!NADG%)-jXE>W=(mc|&E7ZJM1HH_SnXdNA&aLM1WjW||)$C|i}n;;9QB7DAetERnQUGfG)O zyonc2dk`<){0DRo{ttpjkBeu~e?gPD>KqI{cys!E-h3at#{d9C5k~qa_1nhs>+LtM zE}ndU@b>I83_)L=jH8ULjL`sxHUO}MtJ2G+09~uJ&zt(w{jJ@j4_~a~kIDXP3}OGw z-jc~y$`${p5m}dA zIprqq+{$upOvVH?FbqW$ zdWn_Ci-hAIfXZ5{t#-6^)HNt6&oAt0&tJSdR`rvkm+#?SI=ezgW) sI<(DTv>li>vwhp4F=Yw2#AWY(5-O?mER;I7Lm7uQkdsLH+RR1+--O@EFX4O&$93|XpXjezeqC=D zzgx4=fh$BM`}S_*<-Icr&6VHW?Yfzn91Sdiaq?Z$EbR`1KPn-nxG6>eeQt$uR_3T^rG$#IaA_->8=nb}Ub4u@wv0+8)kd-HiVPKs)~kx7*j$b=+`bT|x|WHSaYG#> zH$;PqD2F-Cnx$HggvU0)(+CY+r^Jjb3?*niXIgy@Pu*;`aU@(_>QGHw@*ociLZ}h> zfYo}zQiCJa<3h8#7<~nqBc>^ zI>Yp)tiT)zPi(>(62{mAy%J*J$b|8(7oe;f%Pi+QJh=%Y1?}pcYy=PnicuH3QnaNi zB2%m#V|Z#4YHOo+5K6$CvyK+aYAWqq$!9MyRG%}!+VG~9zohOQD%i(fyR&s?&NakJ~Xsh#BhvzmS zr95HR7k@A)3xPB-Y^>NjS2*2V=b3zd6WF7`k}<|3L}bArOt^N-8J~u4t#sDr3!5+_ z79|8lvnBMRE;Ld{r&VN_XH>(^y|}vxxkW>y@U~b$h!JizdnW8l%*F5-%R@alzgA{LR>5YigwdHBjEC|oh1 z8I%+6IuuwW3IUZe4hq2yVdts7w+T=KJQWCSqQEPAZNH!d+4 zuDLt|+842LupYLugk(3lD*Pv%r}~vmXbN$|Dshok)FESUHZY;AYO5~AEpc|3S2tm_ z%($d6#kN#UIA`&#O_(`F z#LtwvpbpJ>XCWx;a-7G)NzPgDCajKu%27aQ`+mUI^NEe)O}G?Y`bjs8Y=V^JDqTtT zzZP*fvY5@y$G|)UcXBU~C2sv?|9`5}y_c_VuJ8Qu<6A%f;QQbE;X6UV$t<%i$7!QVc* z{jaBg^3Naq_BZ$c_V1tm{@J(x_vG7uF8{gv+>d_t-di8NXK%%@pZx2+JHL4Sr62$L O{f{5~;V=F5cmE61a_G+h literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/_versions/56.manifest b/services/api/tests/test_table.lance/_versions/56.manifest new file mode 100644 index 0000000000000000000000000000000000000000..15e3105890b4d089101c68825c02c26dd43a7a42 GIT binary patch literal 4417 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/_versions/58.manifest b/services/api/tests/test_table.lance/_versions/58.manifest new file mode 100644 index 0000000000000000000000000000000000000000..cfed58282c6003b76e65b5eaee6d18920cb12243 GIT binary patch literal 777 zcmaiy&x_MQ6vt<>ZJM1HH_SnXdNA&aLM1WjW||)$C|i}n;;9QB7DAetERnQUGfG)O zyonc2dk`<){0DRo{ttpjkBeu~e?gPD>KqI{cys!E-h3at#{d9C5k~qa_1nhs>+LtM zE}ndU@b>I83_)L=jH8ULjL`sxHUO}MtJ2G+09~uJ&zt(w{jJ@j4_~a~kIDXP3}OGw z-jc~y$`${p5m}dA zIprqq+{$upOvVH?FbqW$ zdWn_Ci-hAIfXZ5{t#-6^)HNt6&oAt0&tJSdR`rvkm+#?SI=ezgW) sI<(DTv>li>vwhp4F=Yw2#AZ3B8vI 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

- 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

s- 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 XmBZ~u*!&sHUG$XF%^>iF;7R`j{M8|g literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/375faaf4-394d-4c1d-a217-dce02e301028.lance b/services/api/tests/test_table.lance/data/375faaf4-394d-4c1d-a217-dce02e301028.lance new file mode 100644 index 0000000000000000000000000000000000000000..d3b06fe97761ac16d9e0788779f0cc064d00baf0 GIT binary patch literal 12867 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 M>0%c#C2HFL0ac^{ssI20 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance b/services/api/tests/test_table.lance/data/3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance new file mode 100644 index 0000000000000000000000000000000000000000..76a9af89d6ca8c8c408936fc5b10537c77d72bff GIT binary patch literal 11998 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance b/services/api/tests/test_table.lance/data/3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance new file mode 100644 index 0000000000000000000000000000000000000000..907d6d7b2b8b7e107be71a7e306fa99034d85fe7 GIT binary patch literal 12837 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/4038dc14-3f39-4076-b537-d262314ce58e.lance b/services/api/tests/test_table.lance/data/4038dc14-3f39-4076-b537-d262314ce58e.lance new file mode 100644 index 0000000000000000000000000000000000000000..10ab98bdec0b7498eb3c883ab1298f11958629e6 GIT binary patch literal 12337 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance b/services/api/tests/test_table.lance/data/476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance new file mode 100644 index 0000000000000000000000000000000000000000..b3ddef7cde719f958acde3022269cffc6cd79564 GIT binary patch literal 12859 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 I8aDiY00Waz5&!@I literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/4fb6cee8-9f15-4070-a709-02db49fb21b1.lance b/services/api/tests/test_table.lance/data/4fb6cee8-9f15-4070-a709-02db49fb21b1.lance new file mode 100644 index 0000000000000000000000000000000000000000..5bc2b0a7b97a072ed3d0f8c60a1257fac92a2242 GIT binary patch literal 12158 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 c-hKKyX`gX^RYdkjf8P7K@B182WOVd! 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/5e04331f-8465-40b0-af53-455928d381a8.lance b/services/api/tests/test_table.lance/data/5e04331f-8465-40b0-af53-455928d381a8.lance new file mode 100644 index 0000000000000000000000000000000000000000..3e8ed4ba079bde8da96e522f807863eb2239e6a4 GIT binary patch literal 12845 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 XVvx;X>x#e@dkv*@(Fzz7H12-@uh0_o literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/67bff393-906c-432b-bf15-5b854effe0a7.lance b/services/api/tests/test_table.lance/data/67bff393-906c-432b-bf15-5b854effe0a7.lance new file mode 100644 index 0000000000000000000000000000000000000000..e5f2eabfcf0a511bbd2bd503e46232dfaf0f9664 GIT binary patch literal 12517 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 At^fc4 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/6b47eefb-b495-444c-aec6-626a0ed8e441.lance b/services/api/tests/test_table.lance/data/6b47eefb-b495-444c-aec6-626a0ed8e441.lance new file mode 100644 index 0000000000000000000000000000000000000000..8770168a7067fefc582ce500ee5727d0555418ab GIT binary patch literal 12499 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 D%4|X} literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance b/services/api/tests/test_table.lance/data/800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance new file mode 100644 index 0000000000000000000000000000000000000000..9f4d45649fd56634e7e2f16a1078c38dffb8e4e7 GIT binary patch literal 12292 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 L7=(@uANT(Nsz^i5 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/8263336e-11dc-47c1-a0ab-37dced034d86.lance b/services/api/tests/test_table.lance/data/8263336e-11dc-47c1-a0ab-37dced034d86.lance new file mode 100644 index 0000000000000000000000000000000000000000..5f21958dfbcd2861f1f6de333975ec9fec51f589 GIT binary patch literal 12842 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance b/services/api/tests/test_table.lance/data/833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance new file mode 100644 index 0000000000000000000000000000000000000000..3d76233fb50764bd176e6f3e12b531371f726979 GIT binary patch literal 12521 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/85d3b452-5002-4793-bc66-fced85c77ebd.lance b/services/api/tests/test_table.lance/data/85d3b452-5002-4793-bc66-fced85c77ebd.lance new file mode 100644 index 0000000000000000000000000000000000000000..def46271fdd2c4c5959c90a7731158518398b4c0 GIT binary patch literal 12348 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance b/services/api/tests/test_table.lance/data/94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance new file mode 100644 index 0000000000000000000000000000000000000000..59bbb414687ba19f40259b4d584731405ebb96fb GIT binary patch literal 12827 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 Mx^@Yj8b1C10eiz2J^%m! literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/9583d125-a754-4104-ad58-670223a4cfd4.lance b/services/api/tests/test_table.lance/data/9583d125-a754-4104-ad58-670223a4cfd4.lance new file mode 100644 index 0000000000000000000000000000000000000000..2b38d51979d204df821db9570dba53f536d16c9a GIT binary patch literal 12259 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/a20c5619-719e-48c8-a249-607527257890.lance b/services/api/tests/test_table.lance/data/a20c5619-719e-48c8-a249-607527257890.lance new file mode 100644 index 0000000000000000000000000000000000000000..b92018f3b205d386c10dc08aca3cbf925bbe86c8 GIT binary patch literal 12485 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 ap1oAP`?$M(J#L4;C8e}(6*)3m^Zx*Bbv~g0 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/a4a314e7-13e0-4a60-9d6f-b459576cc577.lance b/services/api/tests/test_table.lance/data/a4a314e7-13e0-4a60-9d6f-b459576cc577.lance new file mode 100644 index 0000000000000000000000000000000000000000..0fbfe62dc2042098a99ecb65f8c29e0f667d8e83 GIT binary patch literal 12867 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 YWB&mIgZ?=wDAFBL+IXao%pCpy06I}+`Tzg` literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/abbdcc8c-93da-4e2e-ac61-91be0874a587.lance b/services/api/tests/test_table.lance/data/abbdcc8c-93da-4e2e-ac61-91be0874a587.lance new file mode 100644 index 0000000000000000000000000000000000000000..89587e8ffa6c775d4c0375df4f5fc3072fa545f1 GIT binary patch literal 12801 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 Y6~l&)kbgbz9Y>v{bnoUn*>CFq1A|cv^Z)<= literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/af70162a-d319-403c-bd26-251353c9966c.lance b/services/api/tests/test_table.lance/data/af70162a-d319-403c-bd26-251353c9966c.lance new file mode 100644 index 0000000000000000000000000000000000000000..02201c29ce424b3eba53e28fbd576e1285db471a GIT binary patch literal 12339 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 Iqb7{`AB4|DLjV8( literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance b/services/api/tests/test_table.lance/data/b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance new file mode 100644 index 0000000000000000000000000000000000000000..1726ba9dcf58d196dc69b8e5185772ad329d7f3d GIT binary patch literal 12829 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 SX8z)0kd!W+rj897|Nj6Vs7ucP literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance b/services/api/tests/test_table.lance/data/bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance new file mode 100644 index 0000000000000000000000000000000000000000..1fced8d95160035dc9e8c59b21ea8cdf4b4489ea GIT binary patch literal 12828 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance b/services/api/tests/test_table.lance/data/beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance new file mode 100644 index 0000000000000000000000000000000000000000..1ab40b9c8307c2404e61becb751e4b70ac2ce93e GIT binary patch literal 12867 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 A^8f$< literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance b/services/api/tests/test_table.lance/data/c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance new file mode 100644 index 0000000000000000000000000000000000000000..cb2ba9005b3e020d1cb2f1569714915531ef105e GIT binary patch literal 12799 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 EAAsYte*gdg literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/c6729c82-8cb2-44d5-993a-7b09bf678703.lance b/services/api/tests/test_table.lance/data/c6729c82-8cb2-44d5-993a-7b09bf678703.lance new file mode 100644 index 0000000000000000000000000000000000000000..25d913424e9de02970884c4107b79e7247bb6d63 GIT binary patch literal 12590 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 RV--PGN@-y;`o(DN{{c;Tfh+(3 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/ca3909ff-b21b-45c5-bf85-4911bba60fde.lance b/services/api/tests/test_table.lance/data/ca3909ff-b21b-45c5-bf85-4911bba60fde.lance new file mode 100644 index 0000000000000000000000000000000000000000..29aaf879121828da6b62ce538125fd5dda776cbc GIT binary patch literal 12807 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%0DZpK8K*ooeRXyqrG`KQkvg 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 Vr#d)rYs%N*E=p0jB@a#2{SV%cl3V}) literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance b/services/api/tests/test_table.lance/data/d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance new file mode 100644 index 0000000000000000000000000000000000000000..884f186727df7e670b962b366bf8214807ed614c GIT binary patch literal 12102 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 LU7MiMA!Gjs8FwN# literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/db6054f5-4108-4f23-b508-565b32020f68.lance b/services/api/tests/test_table.lance/data/db6054f5-4108-4f23-b508-565b32020f68.lance new file mode 100644 index 0000000000000000000000000000000000000000..4c4d5b40e5f08af84dfd8ecdf0376d945084ebf8 GIT binary patch literal 12809 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�s4pe*$nW`D$ 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 OzeQ54t;Uaz)cik7UIk$Q literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/e628d9da-95ab-4b1f-8d21-47ca21154b81.lance b/services/api/tests/test_table.lance/data/e628d9da-95ab-4b1f-8d21-47ca21154b81.lance new file mode 100644 index 0000000000000000000000000000000000000000..c19c9a28ce7041a88589adef07a56074cbf0cc52 GIT binary patch literal 12005 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/e856769d-4dac-492c-bd12-679434292b04.lance b/services/api/tests/test_table.lance/data/e856769d-4dac-492c-bd12-679434292b04.lance new file mode 100644 index 0000000000000000000000000000000000000000..82fa57779cd00b226192ae61c5dc1c73e9023798 GIT binary patch literal 12309 zcmbt)30TzC_qV9P%&@4K3a+CD%%C6|qRjVP%Y{`$O|#rGgaHOwlqEGawFHJuG}qEx zQeg&gOLLj;xmud+E^S(wm6hyjyJ>0fxis^8&HlXop7-zb_*^b`IrpA>&gb*Fm#=?d z;As8G(Ib4L{YM4)jtYqN_Z{u;@9#Tuc#uA3cyv%qP^^Ck>%YgXWx=bJtFHHT=rA@t zU7wzwtWU{Mr^cx#7}7J+4D&NH^s&A%OJkA@De>xQF$wx)eY!d|MIA6QC0QLc`Dt}X zQgnKPdXhdlHEpSSY+73M(sZy+oMkPS4Kk$9OUX=18l;PhPDc-g*d3Lo65>2eJzz3cCe(niQKg=7&}b*1a=Lb z%#`7eNe^l#z;`!eSb@1OW>q=yfz>_uAI{H6VFlqxWBG0GSAgckB{f5&d`*GWWB+A& zn(G2+x$rZxx-j`({scZKq8QiRx`j&>PW+;AowR+@Jg~1l3RwqVh8@-Y*gN3^Q0MX) z-YWbKGm7p5?Gw^cSFqJXC&AN0BjA^mUnE^hlQg>28&}r?q_j@N6=C^Oe0Xo3SUyoY zT^J2UmoIU;$;j(deunFzcI;k5N8Zw~8pk?U!+WM8s0~}k6-mKt=dDx=k6M|4yISp zughKBloSQY2a@2W<{?F=^sxsXYHl(1!QqC(18Dd6+BfVBR3s%%(<|;nU{B?Cya~xKQ>U>~>vc!+=&cAv|G`u<(M3F zajEQ|KZ;*V=!RX)kFi%=Q}}a@MiKX{WXNf`w)F#8G&E8=?s6S_Bzkg({hvso2VdeJ zmkwu#YEQ%B#yr^E=*;rF{E5R_D{*LZG0bwA&Aa({@-gOKJfzAIKZyJgf2-^WSIR%c z>RdIBuN^JMOBB;=<`A6@a<_0ACp%9Z$uj(IlCR6;Ef-g^bdw6sRux$m_|9QP&QZ+n z!VRuG_^UVvc-!R4_g1-Z`kCEpoenSi4CU8DSF?Sken53jxrw!5UEyHFF!t*HYj`$k zDj(T0iuvYl#%G;$Y_&08a(6lgV-pLVsMc_xJc8dUUtuDiHbh>KIyY^=4y_YdLZgz;NUD+3Y9(}R3E*>lXTyn!@1C5J zpx{po4daxX%p-i0Zsx_`EpE;gEODZYtBp?hV(AcZ=HOB4$;qd3zvg{7=|HoHF)1&1 zJA7|)_8-!X96PqSpia{J zC4pnhQCu5 z?39#IaVJHdyWD;efA=|=_RI;w3~^Q*Tc>chgf-F=#%$Kbyiw#cnsawZ(!>*dMA<05 zqxwzR;PM4T-)g~XO;_%fS1eyFoG&F#l`IqcOtZ4gyPKBWtDjo3p_;w&Buoj+46lj^E_HEuF7c zLs1#yls_!+{HHiQG6HWky@i9yjtLH9BF^hB{3!p>a+dJY2Yxl|kzF;N<>g%-;F&ys z9*}w$+?@hNEU+hsy+nBsh36Bxf>)jwE-aJSfUqq1VE;EDazxqUIJ{;mz@UOEi1eMsSD5?2MekLx zy6_rM9PnP+9_;7vJxH+!Q=Ds{X2Dp>xr;z~%mPxEqIvB?d_z-+BEMty&z63^`5kbgfk7Vqrjc(rfy<>=5uigd%X4-ep2uuX$22V7$U){rfsOJDS^c| z=isiP7lF>1-3#l^mt4G0Tzfz0=bN!e_|63{2)*#0QjL!dt-z_1m$BpntvbR5oaWks zlEM)NCO^xH!*^JAMjU{p<&kjSu|vS0iAUMmuJAM5k^B{kIPcxqnJ=7t-y*njMwJm;s|U%1g@Wh#y@ppf z<)Zw+*d0fjvI$cLoP04VnN!@tA)_lVzB!LmegsiJ_O)^l<=-(loAd!>Ce=}lGe+@& zBbqjgIATl7>u_`Un~-dN1BkEr#wuq<`2-n7D=eh3;3KDTmR*JOz*m#agx}*!OWQOL z;iO#NdQtv!zaFTRJR>WSa1(y>y@3K_j+}pix8$G2x0E|@x9j)P9F3A|Z$8VT!i*3; zBnS)4+i^xx1n*Ph$weG(Z;j$%H$Ow-ROy?l^_F{K8{pI2ft)lOb_nZ4*mevrr~CvP z{ZfHA3B#fik@OFDGHh(yM_^tF#hh8z8G5l!5qoi2T_pcavw;nrIEu8#J4l)ipB#1% z?Op5P1JgPSX+xxZBHlZQFE~%cBIgU1>n-EtD~k2d;&X(P9~tosyxM{=u4xNQHz_!A zu<$2o)^)I;xm3hD4vO$%lq>SL@pi=T1#LK^bD4?6|8U;wSavT+e6c*;k^zH98p3no3!1czJdE=gNz zeED8&e}O$LG$|azFTN!F4FSq6NMrDdq8qO_3}SEQpF)Zs{876HA{0jUhQ@`(DBq9= zm^WeZ&9C5GV;9zY$T1`>0)ctnLZglHPO1dK3m2NyeE!f{I^#@9oXgj)KjYQ>aWM5( zvn+6wFobk(jWoEa2Z;R6TYF2U*u2IjDHfRU!|{|KWXdOOHV@*93g=jy%Y%h}km#I&;u^ag zs0YGv*8Nt!Twnf?;8C<_ig4bA7U|R6-K1;IO9cl~h#x1yahGTiyh6Cny!;YicC#5j zH}A$f%}<;Z92leQ4MP;!yr*f6Off23{mu*X8T4!!$vyX9k*YQ8=x&h@Z$(~|*Vi)s zz3DZK^!=Q)#A<#s;t3@E1~2CW>*3mfr12T?1$g;2V#38gbWN43;Z;*Uy7*qk2SYz0 z{2n0pEf29|mzL`UZV+~H(sArfp93g(NN_J72ML zkuE+#+G}px`~k{dJl3Ry{wgoR?p^ZzhyWD%Mp}lmF85^8T5@Xg7x+bTjE?e-Wme^} zMMf3RuRCwKRTvB8Ls+gVhZmht8kk@&4LA@lKk$APdN;1YtuFJ}$EAmbW)L~Z{HuHN z$Xs{QXRCqsO7}f%!*Ie1Bn=6X8ih3XKueo0rZ|y)88U&1oFlHqQq5XOH|2q&Up-`= z{{qC_)h_=i>_{jCXf9#BFWv#t5$x;4w*`kt#I-yxw=a5EWy!=nKwK#24B>cq{|ka& zVAe#PT(59rUlzt#XfJRi?;vUH_mOZy=tcRO<7%1kPTHz);S&-~g#S||p?!#hd0bVl zT&6mBJVfgb^L$F-r_v`VZuWt!I)u5mUk17tvlEIg#4&Ru@)wSNOM(dnX1HDkIuHdC+8NL9y2XXt(DF@zpR{M6wo>Ws{^l%eW{sp%R2 zc^HX}rg`k^a8y)P9IGp$b9tQY|WVNo0OVPzZDH}nJFeoX>coT#e&%vRbQ9*uT{5jNm zy#*m9`yn>lUEW^NkKZ@W;9}h6#;0+3#81+G&15VJi{vJIN9IsbBquh8;Sx;*1UNi~ zx`YXAy)hESdiQ9e_>`Oj=w z>99kdRlFt05`O|-&3ctrRR(eY;tk-D-HqMTroj7QQ_-pE3f@xQgxYEYt}E`#BD^+Z zfAeQD8mI814!h(d_6nSy_o6&sQw6T7?;*hy1{n>9<*>^AvRK!_eUAM1bFS>8+~$+% zm4|V;u?BTHLoI&Vt5Uz*nfPvSE(B#ihMvW4AQ$hFh5v;=VzO4Tex~KOU z6c1#*lrt?x(?r&@W+CqwR|L-~zmihJ!lfRLT3mmlGo00YAdLtZICc z-M_IGwl@C(n;Pqo_AJhvi*ulL!=i|#?4~z^W#>Bbj~gtwy<{Z&v0@)*xpVjnURxQ> z!4JldLcF06^GU*oZ`QwwW!3dkV)fU;H+U$#0px@!V5&R@OI7!zO9@Z&8c&sUM)83> zvT?jTu;vH3POoA=*}Jk3!!^)U`k1e|!5gXE_ zb9q?fnJ0I5=*I3e-Grwr{h-QT#dFOu5E;@_>ec)`&QOGkHR8sGqw=IsKPdHd#Ny%| z_^0_6{OWiL%e{}omZn%nF~RF|0La(8NE<6(E#4!mz1-ma?A`cvb&C|JcwZuaBl%c2 z#ykh3RKLojnty^$VNdew!Oo2G1-FL2gNtt1F)?4RXJ@`d`HdW_{W|El_h?w)<-r3S zHnL|LE8x1xGCL9!6sxvrv z@LO4hY(Vu)zD04Khg56=UBdfV6d!OZBqEdl;!rFbs;Xdh%}cOEIUiEA*F|ok);x<- z>`6kDgiy92 z_dBFmgWF-BfPcbxM!sg^+B%Ox0pKL32T1-zFurej zV7lBEgBv~VMgB3d_SGTVbPmKj#*cMaasp|MmYe!Q{4{PbUmWsA zTm1AcUJtZaxT$yHXB1QU!h~vBl)V$)16Ap5Rub_5H&)e2N#2L?OVdZt()cliH~4b> zxmEJhl?NokK1SzABJZDQjU(D4Lu26a!i(9n)h(;J_nKeQo8*sAn?aE z>|MAOw-19C(=^RscgfiM6(R6judIcF)Sq7Uz+y&{eE_T^p` zZalO4I$_j$$Z+V*KQw+QAFZ0qlbe@FmTa}ab*Z!=kZ(v_F8nTS%v&z^(u}1wcVoW> zyAp=XCoWtnQy%N;Lf*nFA$#E4tOB_pq%U6FmxttYzAm8-x*6YX<5#8m3N)Krk+4hn zllN~h%fvH`a#{8#X}ihs9f&i0EfJ2&K8lLi3em4ebzmQzByRx6^J$X*V?`*EA z5-Ioaq_z?MteU}NvN{N#v#?5E>8aA6k>Wwx6M770755VVnZVvn)!!klM`#KpZWF%5 z(^XU2Fw4HU3$}Q90_~kJw5NqQQ6?OL3(96(R@|Fg_9;jYwSY@=s#IAL#M2V*;-TW5 z@TO@J_h}qQXaAZ!EPOU`{yfP($D7YFe1#$(iSNW2!rd%am{T&05!Xv}_CPo#FO45T z`8oqO2ivoI@nc9=UDgq%$UU1rC5}ByTF9Gi^SLi)IJ^OZ*L~wQz;MIWAo?9FH++MG zRcY8z0(@-9CYl_EZx_U3yrd=^bg7(4_p@> zD-m~NALCd?d?1rOIlf{m^G^Ix?v&jHNDD~iH7~;{(8$&(2lZZ@&WPm)cV>m5 z0fgVzEYF1w<6A=dSxDbWg!TBbrxSZOSP6GEmo2|3Q{+fG}EU zZ;aAD&rUS26uy;&Ha-*{4@WAGOTNu%q;c|ue&mJ*f1!i8Tm3pdqOweyQS&QC)?`BO z95WsW9R*dUsfY$G4Zy%(xDG0t%%pX9v4~^h+8na=8i=1EbXqjFY-_R(2xj(Ly~>?v&=rH z1j@Zfk=Aqtx^JOlr8k|a76(*+W+Bbjh9ML;C+$Lt@N?|~$fybj(%ZDIR|J2x={}(e zsE<5Oc}=&%z7vp-xQ|zNwp($bO(QKV_CRqT49@Z80&~L>GbPf+me=Fofq@~f5k~{d z@>wQgj0-%cyPkACxIm`yvTtY^?Djq;i#yK|dn5UKHikR&2ksr(`YFtpcMN*^}u`3xt_MPchOxAT(%T z)9g0v$Za?R@i$iSoRTBruE56R{S46|?t+&rm(HmmD*gc5mlMeSR6j``@n(!@JV&|k zPEcXi8eSjXN;+N3dxosX{OWH>JJieHhbQBu@I-lKRSm3id;(})cr{0oI;n0$u0!8I z57i%CvxU08D5>o7<&PNMr4L{!fe=S^BE3%fUHa6suI{%6A&-fT|7XBtDexcjbF zOqBO#?|`$)CZxM85FW~O2js7-?gQayTfB?A)IS~wJ|6Vf%fSt6>HpV9FT;K3kBp&@ zUV>r+{`%-8ASQagK4$d%CxYTe|6=XyF}R&q2d%Y1XDw}C$JqVVze9kvwAF1Nx7g~o zZ&_^hV~_N=J!RS2ZO&3CXm=l}UdkehY0 zf!6ZB853gdn`m>C)!_g3E23Ad^PRDl|I7InerTP?&C}+Qzsi3^m5;T9WG(;EVWPER z&cDr+VeOFB-b|aU4aJYl^zaMc|Iah$DeHU>tmXf5zJab@fB7oPTG~AL57)$5J1n-A z|LBloZP@y6Gaa&ac(1*gKCm`?^Tb7yVa*#D@Z0)v0 zu+?pGZ>!tJ*~+9x#@oW**54NGwz_SctvvR~cv}?P`rD$=R=16_mCsvG*Vb+eMqAw$ zd$zi5oUJrHGTs(~w*Iz=v(;_mY~_!SjJHLZt-mdTY<1f>TRGnv7q)g=Ald4+xUtpm z(GRLVZXLrXsQ>@6f4+#Nw|aNGhr4ynFwmEr8PUlL zb(!fpdcCIsb}F@-{d7Y{lHR@J!+jr~a-f?-TaS6}o!aZ7b8~9zq7F`;ua9-_{BXbC zafT)O*m>#n$B21J^o6M|j{dfgp&RZ$BA{&n-fqqhjh*Li|M1|4*D=7Y^K|`^40nh2 zI*6@Mhk5Rf?ez$9Q;8ndWhx%-_HWDdqAmVs=sb7j!($&_$ zZVGFkdG1c_baJ;#?4q(yicX2qyE*v#4Ikk*yjSNA9Tct8+&Xr!bFT^U$y2C$y4m?Z zIzBTdL)FVoB|g5L7n7QqLQ&vjZ%D}q44~8Ya8o85Qs%uFos>x@{rNO`ZPM zul|H+Mi-<^xvxgB$^)Y7iGE{`;lxj^@>|8B0Ex@$Y;+^D5pbKM_l zBy*$OANzN6J=Bl2W3DH)v}>-{BaLJ($ld+l&3UNZ+cDRhTG}<&=aEJaw~oX8-TVH# zy?*Mx?bz#2E$!MH@JOSuH{4zQ@Ad|&)$Q0DL@n*w8~pc1Qt5UbJ9V~qa8xK&PR=f_ qUAlI2>;71ep1s^XdiUwuum1q`z(IrE%O^Jvu~ACL4v}M{#{VB3P+4#Q literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/ea967c5f-1d05-4069-8fef-a1d090e42730.lance b/services/api/tests/test_table.lance/data/ea967c5f-1d05-4069-8fef-a1d090e42730.lance new file mode 100644 index 0000000000000000000000000000000000000000..a55e3caa152ba798c14935ce68ce0d25810e4bbb GIT binary patch literal 11766 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 literal 0 HcmV?d00001 diff --git a/services/app/.env.example b/services/app/.env.example index 8894300..0f2257e 100644 --- a/services/app/.env.example +++ b/services/app/.env.example @@ -6,8 +6,9 @@ JAMAI_URL="http://localhost:6969" PUBLIC_JAMAI_URL="" # Playwright test user -TEST_ACC_EMAIL="test@embeddedllm.com" -TEST_ACC_PW="RqYgySzSZ5wPf4t!" # Revoked (example) :P +TEST_ACC_EMAIL="" +TEST_ACC_PW="" +TEST_ACC_USERID="" # Set to false only if you have the secrets PUBLIC_IS_LOCAL="true" diff --git a/services/app/.gitignore b/services/app/.gitignore index 534a878..9c46f28 100644 --- a/services/app/.gitignore +++ b/services/app/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules /build +/build-electron /.svelte-kit /package .env diff --git a/services/app/README.md b/services/app/README.md index 0e32762..6eef1b7 100644 --- a/services/app/README.md +++ b/services/app/README.md @@ -8,9 +8,119 @@ Create a copy of `.env.example` with cp .env.example .env ``` -Install dependencies and start dev server +Install dependencies with `npm i` and start the dev server `npm run dev`. + +### Building Electron app + +> [!IMPORTANT] +> Make sure to install the dependencies and copy the `.env.example` before continuing as [shown above](#developing). +> +> Once copied, change the following values in the `.env` file as shown here: +> ```bash +> PUBLIC_JAMAI_URL="http://localhost:6969" +> PUBLIC_IS_SPA="true" +> CHECK_ORIGIN="false" +> ``` + +Ensure that all cloud modules are removed from the project by running `scripts/remove_cloud_modules.sh` while in the root directory. Cloud frontend cannot be built into static file for a single-page application. + +The following is an equivalent script that can be run in PowerShell for building on Windows by running `scripts/remove_cloud_modules.ps1`: + +```powershell +Get-ChildItem -Recurse -File -Filter "cloud*.py" | Remove-Item -Force +Get-ChildItem -Recurse -File -Filter "compose.*.cloud.yml" | Remove-Item -Force +Get-ChildItem -Recurse -Directory -Filter "(cloud)" | Remove-Item -Recurse -Force +Remove-Item -Force "services/app/ecosystem.config.cjs" +Remove-Item -Force "services/app/ecosystem.json" +``` + +Next, run the following to build the app in whatever OS you're currently in: ```bash -npm install -npm run dev -``` \ No newline at end of file +cd services/app +npm run package +``` + +The Electron Forge Package command packages the app into platform-specific executables. To create distributables, run `npm run make`. [See docs](https://www.electronforge.io/cli#package#:~:text=Please%20note%20that%20this%20does%20not%20make%20a%20distributable%20format.%20To%20make%20proper%20distributables%2C%20please%20use%20the%20Make%20command.) + +Once done, the packaged app will be in `services/app/build-electron`. + +[Electron Forge docs](https://www.electronforge.io/config/makers) + +## Build Complete JamAIBase Electron App +1. Follow all the steps in [Building Electron app](#building-electron-app). But run the following compilation steps. + ```powershell + cd services/app + npm run make + ``` + +2. Extract the compiled zip file: + ```powershell + cd .\services\app\build-electron\make\zip\win32\x64 + Expand-Archive -Path 'jamaibase-app-win32-x64-0.2.0.zip' -DestinationPath 'jamaibase-app-win32-x64-0.2.0' -Force + ``` +3. Copy the executable into `services\app\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0` (doing it this way speeds up compilation of the electron app) *Compiling through the electron-forge is too slow*: + - `infinity_server` (pyinstaller compile all the python services). + - `ellm_api_server` (pyinstaller compile all the python services). + - `docio` (pyinstaller compile all the python services). + - `unstructuredio_api` (pyinstaller compile all the python services). + - `api` (pyinstaller compile all the python services). + +4. Download the embedding model and, reranker model into `services\app\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0`. + ```powershell + conda create -n hfcli python=3.10 + conda activate hfcli + pip install -U "huggingface_hub[cli]" + cd .\services\app\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0\resources + huggingface-cli download sentence-transformers/all-MiniLM-L6-v2 --local-dir .\sentence-transformers_all-MiniLM-L6-v2 + huggingface-cli download cross-encoder/ms-marco-TinyBERT-L-2 --local-dir .\cross-encoder_ms-marco-TinyBERT-L-2 + + # Windows has limit to the filepath length + # So download the model in Documents + huggingface-cli download EmbeddedLLM/Phi-3-mini-4k-instruct-062024-onnx --include="onnx/directml/Phi-3-mini-4k-instruct-062024-int4/*" --local-dir .\llm_model + # copy the content of # C:\Users\{user}\Documents\llm_model\onnx\directml\Phi-3-mini-4k-instruct-062024-int4 + # into .\services\app\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0\resources\llm_model + ``` +5. The directory structure looks like this + ``` + jamaibase-app-win32-x64-0.2.0 + |-- resources + |-- cross-encoder_ms-marco-TinyBERT-L-2 + |-- sentence-transformers_all-MiniLM-L6-v2 + |-- llm_model + |-- infinity_server + |-- _internal + |-- infinity_server.exe + |-- ellm_api_server + |-- _internal + |-- ellm_api_server.exe + |-- docio + |-- _internal + |-- docio.exe + |-- unstructuredio_api + |-- _internal + |-- unstructuredio_api.exe + |-- api + |-- _internal + |-- api.exe + ``` +6. To run the application. Double-click on `jamaibase-app.exe`. + +## Build app installer with Innosetup (Windows) +### Prerequisite +* Install [Innosetup](https://jrsoftware.org/isdl.php) from [link](https://jrsoftware.org/isdl.php) + +1. [Build a complete JamAIBase Electron App](#build-complete-jamaibase-electron-app) + +2. Copy the Innosetup configuration and icon into the unzipped directory: + ```powershell + cd .\services\app + Copy-Item -Path .\JamAIBase.iss -Destination .\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0 + Copy-Item -Path .\electron\icons -Destination .\build-electron\make\zip\win32\x64\jamaibase-app-win32-x64-0.2.0\ -Recurse + ``` +3. Open the `JamAIBase.iss` file using Innosetup. +4. Start building the executable using Innosetup. `Build -> Compile`(`Ctrl+F9`) (Note that to package everything requires a few hours.) +5. After compilation you can find the installation files and resources in `Output` folder under `jamaibase-app-win32-x64-0.2.0`. + +## Developer +1. To run the UI in debug mode. `npm run start:debug_electron`. \ No newline at end of file diff --git a/services/app/build.bat b/services/app/build.bat index 7daba12..1f9db98 100644 --- a/services/app/build.bat +++ b/services/app/build.bat @@ -1,27 +1,77 @@ @echo off setlocal enabledelayedexpansion -rem Load environment variables from .env file -for /f "usebackq delims==" %%a in (".env") do ( - set "%%a" +for /f "usebackq delims=" %%a in (".env") do ( + set "line=%%a" + if not "!line!"=="" ( + for /f "tokens=1,* delims==" %%b in ("!line!") do ( + set "key=%%b" + set "value=%%c" + set "key=!key:"=!" + set "key=!key: =!" + if not "!key!"=="" if not "!value!"=="" ( + for /f "tokens=1,* delims=#" %%d in ("!value!") do set "value=%%d" + set "value=!value:"=!" + call :trim value + set "!key!=!value!" + ) + ) + ) ) +rem Fallback +powershell -Command "Get-Content .env | ForEach-Object { $line = $_ -replace '#.*', ''; if ($line.Trim()) { $key, $value = $line.Split('=', 2); $value = $value.Trim().Trim('\"'); [Environment]::SetEnvironmentVariable($key, $value, 'Process') } }" + +echo %BASE_URL% +echo %PUBLIC_JAMAI_URL% +echo %JAMAI_URL% +echo %PUBLIC_IS_SPA% + rem Set the flag variable set DEV_MODE=1 +rem Check if --reload flag is provided +if "%1" == "--reload" ( + set DEV_MODE=0 +) + +if exist "src\routes\_layout.server.ts" ( + ren "src\routes\_layout.server.ts" "+layout.server.ts" +) +if exist "src\routes\+layout.ts" ( + ren "src\routes\+layout.ts" "_layout.ts" +) + +rem SPA hack +if "%PUBLIC_IS_SPA%" == "true" ( + ren "src\routes\+layout.server.ts" "_layout.server.ts" + ren "src\routes\_layout.ts" "+layout.ts" +) + rem Build the project in /temp call vite build rem Copy the build files to the build directory if exist build rmdir /s /q build -mkdir build -xcopy /s /e /y temp build -rmdir /s /q temp - -rem Static adapter doesn't generate files for these pages, breaking nav -if not "%PUBLIC_IS_SPA%" == "false" ( - xcopy /s /e /y /I "build\project\default\action-table" "build\project\default\chat-table" - xcopy /s /e /y /I "build\project\default\action-table" "build\project\default\knowledge-table" +move temp build + +if "%PUBLIC_IS_SPA%" == "true" ( + ren "src\routes\_layout.server.ts" "+layout.server.ts" + ren "src\routes\+layout.ts" "_layout.ts" ) -endlocal \ No newline at end of file +rem Reload PM2 app if not in dev mode +if %DEV_MODE% equ 0 ( + call pm2 reload ecosystem.config.cjs +) + +endlocal +goto :eof + +:trim +setlocal enabledelayedexpansion +set "x=!%1!" +for /f "tokens=* delims= " %%a in ("!x!") do set "x=%%a" +for /l %%a in (1,1,100) do if "!x:~-1!"==" " set "x=!x:~0,-1!" +endlocal & set "%1=%x%" +goto :eof \ No newline at end of file diff --git a/services/app/build.sh b/services/app/build.sh index 5966296..544a76d 100755 --- a/services/app/build.sh +++ b/services/app/build.sh @@ -19,6 +19,19 @@ while [[ $# -gt 0 ]]; do esac done +if [ -f "src/routes/_layout.server.ts" ]; then + mv "src/routes/_layout.server.ts" "src/routes/+layout.server.ts" +fi +if [ -f "src/routes/+layout.ts" ]; then + mv "src/routes/+layout.ts" "src/routes/_layout.ts" +fi + +# SPA hack +if [ "$PUBLIC_IS_SPA" == "true" ]; then + mv "src/routes/+layout.server.ts" "src/routes/_layout.server.ts" + mv "src/routes/_layout.ts" "src/routes/+layout.ts" +fi + # Build the project in /temp vite build @@ -26,10 +39,9 @@ vite build rm -rf build mv temp build -# Static adapter doesn't generate files for these pages, breaking nav -if [ "$PUBLIC_IS_SPA" != "false" ]; then - cp -r build/project/default/action-table build/project/default/chat-table - cp -r build/project/default/action-table build/project/default/knowledge-table +if [ "$PUBLIC_IS_SPA" == "true" ]; then + mv "src/routes/_layout.server.ts" "src/routes/+layout.server.ts" + mv "src/routes/+layout.ts" "src/routes/_layout.ts" fi # Reload PM2 app if not in dev mode diff --git a/services/app/electron/icons/icon.icns b/services/app/electron/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..f5df47642519350aaf072195dd4c838c4defd8a8 GIT binary patch literal 224090 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

%t%d7-J#lNXEQqUm~op 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

3w=!H(= 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

7O{H~X6#y~@;OMc%u|-XUE` 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 literal 0 HcmV?d00001 diff --git a/services/app/electron/icons/icon.ico b/services/app/electron/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4c82ecf27a2853d15e037de6695979d62ab37671 GIT binary patch literal 24067 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 S9jnDo(Z5ju;`RTB;r<5--s;c* literal 0 HcmV?d00001 diff --git a/services/app/electron/icons/icon.png b/services/app/electron/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..097b8057e1837e11c739c557afde3ffc46d16bb2 GIT binary patch literal 37581 zcmbTdg;!MH_db4S=thuGqyKh#wOJ06?blR#6K8z}Q}BU=&Zo3C+!C>V3#Z&qsG zj2WvmXpQp|z)yn}Fw^U0HXpid*gm_rEr4vF*j-fHJP9~LPbAu)JAU?LYhShs>8fdD z_1ff53;eQHB?Q~oC_>tQUTEkyCo*hTOpf&NDyaLv9Ly%|7jn&V7>j4`$mjgo(Bor# zI+}+$rkg>W+#~#hF&)R}Ky7d3El-XI<=h{8G8`+e}@8$>kGeFjRN~ zS^sA+p(}_sNZKarA$W1&EUEeM8sT!aHX|!NWA9U<`1)3rQu3^!zM@|u^BQ$KIW;Gk}w?C-zUx~tlLlQywM)Sb7Q zWOkUxCrR-{-LO9oB*ceh&s%bdpwC_#dY)wjh`3mM5!u?3IocfwI@g6=ALTvel{cv- zdjJfE06@;ujX1TIntLVWA2kC0^v^K=;cs+z!`B6O9Yr|0ZQ->B#i;^21L~${=T3sX ziAZ1GJs*SQzWGL=Vjm|b5t87cx}XhH!x6tWEuINFzGmRR?LwM*OtCXCcpb+Jn-c-y zasaTB>KY3>{gsR%<$HCad7;ZMjZ8w8N^kh=3MKLycLMm!62UiVLLOj{o)w)3A+*liG*Mo-DNABzvy0Pyd(Lx{A3~x+7-9WxmQGs?Shg zeWHCDnCsyi;_>7U^5#0qri<<~CRz4_>`vFc?lk6pxVik#aKIBVV2c9+pg2wB!L@bU zSKYEZOZ+l>XBusXzbIPQqAd|aX{GFjF`p?Vj>GQTw$Hc=FaM5b)T-FLEzx3hHt{#9 zJo){bZiFV=MfmE21384%MYO!pd3`H}x(yX&L&gTY3Ic-w@!qi(fvNt4z}q*-khoc7o6^bK$U?@g9w zg?I`s4P`Fx%6^&(DlbuedchNGx`ztv89^TuYe?P{nR%ldE{yUxRBJ3+ z=#<F*a@GMgJdVt1Y zZF9jN88sqxQS8!mrkqH9j&K+fQyIpOL|OM_qn$;A9JI$si*W52>ylx$=@yS;#-MNL zK4gD^mM*(=bMPBOHqdMFXWSw9KtI+MHK7JJZL-np=-TX{9U4gRW|&wMB!f}dur!UX zEsA}@FZ!N+|aFWHHv5r~9K^akJa|62so*^nzDS>QLvZ z`m~-=Ucg!|Y~mX^Z0IqW_u*;;BR^)zw%!~B^iPiB0^95oK^jVD&HD`^`vWd*XDBXC zuHfccn`E`Ad%2eKbBJ!(Ethlh#{vq2e&YeTdn4UcI?37qMCj<#M()W!%B7NlQuul%GPd=shbIyh_7tqv%3)dhcZSfYQYzN=GCjlQPLu-P-jDPEJXk zN>gm=1!BUH&&BHt=ogCMe_G3V9h7@2`z7d<&h*%i7FTx48PC1Vbod;}t6JVm*);QJ zo0P`yQjsBet7)co{1101h|dZ{e|VoX45_%sIY8p80no>-OEV^qz0u`&s_CWd-zb!2pzWqpOJYgk7c%c0QeQ ze{vdUdfGD-bozP;`4z@ktP>DH3#^Y-meIgFzCd`~0O1nGPbf32&1kVm%JTKlA3d!b zd@)X9;(s%Sayk5&H~~5--q5YIBdB^RnZAj;~zEgB(oi?i|J_%k=+*1M6U103fQ7JVl?0@yjCXf1*ssh`-Px z#~L0zT3F)L;wX4E?Y8X^NK_xc?w;%kGvdDhSa7g*bQJlmETz0_Pb%7MJeCu-P zT@(~`*CBPzeBrj75Wl6%*$mp1087~CXeEQRxa08X`LX{nawQ-`ByouDD(cjZ%3~f5Kb5*s#oaZNCg?(q6OZXm!dFRc2dTnKeOMLW1^ zCFz(LV#Xz7&DL8Is}~0q8XYz&qPJH=g0K?IGJV{52s`}Hmqz|usBf_8UWLI8F^iX) zd>mOVVM1z$+**^ji6z?NB?f^{~P8 zLhNJsMovNcvS+~N%aCiv=x3CG*}ffKm-PnBRQfX1)Z%m~sm;xL=)hbt2kzJU8&p=sEuyZBTp@ebL+?q?NsB=hxVQkl z;f7?oVg$^;n=}9$oXfm}m~l2rmH70|71oId##5%dQd+WA@o~@DD~aJ=e#H2>xGZ?s zxz>56pvM^0C3h!pzVFc0%^z66zAe&KmoWo&MjAd{_L{H+CO@s*a-L7#dH|Gz1%S_v z+Y_vwV!p4>w4~K8hz$F}PE9`-z!NF-gTy_C${GKG>8Arl%8isd31|T`BCL#L!c*8H zWqqxGw{Wx7{qBH%Ubj>nKblEZx$pOV*pNZvP+FY(h=Ox^>|d%WK#t&lubf%j!O%75 zT(<)SSv*4+sd=(qjp@2ypP)!4`j^u~3{#ErWOm;~$yI?ROC``E08mQEp*E-e70YK&3hz=)LMe9>nSpzJ+;E9=lFP z_-aNNtY@$BvN!ulQn&pr!!kcTC-i?$UrC=y8PzZvwFIA675X3e@e(46IzItQ$NkA)(yV!N zV@kv=v_d>UdTIZ&cHUSfh7PPE&*Ov=lepb8R{?xj~jq(_kVE2@X#;0$7redaw z^}F>q1tWx6nGOQSNZ+~JD9bYAhNa&~yc4m5a>p}|MWug_PH)2Ybh=Z?DCz%)@MbMc zSHpL*tykNfT;xVFUJnybF;ZJ#&8{CVe^AdXWwHmqZw`VrMZ)Xs+MkI%rWeF&$XEJk zNgVn<)+7i>|7oz~7%`1O*S993Q=a@0>HhuIL!a+??p^FSg9wR)r$FHUUKkU(MA_Al z)8wttY<)J3GQ9mS^LwKjf5E$k%)3&n^&Fg1&m%5X5O9lCkQ;ko;!OrK8)mmHl;_I3 zboQkjGBdCHI`Da8knba|m=ovloFeRV$G>iu$s)~AE;(**|mFK@0ym%N6%;c<@|1j_zy2x3ajM6ZSQgS^=umI^ZRZB zJ8=3o#O~7HooVNas?G4q2mI4*`*iq2uk5)&QKkm!F}}l?lXzL7^Q=PMiLN7i-i5Gq zkHCtiuo*|fNMPKQv`c}zXC{9A>>ILiKlKG6d%p@3zph3el|$4p1Y@(c<%|mVhH?N8 z{ir~CHx&0LeK)DHcUL04bpp@gCQ_j!6W>4*&9vd9kPc$o*Nc{i!Y4(T-Ay`MuqV!C_Kebts00&5Z zI!gkVVVbAmePDHI>KmYsI6Vp&Wg+}>CB2QoqSaAnO|R9EjrN)bOSqb?o~H3hwmKE& zRI#a1Cw-`X!E)?5O`HBn+!MWuG^9#i^dxwahFD-6uoS>E3u&TX75)khzQp4yyBio7 z2_Bhf{+u!ssL>{J?gfXJH~#&xLLN?Dce3%_$t+bPS4z9g{A1k@vK5u}rl$1XMEjpq z<;tZje;F$RKhH2j_97rRx(Jw)59-lOY0%K=9F``_NUzT9%F|PiJac|jZF7BnSs(Mx z&oRk3gF*PKx9d{pWeqmFfQzS~DE}kmc~?;{IAYKaeXT@{!~YQfX*9uPqcSC)92UbF zJR#@WUXbu1)w`P_orU!*U0#jv*3QN8L4I_7#geZ}TL18AT!~pvnu_q3jV5z$3iWB9 zW+jA4X`KoUL|7>c3C+>cmckxl7M)H)dkgXoZ_k7ZGBItNiQ2MLk4P$A?S7a1P%g9nhT2h*tqTn^_>mr{+^r%M`+%TmVKv$M9fOwAXo0a?yb z3?d7TxDU)(ig#~wStw1ZNO4&h34tiWgC&atvw&*GCk9;8RwSrQw({)5J)V6>lIRxk z^C;p8a}uvpV%dU7;;al2R$ zBd$Te7^OI}cCeZ1T9YL5f{9nD?zaA`8Dv-hmFhEKvrE0k&lIp2DUFIe-~Zy()3@$4 zR*q`83q{JX@$y^}nY-S$F4QmbDKW}6UaU4}+t(PDUh5R657}o~I|^?OSA__?*l8-0#2cmco^5mF%Q9aq`P`dkjI|8?ReW zJ~X6-d1TYH4y-wjfoiR|o<>ipEe9dxaG%yU-)Tth7u@n*=T0uqIJydwv8YA0*}@f4 z)KnRtw@IM<9Hy&DvOO~B14a{dGkmw4JW99DWoOUeJ}BS7iBhcE{mLt-&Q2oefgm|R z&&scL+A3>?{w^Q-Kr7&5Now{%U<&Tz%Wu4!+UFM)hB09@FVmRF*1@J%8d~dK%CwHJ zpW$lJW54@$gSTtN3iFMP@TNuVOFwLP}G~OGf%zq}oAGRP!4s>&x zy%weA2=~^vOT_t2=Le&8dZOBqt*U#5CGnwv({B0*!LZT#sN9WAa z7b`ZX`HNXEALoV1UA@Qh#n4xM@J{MzEQr!>-+Q**uhd-#1t2d=4MaJX{x(~*Dkn*} z7+QbZw5;x&z98w{c~^64KIin?N~LmaxK0tebG@CW9J(AsoXLAX%%UhkfGanI@kEprM)mS|5+R|96aO<32I`w#2$~8g+CJb={gW>o~Df=gV2F3rb zP4@C9ex1(RU5CYn(9m!wb+6g0^txAOB9LGvPHDB9qT1CVGoJ7nUh~Nv|ntu8u&=S zzD$uirT*>H@+%%+IGYcfOw?_ZdE+cpuN(TI@zxUh1GM_qR1eRfa@;_TZ@)?G{D=M6T^UpafcgzJM^L6oDowA-=go$wuyboI)%(RpmHZgPh#%Gu^o zc6hzCb*`Vn_a#jR_De9C;HX`k_vPE<2@ua&33&3CCZR76Xa4e4z7Su-dA7;92gGOk zfik1&Q{gXH8bhZ+!ZYJISUY6W)&A%or&#$GM(Fp|Zr^Aijj3Q3fp@C%Sa@N##Ys87 zQaWEnnEqplB+0;EzmXTEV3wg8O7N!4qYtbV4$j{0MJAX`S>N_eA9UZFah=gH9xTNE{9^g9&}VF!}ZNjyBep8KULs9`tDIHOJDy(S?f%Shs;S_us--h zvy)x3DkqYz7Oh1zwAxVnFY*m5WsM;=Qq4PvkI=HsS0V_S32;H;8T)Tw8p?r-3+32t zqieG%3vykcE-^{mbE8|BEjg!00S62r?a z9pFuw`GC`uQc{RPqLS!rEKbQBn&oaG3{Yg?a0=CvO=p?7SAa$+kK?T`tAn_Dy(0aSi>F7G79B zH~W1Q$cG;Bb?8|(KA+HKzNUkCA15JTCmYpqG*r1X&RUV&A!BXrw&3@kmxrl^w{b!v z3yD`-?+)ny9(5m_TalYIiWcBRrxkZOhf4M*QdFwHLFu*|z8aR#VLLA)s+K*l8|+ud zbeKxqt}$bl@ydOxUri@#gH3Ek2nD`ify{#S$9GoZEQk?k+6HaAha{t%p*C_}L!_5nFQlQiF6zG?yhvQn=W1&ER+O?zdm>ztNK)`dSLU}-IbqWq{3Lk!ZSd{SxuE?! z_PF~7N)W7Hf(`za@UJKq+!F+=HxV<^cU_!yK8KaxN|J!WhZHRq9ND%^i&`=|C+`ZY z>o}m%u<5tLdc#E)()o(-w7G?)=~V)Ep3KsKFl>r&m#Q}ppworeES+|z40mNlroriA zE+{4-HKmd&r|0dRXwiuQUm?=fPa~rgSwHILp&XO@h>}IMjVzXjWF)X$grhr`5sJ^OK%m6o}O7w=>8u5v_6OAKLwsS+jEZ zReia%?l3@})|J$#U-1nbM*QGNY5D<3k4>*TJ&dy=;tA%xGThih?IJgwsUb;iMxTd} zG|!6WuCihNo%c3ub-6`c|5Wj&q64Z!VHzvrTikIwk`=R|V=ff-%9JpeNM=`6?6Rz60h10`@V z)6Qjv`b#zw{#&ykF*flco!Zv0a{K z;uv;t-{6-qIjbWvL1U(*Oe3KHEBHBp3r!?w6Iz3KmmRYolPs~LUo809k*d;4=rPEe z7`ow8_3PQ%=e`Z{ZGZXraDUq(j%ZKpO|BL3>LaCth>yEPk#4? zELp@jlQ^*5fx?4KM{zH{U4f*UT#(BPO~JtK1R%QJ(=$?EJHT8&rf*QB{ig{py*-53 z@zQW35P2r4Lpms`_uMA2s_aLDq-wus|rc>}z&LE~>8>v&2{IC&l4 zTdDkXT3>7wd(Ieb6vxMT6u@`Yajb$(g$;zXhsl2^tML(J6l$29g==SH2v&nwztEVy zl>=U}nLSLqgSD9tyl|{LHY=^>hr8FY#(j4QlkX$Pc^vqJS<2LpB{Ky?7)*crI z3Xz0LID%`JK0_!=WSR8MQn~cCU%}hg&_XEd5{YmKL(?tz>VH9vSeLvs=?rS*2X=^{ zgH8ryt}o|n?uhwX4rR<8x7l9BAD4yapI4e+7q)2t5@(grD00~O*?yLaAkEQb4|9!y zIv(yUe-5@V@np=H{MlEt>XF6DW7yo8;!|GNsf^~Ls=;`PPIzwj`HUl-){y1frL-uH zKW`454rBhDQ5nn=5QFnxPFhJpt*I0F(aZ%Ir#jOyzhggh(-8s@nl6u5+Fl^2Cjf?S zVQ4h5l_=S$P~1Z5!a)?d?M{-d!zcJNGO+uAL4qBWwcZZNy>8&7Xg31W;nUv)b^b## zA*5w{)FmC40){2l{^Ep&)r748n_$pNe6KLvS&-}~W>^^dLKgb`0PqLiMYYVS|fy>hS``XOj|?WD1Il*03?>N`>U|z*FmMMKck*3a4z*5fY~yAR>`NQ+wk6mL6s;uZB}zc zcGs_lw^CGCpXZHvO^(M^J)^IziqL`1DdnN7EHwfae$fElz>FO5L$0|Rjkrx=UW8~k zpcbcUW7DG=Y2crcD;Cm{1;jtov?Z3hKGD3{9TZN+d;HM!kNR_KUIQj&ADarO)G>lY z8!T${+f&EM8D#4mkWWqU@{>=X+MTnz@_&EFyu(jwf&#=H{vU&dDzC$o<36-)w7PE8Q4HnZ$-Y|Y48v~H#n zY;+D^XSVdh?Bt9*8r^zZmh&lucO%h2l=}O>i@2;*K}OB<%5XJA-1jN50Ll^Sz4>}Y z0UK-O#n>DJPEEd-FGsWfNl99sH;H9?&l*g}kVl5QO)cAgIkCkHc?h6;Aa_U8Hs1R; zimfJzXwX6TLmDbjU4^xp>$QaFvgb1{jX$42+n=ipMWy95O%0Trjn)r;#PUl7iLTBJ zOP^PG(Rm(9MbN+>Py!rOhu*7xHI^u;+nzmCfNXl%Nd2?O7epn!JOwT=fW&Xk?)AVm z*=J+F4D#@IM+u{RM7kQqt2eQ+txdfgA-8E{-plirCS1xGCxa$)?eOJk+b`}9C_HTO zu5M8XAJlMxH^hIGxMFhKYo5HGO!S4eR&a}%jLv{Q*l#+I_2S3x58GXlA;}&13cNqi zZ~dK))1+)aUl*%OSPi|zw~@2K8b7)QJQ+q~hIIUT-+76;B@q2Y(KRXO#v?bS+-+5y zA=-(%FuUCoPB@xD{PrPFT|V{V?D)aPzbV5pcaaJgxx%*~Iid5ubDI(qA9Lg|J0~dSNF6Mya z577%tt*SG!P~TwvMiq2kw-(J)i^n;w_2MJL#sPyof@CWdnPa${6aP0XhNlE5wK4_1 zCgdjCx>$*4|5D@(P_{NSp*wI8`fhIj^Z7}7}V;e2<%1VAv%3+XG2D9bcU z#dihc!s8?LK!<@_g-gt(GlAC@+|GFP0=2r1ttm3?e z>H2ZxDRW>|q>nu#J=ejL%g3dXOik$F7|bg{cAxfNku+xJYev!&gL4+9<-=;h=<0mX$2{WH@zeSmMao&O@e31{uV6p4zC3P8ys7zPI3FdTz`4i{C%Gi_ z-D(P`Zy7L^dBnt^YPZd8qTf{hna7YIahx@M&W5X|vA$Da#OBX==msxWRxqf_hmMtg z|3MJrTUlt5wva=d_)0wWCX5x>#{vqOzYdh5Dkz4Tuox=4O=-K$>U@hFr(<*_96)G& z?(SxYYn^_~Y|bip)> z+b6jx&oiyXe?!Ua*=v7Zx)kTeS9}z8-+qZe>~Id1pBdt7Dc~C#Fq&1TtuN1glkBmU z%em()h!ejaW#4E6j&Ldnapl4&wLu}i4GyMl0jI;|i#NRsErm@|Tif=cJ)tra9O7f`n&W9qaS6>+?lBL4bN1ZarG(bNAnsmf1^kufICI zr7ixcLz`MmLcRP*P&6&gL3H{}T*#Un@R76;tf`JSSUay82}RIogVOcaKFZ;lyhyrx z^slAaNRReyz0{(7rekf4l60qb?@$s=0sVJVX+AJjKF*V7eL38RoPn2WllBfxWW*rs z7A2H;Je~Nee<|H3vJh%|%DMMaRPH}RAtC=lijDSE8f}-=C4%C*9s76s(aHFi$?5#O2k6tOGXk*~4Ukt7}G*T#%yG^lEP$Wn7uFtO3Xztk}9AT)9c z-T{1%ZxYF~OQt$ALAk&_0Maz}*9MtpB4)07<`rWMe-gh9qmxYH^^0-)>^7;cQ>07W z@5T50DtttadCzRYIM%fzV~gS!s}mqc@UIwBA9?VL#Kl-KdzFBr3pt zB!E`fpoDvT5H(Mo?{K(8M~uTa%q%8I8N_SFFe#03jazV41xef6ez70VBqrSW2zQXZ zSVCa1Ee!s-zGoxxpf6pB;FA%>kT%m#cVEMXoB0vumc<0Bg6j?%1Ro2@J{52-(uC3( z4{9~!a$h4JN?Q4@nnb*iy!Wo|+nvyM#8^&O;=~#B=rFb>m%iGk6m3O(KWf-t;bxgm zOSka|xTuiUI5ZoPI-?J`N$8nXKelm3I&Vz{>SX^+-dm%u#SvA1>-JO;4jN|e7sUE} zM=$W&3vmhyMkH@FmpASWp#qmqUF>CQ0}O8PUfN8jgI%PGSACo!|I;%j_L}A3%X}JT z{mt7#$;;=XO`C7jr}CUz#is>G$BGEl@BBkpR9V_V>V==p8K1;CPFxVVM+&nV# zpH8W?d|J)aOMew)xlO%vU)^%BSio6;ST)ySTQ*)PZMsn#Z;O(R?lFgk22WY^l7S{A zA@uu%J%Q7)d|@jxtvT3zcpe9GPg78Odzmc2Fx_&cIp$gp4T+f`QODzr{>7_DU2-?L zo+mY6n>4Yt=FedLkvlHW=fzJ1TOwTkD;rCp+h5qHx3VtcF8s>JrPug|K{wy_fAg63 zPiF@Cn-(Bu>`pf#!$?e{PIZXixAzZ_n{%0X3odx0aQ3sxjfO;*j@FNEzgY|M+QtoW z`rUS?G0Nw#Py#f{GOH#&BRM>n^=9U|dk7l&lRB{$VJkNOqVbz1$( z#2*Do+2W>m4%_818Fe3GDI0{1a#gd8s2u7oZ2EZsC>o{Q9wt*(~)@9gB3Ht2na zIYE9YAHf*mxq!g-cK+KB2e7x>PoCWa4L#(N$FoNx)6Iw0^?@6wj`7hxqK5FCTkmUSgTMdkYnKeBhW5x zUD>)AU&5jF3=ywu;yJL@g!{wSJS+*M(w7&}>KZJOQ6Rfz&2Oc9*O~1j0Wo-T5TPCz z&hwmB5eK!wg?SRZ`)&UOb6Kz-EoRzhQ0-B&WE(c4x27s^rmyqbGL~>0m&J$Z?q7JR zXLm$-kaYTA7rQ8h!RfM)Hz%%SK2M)Idd<>)wxl}sq+KdE@&q&PhLiHODaIH7(MYSx ziTx&HTc>ZBdY6r1kR32NlZ3(Z<*NfnzgarWdB_^cZ2RRB#xot;v>w0n?vo6Tn&RY= zw^2B}btGD^{$p`bL`Vs+hf;!^Si$zDtv09A<;&=82ZviVF@{WIoMLOLVtc*$kUPDQ zzrk-5W)-jk;G54T7Uf8hyOE4Z%|QAhXuj?Sx~8UEsgQ#|fy#vr4fxo7K!~Md>QNMl;={4tAK%-mf&xF8Qa><^_085 z@=9cehthU*PxI;B=EIZ-_F%syoN2~(lZzAxJYTo;p*Hx`l2MNE@7DsWuJ}B5^Ba<{ zxW*T`E|A^ydYL=r+CGCkG`uzRkZy&giLc4_DvHeSu$2o;ZUT8Ki4sF}q;(K$o|{Zq zO%YVwE#?p@C3^R8$3I?E#$d%EeY(qc5`%||Yw0@h3}gDW6AT=bV*5(o4-&7~^QOUT%W^ffs#EmLh-Vo`%Jrwd$>kRVb1v7+j+<$n9XX1h zvWbCC9I#o}12&e_BWaZ3r7WK!{8Kp%+lr(-?j+qCxR!9%hp?&|@i$r`5J1_fG*O5{ zIN|FQ=-RIF%Fas(!_~4&<-L;NAU^?_$~cfmDt;*I`)Mcm=6F{VsQ9k;tmyh-tQaNm z9;zf6&jzPs%i&gp^K_-}-UL6$QCZt|CDP#5>*H!uil%WD7`*y!*jsRR@#v6|Gp6RN zn48;t;gW5YH32hR-S7OpWY6>Y%8O(cCXeIOxz}8b6Z(%|trdgKI$h67a)^@g<#B78 z+04`tY>mB@B&d=|CY-o1KDYC+Iy?RHF$0ft@KohQ`&VGVj({Y>L18*W^D;?7qJ^HRQJ88sgxNs`V54es=$5MCooNY_s;pIE%S+DA(dY_jLc7^BtBzkc z)k_$L7qt=@Mc)~F@+S2QbyJwBbmP0~QkTZ2oo$u`7rWC&BBD);D-|?k%6x2vw+*<- z4Iod`4QgMK8b{j!lz3R3vg;C@t>KnjO=w_-#ULlM%riQwZf-`)mvvg4h3dL^d1Ot# z6d#iAh+l?64~z+&rW_P+@h$x0@}`Z+Ez$L}XCXXuMeO8zOpjy2cauEkLW*aD9VkeA zcokLuPrx_!A+$VhXeN3VZ04Ipw zh#zTsk?SCPJ(bSv|Jv!Zd$BK_o)N2=9HL5&gbj+_o`0q4`YyBPRLhpZa16w_?l@pF z?!cF5^U*s)h)36(d1fplP2YPP#4c1A$^qE~fdn{vTX8~J<6sS_&H+ zgI!WD<5Kx-yl7B!nL<1b+vq5?&_}gx9bh8C#Y=iqz_4sEndE>VC z|8}IU10>8s=Z8J5p%>!$2Fe-RGY*a;iITH-Z9#ZK3>|q>=f<-y`Vn&^z$x&*Hth;I zm%^MY?c09oIO)b+#_a1y=48HKEASnU?Ss`Kag!Ug;B{QEPrd)WY0syD@@`l<;A$8| z4Dq$=>({D;rQ;F*H?Sqe3S3BElP=UcHJ|6Z-ERBvnqhf=m9b%+AjJL42jKr}&kpM4 z28qwpO#>QwI8x?hSNjg=gWU-mWE!fxJT;l;+eR>03)iZPOn}%A$wCnkzoQ~5c@Q02 z`%VWq%r`S=|I$R%XI8M`Dh9vyyVwYiu>AQkeK`$NAm&U9-r5=cPp~K?8p^-|$g#N} zn(Bc!vgcD!!TW`sh|_D+pq>S;4HlCF z0FDiciZNsE(M;Oewj1ee$Zd05;HihtVdiZrCHQG56hS&33gzSjZ@N}tI4d_v=}W5z zjL9MdfBdTPV-rEjeNFb%BrwNIO*3OCyOD7aeXJ_AN0Y6AsyPTR4=UIr?H#b=DyRk3 zVY^igdXvDdb|E(1HE_xDHp|Wrko)fXmecoQne3r(8wFO)M0u!{k7R@ADE)f|3jHV& zv(7{WC%5HiZCPMI8V`lGsT|oTku(3~kdSTifjWEwuy}ZX)|oyRUR+KQheyNsVPFiF zfkODt1m#{sEUroOyjOOolP^4Ru$`s=dxsrpc;0K|)O%R;&d1mT`9hwsWi`{Z>3T6U zU`xi>RB&onjjZOY9B~Nr)0fVKEPYKV&;rRrw@>Gc+%vDF!y z{8`5ATP1q>UTPia^Bb_|^sSKh9#`A5DY;)07D=6Qxz+p>gn(Ie;3ZKaM1$~3p3oQ7 zv=y5k=Z~U;7^sqEoDXI-QcUU&@1GQK*}H!Tp#;0z%R&6%-5&v;S-%Ad1oj-uRyx9~ z9!_KLR4~p5#_Jny6BTd2a9kL6zT2jo)Zk6XSt6Qnn@NFn*Dbm67Y~dd8&T7xjMFhnx$Hl6>z9~FEPt_|};{`2wIrlufaU5PTSie`s5zb6zy zs3zxw{8viqs`_iweypzbAa-Ue29K{I*?}W%r3q>3UtGDR1>2Wv}X<1i1X&F68s%4;QG24Mf)XYvZ&D z5V@K(ALcp*@ZG@cW&Ex%*LQzVcc}F2`k|U$lDM8S5h47IrTK4=GG< zLWlR;aSdwms$bIhm^B zVVf_bhCg{%1=Pz9wOvF&I=!VEEuXQ;nP~8-{K)+dJ4Zr#>XvFl)DG*Vqz>Jieme>n zBJl|UP2v}G+0@h%I2obzN1@M-%7e}J+UkSOx5`WJ2S#9@B15!8>93fL1uM8~NUuy{ zqH12&L5;jv@}mvc;^+?*^T=rfTTh=KCOE&>v{L2ySUUWzK4Fie-sp!^HZa%M&*9x% z?_^_oe-E>WbMX~;2s%-miS?Om4R6VcR==vn+?!lnF`Ch^=T_G&!^C)^URmp<@73HEN?GEf zk0a9E50NK^7!E#)W45dw*|*YON`Q=sT9C3Ix?#PX6z}?%T||3dAtN=;(@;3yF|tVZ zdfG{s2m}wKe07+_RU(e*$RlqwD>9L^vDmCy@9Il*Y}*NYLQ z7UZDg(}ps@d371h95b43DjY9->5*L>G;(NBAH4S7u~V$fGPsq=fXz`|_Lc8<<+b^B zeC#N`cW)|Dla?;iYYRRfOVU{P zAk-(B@MJP3=es`GaVfT~+?osJ=V(7gTz9NoU)|GfbVtMwc_68;hjSshk%RVZ4M%ZL z{tbgZTiY5B(v&W5PGtUH8L^5?7m~en|EG9vnaewr6C+SqBkH=orc3OJ#`0s}`5O(= znq~_*?}=ZDDe581fyHXNOR!oFv?C?L@hTm)biXtAIo;U(%$$dh_zj2=!-Hgmbo@Xu zat%w`U!wTKxe6C+sBSFtGhOo%b+yhOk1AdCo{LpSz`GM1&vS19q>J`JcnfOQii1uE4YvAQ>|a=wan>!lhP#`t3VW4Cl2iLbTNqJs2ukf zM@A7D#(bLY0ZZHEeYB?zYPOSf$yNqFSlFqYv`c+L=(J zZR!@5=Hd@>(O<6F&eYxRmP3YCyfNJ6#>hEr101ztekkLGp&DR)WMT^74-K^S99B~^Y5WUW-FL4WdP}%Je}Wruv)Y5L ziu(LCO+3AraJ1MwsnDiKq5Q7iDk9eN7+cc$R~A(AqU&qM%Xp8w+8rX%rKNGPrb-Qz zK`x=D=Pp#6WgF|YO35ZE4Kr>xwK5gB zr8e1d5Yah>`!JLdLW#PvyqBgC(hm{Dp0ecSW^?=USLHP#cR9HxpxJ zx22J3(<#|3({zM0N&|}WQXwJGcT`beeUQ_%TjfBb_pFlsJmUY+^ppWnHr?*hB}j*q zbP57W!_wVIw{%H&EZyBoN{J%f9n#&>A>G|^A9(NggI}CIGjnE6$72us9m8})l4=4$ zsAQyl%{@^EBlF4phnglWKfx=Y;pZ;|^-1%Z_r7#{U4zM%Xe~O?Nl;Y`Vh*3JSNi5C zI+VIA?41n{)GBNG0Z}Ydip`ud^Z3iSr7;&mG-oIr5{Q7vIFWVfM7vj?zIJ`E+Lfd# zQ*Ue_v7Z5h0GAvD0*M51jQ72FY^@DxP=Fqsieh7Yg3AkvSef0CwxU$jd;yVY7FmsZ zEp{kONE8NgaEmHFhxMm3Y>;Vh0%NlJKmaTZe$!5nETrvrO|5gYgrR`Y9R(#cUmkK= znmYnN@X<_1R9#MIu4Dls(b5xDh#S^mSo^zleP$Acj zVx9D(ix-U3$SOzi4z#_?1GN%#vI82}uz$q_vV{Ubs_4pcv8k~=kI}x<_|#gtpC(A2 zP-?XnP?W95TbX1)1fk^7u>Rn*q-|zX zYQj~hwt>Q@R&@drQ=0z8S@fw$Hl8LW~Y$wk^{TR zUo*y$3wY8yM_%bSVQ`SjKPM`W2v3qQ$s0jbsB%-urv3Tc5o%!0^`2Sc{sCd)EGH=| zsf>@cvBZnqHN!xNMjTC>0wnMs1(#rf%=6AS%aqmMOp|J0F91#= zh=74c-t@Mp-XYq==_9JMS{(uBkT!jfZ55>7IzAl_HWvk0D;uls1bGG7z31N=&C%PH z{xjW~o8d__ibYyzaiZ0aD|`(=7lT+G2%N)l1U*+Gf_zo{9CoPQxh_L95skh&Qq45{ zVC<1X3Oa=X2UJC%bv$0ov@Gvb*2mEAYU6?6osQr32AobkiWQSVsyJ|JkKGBAmJUx--vdy#cHh>>i=UoqFbpe(9CiL-wc>WVnw;z$5ScRes?}{g1tkz-+GPHb9299p9j+j! zSiB^ag$TpskNJX#v^=w)DzKzlp7XhzgmS*enR@)buWZoH)YT8#0VP}piv9P# zS4B2-{hhEFJMka){;**>%dB`1{?Qfo}#;H0W-9Vt>9nV$TgOB)fTXg~K48BG&Nr!YA zmO8$L6X0EXbAnf36hN>8+>96EFYk@S(L9w?>2HCJ$r1nFsz^D0v+yerFVqgW1LJWyD21D&^mVFS{1WS z9wuw^yznx}cZTqBwka1y4Ho^0~j6odrxIMl) zUw8Qa#BGo5Ib~s1R4fLae4*n+CV08IobQb|rBydhQGtnEv;2Vs;5c_z5-Ug?Yx9ot zr%-HzBEB}XXE7E=;1X`~J5GiA&}Jle9*LHk&WF2m`w#VU-~N{s91Ui2U|-Qv+suy2 z)Z>m-oROG{UWwnb>U_ft7T(9=GsH+$?zz;_93<=`8q!Lyjl2FPewNhG_&z*vqO;cc z7MAp{nA>Rn;o1+=d}Fo~r~9bfD4)?Lp#WMg8h5z#pr1%|jN(w+$AC9TCi?Z36m4`* zlev7n{IjJjim(9ltl${g{yzSZaE)c|RQ|82|m?|haN?ZEiw#!W*q7X;Ws zdIGW7JHhl~EWSOSn~jRO`agwqlFdFes3fw5us`}8kLbl=|MxB^h`1GOu1hB0sx7>Y zy{$PInvUnOp`|J9s;lgT9gLU=|YXa4^ z-`j~H=M1(Zv*W`{um9uvcquK~kpp8h^N-W}sQBYwnKf002O=vlZ8Edsg&q;M^?3&F#mz8l$4H(5qyr&FZ*mWT`A%h4=MG?%WuY;Gb=T zFSpREiD5gzCvPz`DzIodr{~fC`_~P+f5||pFZxIrcI=#F+xus@91(6qN5ly;FQI@Q zSsu~C40Fr0;1$ZY2(lYG2o(-V*HPF=9;l1kcos5t3c@kDOUTq=pymz#B9riYU2$^a{*vzT149i! z27$fbutv!RetfKg!>O+l{)C%OK0t=2aw*>}HAOcnfUDbB4+jN~o`^_K(%<);vp|&$ z%QRXzca|`vuiV1Rka4vN_v*n$soyDhZOxZKu|R%^V-jH$=2_Lf3-au5;sWceN{ZJ@ zSHZ0((@wXK3rmpU3kLAp7a7L%={^!%H~z=i%Cfmn6XG^lHo=Dkv;D zu-|;vW0+c_<&B6pUB>!0E93RZ4Zb=q+ugY~%B76#OoF)Fv*0o?P+j=^s!YxVOdi=` zGY!v=yh`ortBUV-SAKd|?f&wDsa5;9;Y5_AWTWDAfA~TO7z^JV*byWOkz1495sxui z$i$#c^%>D?NgR5N5&rfYY53%t7(cb@$O+K%EDlh993u?M;olkGg8Z8kug}8e_j2Q> zhrS5Oy?a3zYRn00By^^jWmN&>qR{`~g%Oa?3t;OOI~*n|QZsUm#bT(m?tkE8=TuSq zXIDS~l+q!^)Y81wGlW`5IEk@!3sLqss9A3D{1G5r?)G+c|Npll6c5EYn%sn#Wfiy- zO1Y8st3yL*Okg&jF$5#v%Ex*$Ty&wXU-qjQv6xIt?S+C|8TU7$y943xlK=-0#HxxV zk%V`Nw%ryrAo6u5E%#j9c}9!k$8tr#b7=7UX>$#Ld{Aj29c-VKTqZu|G2~n!CHod&i@K9>@n3nr?Rn<-F0ttvL^ofHH~8e)(Qf^GDvL> z2pC4Vv17(2+JmJR8uWgNtm8J-mW$+f!dE1a z%Zn8N&q$&Z%-(wQu|sKGqML8e$tn(wq-qP&QKtaHZ4I740ck()Qv8h!<1OCe;CDjw zB6g0ZU^Nd?1%>HSLmeO4>y+oAI0oh#j`1=#<9xnWKfjmi6g+$#_#KF^j$j=1=-xql zu#0cW`*M&r#iFIl3ioz@x04F>9F+)J^-<{SP?m<=7KL|Wvigt5_m0mFfx$Kx>e#O& zUoUzcC(-mn)a)iNmiSmVzcy~MdD^fRq~Iw6myrCn?PdCBl_IV9K?Z82;!QnIKgh>( zyLDMP>&&Gh{uO>T*WPl!$DNqO_vKgH4nd3B7@jxfKvyS1irZ|R zeDF=S1~dARpLc1)Bvke zkBA$fJ^z=FNfuFK5lU;=O1AO$@$IeEM)fI6T|_poo3U8ngJ&=Zvhl2(>@e#4#y7Bp zoa>s=87=I=X=WQ7M%kOj-k+}22+YWV!*9e+*lW1{WQ<+9=h0yd`Dvq=w-vUm{o3L^ zjqnn+-=}AoB!7lvPQv^eW73$?6UvWXS+GU{&UsWJZN`XwY(^v?pn?@(-<;yndSl8b z|6HAZg}h20s6)$)C4$V*rjJOjL-%$W+}~%BqzHqY=|WhCj+ZyNig$^u$R1&(UlN#O zcQOShWe-y^{%Zr35vu!)Z&=eSj5amM!p+1X!Kb`&JZih}pSpT?O`r4@v#Pr9Ei4+) zJOuFVIZs_z8{dttGU3~R1J~~lluxRw3Gua;U%f&urdm4}@3B9>-_sO@dOjn~kDWWQ zYs~x?#LLZa?%d>noW;y7kO;>~k3aI0bGBK(jo=w4vM8^g{A)t>Z~ru?sU0wI`$yFn zOpW13jeOz5Q}eXR0vL!p1UQGZX*sr}9$iWc->8$*4^Q-&!ALY| z(Oq@b2)w^{Izi@saTQFN*tq=7W*{Sj)R$k7=cGt?O0#C}TYnngwI$8NX0|&)Lwy?{ zRX7U2SFEB>bk7;*Ap;bs^FP1Rx38U1v^ic^{w-{x>kx7DlG$mzsKFdYc{3O>mDXRL zoGQwK#taS@22r#zzrbTJ)6q2a$p*>&G3I?^E9lr+oE{3@wu!p+rV1xH{7wydaZolS zs}M8>8_PDbIlVKCd}c-jEjan3PBltO`#yxz3a3LWo*9x$teHIq5mmaEsuNrqxdUzS z)rinGFx{a0%mShnraTLND*#D*K2-Vys(ffEdEaW7 zT3-DxHQ0`;HE;j1dy>6HdND!?7%WL**94$>&UFxXj?=A8lQ2^VLmGZVbQceP3!>Lp zTj#)!A-8AOmEf&M##{YR=28B#PLH0t~3w1sFfFP;!ztaU}? zQ{L=$jhlz9;{`NbSG)SIXp^5D5=*X@YWH3t&Sj~sr)nLuh){TP;4YGcy*c?Ssq!PB zd8|+GS-i%3nVdfaA|tQmDcj?i+kXPJBN+g5O#lOM;ahU!1Q^{DX=Y$!vNWz%CexJ3 z+mVI;-{lxT;%8si=bM#oUyR_}Wlko016j4Q+M+UDV_NZTPec{~m;5Rq-9}<-gMYpe zk~|dHXri!JQOhuI1|!+fc6FWBtj;v=2O;SIobYTjm%jF5 z={ZjFJ0dvD6`<4!1l4A;o=y;~1FqCSts0@Vdg#@D&8p0!&k3O7Mde`?XOJ0z7GJ~p)Dhg9Ih zO1@Z_6+iBpXd|Jx9;G?15M&tw=RfgbpR?66xOpy={&V;;Y!(+&yZ=S|CBtsvaP=tD z`2_88q2L*7_+a~jkP!bfs@kiuHChaL-zq9D2)6rw&bBa<)J&tgX57AkbK;bhj>=HX zCWXI5w~NF3@#08D@BP-1gV}Vt2QPe4T0bRrip&P;gMpZGo=Z6}5teGmo0b`KkYz@Z z$GjpXXK%ypFXBs52$Tr(%1iQAd5=-V&EYYfQMV~P~49ZWl1!vn2>sVY>n; zE_8Mu{&2AK=jZEL<%6Fc&W^>_>&aO$>p4VF)n-Hp|{@38rk zR&Cb~4~w7kkLAS_y`Y<-C6^b^&Stn!rWf)*XIDxOQR>UW$TZ;j8A5LN96N3!>}Ijo zbKyo1K}g7TNIN9S{t97!>XH}qOzGO^$us?yP4Bc9vFN+_LapFknRFc3Q=E3|kk-E? z9`u<8m#2&l!wu|9Um(r!!X5z18rH)EG z3%c%2svyITg*_p74=lT0>b+Q_s5p7aIchhJVdbKB*Uy+4qzXHu9y^5|>y0(QOo!y= z&G$hf1U(sG<{Ar5D-%Q|1f6HKY9}raH-CS|{tszOU!m5kZ~6FKSW~asz?+=}wcWkf z6rNHujmH7T^BHBj(kK>*uoU9{l>{<%OyNpj2HqCJ4ZGD=opazix4N_*VK#9sv~0=u zKb4v@YQVjOu`$>>|Ej7J`!QpMSuiiB*g-dn65*6s26?1W$8-X#9gFqF1Iqtw!T@E^ z@<7!_VmBR&i`7^yg0`PGLF8u-;QC*LJWlb5Yhn6lPG5mk!6Ftnmd~%6q7|T-Dh5qa zO#37TJH!S+_8DaG17{Z}_Y6fIc7%!>p2jJhuq8mOpS@}d4%Y6#k!_$9^Xz|KF}}PW z?AT46k;me_qg5OmqTU9zKXRC?C{*i?ATdG+G4WYdwGsX#$tW*MT}aj?DRKVm?T#b9 zsCKH356i;{D?0#p4h86<3Nu5|cHwmn{cDd?+cx#F!z7XRibyJgAsZ(BC2q^Oe?(g?Y3?`VHjq~Q*+c)nEWoJevmnzSUf61TSO z#HoN>Qd%g=rs6qA_H&jYo5N}AI*O+=FTw4kX=+#wMYgT7^IYp^W728i0{I!Wzso;Z zP~!_8inoy^FnW^xa`;IFGjcQq)cXt&lG&{ zQrPk1)NAyP<>cCRh3b~sW!eo?OvRVTV$SN^;37U-s#RuZc)`|e^+uDyW7j;#`={%l z-NJDDeBcAieJ=sD-=iFy#=7Trs%M*GwM^tDWRo%f+wKXI+r@ z(AMLDad5>}S5ol@eObL%%i5EF(>%P#lsqFgc=MvNu$EV(-k-~yB9j*^JZUU@g@~cX z8r-a0ih(NK(Q&P^^2+*Z1~4X{*XN>MOth-ADK?yIar{n^eXdua?~Ug56Y_UukV1QY z1R@v2tcREIQpN_V3!zY1Y9KYc(fp=>NQW+Y5Q27C3uSV$&D(T1(yFAhAvLb^iZ zn{cJufh&CvPb*OWSG%^g3S>&U0&k!|wW0GiN{tcY3 z0Y2x%Skt;9d`T;@;+U_9oRbCEpVPZKrakJQxQ)z;h2NSu+n0Yt&Xqdt@Vd)yS5uqBpq*_jOOVGG z9|F*f0k$jP-$uRyU;hW7VpTyzZUnJElc->(Ke-uRQmZ~i@na>Mq4J9%2Pz~u*dx`t zpGserWOvQofug*E)c;EYH*CeWOYLfd4A+mao*#i@qImbB=&q@4X8hnR&E+r!Jl>^Q zS)GF)+JLcZ*h+RQqBZE!4@97HovQaH5@D9*-gp4A%I%zly+EZ-)rTB^<(mOGK6Em0 zzxOReYlBgN7Qgbqg!*g7dzt-*ZSN6O^$%Tz==ik~nq2Zy78=9uCsyKDTDj@xOk0iH z`JMQT@TiUTpRX$R5&rRnu`7i0Q-5vTj5VCa&zw!~kKK$BXZCKr-eH8v&eQGa^@vR-qnC9!gC#sMXLPlA= z^XA1{gb|s;ef-j#cL+}XpMHP)J1=HeF1Da2MUr_TChV1afB&{f7pTYGdTxmKxx_0d-e*HQo?_dKn_;PL4 zeeE-=QYk!!&9W@;ha)?=oAFrwjm*qDtJCX80!EjBYw6qIr5vFEikS`A9^RXiY+fH! zql>7SW^IJ{EvFbIPFu%tHr7v+Oyx;Q}Hv$To|U`U}1Xj4@Nq@q(1h! zwT@LWgTpq7GQXx7d6VLQH~lBVOX_(XSNKtoj_2X-%beRxLX?ZjeJO6Ui<}pJco?)B7+r-;q}C`h z##NLk&$E*kDL>PKwEZBrc&>{H@v?8}Fv^hgG2kout93@XA>mJqPY&u1T?#!7wOGlyg=olCufLXImN5ZDMm^K z;1Ti+h&8|#?qXZptFs%?mI?&(yAFj3+rTTJ+LsAAJ=HE%>>QE})@**cKPhM=!!pXI z78Tm!(HZPV8W8%HyqPXJeP&#!BI}{`J;P}19(SA(y=b74xUT&D&h3YFQ>kT55!O~b=Y$;6BWImlH=pFA-47JEa^Iz47|FRz<(dP7e@H=?(rRAwF>bFZ_Y9>no zvglVAR`GYR(iuAOG5X74O&X74;|b$~qV5}p^ggeXnXn^wQfNb5@e(!Yg7cAi6PSRnU~w$ZnHY$z9U;R6!;0;vzLCbmfbHWJjT zlQM3M{Jp?WhB^RjC&8EhNj94cwUq0fH>-QkD%&{>&cANmGtc~;r$H5UGS7n4ch@TK zKOMh%3nOs75-qpf^wcLYpJCvZ^IBsZ51fF>`C0QFG=Ma~WY9Upgw5V=v~Rge4EK-9 zPIOdwQU`B8piVy{>xY7~8-g>-*Dr3zIQX{s<|DW1gz#pL?rED|Idp-P9Bn^7k}crI{kXvgp~QpvUz zF|zZXovbvvbhqtDq#Y|;`yHdr*Q(UF8;l0{0LO?n5j9Z>rf)CzZS6ep9@V|G2-fMp z5n#qBakpjB)yjxR&_KEIu#tmPf zQ>UFAnnXzOeBD%1}ZDquAy7_ESgh$8SpS4!e3X&-{ySjkR4K*Y1e)*S<)4QnngLKz1l) z#(9eGC;Cdx)>_Yect7@={JuxZ*Ccu-{E>bk{*l*2@MjBtt)+1H{@!mIw`E#N1bLgK z40fme!)&LU;qGQhR%NzE;Sh}nZj8&}qlDZC{FdFsh}NqRmYXhL>}0=yAoxP+-(38K z{`uk2bbDW{h@`o(M(jBh^6@^zFlH%LEvaeqUCsyK*&)KUaS0njvYR;ivd+eT45I!R zCfU}}&);_^sgUyF+1jm!IHL+GC^!_p8}Wg)CV>7**~T+O?%oQ3Mtnln`?fy7Q`RZt$_lOUb|&Px^YDG zA?v#H)y4WJgO(3uvePunr<;|f*C*6H8J^K@S+$`*YG)uOIcy`Jc>Ge7@l-Gkj;`L* zc{rWA($7`Bi^dOj24v;yaAx)4vkS^7L0QU--3!hB_eoalT}_(Z7vracjOxSul7qB6 zcN#vcRglU>>oC{b_v)E0I~dFNO^iNWSNARuaF7ZDF0~bW(8kt$_QcxU3@bx|_|d?J zGn%OMUIS$4Ks$6X!4C+i*ByxnIoBH|n=AGu;G7Mg1<3YOmg+DFhm~8ec8$;ZJHEdf znO8XQp61;t`}}x8!g5#rL|Xutx^SQr>UZkuHr})~ymZ8OB$Md?!+oVPYQxC>HdopA zEYs)5FIXsW7Q5y4?X|m1;5S4<&1z4JwV8KwEX0n-aoB7Iil4+FIJ5;OpG?dRf2Y|a z+AMdM&X$brKtXVPFx);T5HC)$ zsIR3^9O4+RJ}b5KXmrBj(1$f|~H)gj7PI_(+&`zh8szV#1MP zx0{ZuKVpRY4oBjB8s3HeIZxhNc19IBVWiif*I~&GM6$!+@qZua5eFK>kwF=D*A0h4 z{VN$vkjd|6>0RlXp3)iKm+`a-@zQ^gzj7>$I!}lFW&Qx#g@7Vy`T8(_U)BCJ6O8aq zIp>yNl7=ma6&&ajWQ^e}Y-3XO$3%bVM!_7_!vX6b-N323I|JE5rgiMHW8GWg>y@2_ zCMNh1`vl+axk$_~W-#!z9s-41@jx;E*)(g~HV%P3rUVtu?>!oi@jZE7^jluZ@hN@E zVo4j(nmt_+jZ3?-h&sn-0D;V+`LBEJoFP;H;J88%=IFQJmUyFz-WfUB?7F1*4(qiO z5++f28KOLot8p8fm&!~ot z(?HDdGRZblcYJNAd6Yp^~dH~$-? zXSHw!(Wkd9q&47+cYjTC`K+ngq#e-`ud~xpvc%}7KMV90GOcFH6Unj?`Aw0I(pm~)%^ZZ^!tyR1 z`dFsg4aND6BHbmW8CCGO-{i#hyZ?0mHI$>pct=iZKMjnxMr=K-XYoB!wNZ}jE?Reo zO4>6{^Y$iZ59Tm#2p+wgk1T&1-GuE0Bg?lQR7Fm{#7`YWur-7xlHAf zy5l~_4g>dPmSkOdc#eRNzf-jyHyvYm{&lUH-{kmPWNY*n;->K^I=`kWaCkfXT>#wR zJj(2wWeb!DlNSxsE0Of-We5S8qDV?_mnOZ!EViG7ZhPJOAikV{{f(KuQO^v%3r{Q- zSp*f|Y45G>;h4fOy@;L>nI93#v$-a{44!|qBd)VAa%i(XL}bFNB}xYqRn71Q1l2Ch z9}xb?7Vk4DyTosjkq&;`oTcs&cw(6*vE$kcMA_^thx_xTxl@KPBY}|V$HeiU^8-+~ ziSjwJ9Vf}C1tMFW{~p(C$g`y{$^$OIfU4j;c9eUaZDm&~TKA@WbzOQ-iahWYCn~V& zw7Psb zo@=yOyW?Ss2kBkQoks5arRbpo!rn3KieeBJeioPZ^ywfZJHSXL=xBT@OhvBTGR(ZH z7Zh&@zv5LmH!kc&H+~>J#OaGrKHcP=*>Tm`P4YYr66V&sl&Yj2!xtQh#LZezItgm| z#x~T8++mPIS?G7is=>abKHcTTl>qj?{AfCBA3}W58pC6EfTKk4#$a17MYZdm{wN~8 z22-9bF=<9UbVV>WUb;K_7bXkwMf<@7>geo|B z_SAr-3tDhx-5e4AHfL>WbW1(LdwR~Es^hgy#X~;xrpp;S1(QvK(KpjY`F1x-xd4`< z7IS)7|Ah3LskqtzSXD@ZH4`Em5M-9qs1)lv99P&X^bQV*&hFStahWIV=kL3jAwj|D z7WevM*%S?1xWyun^w&!v3_b2={eMTd=8PeZZFmY8=-&WVblJFtoskO@Imlr`9t@|m zDHF4$AF%>boU}OAMm0aTVm-7tC%`8naW$cT@)=5H$x)vL1B+;|iM!RcoXam2ZD7!~ zg5IM=mh)aByoDa)kypl1s_-m|7YiH&qEcP-FNS}ed&`v!bKoereTm;OFm)z1TmxfF z26;F8y$88QQm^Bjz1rAOeU)q3JUB1-qI{Kqv7=tDVF(?)x#RoKTv6FzY{8X0E83KGZCwu#} zF3}qstMzkHoT;ueR>RHJ<`|yN3eG?HIy|uIx-sL%PS)|r!p^*~x1Rv;d=i4{eL)^0 zf!oLPQEHv~l~0EFy}&#;9B;w%rq;RHFP{CRxU}2we_}-RpQH3o4Fj>aWP`ag1V=)2Op8R( z7{tgnv-lcC67DTOr>0Ia6CLP6L13zh&}*|?NT0fZijcXV*BhdeX^#M7z^CL{w0C|D z`Nb{QqCO-j1Gk|QQ5yhd{+6!S`k3v!$AcklF9D<LcsdFkihTaUmw$IRi#&kLqqj z+0+9bH;*y#JQ+z@n8eMpYo&M}9SLgRawHbK&9qS|&r)dhjng})VX(3H;#W#5@lnL% z*McjK%ZyQOgUiPQxkpcmfH19xqW$+JOUZ*4BR15c5GHxZhA31BLfv?-;L7wTgi5+v z^+y=00brKZEno4SWm{}%QjMdVBr*@slMhV~rQLQsR5hiUk}_@FY94es?8Z^ojQ96( z2!x1j2vsf`?P2J+rr+0=ArT-$^=c}I+%Jz+zPV1`e^tdxXH)4>I!>gmPRCKGl~|C9 zyWLB25+)pgrT1_L+Epm*vFMaeo#$~Vae_;PS@{7wL(2Op3oYjBR|6pWS9&>mDK}4h&x0D;)XpF(5qP5l%Tqfq z=CR1^c{kjL13n?SAf8T94p-zUF9Hn@F~CI@rB^44k$JWXylGI~jlH9np=f)4+r)KS_S5E^GYCR;NaU=U?mKf2dp_RU%(D#KqUD!lsh|FY2p$yB-E--DGNlvFUMA#VLQsP5|Dg9QgEK53T4?aaY?~B8 z9R2v}z1hcmr4(m3W9s!4pC-tkiamR==lG>Y#fFiWJ4vt5@l8Dyko+J#F!7fze2d@= zE}8YpsefKsy@oVA44cx-W_Di^OWwQvtb7c{lRM(O5C*KLjvayxmr;^Q3>?ZrFv?ab zDCk2puac7=fgMZ%sQbF*ALj<1?vv!i{e%%IYr*Cgp#tS+aB;ex{YsjZLK=D&c4rXC zXp+qI;W)Y{b73acQ&<-HT=!kQoZH&636~#t)?UG(m$Z>d{6`szLTB=E!9L>#QLAOB zUyY;{r2#A;G~rsQVsETN%m4O&Ro7Oc%C&tOA)Pz<+roc#M|x7vxl)|_=3!jLGZtHAWn$J;_p-mfcN<9$HF_^o#iPhw09>V}cBXI+g zZ0u3YNZK+Gl+VlXChJ|i(8vZP^(h~Bh4$i58S)^J$ksXpd?az>yQ&W-p#QKkKnbOX zRrfd(6wwS>&1f9-im!>mI)G2#eWhy>t<@8l!daW1>^jELHpVV&{oS0VIUgFsSA9df zm=A4PYL<7Qop33bBo=BQS?&HQZc&fs5=oX>kfCdv63ZO#8hB)`AgjB@2a*HZ!;=X#A(&-Kza1qJAx8{obV1dHFATC6at#HoC5$bFM zDfkiySD?^@-4|ExMQXbext*Vkc<24LH38YvADsL@5!S>!I^z81ErWmd0q@|4zGe}T z#pQdy)B1B=jS>lmeOCF&Ws1+bwaGe#|Ad$E$T`MSHWdXGOlJ7NT3s!f5eE>E*pCYKApEPL|i;u=(e50EzL z2Hj5&UGcTlzQ3bXXvcs&)D!cm8Thm#)A;#M zGJ!>iNVKcqeh}gtzyl>o0C^U(yp6tj-MRSu;A?*y8NwcUa {$ennCgIHO0k4N$~ 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 Z{{h5M(EoQptXu#9002ovPDHLkV1j)_{bK+C literal 0 HcmV?d00001 diff --git a/services/app/electron/main.js b/services/app/electron/main.js new file mode 100644 index 0000000..b49bf3d --- /dev/null +++ b/services/app/electron/main.js @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { app, BrowserWindow } from 'electron'; +import { spawn } from 'child_process'; +import path from 'path'; +import serve from 'electron-serve'; + +const loadURL = serve({ directory: 'build' }); + +let childProcesses = []; + +const createWindow = () => { + // Create the browser window. + let mainWindow = new BrowserWindow({ + width: 800, + minWidth: 800, + height: 600, + minHeight: 600, + title: 'JamAI Base' + }); + + mainWindow.on('page-title-updated', (e) => { + e.preventDefault(); + }); + + mainWindow.removeMenu(); + + loadURL(mainWindow); + + // mainWindow.webContents.openDevTools() + + mainWindow.on('closed', () => { + terminateChildProcesses(); + }); +}; + +function spawnChildProcess(config) { + const child = spawn(config.cmd[0], config.cmd.slice(1), { cwd: config.cwd }); + + child.on('exit', (code, signal) => { + console.log(`Child process ${config.cmd[0]} exited with code ${code} and signal ${signal}`); + terminateChildProcesses(); + app.quit(); + }); + + child.on('error', (err) => { + console.error(`Failed to start child process ${config.cmd[0]}: ${err}`); + }); + + childProcesses.push(child); +} + +function terminateChildProcesses() { + childProcesses.forEach((child) => { + child.kill(); + }); + childProcesses = []; +} + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + + const pyinstallerExecutables = { + // embedding: { + // cmd: [path.resolve('resources/infinity_server/infinity_server.exe'), 'v1', '--host', '127.0.0.1', '--port', '6909', '--model-warmup', '--device', 'cpu', '--model-name-or-path', 'sentence-transformers/all-MiniLM-L6-v2'], + // cwd: path.resolve('resources/infinity_server'), + // }, + // reranker: { + // cmd: [path.resolve('resources/infinity_server/infinity_server.exe'), 'v1', '--host', '127.0.0.1', '--port', '6919', '--model-warmup', '--device', 'cpu', '--model-name-or-path', 'cross-encoder/ms-marco-TinyBERT-L-2'], + // cwd: path.resolve('resources/infinity_server'), + // }, + // ellm_api_server: { + // cmd: [path.resolve('resources/ellm_api_server/ellm_api_server.exe'), '--model_path', path.resolve('resources/phi3-mini-directml-int4-awq-block-128'), '--port', '5555'], + // cwd: path.resolve('resources'), + // }, + // docio: { + // cmd: [path.resolve('resources/docio/docio.exe'), "--docio_workers", "1", "--docio_host", "127.0.0.1"], + // cwd: path.resolve('resources/docio'), + // }, + // unstructuredio_api: { + // cmd: [path.resolve('resources/unstructuredio_api/unstructuredio_api.exe')], + // cwd: path.resolve('resources/unstructuredio_api'), + // }, + // api: { + // cmd: [ + // path.resolve('resources/api/api.exe'), + // "--owl_workers", "1", + // "--owl_host", "127.0.0.1", + // "--owl_port", "6969", + // "--docio_url", "http://127.0.0.1:6979/api/docio", + // "--unstructuredio_url", "http://127.0.0.1:6989", + // "--owl_models_config", path.resolve('resources/api/_internal/owl/configsmodels_aipc.json') + // ], + // cwd: path.resolve('resources/api'), + // }, + }; + + + const checkFileExists = (filePath) => { + return new Promise((resolve) => { + fs.access(filePath, fs.constants.F_OK, (err) => { + resolve(!err); + }); + }); + }; + + const checkAllResources = async () => { + for (const key in pyinstallerExecutables) { + const executable = pyinstallerExecutables[key]; + const filePath = executable.cmd[0]; // The first element in the cmd array is the executable path + const exists = await checkFileExists(filePath); + if (!exists) { + console.error(`File not found: ${filePath}`); + return false; + } + } + return true; + }; + + checkAllResources().then((allExist) => { + if (allExist) { + for (const key in pyinstallerExecutables) { + spawnChildProcess(pyinstallerExecutables[key]); + } + } else { + console.error('One or more resources do not exist.'); + } + }); + +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); \ No newline at end of file diff --git a/services/app/forge.config.cjs b/services/app/forge.config.cjs new file mode 100644 index 0000000..9fc2950 --- /dev/null +++ b/services/app/forge.config.cjs @@ -0,0 +1,30 @@ +module.exports = { + packagerConfig: { + icon: './electron/icons/icon', + asar: true, + }, + outDir: './build-electron', + rebuildConfig: {}, + makers: [ + { + name: '@electron-forge/maker-squirrel' + }, + { + name: '@electron-forge/maker-zip' + }, + { + name: '@electron-forge/maker-dmg', + config: { + format: 'ULFO' + } + }, + { + name: '@electron-forge/maker-deb', + config: {} + } + // { + // name: '@electron-forge/maker-rpm', + // config: {}, + // }, + ] +}; diff --git a/services/app/package-lock.json b/services/app/package-lock.json index 08d7537..4115254 100644 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -1,12 +1,12 @@ { - "name": "jamai-frontend", - "version": "0.1.0", + "name": "jamaibase-app", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "jamai-frontend", - "version": "0.1.0", + "name": "jamaibase-app", + "version": "0.2.0", "dependencies": { "@fontsource-variable/roboto-flex": "^5.0.15", "@formkit/auto-animate": "^0.8.1", @@ -20,6 +20,7 @@ "clsx": "^2.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", + "electron-serve": "^2.0.0", "express": "^4.19.2", "express-openid-connect": "^2.17.1", "fuse.js": "^7.0.0", @@ -36,15 +37,21 @@ "showdown": "^2.1.0", "showdown-htmlescape": "^0.1.9", "stripe": "^15.5.0", - "svelte-file-dropzone": "^2.0.2", "svelte-persisted-store": "^0.9.1", "svelte-sonner": "^0.3.24", "tailwind-merge": "^2.2.2", "tailwind-variants": "^0.2.1", + "undici": "^6.19.4", "uuid": "^9.0.1", "zod": "^3.22.4" }, "devDependencies": { + "@electron-forge/cli": "^7.4.0", + "@electron-forge/maker-deb": "^7.4.0", + "@electron-forge/maker-dmg": "^7.4.0", + "@electron-forge/maker-squirrel": "^7.4.0", + "@electron-forge/maker-zip": "^7.4.0", + "@faker-js/faker": "^8.4.1", "@playwright/test": "^1.28.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.2", @@ -62,6 +69,8 @@ "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.18", "concurrently": "^8.2.2", + "cross-env": "^7.0.3", + "electron": "^31.0.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", @@ -72,6 +81,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.7", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3", @@ -121,250 +131,1863 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", - "cpu": [ - "ppc64" - ], + "node_modules/@electron-forge/cli": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.4.0.tgz", + "integrity": "sha512-a+zZv3ja/IxkJzNyx4sOHSZv6DPV85S0PEVF6pcRjUpbDL5r+DxjRFsNc0Nq4UIWyFm1nw7RWoPdd9uDst4Tvg==", "dev": true, - "optional": true, - "os": [ - "aix" + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-cli?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "MIT", + "dependencies": { + "@electron-forge/core": "7.4.0", + "@electron-forge/shared-types": "7.4.0", + "@electron/get": "^3.0.0", + "chalk": "^4.0.0", + "commander": "^4.1.1", + "debug": "^4.3.1", + "fs-extra": "^10.0.0", + "listr2": "^7.0.2", + "semver": "^7.2.1" + }, + "bin": { + "electron-forge": "dist/electron-forge.js", + "electron-forge-vscode-nix": "script/vscode.sh", + "electron-forge-vscode-win": "script/vscode.cmd" + }, "engines": { - "node": ">=12" + "node": ">= 16.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", - "cpu": [ - "arm" - ], + "node_modules/@electron-forge/cli/node_modules/@electron/get": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.0.0.tgz", + "integrity": "sha512-hLv4BYFiyrNRI+U0Mm2X7RxCCdJLkDUn8GCEp9QJzbLpZRko+UaLlCjOMkj6TEtirNLPyBA7y1SeGfnpOB21aQ==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, "engines": { - "node": ">=12" + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", - "cpu": [ - "arm64" - ], + "node_modules/@electron-forge/cli/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=12" + "node": ">=6 <7 || >=8" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", - "cpu": [ - "x64" - ], + "node_modules/@electron-forge/cli/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", - "cpu": [ - "arm64" - ], + "node_modules/@electron-forge/cli/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", - "cpu": [ - "x64" - ], + "node_modules/@electron-forge/cli/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", - "cpu": [ - "arm64" - ], + "node_modules/@electron-forge/cli/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 10.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", - "cpu": [ - "x64" - ], + "node_modules/@electron-forge/core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.4.0.tgz", + "integrity": "sha512-pYHKpB2CKeQgWsb+gox+FPkEvP+6Q2zGj2eZtgZRtKppoWIXrHIpOtcm6FllJ/gZ5u4AsQzVIYReAHGaBa0osw==", "dev": true, - "optional": true, - "os": [ - "freebsd" + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-core?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "MIT", + "dependencies": { + "@electron-forge/core-utils": "7.4.0", + "@electron-forge/maker-base": "7.4.0", + "@electron-forge/plugin-base": "7.4.0", + "@electron-forge/publisher-base": "7.4.0", + "@electron-forge/shared-types": "7.4.0", + "@electron-forge/template-base": "7.4.0", + "@electron-forge/template-vite": "7.4.0", + "@electron-forge/template-vite-typescript": "7.4.0", + "@electron-forge/template-webpack": "7.4.0", + "@electron-forge/template-webpack-typescript": "7.4.0", + "@electron-forge/tracer": "7.4.0", + "@electron/get": "^3.0.0", + "@electron/packager": "^18.3.1", + "@electron/rebuild": "^3.2.10", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.3.1", + "fast-glob": "^3.2.7", + "filenamify": "^4.1.0", + "find-up": "^5.0.0", + "fs-extra": "^10.0.0", + "got": "^11.8.5", + "interpret": "^3.1.1", + "listr2": "^7.0.2", + "lodash": "^4.17.20", + "log-symbols": "^4.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "rechoir": "^0.8.0", + "resolve-package": "^1.0.1", + "semver": "^7.2.1", + "source-map-support": "^0.5.13", + "sudo-prompt": "^9.1.1", + "username": "^5.1.0", + "yarn-or-npm": "^3.0.1" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core-utils": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.4.0.tgz", + "integrity": "sha512-9RLG0F9SX466TpkaTcW+V15KmnGuTpmr7NKMRlngtHXmnkBUJz4Mxp1x33WZLgL90dJrxrRgHSfVBtA4lstDPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@electron/rebuild": "^3.2.10", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.3.1", + "find-up": "^5.0.0", + "fs-extra": "^10.0.0", + "log-symbols": "^4.0.0", + "semver": "^7.2.1", + "yarn-or-npm": "^3.0.1" + }, "engines": { - "node": ">=12" + "node": ">= 16.4.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", - "cpu": [ - "arm" - ], + "node_modules/@electron-forge/core-utils/node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", "dev": true, - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, "engines": { - "node": ">=12" + "node": ">= 12.13.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", - "cpu": [ - "arm64" - ], + "node_modules/@electron-forge/core-utils/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { "node": ">=12" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", - "cpu": [ - "ia32" - ], + "node_modules/@electron-forge/core-utils/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/core-utils/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 10.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", - "cpu": [ - "loong64" - ], + "node_modules/@electron-forge/core/node_modules/@electron/get": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.0.0.tgz", + "integrity": "sha512-hLv4BYFiyrNRI+U0Mm2X7RxCCdJLkDUn8GCEp9QJzbLpZRko+UaLlCjOMkj6TEtirNLPyBA7y1SeGfnpOB21aQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, "engines": { - "node": ">=12" + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", - "cpu": [ - "mips64el" - ], + "node_modules/@electron-forge/core/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=12" + "node": ">=6 <7 || >=8" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", - "cpu": [ - "ppc64" - ], + "node_modules/@electron-forge/core/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "optional": true, - "os": [ - "linux" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron-forge/core/node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, "engines": { - "node": ">=12" + "node": ">= 12.13.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", - "cpu": [ - "riscv64" - ], + "node_modules/@electron-forge/core/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/core/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/core/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/maker-base": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.4.0.tgz", + "integrity": "sha512-LwWS4VPdwjISl1KpLhmM1Qr1M3sRTTQ/RsX+GlFd7cQ1W/FsgxMjaTG4Od1d+a5CGVTh3s6X2g99TSUfxjOveg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "fs-extra": "^10.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/maker-base/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/maker-base/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/maker-base/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/maker-deb": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.4.0.tgz", + "integrity": "sha512-npWea3IpGeu96xNqJpsCOYX6V4E+HY6u/okeTUzUOMX96UteT14MecdUefMam158glRTX84k2ryh7WcBoOa4mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "7.4.0", + "@electron-forge/shared-types": "7.4.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-installer-debian": "^3.2.0" + } + }, + "node_modules/@electron-forge/maker-dmg": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-dmg/-/maker-dmg-7.4.0.tgz", + "integrity": "sha512-xRCMNtnpvQNwrDYvwbVFegnErnIMpHGZANrjwushlH9+Fsu60DFvf5s3AVkgsYdQTqlY7wYRG1mziYZmRlPAIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "7.4.0", + "@electron-forge/shared-types": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-installer-dmg": "^4.0.0" + } + }, + "node_modules/@electron-forge/maker-dmg/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/maker-dmg/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/maker-dmg/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/maker-squirrel": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.4.0.tgz", + "integrity": "sha512-mCQyufnSNfjffiKho59ZqVg4W601zGOl6h01OyfDwjOU/G4iQtpnnDEOXGe26q7OVT5ORb1WDnfyGgBeJ6Ge7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "7.4.0", + "@electron-forge/shared-types": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-winstaller": "^5.3.0" + } + }, + "node_modules/@electron-forge/maker-squirrel/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/maker-squirrel/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/maker-squirrel/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/maker-zip": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.4.0.tgz", + "integrity": "sha512-UGbMdpuK/P29x1FFRWNOs3bNz+7QNFWVWyTM5hcWqib66cNuUmoaPifQyuwW2POIrIohrxlzLK87/i9Zc8g4dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/maker-base": "7.4.0", + "@electron-forge/shared-types": "7.4.0", + "cross-zip": "^4.0.0", + "fs-extra": "^10.0.0", + "got": "^11.8.5" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/maker-zip/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/maker-zip/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/maker-zip/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/plugin-base": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.4.0.tgz", + "integrity": "sha512-LcTNtEc2YaWvhhqWVIfdJ+J0/krSgc2dqYAHhOH2aLUSm9End3dKO/PZ1Y6DPsiPiJKHnSLBJ/XBN/16NY4Sjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/publisher-base": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.4.0.tgz", + "integrity": "sha512-PiJk4RfaC55SnVnteLW2ZIQNM9DpGOi6YoUn5t8i9UcVp2rFIdya7bJY/b9u1hwubm4d5+TdypMVEuJjM44CJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/shared-types": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.4.0.tgz", + "integrity": "sha512-5Ehy6enUjBaU08odf9u9TOhmOVXlqobzMvKUixtkdAWgV1XZAUJmn+p21xhj0IkO92MQiXMGv66w9pDNjRT8uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.4.0", + "@electron/packager": "^18.3.1", + "@electron/rebuild": "^3.2.10", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-base": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.4.0.tgz", + "integrity": "sha512-3YWdRSGzQfQPQkQxStn2wkJ/SuNGGKo9slwFJGvqMV+Pbx3/M/hYi9sMXOuaqVZgeaBp8Ap27yFPxaIIOC3vcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "fs-extra": "^10.0.0", + "username": "^5.1.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-base/node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@electron-forge/template-base/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/template-base/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/template-base/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/template-vite": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.4.0.tgz", + "integrity": "sha512-YPVyCGiBKmZPCxK/Bd2louV3PBcxI2nT2+tRKP+mlEHOWrxbZIfmZSR2lIAFvK/ALKlwUKROdmlwyi7ZcdT7JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@electron-forge/template-base": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite-typescript": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.4.0.tgz", + "integrity": "sha512-wdByG807VWcUd81E6572b/G/Ki8gb+GrCIWxO7Cl3qBa+yNaU1sHhBwB1RyTbQy1r8ubSBtsWrRD1J/yzHKWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@electron-forge/template-base": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite-typescript/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/template-vite-typescript/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/template-vite-typescript/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/template-vite/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/template-vite/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/template-vite/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/template-webpack": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.4.0.tgz", + "integrity": "sha512-W558AEGwQrwEtKIbIJPPs0LIsaC/1Vncj5NgqKehEMJjBb0KQq4hwBu/6dauQrfun4jRCOp7LV+OVrf5XPJ7QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@electron-forge/template-base": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack-typescript": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.4.0.tgz", + "integrity": "sha512-O5gwjNSGFNRdJWyiCtevcOBDPAMhgOPvLORh9qR1GcjyTutWwHWmZzycqH+MmkhpQPgrAYDEeipXcOQhSbzNZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.4.0", + "@electron-forge/template-base": "7.4.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack-typescript/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/template-webpack-typescript/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/template-webpack-typescript/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/template-webpack/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron-forge/template-webpack/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron-forge/template-webpack/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron-forge/tracer": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.4.0.tgz", + "integrity": "sha512-F4jbnDn4yIZjmky1FZ6rgBKTM05AZQQfHkyJW2hdS4pDKJjdKAqWytoZKDi1/S6Cr6tN+DD0TFGD3V0i6HPHYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron/asar": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.10.tgz", + "integrity": "sha512-mvBSwIBUeiRscrCeJE1LwctAriBj65eUDm0Pc11iE5gRwzkmsdbS7FnZ1XUWjpSeQWL1L5g12Fc/SchPM9DUOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/packager": { + "version": "18.3.2", + "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-18.3.2.tgz", + "integrity": "sha512-orjylavppgIh24qkNpWm2B/LQUpCS/YLOoKoU+eMK/hJgIhShLDsusPIQzgUGVwNCichu8/zPAGfdQZXHG0gtw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@electron/asar": "^3.2.1", + "@electron/get": "^3.0.0", + "@electron/notarize": "^2.1.0", + "@electron/osx-sign": "^1.0.5", + "@electron/universal": "^2.0.1", + "@electron/windows-sign": "^1.0.0", + "debug": "^4.0.1", + "extract-zip": "^2.0.0", + "filenamify": "^4.1.0", + "fs-extra": "^11.1.0", + "galactus": "^1.0.0", + "get-package-info": "^1.0.0", + "junk": "^3.1.0", + "parse-author": "^2.0.0", + "plist": "^3.0.0", + "resedit": "^2.0.0", + "resolve": "^1.1.6", + "semver": "^7.1.3", + "yargs-parser": "^21.1.1" + }, + "bin": { + "electron-packager": "bin/electron-packager.js" + }, + "engines": { + "node": ">= 16.13.0" + }, + "funding": { + "url": "https://github.com/electron/packager?sponsor=1" + } + }, + "node_modules/@electron/packager/node_modules/@electron/get": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.0.0.tgz", + "integrity": "sha512-hLv4BYFiyrNRI+U0Mm2X7RxCCdJLkDUn8GCEp9QJzbLpZRko+UaLlCjOMkj6TEtirNLPyBA7y1SeGfnpOB21aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/packager/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/packager/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/packager/node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/packager/node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@electron/packager/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/packager/node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/@electron/packager/node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/packager/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/packager/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/packager/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/packager/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.0.tgz", + "integrity": "sha512-zF4x3QupRU3uNGaP5X1wjpmcjfw1H87kyqZ00Tc3HvriV+4gmOGuvQjGNkrJuXdsApssdNyVwLsy+TaeTGGcVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@electron/rebuild/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.1.2.tgz", + "integrity": "sha512-eXEiZjDtxW3QORCWfRUarANPRTlH9B6At4jqBZJ0NzokSGutXQUVLPA6WmGpIhDW6w2yCMdHW1EJd1HrXtU5sg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -378,9 +2001,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -394,9 +2017,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -410,9 +2033,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -426,9 +2049,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -442,9 +2065,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -458,9 +2081,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -474,9 +2097,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -567,6 +2190,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@floating-ui/core": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz", @@ -599,6 +2238,13 @@ "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.1.tgz", "integrity": "sha512-0/Z2cuNXWVVIG/l0SpcHAWFhGdvLJ8DRvEfRWvmojtmRWfEy+LWNwgDazbZqY0qQYtkHcoEK3jBLkhiZaB/4Ig==" }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -776,6 +2422,30 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/@melt-ui/svelte": { "version": "0.76.2", "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", @@ -841,6 +2511,48 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@panva/asn1.js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", @@ -880,20 +2592,21 @@ "dev": true }, "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.7", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", - "integrity": "sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==", + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.0.tgz", + "integrity": "sha512-BJcu+a+Mpq476DMXG+hevgPSl56bkUoi88dKT8t3RyUp8kGuOh+2bU8Gs7zXDlu+fyZggnJ+iOBGrb/O1SorYg==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", - "glob": "^8.0.3", + "fdir": "^6.1.1", "is-reference": "1.2.1", - "magic-string": "^0.30.3" + "magic-string": "^0.30.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0 || 14 >= 14.17" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" @@ -910,25 +2623,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, - "node_modules/@rollup/plugin-commonjs/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -938,16 +2632,16 @@ "@types/estree": "*" } }, - "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, "engines": { - "node": ">=10" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/@rollup/plugin-json": { @@ -971,15 +2665,14 @@ } }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz", + "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", "is-module": "^1.0.0", "resolve": "^1.22.1" }, @@ -1023,10 +2716,22 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", "cpu": [ "arm" ], @@ -1037,9 +2742,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", "cpu": [ "arm64" ], @@ -1050,9 +2755,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", "cpu": [ "arm64" ], @@ -1063,9 +2768,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", "cpu": [ "x64" ], @@ -1076,9 +2781,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", "cpu": [ "arm" ], @@ -1089,9 +2807,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", "cpu": [ "arm64" ], @@ -1102,9 +2820,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", "cpu": [ "arm64" ], @@ -1114,10 +2832,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", "cpu": [ "riscv64" ], @@ -1127,10 +2858,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", "cpu": [ "x64" ], @@ -1141,9 +2885,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", "cpu": [ "x64" ], @@ -1154,9 +2898,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", "cpu": [ "arm64" ], @@ -1167,9 +2911,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", "cpu": [ "ia32" ], @@ -1180,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", "cpu": [ "x64" ], @@ -1236,14 +2980,14 @@ } }, "node_modules/@sveltejs/adapter-node": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", - "integrity": "sha512-eYdmxdUWMW+dad1JfMsWBPY2vjXz9eE+52A2AQnXPScPJlIxIVk5mmbaEEzrZivLfO2wEcLTZ5vdC03W69x+iA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.5.tgz", + "integrity": "sha512-FVeysFqeIlKFpDF1Oj38gby34f6uA9FuXnV330Z0RHmSyOR9JzJs70/nFKy1Ue3fWtf7S0RemOrP66Vr9Jcmew==", "dev": true, "dependencies": { - "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-commonjs": "^28.0.0", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-node-resolve": "^15.3.0", "rollup": "^4.9.5" }, "peerDependencies": { @@ -1251,26 +2995,26 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.2.tgz", - "integrity": "sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", + "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", "dev": true, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.4.tgz", - "integrity": "sha512-eDxK2d4EGzk99QsZNoPXe7jlzA5EGqfcCpUwZ912bhnalsZ2ZsG5wGRthkydupVjYyqdmzEanVKFhLxU2vkPSQ==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.6.3.tgz", + "integrity": "sha512-baIAnmfMqAISrPtTC/22w6ay5kTEIQ/vq9bctiaQgRIoLCPBNhb6LEidTuWQS7OzPYCDBMuMX1t/fMvi4r3q/g==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^4.3.2", + "devalue": "^5.1.0", "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", + "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", @@ -1286,7 +3030,7 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } @@ -1357,6 +3101,16 @@ "tailwindcss": ">=3.2.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -1413,9 +3167,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" }, "node_modules/@types/express": { "version": "4.17.21", @@ -1441,6 +3195,29 @@ "@types/send": "*" } }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -1478,10 +3255,19 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/node": { - "version": "18.19.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.26.tgz", - "integrity": "sha512-+wiMJsIwLOYCvUqSdKTrfkS8mpTp+MPINe6+Np4TAGFWWRWiBQ5kSq9nZGCSPkzx9mvT+uEukzpX4MOSCydcvw==", + "version": "20.14.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.4.tgz", + "integrity": "sha512-1ChboN+57suCT2t/f8lwtPY/k3qTpuD/qnqQuYoBg6OQOcPyaw7PiZVdGpaZYAvhDDtqrt0oAaM8+oSu1xsUGw==", + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } @@ -1572,6 +3358,17 @@ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", @@ -1864,12 +3661,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", "optional": true }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1911,6 +3725,32 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1939,6 +3779,35 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", + "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1978,6 +3847,223 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/appdmg": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/appdmg/-/appdmg-0.6.6.tgz", + "integrity": "sha512-GRmFKlCG+PWbcYF4LUNonTYmy0GjguDy6Jh9WP8mpd0T6j80XIJyXBiWlD0U+MLNhqV9Nhx49Gl9GpVToulpLg==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "async": "^1.4.2", + "ds-store": "^0.1.5", + "execa": "^1.0.0", + "fs-temp": "^1.0.0", + "fs-xattr": "^0.3.0", + "image-size": "^0.7.4", + "is-my-json-valid": "^2.20.0", + "minimist": "^1.1.3", + "parse-color": "^1.0.0", + "path-exists": "^4.0.0", + "repeat-string": "^1.5.4" + }, + "bin": { + "appdmg": "bin/appdmg.js" + }, + "engines": { + "node": ">=8.5" + } + }, + "node_modules/appdmg/node_modules/async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/appdmg/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/appdmg/node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/appdmg/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/appdmg/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/appdmg/node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/appdmg/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/appdmg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/appdmg/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/appdmg/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/appdmg/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/appdmg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2011,6 +4097,67 @@ "node": ">=8" } }, + "node_modules/asar": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/asar/-/asar-3.2.0.tgz", + "integrity": "sha512-COdw2ZQvKdFGFxXwX3oYh2/sOsJWJegrdJCGxnN4MZ7IULgRBp9P6665aqj9z1v9VwP4oP1hRBojRDQ//IGgAg==", + "deprecated": "Please use @electron/asar moving forward. There is no API change, just a package name change", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chromium-pickle-js": "^0.2.0", + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + }, + "optionalDependencies": { + "@types/glob": "^7.1.1" + } + }, + "node_modules/asar/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -2030,6 +4177,16 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/auth0": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.4.0.tgz", @@ -2048,8 +4205,18 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/author-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", + "integrity": "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" } }, "node_modules/autoprefixer": { @@ -2104,9 +4271,9 @@ } }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2126,6 +4293,38 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base32-encode": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/base32-encode/-/base32-encode-1.2.0.tgz", + "integrity": "sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-data-view": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -2175,6 +4374,18 @@ "node": "^18 || >=20" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/block-stream2": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", @@ -2183,10 +4394,17 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -2196,7 +4414,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -2219,6 +4437,25 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/bplist-creator": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz", + "integrity": "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "stream-buffers": "~2.2.0" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -2275,6 +4512,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -2283,17 +4545,12 @@ "node": "*" } }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, "node_modules/bytes": { "version": "3.1.2", @@ -2312,6 +4569,106 @@ "node": ">=8" } }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -2514,6 +4871,34 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -2522,6 +4907,35 @@ "node": ">=6" } }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -2669,6 +5083,23 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2694,6 +5125,16 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2829,6 +5270,13 @@ "node": ">=12" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2852,6 +5300,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -2873,6 +5322,31 @@ "node": ">= 0.10" } }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2886,6 +5360,30 @@ "node": ">= 8" } }, + "node_modules/cross-zip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cross-zip/-/cross-zip-4.0.1.tgz", + "integrity": "sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.10" + } + }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -3009,6 +5507,29 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3033,6 +5554,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3041,6 +5581,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3075,10 +5622,28 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "dev": true }, "node_modules/didyoumean": { @@ -3135,6 +5700,19 @@ "url": "https://dotenvx.com" } }, + "node_modules/ds-store": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ds-store/-/ds-store-0.1.6.tgz", + "integrity": "sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bplist-creator": "~0.0.3", + "macos-alias": "~0.2.5", + "tn1150": "^0.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3145,17 +5723,353 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/electron": { + "version": "31.0.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-31.0.1.tgz", + "integrity": "sha512-2eBcp4iqLkTsml6mMq+iqrS5u3kJ/2mpOLP7Mj7lo0uNK3OyfNqRS9z1ArsHjBF2/HV250Te/O9nKrwQRTX/+g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-installer-common": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/electron-installer-common/-/electron-installer-common-0.10.3.tgz", + "integrity": "sha512-mYbP+6i+nHMIm0WZHXgGdmmXMe+KXncl6jZYQNcCF9C1WsNA9C5SZ2VP4TLQMSIoFO+X4ugkMEA5uld1bmyEvA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "asar": "^3.0.0", + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "glob": "^7.1.4", + "lodash": "^4.17.15", + "parse-author": "^2.0.0", + "semver": "^7.1.1", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "url": "https://github.com/electron-userland/electron-installer-common?sponsor=1" + }, + "optionalDependencies": { + "@types/fs-extra": "^9.0.1" + } + }, + "node_modules/electron-installer-common/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-common/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-installer-common/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-installer-debian": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/electron-installer-debian/-/electron-installer-debian-3.2.0.tgz", + "integrity": "sha512-58ZrlJ1HQY80VucsEIG9tQ//HrTlG6sfofA3nRGr6TmkX661uJyu4cMPPh6kXW+aHdq/7+q25KyQhDrXvRL7jw==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.10.2", + "fs-extra": "^9.0.0", + "get-folder-size": "^2.0.1", + "lodash": "^4.17.4", + "word-wrap": "^1.2.3", + "yargs": "^16.0.2" + }, + "bin": { + "electron-installer-debian": "src/cli.js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-installer-debian/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/electron-installer-debian/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron-installer-debian/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-installer-debian/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-installer-debian/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-installer-debian/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-installer-debian/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-dmg": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/electron-installer-dmg/-/electron-installer-dmg-4.0.0.tgz", + "integrity": "sha512-g3W6XnyUa7QGrAF7ViewHdt6bXV2KYU1Pm1CY3pZpp+H6mOjCHHAhf/iZAxtaX1ERCb+SQHz7xSsAHuNH9I8ZQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "debug": "^4.3.2", + "minimist": "^1.1.1" + }, + "bin": { + "electron-installer-dmg": "bin/electron-installer-dmg.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "optionalDependencies": { + "appdmg": "^0.6.4" + } + }, + "node_modules/electron-serve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-2.0.0.tgz", + "integrity": "sha512-rXpUJxa8xqjuybBwJEX8uvMAwNpMbozvUXkXtBGzE71jzA7G36nv3MODgN9aOaAIDvjySkf2s2uDUirFgfKCjQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.711", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.711.tgz", "integrity": "sha512-hRg81qzvUEibX2lDxnFlVCHACa+LtrCPIsWAxo161LDYIB3jauf57RGsMZV9mvGwE98yGH06icj3zBEoOkxd/w==", "dev": true }, + "node_modules/electron-winstaller": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.3.1.tgz", + "integrity": "sha512-oM8BW3a8NEqG0XW+Vx3xywhk0DyDV4T0jT0zZfWt0IczNT3jHAAvQWBorF8osQDplSsCyXXyxrsrQ8cY0Slb/A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -3164,12 +6078,64 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "dependencies": { - "once": "^1.4.0" + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" } }, "node_modules/es-define-property": { @@ -3191,6 +6157,14 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -3198,9 +6172,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -3210,29 +6184,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -3506,6 +6480,13 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3529,37 +6510,57 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -3633,6 +6634,14 @@ "node": ">= 0.6" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3641,11 +6650,56 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3691,17 +6745,17 @@ "dev": true }, "node_modules/fast-xml-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", - "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" - }, { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" } ], "dependencies": { @@ -3719,6 +6773,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz", + "integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3731,15 +6809,32 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-selector": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz", - "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==", + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.0.3" + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" }, "engines": { - "node": ">= 10" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/fill-range": { @@ -3762,12 +6857,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -3786,6 +6881,14 @@ "ms": "2.0.0" } }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -3827,6 +6930,69 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/flora-colossus": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", + "integrity": "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/flora-colossus/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/flora-colossus/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/flora-colossus/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/fmix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fmix/-/fmix-0.1.0.tgz", + "integrity": "sha512-Y6hyofImk9JdzU8k5INtTXX1cu8LDlePWDFU5sftm9H+zKCr5SGrVjdhkvsim646cw5zD0nADj8oHyXMZmCZ9w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "imul": "^1.0.0" + } + }, "node_modules/focus-trap": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", @@ -3919,49 +7085,250 @@ "node": ">= 0.6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-temp": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fs-temp/-/fs-temp-1.2.1.tgz", + "integrity": "sha512-okTwLB7/Qsq82G6iN5zZJFsOfZtx2/pqrA7Hk/9fvy+c+eJS9CvgGXT2uNxwnI14BDY9L/jQPkaBgSvlKfSW9w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "random-path": "^0.1.0" + } + }, + "node_modules/fs-xattr": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/fs-xattr/-/fs-xattr-0.3.1.tgz", + "integrity": "sha512-UVqkrEW0GfDabw4C3HOrFlxKfx0eeigfRne69FxSBdHIP8Qt5Sq6Pu3RM9KmMlkygtC4pPKkj5CiPO5USnj2GA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "!win32" + ], + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "engines": { + "node": ">=10" + } + }, + "node_modules/futoin-hkdf": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz", + "integrity": "sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/galactus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/galactus/-/galactus-1.0.0.tgz", + "integrity": "sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "flora-colossus": "^2.0.0", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/galactus/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/galactus/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/galactus/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 10.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/gar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz", + "integrity": "sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true }, - "node_modules/fuse.js": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", - "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, "engines": { - "node": ">=10" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/futoin-hkdf": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz", - "integrity": "sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==", + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { "node": ">=8" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-property": "^1.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3970,6 +7337,21 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-folder-size": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-2.0.1.tgz", + "integrity": "sha512-+CEb+GDCM7tkOS2wdMKTn9vU7DgnKUTuDlehkNJKNSovdCOVxs14OfKCk4cvSaR3za4gj+OBdl9opPN9xrJ0zA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "gar": "^1.0.4", + "tiny-each-async": "2.0.3" + }, + "bin": { + "get-folder-size": "bin/get-folder-size" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -3979,6 +7361,16 @@ "node": "*" } }, + "node_modules/get-installed-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/get-installed-path/-/get-installed-path-2.1.1.tgz", + "integrity": "sha512-Qkn9eq6tW5/q9BDVdMpB8tOHljX9OSP0jRC5TRNVA4qRc839t4g8KQaR8t0Uv0EFVL0MlyG7m/ofjEgAROtYsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-modules": "1.0.0" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3997,6 +7389,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-info": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-package-info/-/get-package-info-1.0.0.tgz", + "integrity": "sha512-SCbprXGAPdIhKAXiG+Mk6yeoFH61JlYunqdFQFHDtLjJlDjFf6x07dsS8acO+xWt52jpdVo49AlVDnUVK1sDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.1.1", + "debug": "^2.2.0", + "lodash.get": "^4.0.0", + "read-pkg-up": "^2.0.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/get-package-info/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/get-package-info/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -4062,6 +7487,70 @@ "node": "*" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -4077,6 +7566,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -4212,6 +7719,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4231,6 +7745,19 @@ "node": ">=12.0.0" } }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -4251,6 +7778,21 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -4263,6 +7805,20 @@ "node": ">=10.19.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -4272,6 +7828,16 @@ "node": ">=16.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4283,6 +7849,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -4292,6 +7879,20 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4309,15 +7910,26 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/imul": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz", + "integrity": "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4335,21 +7947,59 @@ "node": ">=8" } }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, "node_modules/ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", @@ -4373,6 +8023,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4384,21 +8041,6 @@ "node": ">=8" } }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", - "dev": true, - "dependencies": { - "builtin-modules": "^3.3.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -4462,12 +8104,52 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, + "node_modules/is-my-ip-valid": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.1.tgz", + "integrity": "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/is-my-json-valid": { + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.20.6.tgz", + "integrity": "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^5.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4485,6 +8167,14 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/is-reference": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", @@ -4519,6 +8209,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4593,6 +8306,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4615,12 +8335,51 @@ "resolved": "https://registry.npmjs.org/json-stream/-/json-stream-1.0.0.tgz", "integrity": "sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/jsonc-parser": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", "dev": true }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/junk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", + "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4665,58 +8424,268 @@ "node": ">=10" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/listr2": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz", + "integrity": "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^3.1.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^5.0.1", + "rfdc": "^1.3.0", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", + "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^5.0.0", + "cli-cursor": "^4.0.0", + "slice-ansi": "^5.0.0", + "strip-ansi": "^7.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, + "license": "MIT", "dependencies": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/antfu" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -4753,6 +8722,21 @@ "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, + "node_modules/macos-alias": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.11.tgz", + "integrity": "sha512-zIUs3+qpml+w3wiRuADutd7XIO8UABqksot10Utl/tji4UxZzLG4fWDC+yJZoO8/Ehg5RqsvSRE/6TS5AEOeWw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "nan": "^2.4.0" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -4769,6 +8753,84 @@ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -4782,10 +8844,38 @@ "node": ">= 0.6" } }, + "node_modules/mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mem/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -4810,17 +8900,28 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -4928,13 +9029,176 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -4999,6 +9263,19 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/murmur-32": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/murmur-32/-/murmur-32-0.2.0.tgz", + "integrity": "sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "encode-utf8": "^1.0.3", + "fmix": "^0.1.0", + "imul": "^1.0.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5009,6 +9286,14 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -5037,7 +9322,37 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "engines": { - "node": ">= 0.6" + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.65.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz", + "integrity": "sha512-ThjYBfoDNr08AWx6hGaRbfPwxKV9kVzAzOzlLKbk2CuqXE2xnCh+cbAGnwM3t8Lq4v9rUB7VfondlkBckcJrVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-api-version": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.0.tgz", + "integrity": "sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" } }, "node_modules/node-cache": { @@ -5051,12 +9366,105 @@ "node": ">= 8.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5112,6 +9520,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -5141,6 +9566,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", @@ -5236,6 +9672,90 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/overlayscrollbars": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.6.1.tgz", @@ -5259,6 +9779,36 @@ "node": ">=8" } }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5289,6 +9839,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5314,6 +9880,60 @@ "node": ">=6" } }, + "node_modules/parse-author": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-author/-/parse-author-2.0.0.tgz", + "integrity": "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "author-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-color": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "~0.5.0" + } + }, + "node_modules/parse-color/node_modules/color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", + "dev": true, + "optional": true + }, + "node_modules/parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5354,15 +9974,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5377,9 +9998,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -5405,6 +10026,28 @@ "node": "*" } }, + "node_modules/pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/periscopic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", @@ -5416,35 +10059,107 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "optional": true, + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=8" } }, - "node_modules/pify": { + "node_modules/pkg-dir/node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, "engines": { - "node": ">= 6" + "node": ">=8" } }, "node_modules/pkg-types": { @@ -5488,6 +10203,31 @@ "node": ">=16" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -5497,9 +10237,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -5516,8 +10256,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5663,6 +10403,32 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5734,6 +10500,37 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5778,11 +10575,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -5838,6 +10635,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/random-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/random-path/-/random-path-0.1.2.tgz", + "integrity": "sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "base32-encode": "^0.1.0 || ^1.0.0", + "murmur-32": "^0.1.0 || ^0.2.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5866,6 +10675,19 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5874,6 +10696,121 @@ "pify": "^2.3.0" } }, + "node_modules/read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5898,11 +10835,46 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5916,6 +10888,24 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, + "node_modules/resedit": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.2.tgz", + "integrity": "sha512-UKTnq602iVe+W5SyRAQx/WdWMnlDiONfXBLFg/ur4QE4EQQ8eP7Jgm5mNXdK12kKawk1vvXPja2iXKqZiGDW6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^1.0.1" + }, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -5937,6 +10927,20 @@ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5946,17 +10950,91 @@ "node": ">=4" } }, + "node_modules/resolve-package": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-package/-/resolve-package-1.0.1.tgz", + "integrity": "sha512-rzB7NnQpOkPHBWFPP3prUMqOP6yg3HkRGgcvR+lDyvyHoY3fZLFLYDkPXh78SPVBAE6VTCk/V+j8we4djg6o4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-installed-path": "^2.0.3" + }, + "engines": { + "node": ">=4", + "npm": ">=2" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", "dependencies": { - "lowercase-keys": "^2.0.0" + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -5966,6 +11044,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -5981,13 +11066,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -5997,19 +11101,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" } }, @@ -6134,10 +11241,18 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -6175,20 +11290,59 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" } }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -6354,6 +11508,47 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sorcery": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", @@ -6369,20 +11564,77 @@ "sorcery": "bin/sorcery" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -6391,6 +11643,39 @@ "node": ">=6" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6411,6 +11696,17 @@ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", + "optional": true, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -6510,6 +11806,26 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -6558,6 +11874,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-outer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/stripe": { "version": "15.5.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.5.0.tgz", @@ -6617,6 +11956,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6641,9 +12000,9 @@ } }, "node_modules/svelte": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.12.tgz", - "integrity": "sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -6713,17 +12072,6 @@ } } }, - "node_modules/svelte-file-dropzone": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/svelte-file-dropzone/-/svelte-file-dropzone-2.0.2.tgz", - "integrity": "sha512-+0gSO21Jp3zDvpzq0AQTz/afInQGjkjv0CSr8+dXvLg+JjIQxQ9mJRa5+Taqx4/NTKExqB5zr8XoTfCdk30FTQ==", - "dependencies": { - "file-selector": "^0.2.4" - }, - "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0" - } - }, "node_modules/svelte-hmr": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", @@ -6886,6 +12234,15 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tailwindcss/node_modules/postcss-load-config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", @@ -6942,6 +12299,77 @@ "node": ">= 14" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6975,6 +12403,14 @@ "readable-stream": "3" } }, + "node_modules/tiny-each-async": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", + "integrity": "sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -7006,9 +12442,53 @@ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, "engines": { - "node": ">=14.0.0" + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tn1150": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/tn1150/-/tn1150-0.1.0.tgz", + "integrity": "sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "unorm": "^1.4.1" + }, + "engines": { + "node": ">=0.12" } }, + "node_modules/to-data-view": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-1.1.0.tgz", + "integrity": "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7037,6 +12517,13 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7046,6 +12533,29 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/trim-repeated/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -7132,11 +12642,66 @@ "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", "dev": true }, + "node_modules/undici": { + "version": "6.19.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.4.tgz", + "integrity": "sha512-i3uaEUwNdkRq2qtTRRJb13moW5HWqviu7Vl7oYRYz++uPtGHJj+x7TGjcEuwS5Mt2P4nA0U9dhIX3DdB6JGY0g==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unorm": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz", + "integrity": "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==", + "dev": true, + "license": "MIT or GPL-2.0", + "optional": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7189,6 +12754,155 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" }, + "node_modules/username": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/username/-/username-5.1.0.tgz", + "integrity": "sha512-PCKbdWw85JsYMvmCv5GH3kXmM66rCd9m1hBEDutPNv94b/pqCMT4NtcKyeWYvLFiE8b+ha1Jdl8XAaUdPn5QTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0", + "mem": "^4.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/username/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/username/node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/username/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/username/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/username/node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/username/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/username/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/username/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/username/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/username/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/username/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -7226,6 +12940,17 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7235,14 +12960,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -7261,6 +12986,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -7278,6 +13004,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -7404,6 +13133,16 @@ } } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-encoding": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", @@ -7415,6 +13154,24 @@ "@zxing/text-encoding": "0.9.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7468,6 +13225,49 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -7585,6 +13385,17 @@ "node": ">=4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -7732,6 +13543,108 @@ "node": ">=6" } }, + "node_modules/yarn-or-npm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/yarn-or-npm/-/yarn-or-npm-3.0.1.tgz", + "integrity": "sha512-fTiQP6WbDAh5QZAVdbMQkecZoahnbOjClTQhzv74WX5h2Uaidj1isf9FDes11TKtsZ0/ZVfZsqZ+O3x6aLERHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.5", + "pkg-dir": "^4.2.0" + }, + "bin": { + "yarn-or-npm": "bin/index.js", + "yon": "bin/index.js" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/yarn-or-npm/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/yarn-or-npm/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/yarn-or-npm/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/yarn-or-npm/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yarn-or-npm/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yarn-or-npm/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/services/app/package.json b/services/app/package.json index ff7fafd..a534027 100644 --- a/services/app/package.json +++ b/services/app/package.json @@ -1,13 +1,18 @@ { - "name": "jamai-frontend", - "version": "0.1.0", + "name": "jamaibase-app", + "version": "0.2.0", "private": true, + "main": "electron/main.js", + "author": "EmbeddedLLM", + "description": "JamAI Base App", "scripts": { "dev": "vite dev", "dev:stripe": "concurrently \"vite dev\" \"stripe listen --forward-to localhost:5173/api/stripe-webhook\"", "build": "run-script-os", "build:win32": "build.bat", "build:darwin:linux": "/bin/bash build.sh", + "package": "npm run build && electron-forge package", + "make": "npm run build && electron-forge make", "preview": "vite preview", "start": "node server", "devstart": "ORIGIN=http://localhost:4173 HOST=localhost FRONTEND_PORT=4173 NODE_ENV=development node server", @@ -17,9 +22,16 @@ "lint": "prettier --check . && eslint .", "format": "prettier --write .", "test:integration": "playwright test", - "test:unit": "vitest" + "test:unit": "vitest", + "start:debug_electron": "electron-forge start --inspect-electron" }, "devDependencies": { + "@electron-forge/cli": "^7.4.0", + "@electron-forge/maker-deb": "^7.4.0", + "@electron-forge/maker-dmg": "^7.4.0", + "@electron-forge/maker-squirrel": "^7.4.0", + "@electron-forge/maker-zip": "^7.4.0", + "@faker-js/faker": "^8.4.1", "@playwright/test": "^1.28.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.2", @@ -37,6 +49,8 @@ "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.18", "concurrently": "^8.2.2", + "cross-env": "^7.0.3", + "electron": "^31.0.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", @@ -47,6 +61,7 @@ "svelte": "^4.2.7", "svelte-check": "^3.6.0", "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.7", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3", @@ -66,6 +81,7 @@ "clsx": "^2.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", + "electron-serve": "^2.0.0", "express": "^4.19.2", "express-openid-connect": "^2.17.1", "fuse.js": "^7.0.0", @@ -82,11 +98,11 @@ "showdown": "^2.1.0", "showdown-htmlescape": "^0.1.9", "stripe": "^15.5.0", - "svelte-file-dropzone": "^2.0.2", "svelte-persisted-store": "^0.9.1", "svelte-sonner": "^0.3.24", "tailwind-merge": "^2.2.2", "tailwind-variants": "^0.2.1", + "undici": "^6.19.4", "uuid": "^9.0.1", "zod": "^3.22.4" } diff --git a/services/app/playwright.config.ts b/services/app/playwright.config.ts index 6dd58ac..ec6e8b8 100644 --- a/services/app/playwright.config.ts +++ b/services/app/playwright.config.ts @@ -9,34 +9,52 @@ const config: PlaywrightTestConfig = { testDir: 'tests', testMatch: /(.+\.)?(test|spec)\.[jt]s/, outputDir: 'playwright/results', - reporter: [['html', { open: 'always', outputFolder: 'playwright/reports' }]], + reporter: [['html', { open: 'never', outputFolder: 'playwright/reports' }]], use: { screenshot: 'on', baseURL: 'http://localhost:4173/' }, projects: [ - // Setup project - { name: 'setup', testMatch: /.*\.setup\.ts/ }, - - /* { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - // Use prepared auth state. - storageState: 'playwright/.auth/user.json' - }, - dependencies: ['setup'] - }, */ + { name: 'auth-setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'main-setup', + testMatch: /main\.setup\.ts/, + teardown: 'cleanup', + dependencies: ['auth-setup'] + }, + { name: 'cleanup', testMatch: /main\.teardown\.ts/ }, + //* main { name: 'chrome', use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, - dependencies: ['setup'] + dependencies: ['main-setup'] } - ] + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // storageState: 'playwright/.auth/user.json' + // }, + // dependencies: ['main-setup'] + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // storageState: 'playwright/.auth/user.json' + // }, + // dependencies: ['main-setup'] + // } + ], + retries: 2, + timeout: 45_000, + expect: { + timeout: 10_000 + } }; export default config; diff --git a/services/app/src/app.css b/services/app/src/app.css index fea5b15..db664f0 100644 --- a/services/app/src/app.css +++ b/services/app/src/app.css @@ -2,10 +2,6 @@ @tailwind components; @tailwind utilities; -body { - overflow: hidden; -} - @layer base { :root { --background: 0 0% 100%; diff --git a/services/app/src/app.html b/services/app/src/app.html index 77a5ff5..9b1b3b8 100644 --- a/services/app/src/app.html +++ b/services/app/src/app.html @@ -3,6 +3,7 @@ + %sveltekit.head% diff --git a/services/app/src/globalStore.ts b/services/app/src/globalStore.ts index 9e29cb6..1ca4e21 100644 --- a/services/app/src/globalStore.ts +++ b/services/app/src/globalStore.ts @@ -1,4 +1,4 @@ -import type { AvailableModel, Organization, UploadQueue } from '$lib/types'; +import type { AvailableModel, Organization, Project, UploadQueue } from '$lib/types'; import { serializer } from '$lib/utils'; import { persisted } from 'svelte-persisted-store'; import { writable } from 'svelte/store'; @@ -10,6 +10,31 @@ export const preferredTheme = persisted<'LIGHT' | 'DARK' | 'SYSTEM'>('theme', 'L serializer }); +type SortOptions = { + orderBy: string; + order: 'asc' | 'desc'; +}; +export const projectSort = persisted( + 'projectSort', + { orderBy: 'updated_at', order: 'desc' }, + { serializer } +); +export const aTableSort = persisted( + 'aTableSort', + { orderBy: 'updated_at', order: 'desc' }, + { serializer } +); +export const kTableSort = persisted( + 'kTableSort', + { orderBy: 'updated_at', order: 'desc' }, + { serializer } +); +export const cTableSort = persisted( + 'cTableSort', + { orderBy: 'updated_at', order: 'desc' }, + { serializer } +); + export const modelsAvailable = writable([]); export const uploadQueue = writable({ @@ -21,3 +46,8 @@ export const uploadController = writable(null); //* Non-local export const activeOrganization = writable(null); +export const activeProject = writable(null); +export const loadingProjectData = writable<{ loading: boolean; error?: string }>({ + loading: true, + error: undefined +}); diff --git a/services/app/src/hooks.server.ts b/services/app/src/hooks.server.ts index 58299f4..399d9b7 100644 --- a/services/app/src/hooks.server.ts +++ b/services/app/src/hooks.server.ts @@ -2,9 +2,9 @@ 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 nodeCache from '$lib/nodeCache'; +import { Agent } from 'undici'; +import { getPrices } from '$lib/server/nodeCache'; import logger from '$lib/logger'; -import type { OrganizationReadRes, Project } from '$lib/types'; const PROXY_PATHS: { path: string; target: string }[] = [ { @@ -22,6 +22,18 @@ const PROXY_PATHS: { path: string; target: string }[] = [ { path: '/api/v1/chat/completions', target: JAMAI_URL + }, + { + path: '/api/v1/files', + target: JAMAI_URL + }, + { + path: '/api/file', + target: JAMAI_URL + }, + { + path: '/api/public/v1/templates', + target: JAMAI_URL } ]; @@ -34,77 +46,44 @@ const handleApiProxy: Handle = async ({ event }) => { if (PUBLIC_IS_LOCAL === 'false') { if (event.locals.user) { - const activeOrganizationId = event.cookies.get('activeOrganizationId'); - const projectId = - event.request.headers.get('x-project-id') || event.cookies.get('activeProjectId'); - - if (!projectId) { - return json({ message: 'Missing project ID' }, { status: 400 }); - } - - //* Get organization ID from project ID - const projectRes = await fetch(`${JAMAI_URL}/api/admin/v1/projects/${projectId}`, { - headers: { Authorization: `Bearer ${JAMAI_SERVICE_KEY}` } - }); - const projectBody = (await projectRes.json()) as Project; - - if (!projectRes.ok) { - if (projectRes.status === 404) { - return json(projectBody, { status: 404 }); - } - logger.error('APP_PROXY_PROJECTGET', projectBody); - return json(projectBody, { status: projectRes.status }); - } - - //* Ensure project is in organization, if applicable - if (activeOrganizationId && activeOrganizationId !== projectBody.organization_id) { - return json( - { message: 'Project not found.', org_id: projectBody.organization_id }, - { status: 404 } - ); - } - - const orgId = projectBody.organization_id; - - //* Check if user is part of organization - const orgInfoRes = await fetch(`${JAMAI_URL}/api/admin/v1/organizations/${orgId}`, { - headers: { Authorization: `Bearer ${JAMAI_SERVICE_KEY}` } - }); - const orgInfoBody = (await orgInfoRes.json()) as OrganizationReadRes; - - if (!orgInfoRes.ok) { - if (orgInfoRes.status === 404) { - return json(orgInfoBody, { status: 404 }); - } - logger.error('APP_PROXY_ORGGET', orgInfoBody); - return json(orgInfoBody, { status: orgInfoRes.status }); - } - - if (!orgInfoBody.users!.find((user) => user.user_id === event.locals.user!.sub)) { - return json({ message: 'Forbidden' }, { status: 403 }); - } - event.request.headers.append('Authorization', `Bearer ${JAMAI_SERVICE_KEY}`); - if (!event.request.headers.get('x-project-id')) { - event.request.headers.append('x-project-id', projectId); - } + event.request.headers.append('x-user-id', event.locals.user.sub); } } + const projectId = + event.request.headers.get('x-project-id') || event.cookies.get('activeProjectId'); + if (!projectId) { + return json({ message: 'Missing project ID' }, { status: 400 }); + } + + if (!event.request.headers.get('x-project-id')) { + event.request.headers.append('x-project-id', projectId); + } + return fetch(proxiedUrl.toString(), { body: event.request.body, method: event.request.method, headers: event.request.headers, //@ts-expect-error missing type - duplex: 'half' + duplex: 'half', + dispatcher: new Agent({ + connectTimeout: 0, + headersTimeout: 0, + bodyTimeout: 0 + }) }).catch((err) => { - logger.error('APP_PROXY_ERROR', { url: proxiedUrl.toString(), error: err }); - throw err; + if (err.cause.message !== 'aborted') { + logger.error('APP_PROXY_ERROR', { url: proxiedUrl.toString(), error: err }); + throw err; + } + return new Response('Internal Server Error', { status: 500 }); }); }; export const handle: Handle = async ({ event, resolve }) => { - if (dev) console.log('Connecting', event.request.url); + if (dev && !event.request.url.includes('/api/v1/files')) + console.log('Connecting', event.request.url); if (PUBLIC_IS_LOCAL === 'false') { //? Workaround for event.platform unavailable in development @@ -131,16 +110,6 @@ export const handle: Handle = async ({ event, resolve }) => { //* Server startup script if (PUBLIC_IS_LOCAL === 'false') { (async function () { - const pricesRes = await fetch(`${JAMAI_URL}/api/admin/v1/prices`, { - headers: { Authorization: `Bearer ${JAMAI_SERVICE_KEY}` } - }); - const pricesBody = await pricesRes.json(); - - if (!pricesRes.ok) { - logger.error('APP_PRICES', pricesBody); - throw new Error('Error fetching prices'); - } - - nodeCache.set('prices', pricesBody); + await getPrices(); })(); } diff --git a/services/app/src/lib/assets/jamai-onboarding-bg.svg b/services/app/src/lib/assets/jamai-onboarding-bg.svg index b527d04..a314422 100644 --- a/services/app/src/lib/assets/jamai-onboarding-bg.svg +++ b/services/app/src/lib/assets/jamai-onboarding-bg.svg @@ -182,7 +182,7 @@ - - + + - + \ No newline at end of file diff --git a/services/app/src/lib/components/Checkbox.svelte b/services/app/src/lib/components/Checkbox.svelte index dd73740..119805c 100644 --- a/services/app/src/lib/components/Checkbox.svelte +++ b/services/app/src/lib/components/Checkbox.svelte @@ -40,7 +40,7 @@ role="checkbox" value="on" class={cn( - 'peer h-4 w-4 shrink-0 rounded-sm border border-[#4169e1] data-dark:border-[#5b7ee5] ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:border-neutral-500 aria-checked:bg-[#4169e1] data-dark:checked:bg-[#5b7ee5] aria-checked:text-white aria-checked:border-[#4169e1] data-dark:aria-checked:border-[#5b7ee5] overflow-hidden', + 'peer h-4 w-4 shrink-0 rounded-sm hover:bg-white focus-visible:bg-white border border-[#98A2B3] data-dark:border-[#5b7ee5] ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:border-neutral-500 aria-checked:bg-[#BF416E] data-dark:checked:bg-[#5b7ee5] aria-checked:text-white aria-checked:border-[#BF416E] data-dark:aria-checked:border-[#5b7ee5] overflow-hidden', className )} > diff --git a/services/app/src/lib/components/CustomToast.svelte b/services/app/src/lib/components/CustomToast.svelte deleted file mode 100644 index 8550390..0000000 --- a/services/app/src/lib/components/CustomToast.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - -

-
-
- -
- {title} - - {subtitle} -
- - -
-
diff --git a/services/app/src/lib/components/DraggableList.svelte b/services/app/src/lib/components/DraggableList.svelte new file mode 100644 index 0000000..baf892d --- /dev/null +++ b/services/app/src/lib/components/DraggableList.svelte @@ -0,0 +1,132 @@ + + + + + + {#each itemList as item, itemIndex (item)} + + {/each} + + + + + diff --git a/services/app/src/lib/components/InputText.svelte b/services/app/src/lib/components/InputText.svelte index 804cf35..d10c8fb 100644 --- a/services/app/src/lib/components/InputText.svelte +++ b/services/app/src/lib/components/InputText.svelte @@ -19,7 +19,7 @@ }>(); $: inputClass = cn( - `${obfuscate ? 'pl-3 pr-12' : 'px-3'} py-2 w-full text-sm placeholder:italic bg-transparent data-dark:bg-[#42464e] rounded-md border border-[#DDD] data-dark:border-[#42464E] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-[#4169e1] data-dark:focus-visible:border-[#5b7ee5] disabled:cursor-not-allowed disabled:opacity-50 transition-colors`, + `${obfuscate ? 'pl-3 pr-12' : 'px-3'} py-2 w-full text-sm placeholder:italic bg-[#F2F4F7] data-dark:bg-[#42464e] rounded-md border border-transparent placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-[#4169e1] data-dark:focus-visible:border-[#5b7ee5] disabled:cursor-not-allowed disabled:opacity-50 transition-colors`, className ); let showVal = false; @@ -34,14 +34,7 @@ {#if type === 'search'} - + {:else if obfuscate && !showVal} diff --git a/services/app/src/lib/components/Range.svelte b/services/app/src/lib/components/Range.svelte index 761c81f..5e06daf 100644 --- a/services/app/src/lib/components/Range.svelte +++ b/services/app/src/lib/components/Range.svelte @@ -37,7 +37,7 @@ cursor: pointer; /* animate: 0.2s; */ box-shadow: 0px 0px 1px #000000; - background: #5b7ee5; + background: #d5607c; border-radius: 50px; border: 0px solid #000000; } @@ -47,13 +47,13 @@ height: 14px; width: 14px; border-radius: 50px; - background: #4169e1; + background: #a62050; cursor: pointer; -webkit-appearance: none; margin-top: -4px; } input[type='range']:focus::-webkit-slider-runnable-track { - background: #5b7ee5; + background: #d5607c; } input[type='range']::-moz-range-track { width: 100%; @@ -61,7 +61,7 @@ cursor: pointer; /* animate: 0.2s; */ box-shadow: 0px 0px 1px #000000; - background: #5b7ee5; + background: #d5607c; border-radius: 50px; border: 0px solid #000000; } @@ -71,7 +71,7 @@ height: 14px; width: 14px; border-radius: 50px; - background: #4169e1; + background: #a62050; cursor: pointer; } input[type='range']::-ms-track { @@ -84,13 +84,13 @@ color: transparent; } input[type='range']::-ms-fill-lower { - background: #5b7ee5; + background: #d5607c; border: 0px solid #000000; border-radius: 100px; box-shadow: 0px 0px 1px #000000; } input[type='range']::-ms-fill-upper { - background: #5b7ee5; + background: #d5607c; border: 0px; border-radius: 100px; box-shadow: 0px 0px 1px #000000; @@ -102,13 +102,13 @@ height: 14px; width: 14px; border-radius: 50px; - background: #4169e1; + background: #a62050; cursor: pointer; } input[type='range']:focus::-ms-fill-lower { - background: #5b7ee5; + background: #d5607c; } input[type='range']:focus::-ms-fill-upper { - background: #5b7ee5; + background: #d5607c; } diff --git a/services/app/src/lib/components/Tooltip.svelte b/services/app/src/lib/components/Tooltip.svelte index d55becd..9e732ca 100644 --- a/services/app/src/lib/components/Tooltip.svelte +++ b/services/app/src/lib/components/Tooltip.svelte @@ -14,7 +14,7 @@ bind:this={tooltip} style="--arrow-size: {arrowSize}px; {style || ''}" class={cn( - "absolute p-2 bg-black text-white rounded-lg after:content-[''] after:absolute after:left-3 after:-bottom-5 pointer-events-none transition-opacity", + "absolute px-1.5 py-[3px] text-xs bg-[#1D2939] text-white rounded-sm after:content-[''] after:absolute after:left-1/2 after:-translate-x-1/2 pointer-events-none transition-opacity", showArrow ? 'after:block' : 'after:hidden', className )} @@ -24,7 +24,8 @@ diff --git a/services/app/src/lib/components/tables/(sub)/DeleteFileDialog.svelte b/services/app/src/lib/components/tables/(sub)/DeleteFileDialog.svelte new file mode 100644 index 0000000..394b428 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/DeleteFileDialog.svelte @@ -0,0 +1,70 @@ + + + { + if (!e) { + isDeletingFile = null; + } + }} +> + + + + Close + + +
+ +

Are you sure?

+

+ Do you really want to delete file + + `{isDeletingFile?.fileUri + ? isDeletingFile.fileUri.split('/').pop() + : $genTableRows + ?.find((row) => row.ID == isDeletingFile?.rowID) + ?.[isDeletingFile?.columnID ?? ''].value.split('/') + .pop()}` + ? +

+
+ + +
+ + + + +
+
+
+
diff --git a/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte b/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte new file mode 100644 index 0000000..0d3afeb --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte @@ -0,0 +1,164 @@ + + +{#if fileUri && isValidUri(fileUri)} +
+ {#if fileUrl && isValidUri(fileUrl)?.protocol.startsWith('http')} + + {:else} +
+
+ + {fileUri.split('/').pop()} +
+
+ {/if} + +
+ + + {#if fileUrl && isValidUri(fileUrl)?.protocol.startsWith('http')} + + + + + +
+ + {fileUri.split('/').pop()} + +
+ +
+ + + + + + +
+
+
+ {:else if fileUri} + + {/if} +
+
+{:else} + + {fileUri === undefined ? null : fileUri} + +{/if} diff --git a/services/app/src/lib/components/tables/(sub)/FileSelect.svelte b/services/app/src/lib/components/tables/(sub)/FileSelect.svelte new file mode 100644 index 0000000..ba19923 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/FileSelect.svelte @@ -0,0 +1,189 @@ + + + +
{ + if (e.dataTransfer?.items) { + if ([...e.dataTransfer.items].some((item) => item.kind === 'file')) { + filesDragover = true; + } + } + }} + on:dragleave={debounce(handleDragLeave, 50)} + on:drop|preventDefault={(e) => { + filesDragover = false; + if (e.dataTransfer?.items) { + handleSelectFiles( + [...e.dataTransfer.items] + .map((item) => { + if (item.kind === 'file') { + const itemFile = item.getAsFile(); + if (itemFile) { + return itemFile; + } else { + return []; + } + } else { + return []; + } + }) + .flat() + ); + } else { + handleSelectFiles([...(e.dataTransfer?.files ?? [])]); + } + }} + class="flex flex-col gap-1 px-2 py-2 h-full w-full" +> + + {/if} + + handleSelectFiles([...(e.currentTarget.files ?? [])])} + multiple={false} + class="fixed max-h-[0] max-w-0 !p-0 !border-none overflow-hidden" + /> + + + Supports: {fileColumnFiletypes.join(', ')} +
diff --git a/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte b/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte new file mode 100644 index 0000000..1bb1889 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte @@ -0,0 +1,124 @@ + diff --git a/services/app/src/lib/components/tables/(sub)/NewRow.svelte b/services/app/src/lib/components/tables/(sub)/NewRow.svelte new file mode 100644 index 0000000..df7d642 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/NewRow.svelte @@ -0,0 +1,484 @@ + + + { + if (!newRowForm.contains(document.activeElement)) { + const formData = new FormData(newRowForm); + const obj = Object.fromEntries( + Array.from(formData.keys()).map((key) => [ + key.replace('new-row-', ''), + formData.getAll(key).length > 1 ? formData.getAll(key) : formData.get(key) + ]) + ); + + if (!Object.keys(obj).every((key) => !obj[key]) || Object.keys(uploadColumns).length !== 0) { + return; + } + + isAddingRow = false; + maxInputHeight = 36; + uploadColumns = {}; + inputValues = {}; + } + }} +/> + + + +
(isAddingRow = true)} + on:keydown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.requestSubmit(); + } + }} + on:submit|preventDefault={handleAddRow} + style="grid-template-columns: 45px {focusedCol === 'ID' ? '320px' : '120px'} {focusedCol === + 'Updated at' + ? '320px' + : '130px'} {tableData.cols.length - 2 !== 0 + ? `repeat(${tableData.cols.length - 2}, minmax(320px, 1fr))` + : ''};" + class="sticky top-[36px] z-20 grid place-items-start h-min max-h-[100px] sm:max-h-[150px] text-xs sm:text-sm text-[#667085] bg-[#FAFBFC] data-dark:bg-[#1E2024] transition-[border-color,grid-template-columns] duration-200 group border-l border-l-transparent data-dark:border-l-transparent border-r border-r-transparent data-dark:border-r-transparent border-b border-[#E4E7EC] data-dark:border-[#333]" +> +
+
+
+ + {#if isAddingRow} + + {:else} + + {/if} +
+ +
+
+ New Row + + {#if isAddingRow} + + {/if} +
+
+ + {#each tableData.cols as column} + {#if column.id !== 'ID' && column.id !== 'Updated at'} + {@const columnFile = uploadColumns[column.id]} + +
+ {#if isAddingRow} + {#if column.dtype === 'file'} + {#if typeof columnFile !== 'string'} + handleFilesUpload(files, column.id)} + /> + {:else} + + {/if} + + {:else} +

}mw*hAEn*f=SdkAME;7<%z))F|#wZOvjc-VmLO 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 z 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/0cab0285-7b8d-4019-8662-662342476266.lance b/services/api/tests/test_table.lance/data/0cab0285-7b8d-4019-8662-662342476266.lance new file mode 100644 index 0000000000000000000000000000000000000000..d0158c727341826bae1821aed8f3d96f267c0599 GIT binary patch literal 12344 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 DX)g}r literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/2ac6344b-650c-4991-9bd1-48046519287a.lance b/services/api/tests/test_table.lance/data/2ac6344b-650c-4991-9bd1-48046519287a.lance new file mode 100644 index 0000000000000000000000000000000000000000..bfb0fa667373b8d06072b6aeee495ecd0883df17 GIT binary patch literal 12854 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/test_table.lance/data/2fe78714-ed7b-4dd7-8388-889e58479f7c.lance b/services/api/tests/test_table.lance/data/2fe78714-ed7b-4dd7-8388-889e58479f7c.lance new file mode 100644 index 0000000000000000000000000000000000000000..23692ca08beef252a585dc19754a0d42ff679b61 GIT binary patch literal 490087 zcmeFa2Y4LS)jm$g9yfX~6GD+~?RuNEl6EGE+^}p(HkL5OIApZDlGa}B3aw-#T1m5^S4tqE=l`C2@67JXhA;WP=lT7^^PD_E$J=J^ zJ@@o`?mL0X%Bo~VRmc~Kw}ySuWVPk13IqZ^D-=$)wzgRDXmvE;eJ2@=`l69QQhp~C zOqmv3GX`9fe3vkuI?4VNR#1EBp9|@3*QNbf*5nuoqI4EP6mR3)?j5t(RW%gW-F|| zQ;|qiS=CkH%CJ>*uTY{2{Hbh}<4IHoDxwwfia;n*7;|;lqV;N3>y@-xf{9SDqB