From 70ac95d6b59500b40bc689e9eba04d5d58431702 Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Tue, 11 Nov 2025 06:11:08 +0000 Subject: [PATCH 1/6] Sync from private --- .env.example | 77 +- .gitattributes | 15 +- .github/workflows/cd.yml | 51 + .github/workflows/ci.yml | 556 +- .github/workflows/github_bot.yml | 45 + .github/workflows/lint.yml | 10 +- .gitignore | 44 +- .prettierrc | 3 +- CHANGELOG.md | 88 + MIGRATION_GUIDE.md | 68 - clients/python/README.md | 159 +- clients/python/pyproject.toml | 68 +- clients/python/src/jamaibase/client.py | 8936 +++++++++-------- clients/python/src/jamaibase/protocol.py | 2386 +---- .../python/src/jamaibase/types/__init__.py | 508 + clients/python/src/jamaibase/types/billing.py | 136 + clients/python/src/jamaibase/types/common.py | 263 + clients/python/src/jamaibase/types/compat.py | 210 + .../src/jamaibase/types/conversation.py | 105 + clients/python/src/jamaibase/types/db.py | 1284 +++ clients/python/src/jamaibase/types/file.py | 42 + .../python/src/jamaibase/types/gen_table.py | 667 ++ clients/python/src/jamaibase/types/legacy.py | 49 + clients/python/src/jamaibase/types/lm.py | 1433 +++ clients/python/src/jamaibase/types/logs.py | 7 + clients/python/src/jamaibase/types/mcp.py | 461 + clients/python/src/jamaibase/types/model.py | 126 + .../python/src/jamaibase/types/telemetry.py | 162 + .../python/src/jamaibase/utils/__init__.py | 59 +- .../src/jamaibase/utils/background_loop.py | 33 + clients/python/src/jamaibase/utils/dates.py | 82 + .../src/jamaibase/{ => utils}/exceptions.py | 93 +- clients/python/src/jamaibase/utils/io.py | 144 +- clients/python/src/jamaibase/utils/types.py | 55 +- clients/python/src/jamaibase/version.py | 2 +- clients/python/tests/cloud/test_admin.py | 1445 --- clients/python/tests/cloud/test_org_admin.py | 848 -- .../tests/oss/gen_table/test_export_ops.py | 277 +- .../tests/oss/gen_table/test_row_ops.py | 823 +- .../tests/oss/gen_table/test_table_ops.py | 1007 +- clients/python/tests/oss/test_admin.py | 4 +- clients/python/tests/oss/test_chat.py | 116 +- clients/python/tests/oss/test_embeddings.py | 18 +- clients/python/tests/oss/test_file.py | 122 +- clients/python/tests/oss/test_gen_executor.py | 198 +- clients/python/tests/oss/test_template.py | 60 +- clients/typescript/README.md | 14 +- clients/typescript/__tests__/file.test.ts | 9 + clients/typescript/__tests__/gentable.test.ts | 10 +- clients/typescript/__tests__/llm.test.ts | 9 +- clients/typescript/__tests__/template.test.ts | 2 +- .../typescript/__tests__/zoom-in-audio.mp3 | Bin 0 -> 19968 bytes clients/typescript/build | 13 +- clients/typescript/package-lock.json | 4 +- clients/typescript/package.json | 31 +- clients/typescript/rollup.config.ts | 117 + .../typescript/scripts/postprocess-files.cjs | 2 +- clients/typescript/src/index.ts | 9 +- .../typescript/src/resources/files/index.ts | 24 +- .../typescript/src/resources/files/types.ts | 3 - .../src/resources/gen_tables/chat.ts | 20 +- .../src/resources/gen_tables/index.ts | 360 +- .../src/resources/gen_tables/knowledge.ts | 7 +- .../src/resources/gen_tables/tables.ts | 80 +- clients/typescript/src/resources/llm/model.ts | 6 +- .../src/resources/templates/index.ts | 30 +- .../src/resources/templates/types.ts | 20 +- clients/typescript/tsconfig.json | 2 +- docker/Dockerfile.cnpg17 | 76 + docker/Dockerfile.docio | 12 - docker/Dockerfile.frontend | 11 +- docker/Dockerfile.owl | 41 +- docker/Dockerfile.owl.base | 12 + docker/Dockerfile.pg17 | 63 + docker/amd.yml | 13 - docker/build_owl_base_image.sh | 11 + docker/ch_configs/clickhouse_config.xml | 35 + docker/ch_configs/clickhouse_user_config.xml | 8 + docker/ch_configs/create_ch_prom_db.sh | 182 + docker/compose.amd.yml | 4 - docker/compose.bake.hcl | 15 + docker/compose.base.yml | 327 + docker/compose.ci.yml | 6 + docker/compose.cpu.ollama.yml | 43 - docker/compose.cpu.yml | 196 - docker/compose.dev.yml | 6 + docker/compose.nvidia.yml | 4 - docker/compose.test-llm.yml | 26 + docker/infinity.yml | 18 + docker/kong.yml | 42 + docker/ollama.yml | 4 - .../otel_configs/otel-collector-config.yaml | 69 + docker/override.ci.yml | 25 + docker/override.dev.yml | 65 + docker/{nvidia.yml => override.nvidia.yml} | 2 +- docker/vmagent/streamingAggregation.yaml | 73 + docker/vmauth/config.yml | 12 + docs/alert_guide.md | 255 + docs/pgaudit_guide.md | 71 + scripts/compile_docio_exe.ps1 | 9 - scripts/migrate_model_json.py | 4 +- scripts/migrate_v1_to_v2.py | 394 + scripts/migration_s3_v1_to_v2.py | 486 + scripts/migration_v030.py | 8 +- scripts/migration_v040.py | 2 +- scripts/remove_cloud_modules.ps1 | 22 - scripts/remove_cloud_modules.sh | 13 +- scripts/update_model_id.py | 429 + services/api/Chat.md | 71 + services/api/README.md | 81 +- services/api/api.spec | 73 - services/api/pyproject.toml | 175 +- services/api/scripts/recreate_template.py | 83 + services/api/src/owl/__init__.py | 3 - .../api/src/owl/assets/Roboto-Regular.ttf | Bin 0 -> 146004 bytes services/api/src/owl/assets/icons/csv.webp | Bin 0 -> 7964 bytes services/api/src/owl/assets/icons/docx.webp | Bin 0 -> 10156 bytes services/api/src/owl/assets/icons/html.webp | Bin 0 -> 7194 bytes services/api/src/owl/assets/icons/jpg.webp | Bin 0 -> 7320 bytes services/api/src/owl/assets/icons/json.webp | Bin 0 -> 6530 bytes services/api/src/owl/assets/icons/jsonl.webp | Bin 0 -> 6512 bytes services/api/src/owl/assets/icons/md.webp | Bin 0 -> 5766 bytes services/api/src/owl/assets/icons/pdf.webp | Bin 0 -> 6640 bytes services/api/src/owl/assets/icons/pptx.webp | Bin 0 -> 7816 bytes services/api/src/owl/assets/icons/tsv.webp | Bin 0 -> 7240 bytes services/api/src/owl/assets/icons/txt.webp | Bin 0 -> 5300 bytes services/api/src/owl/assets/icons/xlsx.webp | Bin 0 -> 8648 bytes services/api/src/owl/assets/icons/xml.webp | Bin 0 -> 7512 bytes services/api/src/owl/billing.py | 663 -- services/api/src/owl/client.py | 125 + services/api/src/owl/configs/__init__.py | 35 + services/api/src/owl/configs/manager.py | 553 - services/api/src/owl/configs/models.json | 155 - services/api/src/owl/configs/models_aipc.json | 241 - services/api/src/owl/configs/models_ci.json | 139 - .../api/src/owl/configs/models_ollama.json | 171 - services/api/src/owl/configs/oss.py | 306 + .../api/src/owl/configs/preset_models.json | 219 +- services/api/src/owl/db/__init__.py | 604 +- services/api/src/owl/db/gen_executor.py | 2034 ++-- services/api/src/owl/db/gen_table.py | 6144 ++++++++---- services/api/src/owl/db/gen_table_v2.py | 126 - services/api/src/owl/db/models/__init__.py | 22 + services/api/src/owl/db/models/oss.py | 1473 +++ services/api/src/owl/db/oss_admin.py | 171 - services/api/src/owl/db/template.py | 55 - services/api/src/owl/docio.py | 52 - services/api/src/owl/docparse.py | 602 ++ services/api/src/owl/entrypoints/api.py | 710 +- services/api/src/owl/entrypoints/chat_echo.py | 121 - .../api/src/owl/entrypoints/chat_python.py | 137 - services/api/src/owl/entrypoints/llm.py | 626 ++ services/api/src/owl/entrypoints/starling.py | 137 +- services/api/src/owl/llm.py | 698 -- services/api/src/owl/loaders.py | 283 - services/api/src/owl/models.py | 416 - services/api/src/owl/protocol.py | 2598 ----- services/api/src/owl/routers/auth.py | 90 + services/api/src/owl/routers/conversation.py | 574 ++ services/api/src/owl/routers/file.py | 225 +- services/api/src/owl/routers/gen_table.py | 2154 ++-- services/api/src/owl/routers/gen_table_v1.py | 916 ++ services/api/src/owl/routers/llm.py | 174 - services/api/src/owl/routers/meters.py | 631 ++ services/api/src/owl/routers/models.py | 318 + services/api/src/owl/routers/org_admin.py | 703 -- .../src/owl/routers/organizations/__init__.py | 12 + .../api/src/owl/routers/organizations/oss.py | 732 ++ services/api/src/owl/routers/oss_admin.py | 94 - .../api/src/owl/routers/projects/__init__.py | 12 + services/api/src/owl/routers/projects/oss.py | 927 ++ services/api/src/owl/routers/projects/v1.py | 156 + services/api/src/owl/routers/serving.py | 270 + services/api/src/owl/routers/tasks.py | 24 + services/api/src/owl/routers/template.py | 417 - services/api/src/owl/routers/templates.py | 216 + .../api/src/owl/routers/users/__init__.py | 12 + services/api/src/owl/routers/users/oss.py | 207 + services/api/src/owl/scripts/backup_db.py | 4 +- services/api/src/owl/scripts/update_db.py | 95 - services/api/src/owl/tasks/checks.py | 88 + services/api/src/owl/tasks/database.py | 12 + services/api/src/owl/tasks/gen_table.py | 62 + services/api/src/owl/tasks/genitor.py | 93 +- services/api/src/owl/tasks/restore.py | 174 +- 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/types/__init__.py | 1032 ++ services/api/src/owl/types/db.py | 69 + services/api/src/owl/unstructuredio.py | 206 - services/api/src/owl/utils/__init__.py | 152 +- services/api/src/owl/utils/auth.py | 471 - services/api/src/owl/utils/auth/__init__.py | 18 + services/api/src/owl/utils/auth/oss.py | 156 + .../api/src/owl/utils/billing/__init__.py | 18 + services/api/src/owl/utils/billing/oss.py | 788 ++ services/api/src/owl/utils/billing_metrics.py | 742 ++ services/api/src/owl/utils/cache.py | 267 + services/api/src/owl/utils/code.py | 169 +- services/api/src/owl/utils/crypt.py | 82 +- services/api/src/owl/utils/dates.py | 14 + services/api/src/owl/utils/exceptions.py | 158 +- services/api/src/owl/utils/handlers.py | 391 + services/api/src/owl/utils/io.py | 859 +- services/api/src/owl/utils/ip_address.py | 136 - services/api/src/owl/utils/jwt.py | 8 +- services/api/src/owl/utils/kb.py | 2 +- services/api/src/owl/utils/lm.py | 1843 ++++ services/api/src/owl/utils/logging.py | 124 +- .../api/src/owl/utils/loguru_otlp_handler.py | 290 + services/api/src/owl/utils/mcp/__init__.py | 35 + .../api/src/owl/utils/mcp/custom_tools.py | 6 + services/api/src/owl/utils/mcp/helpers.py | 28 + services/api/src/owl/utils/mcp/server.py | 483 + services/api/src/owl/utils/metrics.py | 424 + services/api/src/owl/utils/openapi.py | 6 - services/api/src/owl/utils/responses.py | 360 - services/api/src/owl/utils/tasks.py | 117 - services/api/src/owl/utils/test.py | 1084 ++ services/api/src/owl/utils/types.py | 44 + services/api/src/owl/utils/victoriametrics.py | 45 + services/api/src/owl/version.py | 2 +- services/api/tests/README.md | 6 + ...25 - GitHub \346\226\207\346\241\243.json" | 14 + .../Swire_AR22_e_230406_sample.json | 14 + .../api}/tests/files/bmp/cifar10-deer.bmp | Bin .../api}/tests/files/csv/company-profile.csv | 0 .../api}/tests/files/csv/empty.csv | 0 .../files/csv/weather_observations_long.csv | 0 .../tests/files/doc/Recommendation Letter.doc | Bin .../files/docx/Recommendation Letter.docx | Bin .../tests/files/gif/rabbit_cifar10-deer.gif | Bin .../gif/rabbit_cifar10-deer.gif.thumb.webp | Bin 0 -> 4162 bytes .../html/RAG and LLM Integration Guide.html | 0 .../html/multilingual-code-examples.html | 0 .../api}/tests/files/html/table.html | 0 .../api}/tests/files/jpeg/cifar10-deer.jpg | Bin .../files/jpeg/cifar10-deer.jpg.thumb.webp | Bin 0 -> 274 bytes services/api/tests/files/jpeg/doe.jpg | Bin 0 -> 13252 bytes .../api}/tests/files/jpeg/rabbit.jpeg | Bin .../tests/files/json/company-profile.json | 0 .../jsonl/ChatMed_TCM-v0.2-5records.jsonl | 0 .../api}/tests/files/jsonl/llm-models.jsonl | 0 .../api}/tests/files/md/creative-story.md | 0 services/api/tests/files/mp3/grand-scheme.mp3 | Bin 0 -> 116655 bytes services/api/tests/files/mp3/gutter.mp3 | Bin 0 -> 80711 bytes .../api/tests/files/mp3/gutter.mp3.thumb.mp3 | Bin 0 -> 40978 bytes services/api/tests/files/mp3/stars.mp3 | Bin 0 -> 94921 bytes .../files/mp3/turning-a4-size-magazine.mp3 | Bin .../turning-a4-size-magazine.mp3.thumb.mp3 | Bin 0 -> 20733 bytes .../files/pdf/1970_PSS_ThAT_mechanism.pdf | Bin .../1970_PSS_ThAT_mechanism.pdf.thumb.webp | Bin 0 -> 82826 bytes ..._PRB_phonon-assisted_tunnel_ionization.pdf | Bin ...225 - GitHub \346\226\207\346\241\243.pdf" | Bin 0 -> 653840 bytes .../LLMs as Optimizers [DeepMind ; 2023].pdf | Bin .../files/pdf/Swire_AR22_e_230406_sample.pdf | Bin ... Design Blueprint - The Ultimate Guide.pdf | Bin .../files/pdf/Vehicle Detail - MyPUSPAKOM.pdf | Bin .../pdf/ag-energy-round-up-2017-02-24.pdf | Bin .../tests/files/pdf/background-checks.pdf | Bin .../api}/tests/files/pdf/ca-warn-report.pdf | Bin .../api}/tests/files/pdf/empty.pdf | Bin .../api}/tests/files/pdf/empty_3pages.pdf | Bin services/api/tests/files/pdf/hello.pdf | Bin 0 -> 81474 bytes .../pdf/salary \346\200\273\347\273\223.pdf" | Bin .../api}/tests/files/pdf/sample_tables.pdf | Bin .../files/pdf/san-jose-pd-firearm-sample.pdf | Bin .../api}/tests/files/pdf/statement_card.pdf | Bin .../tests/files/pdf/statement_ewallet.pdf | Bin .../files/pdf_mixed/digital_scan_combined.pdf | Bin .../files/pdf_scan/1978_APL_FP_detrapping.PDF | Bin .../uuk_bangunan_seragam_pindaan_2017.pdf | Bin 0 -> 79435 bytes .../api}/tests/files/png/cifar10-deer.png | Bin .../tests/files/png/github-mark-white.png | Bin .../api}/tests/files/png/rabbit.png | Bin .../api/tests/files/png/rabbit.png.thumb.webp | Bin 0 -> 7792 bytes ...e Translation in Linear Time (ByteNet).ppt | Bin ...7.06.30) NMT in Linear Time (ByteNet).pptx | Bin .../api}/tests/files/tiff/cifar10-deer.tiff | Bin .../api}/tests/files/tiff/rabbit.tiff | Bin .../tests/files/tsv/weather_observations.tsv | 0 .../api}/tests/files/txt/creative-story.txt | 0 .../api}/tests/files/txt/empty.txt | 0 .../api}/tests/files/txt/weather.txt | 0 services/api/tests/files/wav/gutter.wav | Bin 0 -> 222414 bytes .../api/tests/files/wav/gutter.wav.thumb.mp3 | Bin 0 -> 20707 bytes .../files/wav/turning-a4-size-magazine.wav | Bin .../turning-a4-size-magazine.wav.thumb.mp3 | Bin 0 -> 19897 bytes .../tests/files/webp/rabbit_cifar10-deer.webp | Bin .../webp/rabbit_cifar10-deer.webp.thumb.webp | Bin 0 -> 4360 bytes .../api}/tests/files/xls/Claims Form.xls | Bin .../api}/tests/files/xlsx/Claims Form.xlsx | Bin .../xlsx/Claims Form.xlsx.thumb.gen.webp | Bin 0 -> 3354 bytes .../files/xlsx/Claims Form.xlsx.thumb.webp | Bin 0 -> 4738 bytes .../files/xml/weather-forecast-service.xml | 0 services/api/tests/gen_table/test_empty_db.py | 32 + .../api/tests/gen_table/test_import_export.py | 1025 ++ services/api/tests/gen_table/test_row_ops.py | 2206 ++++ .../api/tests/gen_table/test_row_ops_v2.py | 2097 ++++ .../api/tests/gen_table/test_table_ops.py | 2160 ++++ .../api/tests/gen_table/test_table_ops_v2.py | 824 ++ services/api/tests/gen_table/test_v1.py | 366 + .../gen_table_core/test_gen_table_core.py | 1909 ++++ .../tests/gen_table_core/test_manipulation.py | 0 .../api/tests/routers/test_conversation.py | 816 ++ services/api/tests/routers/test_models.py | 204 + .../api/tests/routers/test_organizations.py | 380 + services/api/tests/routers/test_projects.py | 577 ++ services/api/tests/routers/test_serving.py | 1201 +++ services/api/tests/routers/test_templates.py | 253 + services/api/tests/routers/test_users.py | 341 + services/api/tests/test_db.py | 24 + services/api/tests/test_docparse.py | 84 + services/api/tests/test_lance.py | 31 - services/api/tests/test_protocol.py | 209 + .../page_data.lance | Bin 1214 -> 0 bytes .../page_lookup.lance | Bin 675 -> 0 bytes .../page_data.lance | Bin 3412 -> 0 bytes .../page_lookup.lance | Bin 1326 -> 0 bytes .../page_data.lance | Bin 3412 -> 0 bytes .../page_lookup.lance | Bin 1326 -> 0 bytes .../page_data.lance | Bin 1214 -> 0 bytes .../page_lookup.lance | Bin 675 -> 0 bytes .../tests/test_table.lance/_latest.manifest | Bin 777 -> 0 bytes ...5-83679c50-04f5-4ad6-a5d3-c90147f82175.txn | Bin 212 -> 0 bytes ...6-2a19e3d8-6397-4d41-8667-3f8c005bdb47.txn | 1 - ...6-c54f1ffd-ae26-4520-9639-0d08015a5dce.txn | Bin 3693 -> 0 bytes .../test_table.lance/_versions/56.manifest | Bin 4417 -> 0 bytes .../test_table.lance/_versions/57.manifest | Bin 4417 -> 0 bytes .../test_table.lance/_versions/58.manifest | Bin 777 -> 0 bytes ...0218dfb5-7d6a-4594-be2f-b6da21fc2991.lance | Bin 12443 -> 0 bytes ...0841f676-2d34-4a0d-beb2-b66eee322f5b.lance | Bin 12388 -> 0 bytes ...0c4d5f53-dce1-4c49-8095-fe16471dfe92.lance | Bin 12850 -> 0 bytes ...0cab0285-7b8d-4019-8662-662342476266.lance | Bin 12344 -> 0 bytes ...195821c2-10b0-4681-9b60-50f79fa017a9.lance | Bin 12862 -> 0 bytes ...2ac6344b-650c-4991-9bd1-48046519287a.lance | Bin 12854 -> 0 bytes ...2fe78714-ed7b-4dd7-8388-889e58479f7c.lance | Bin 490087 -> 0 bytes ...30fb0867-31d2-4cf6-8d7e-f8c68bcd4882.lance | Bin 12264 -> 0 bytes ...375faaf4-394d-4c1d-a217-dce02e301028.lance | Bin 12867 -> 0 bytes ...3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance | Bin 11998 -> 0 bytes ...3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance | Bin 12837 -> 0 bytes ...4038dc14-3f39-4076-b537-d262314ce58e.lance | Bin 12337 -> 0 bytes ...476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance | Bin 12859 -> 0 bytes ...4c5cd4e2-3156-4e17-8129-2e600e91d019.lance | Bin 11998 -> 0 bytes ...4fb6cee8-9f15-4070-a709-02db49fb21b1.lance | Bin 12158 -> 0 bytes ...556e81d3-9067-4d1d-b17d-a4cc0a3eeefc.lance | Bin 12338 -> 0 bytes ...5bb527a1-3c37-4cc7-8db9-85e610247143.lance | Bin 12267 -> 0 bytes ...5e04331f-8465-40b0-af53-455928d381a8.lance | Bin 12845 -> 0 bytes ...62661a16-01fe-4513-b58d-9699f70b5ae4.lance | Bin 12054 -> 0 bytes ...67bff393-906c-432b-bf15-5b854effe0a7.lance | Bin 12517 -> 0 bytes ...681de983-0000-4db1-a193-ee04cee80253.lance | Bin 12825 -> 0 bytes ...6b47eefb-b495-444c-aec6-626a0ed8e441.lance | Bin 12499 -> 0 bytes ...70ba4ca1-f11d-4a37-8bf2-33e642d64baf.lance | Bin 12454 -> 0 bytes ...800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance | Bin 12292 -> 0 bytes ...8263336e-11dc-47c1-a0ab-37dced034d86.lance | Bin 12842 -> 0 bytes ...833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance | Bin 12521 -> 0 bytes ...85d3b452-5002-4793-bc66-fced85c77ebd.lance | Bin 12348 -> 0 bytes ...94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance | Bin 12827 -> 0 bytes ...9583d125-a754-4104-ad58-670223a4cfd4.lance | Bin 12259 -> 0 bytes ...9ae5a155-6864-4aee-9622-7c1d04913ae9.lance | Bin 12031 -> 0 bytes ...a20c5619-719e-48c8-a249-607527257890.lance | Bin 12485 -> 0 bytes ...a4a314e7-13e0-4a60-9d6f-b459576cc577.lance | Bin 12867 -> 0 bytes ...abbdcc8c-93da-4e2e-ac61-91be0874a587.lance | Bin 12801 -> 0 bytes ...af0d8632-619d-4e09-bb2f-ba13d0f568e5.lance | Bin 12733 -> 0 bytes ...af70162a-d319-403c-bd26-251353c9966c.lance | Bin 12339 -> 0 bytes ...b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance | Bin 12829 -> 0 bytes ...bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance | Bin 12828 -> 0 bytes ...beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance | Bin 12867 -> 0 bytes ...c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance | Bin 12799 -> 0 bytes ...c6729c82-8cb2-44d5-993a-7b09bf678703.lance | Bin 12590 -> 0 bytes ...ca3909ff-b21b-45c5-bf85-4911bba60fde.lance | Bin 12807 -> 0 bytes ...ca71816d-8322-43a4-9502-bca6cd2e020c.lance | Bin 12038 -> 0 bytes ...d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance | Bin 12102 -> 0 bytes ...da9c0e9e-7f8b-44ea-82a4-160c98ca17ae.lance | Bin 12823 -> 0 bytes ...db6054f5-4108-4f23-b508-565b32020f68.lance | Bin 12809 -> 0 bytes ...e4c544bd-eb55-4af4-a2dc-f91d7863e671.lance | Bin 12789 -> 0 bytes ...e628d9da-95ab-4b1f-8d21-47ca21154b81.lance | Bin 12005 -> 0 bytes ...e6541d20-5c18-4b80-8d71-84f899310512.lance | Bin 12836 -> 0 bytes ...e856769d-4dac-492c-bd12-679434292b04.lance | Bin 12309 -> 0 bytes ...ea967c5f-1d05-4069-8fef-a1d090e42730.lance | Bin 11766 -> 0 bytes ...ee2829e0-90b5-416b-8c05-203ee77b75aa.lance | Bin 12846 -> 0 bytes ...fc8dc641-5cba-4570-8ef2-8b10992f2cf8.lance | Bin 12823 -> 0 bytes ...fd13dd8d-4f5c-4040-b8da-af081fa49e9d.lance | Bin 12130 -> 0 bytes ...fe71820f-1a4e-4345-8df1-777443efb74a.lance | Bin 12829 -> 0 bytes services/api/tests/test_types.py | 108 + services/api/tests/utils/test_auth.py | 126 + .../api/tests/utils/test_billing_event.py | 608 ++ services/api/tests/utils/test_crypt.py | 112 + services/api/tests/utils/test_dates.py | 109 + services/api/tests/utils/test_file.py | 268 + services/api/tests/utils/test_io.py | 140 + services/api/tests/utils/test_jwt.py | 44 + services/api/tests/utils/test_mcp.py | 227 + services/api/tests/utils/test_utils.py | 166 + services/app/.env.example | 32 +- services/app/.eslintignore | 0 services/app/.eslintrc.cjs | 0 services/app/.gitignore | 3 +- services/app/.npmrc | 0 services/app/.prettierignore | 0 services/app/.prettierrc | 7 +- services/app/README.md | 0 services/app/build.bat | 2 +- services/app/components.json | 28 +- services/app/electron/icons/icon.icns | Bin services/app/electron/icons/icon.ico | Bin services/app/electron/icons/icon.png | Bin services/app/electron/main.js | 0 services/app/forge.config.cjs | 0 services/app/messages/en.json | 89 + services/app/package-lock.json | 2572 ++++- services/app/package.json | 45 +- services/app/playwright.config.ts | 0 services/app/postcss.config.js | 0 services/app/project.inlang/.gitignore | 1 + services/app/project.inlang/project_id | 1 + services/app/project.inlang/settings.json | 12 + services/app/server/index.js | 10 +- services/app/src/app.css | 0 services/app/src/app.d.ts | 25 +- services/app/src/app.html | 2 +- services/app/src/globalStore.ts | 27 +- services/app/src/hljs-theme.css | 0 services/app/src/hooks.server.ts | 238 +- services/app/src/hooks.ts | 6 + .../app/src/lib/assets/Black-Long-Main.svg | 0 services/app/src/lib/assets/Black-Long.svg | 0 services/app/src/lib/assets/Black-Main.svg | 0 services/app/src/lib/assets/Black.svg | 0 .../src/lib/assets/Jamai-Long-Black-Main.svg | 0 .../src/lib/assets/Jamai-Long-White-Main.svg | 0 .../app/src/lib/assets/White-Long-Main.svg | 0 services/app/src/lib/assets/White-Long.svg | 0 services/app/src/lib/assets/White-Main.svg | 0 services/app/src/lib/assets/White.svg | 0 services/app/src/lib/assets/dark-mode.svg | 0 .../src/lib/assets/jamai-onboarding-bg.svg | 0 services/app/src/lib/assets/light-mode.svg | 0 .../src/lib/assets/model-icons/allenai.png | Bin 0 -> 149373 bytes .../src/lib/assets/model-icons/anthropic.png | Bin 0 -> 30176 bytes .../app/src/lib/assets/model-icons/cohere.png | Bin 0 -> 26469 bytes .../src/lib/assets/model-icons/deepseek.png | Bin 0 -> 160050 bytes .../app/src/lib/assets/model-icons/gemini.png | Bin 0 -> 28112 bytes .../src/lib/assets/model-icons/generic.png | Bin 0 -> 71717 bytes .../src/lib/assets/model-icons/generic2.png | Bin 0 -> 325524 bytes .../app/src/lib/assets/model-icons/index.ts | 25 + .../app/src/lib/assets/model-icons/meta.png | Bin 0 -> 30327 bytes .../src/lib/assets/model-icons/mistral.png | Bin 0 -> 3172 bytes .../app/src/lib/assets/model-icons/openai.png | Bin 0 -> 46614 bytes .../app/src/lib/assets/model-icons/qwen.png | Bin 0 -> 53045 bytes services/app/src/lib/assets/system-mode.svg | 0 services/app/src/lib/auth.ts | 205 + .../app/src/lib/components/Checkbox.svelte | 35 +- .../src/lib/components/DraggableList.svelte | 110 +- .../app/src/lib/components/InputText.svelte | 62 +- .../src/lib/components/PermissionGuard.svelte | 66 + services/app/src/lib/components/Portal.svelte | 13 +- services/app/src/lib/components/Range.svelte | 32 +- .../app/src/lib/components/TextField.svelte | 71 +- .../app/src/lib/components/Tooltip.svelte | 26 +- .../components/chat/ChatFilePreview.svelte | 225 + .../components/chat/ChatThumbsFetch.svelte | 57 + services/app/src/lib/components/chat/index.ts | 4 + .../lib/components/preset/CodeEditor.svelte | 70 + .../preset/FoundProjectOrgSwitcher.svelte | 26 +- .../lib/components/preset/ModelSelect.svelte | 225 +- .../lib/components/preset/PlanSelect.svelte | 73 +- .../lib/components/preset/PromptEditor.svelte | 258 + .../preset/RowStreamIndicator.svelte | 2 +- .../lib/components/preset/SearchBar.svelte | 45 +- .../lib/components/preset/SorterSelect.svelte | 135 +- .../components/preset/UserDetailsBtn.svelte | 156 + .../tables/(sub)/ColumnDropdown.svelte | 263 +- .../tables/(sub)/ColumnHeader.svelte | 216 +- .../tables/(sub)/ColumnSettings.svelte | 1193 ++- .../components/tables/(sub)/ConvList.svelte | 30 +- .../tables/(sub)/Conversations.svelte | 105 +- .../tables/(sub)/DeleteFileDialog.svelte | 51 +- .../tables/(sub)/FileColumnView.svelte | 186 +- .../components/tables/(sub)/FileSelect.svelte | 68 +- .../tables/(sub)/FileThumbsFetch.svelte | 37 +- .../lib/components/tables/(sub)/NewRow.svelte | 290 +- .../tables/(sub)/PlaceholderNewCol.svelte | 324 + .../(sub)/SelectKnowledgeTableDialog.svelte | 70 +- .../tables/(sub)/TablePagination.svelte | 164 +- .../tables/(sub)/TableSorter.svelte | 210 + .../src/lib/components/tables/(sub)/index.ts | 4 +- .../tables/(svg)/NoRowsGraphic.svelte | 8 +- .../lib/components/tables/ActionTable.svelte | 255 +- .../lib/components/tables/ChatTable.svelte | 256 +- .../components/tables/KnowledgeTable.svelte | 263 +- .../components/tables/tablesState.svelte.ts | 280 + .../src/lib/components/tables/tablesStore.ts | 232 - .../ui/alert/alert-description.svelte | 16 + .../components/ui/alert/alert-title.svelte | 25 + .../src/lib/components/ui/alert/alert.svelte | 39 + .../app/src/lib/components/ui/alert/index.ts | 14 + .../src/lib/components/ui/badge/badge.svelte | 50 + .../app/src/lib/components/ui/badge/index.ts | 2 + .../lib/components/ui/button/button.svelte | 125 +- .../app/src/lib/components/ui/button/index.ts | 58 +- .../ui/calendar/calendar-cell.svelte | 19 + .../ui/calendar/calendar-day.svelte | 30 + .../ui/calendar/calendar-grid-body.svelte | 12 + .../ui/calendar/calendar-grid-head.svelte | 12 + .../ui/calendar/calendar-grid-row.svelte | 12 + .../ui/calendar/calendar-grid.svelte | 16 + .../ui/calendar/calendar-head-cell.svelte | 16 + .../ui/calendar/calendar-header.svelte | 16 + .../ui/calendar/calendar-heading.svelte | 12 + .../ui/calendar/calendar-months.svelte | 20 + .../ui/calendar/calendar-next-button.svelte | 28 + .../ui/calendar/calendar-prev-button.svelte | 28 + .../components/ui/calendar/calendar.svelte | 61 + .../src/lib/components/ui/calendar/index.ts | 30 + .../components/ui/checkbox/checkbox.svelte | 35 + .../src/lib/components/ui/checkbox/index.ts | 6 + .../ui/dialog/dialog-actions.svelte | 11 +- .../ui/dialog/dialog-content.svelte | 44 +- .../ui/dialog/dialog-description.svelte | 18 +- .../components/ui/dialog/dialog-footer.svelte | 18 +- .../components/ui/dialog/dialog-header.svelte | 27 +- .../ui/dialog/dialog-overlay.svelte | 24 +- .../components/ui/dialog/dialog-portal.svelte | 10 +- .../components/ui/dialog/dialog-root.svelte | 22 - .../components/ui/dialog/dialog-title.svelte | 16 +- .../app/src/lib/components/ui/dialog/index.ts | 25 +- .../dropdown-menu-checkbox-item.svelte | 51 +- .../dropdown-menu-content.svelte | 45 +- .../dropdown-menu-group-heading.svelte | 19 + .../dropdown-menu/dropdown-menu-item.svelte | 37 +- .../dropdown-menu/dropdown-menu-label.svelte | 26 +- .../dropdown-menu-radio-group.svelte | 12 +- .../dropdown-menu-radio-item.svelte | 45 +- .../dropdown-menu-separator.svelte | 18 +- .../dropdown-menu-shortcut.svelte | 21 +- .../dropdown-menu-sub-content.svelte | 31 +- .../dropdown-menu-sub-trigger.svelte | 38 +- .../lib/components/ui/dropdown-menu/index.ts | 52 +- .../src/lib/components/ui/input-otp/index.ts | 15 + .../ui/input-otp/input-otp-group.svelte | 16 + .../ui/input-otp/input-otp-separator.svelte | 19 + .../ui/input-otp/input-otp-slot.svelte | 30 + .../components/ui/input-otp/input-otp.svelte | 22 + .../app/src/lib/components/ui/input/index.ts | 7 + .../src/lib/components/ui/input/input.svelte | 46 + .../app/src/lib/components/ui/label/index.ts | 0 .../src/lib/components/ui/label/label.svelte | 27 +- .../src/lib/components/ui/pagination/index.ts | 0 .../ui/pagination/pagination-content.svelte | 15 +- .../ui/pagination/pagination-ellipsis.svelte | 23 +- .../ui/pagination/pagination-item.svelte | 17 +- .../ui/pagination/pagination-link.svelte | 47 +- .../pagination/pagination-next-button.svelte | 33 +- .../pagination/pagination-prev-button.svelte | 33 +- .../ui/pagination/pagination.svelte | 34 +- .../src/lib/components/ui/popover/index.ts | 17 + .../ui/popover/popover-content.svelte | 28 + .../lib/components/ui/range-calendar/index.ts | 32 + .../range-calendar/range-calendar-cell.svelte | 19 + .../range-calendar/range-calendar-day.svelte | 34 + .../range-calendar-grid-row.svelte | 12 + .../range-calendar/range-calendar-grid.svelte | 16 + .../range-calendar-head-cell.svelte | 16 + .../range-calendar-header.svelte | 16 + .../range-calendar-heading.svelte | 16 + .../range-calendar-months.svelte | 20 + .../range-calendar-next-button.svelte | 28 + .../range-calendar-prev-button.svelte | 28 + .../ui/range-calendar/range-calendar.svelte | 57 + .../app/src/lib/components/ui/select/index.ts | 18 +- .../ui/select/select-content.svelte | 70 +- .../ui/select/select-group-heading.svelte | 16 + .../components/ui/select/select-item.svelte | 69 +- .../components/ui/select/select-label.svelte | 16 - .../select/select-scroll-down-button.svelte | 19 + .../ui/select/select-scroll-up-button.svelte | 19 + .../ui/select/select-separator.svelte | 14 +- .../ui/select/select-trigger.svelte | 28 +- .../src/lib/components/ui/separator/index.ts | 7 + .../components/ui/separator/separator.svelte | 22 + .../src/lib/components/ui/skeleton/index.ts | 0 .../components/ui/skeleton/skeleton.svelte | 20 +- .../ui/sonner/CustomToastDesc.svelte | 18 +- .../app/src/lib/components/ui/sonner/index.ts | 4 +- .../lib/components/ui/sonner/sonner.svelte | 21 +- .../app/src/lib/components/ui/switch/index.ts | 0 .../lib/components/ui/switch/switch.svelte | 21 +- .../app/src/lib/components/ui/table/index.ts | 0 .../lib/components/ui/table/table-body.svelte | 15 +- .../components/ui/table/table-caption.svelte | 15 +- .../lib/components/ui/table/table-cell.svelte | 24 +- .../components/ui/table/table-footer.svelte | 15 +- .../lib/components/ui/table/table-head.svelte | 22 +- .../components/ui/table/table-header.svelte | 16 +- .../lib/components/ui/table/table-row.svelte | 22 +- .../src/lib/components/ui/table/table.svelte | 19 +- .../app/src/lib/components/ui/tabs/index.ts | 18 + .../components/ui/tabs/tabs-content.svelte | 19 + .../lib/components/ui/tabs/tabs-list.svelte | 19 + .../components/ui/tabs/tabs-trigger.svelte | 19 + .../src/lib/components/ui/tooltip/index.ts | 20 + .../ui/tooltip/tooltip-content.svelte | 21 + services/app/src/lib/constants.ts | 197 +- services/app/src/lib/db.ts | 0 .../app/src/lib/icons/ActionTableIcon.svelte | 8 +- .../app/src/lib/icons/AddColumnIcon.svelte | 7 +- services/app/src/lib/icons/AddIcon.svelte | 8 +- .../app/src/lib/icons/ArrowBackIcon.svelte | 8 +- .../app/src/lib/icons/ArrowDownIcon.svelte | 8 +- .../src/lib/icons/ArrowFilledRightIcon.svelte | 8 +- .../app/src/lib/icons/ArrowLeftIcon.svelte | 8 +- .../app/src/lib/icons/ArrowRightIcon.svelte | 8 +- .../app/src/lib/icons/AssignmentIcon.svelte | 19 - .../app/src/lib/icons/ChatAgentIcon.svelte | 8 +- .../app/src/lib/icons/ChatTableIcon.svelte | 8 +- .../app/src/lib/icons/CheckDoneIcon.svelte | 8 +- services/app/src/lib/icons/CheckIcon.svelte | 8 +- .../app/src/lib/icons/ChunkEditorIcon.svelte | 8 +- services/app/src/lib/icons/CloseIcon.svelte | 8 +- services/app/src/lib/icons/CodeIcon.svelte | 8 +- services/app/src/lib/icons/CopyIcon.svelte | 8 +- services/app/src/lib/icons/DeleteIcon.svelte | 8 +- .../app/src/lib/icons/DialogCloseIcon.svelte | 8 +- .../src/lib/icons/DocumentFilledIcon.svelte | 8 +- services/app/src/lib/icons/EditIcon.svelte | 8 +- services/app/src/lib/icons/ExportIcon.svelte | 8 +- .../app/src/lib/icons/ExternalLinkIcon.svelte | 8 +- services/app/src/lib/icons/EyeOffIcon.svelte | 8 +- services/app/src/lib/icons/EyeOnIcon.svelte | 8 +- services/app/src/lib/icons/FourCircles.svelte | 34 + .../app/src/lib/icons/HamburgerIcon.svelte | 8 +- services/app/src/lib/icons/HomeIcon.svelte | 8 +- services/app/src/lib/icons/ImportIcon.svelte | 8 +- services/app/src/lib/icons/InfoIcon.svelte | 8 +- services/app/src/lib/icons/Jambu.svelte | 35 + .../src/lib/icons/KnowledgeTableIcon.svelte | 8 +- .../app/src/lib/icons/LeftArrowIcon.svelte | 8 +- .../app/src/lib/icons/LoadingSpinner.svelte | 8 +- services/app/src/lib/icons/LockIcon.svelte | 8 +- services/app/src/lib/icons/LogoutIcon.svelte | 8 +- .../app/src/lib/icons/MoreVertIcon.svelte | 8 +- .../src/lib/icons/MultiturnChatIcon.svelte | 10 +- services/app/src/lib/icons/OpenIcon.svelte | 14 + services/app/src/lib/icons/PeopleIcon.svelte | 8 +- .../app/src/lib/icons/PersonAddIcon.svelte | 8 +- .../app/src/lib/icons/RegenerateIcon.svelte | 8 +- .../app/src/lib/icons/RowSearchIcon.svelte | 8 +- services/app/src/lib/icons/SearchIcon.svelte | 8 +- services/app/src/lib/icons/SendIcon.svelte | 8 +- .../app/src/lib/icons/SettingsIcon.svelte | 8 +- services/app/src/lib/icons/SideBarIcon.svelte | 8 +- .../app/src/lib/icons/SortAlphabetIcon.svelte | 10 +- services/app/src/lib/icons/SortByIcon.svelte | 10 +- services/app/src/lib/icons/StarIcon.svelte | 8 +- .../app/src/lib/icons/StickyNoteIcon.svelte | 8 +- services/app/src/lib/icons/StopIcon.svelte | 8 +- services/app/src/lib/icons/TuneIcon.svelte | 8 +- services/app/src/lib/icons/WarningIcon.svelte | 8 +- services/app/src/lib/logger.ts | 0 services/app/src/lib/server/nodeCache.ts | 34 +- services/app/src/lib/server/utils.ts | 0 services/app/src/lib/showdown/codeblock.ts | 0 .../app/src/lib/showdown/codehighlight.ts | 0 services/app/src/lib/showdown/index.ts | 0 services/app/src/lib/showdown/table.ts | 0 services/app/src/lib/types.ts | 385 +- services/app/src/lib/utils.ts | 95 +- services/app/src/routes/(main)/+layout.svelte | 104 +- .../src/routes/(main)/BreadcrumbsBar.svelte | 313 +- .../app/src/routes/(main)/ChatSideDock.svelte | 384 + .../app/src/routes/(main)/SideDock.svelte | 415 +- .../app/src/routes/(main)/UploadTab.svelte | 122 +- .../app/src/routes/(main)/UserDetails.svelte | 53 +- .../(charts)/CreditUsageChart.svelte | 116 + .../(charts)/EgressUsageChart.svelte | 117 + .../(charts)/StorageUsageChart.svelte | 216 + .../analytics/(charts)/TokenUsageChart.svelte | 252 + .../routes/(main)/analytics/(charts)/index.ts | 5 + .../routes/(main)/analytics/+layout.server.ts | 36 + .../routes/(main)/analytics/+layout.svelte | 26 + .../routes/(main)/analytics/+page.server.ts | 296 + .../src/routes/(main)/analytics/+page.svelte | 192 + .../(main)/analytics/SelectMonth.svelte | 53 + .../chat/(components)/ProjectAgents.svelte | 40 + .../routes/(main)/chat/(components)/index.ts | 3 + .../app/src/routes/(main)/chat/+page.svelte | 584 ++ .../(components)/ChatControls.svelte | 133 + .../(components)/DeleteConvDialog.svelte | 100 + .../(components)/PDFViewer.svelte | 354 + .../(components)/ReferencesSection.svelte | 249 + .../[conversation_id]/(components)/index.ts | 5 + .../[conversation_id]/+page.svelte | 1359 +++ .../[project_id]/[conversation_id]/+page.ts | 5 + .../app/src/routes/(main)/chat/chat.svelte.ts | 989 ++ .../(main)/organization/+layout.server.ts | 141 + .../routes/(main)/organization/+layout.svelte | 93 + .../(main)/organization/+page.server.ts | 5 + .../organization/general/+page.server.ts | 205 + .../(main)/organization/general/+page.svelte | 354 + .../(components)/DeleteExtKeyDialog.svelte | 111 + .../(components)/EditExtKeyDialog.svelte | 180 + .../secrets/(components)/index.ts | 4 + .../organization/secrets/+page.server.ts | 55 + .../(main)/organization/secrets/+page.svelte | 138 + .../(main)/organization/team/+page.server.ts | 262 + .../(main)/organization/team/+page.svelte | 340 + .../routes/(main)/organization/team/+page.ts | 34 + .../organization/team/OrgInviteDialog.svelte | 188 + .../(main)/organization/usage/+page.svelte | 77 + .../src/routes/(main)/project/+layout.svelte | 41 +- .../app/src/routes/(main)/project/+layout.ts | 0 .../src/routes/(main)/project/+page.svelte | 392 +- .../(main)/project/ExportProjectButton.svelte | 31 +- .../(main)/project/ProjectDialogs.svelte | 265 +- .../(components)/ActionsDropdown.svelte | 163 +- .../(components)/ExportTableButton.svelte | 28 +- .../(components)/GenerateButton.svelte | 216 +- .../[project_id]/(components)/index.ts | 0 .../(dialogs)/AddColumnDialog.svelte | 344 +- .../(dialogs)/ColumnMatchDialog.svelte | 165 +- .../(dialogs)/DeleteDialogs.svelte | 127 +- .../(dialogs)/DeleteTableDialog.svelte | 67 +- .../(dialogs)/ImportTableDialog.svelte | 92 +- .../(dialogs)/RenameTableDialog.svelte | 83 +- .../project/[project_id]/(dialogs)/index.ts | 0 .../project/[project_id]/+layout.server.ts | 44 + .../project/[project_id]/+layout.svelte | 182 +- .../(main)/project/[project_id]/+page.ts | 0 .../(dialogs)/AddTableDialog.svelte | 567 +- .../[project_id]/action-table/+page.svelte | 219 +- .../[project_id]/action-table/+page.ts | 0 .../action-table/[table_id]/+page.ts | 36 +- .../[table_id]/+page@project.svelte | 232 +- .../(dialogs)/AddAgentDialog.svelte | 63 +- .../(dialogs)/AddConversationDialog.svelte | 121 +- .../chat-table/(dialogs)/index.ts | 0 .../[project_id]/chat-table/+page.svelte | 379 +- .../project/[project_id]/chat-table/+page.ts | 0 .../chat-table/[table_id]/+page.ts | 37 +- .../[table_id]/+page@project.svelte | 463 +- .../chat-table/[table_id]/ChatMode.svelte | 938 +- .../chat-table/[table_id]/ModeToggle.svelte | 42 +- .../(dialogs)/AddTableDialog.svelte | 153 +- .../(dialogs)/UploadingFileDialog.svelte | 16 +- .../knowledge-table/(dialogs)/index.ts | 0 .../[project_id]/knowledge-table/+page.svelte | 218 +- .../[project_id]/knowledge-table/+page.ts | 0 .../knowledge-table/[table_id]/+page.ts | 43 +- .../[table_id]/+page@project.svelte | 277 +- .../(components)/EditProjMemberDialog.svelte | 117 + .../(components)/ProjectInviteDialog.svelte | 234 + .../RemoveProjMemberDialog.svelte | 94 + .../members/(components)/index.ts | 5 + .../[project_id]/members/+page.server.ts | 308 + .../project/[project_id]/members/+page.svelte | 187 + .../project/[project_id]/members/+page.ts | 35 + .../[project_id]/overview/+page.svelte | 43 + .../src/routes/(main)/settings/+layout.svelte | 53 +- .../app/src/routes/(main)/settings/+layout.ts | 0 .../app/src/routes/(main)/settings/+page.ts | 7 +- .../(components)/ChangePasswordDialog.svelte | 135 + .../(components)/CreatePATDialog.svelte | 165 + .../(components)/DeleteAccountDialog.svelte | 101 + .../(components)/DeletePATDialog.svelte | 89 + .../settings/account/(components)/index.ts | 6 + .../(main)/settings/account/+page.server.ts | 236 + .../(main)/settings/account/+page.svelte | 264 + .../routes/(main)/settings/theme/page.svelte | 6 +- .../src/routes/(main)/system/+page.server.ts | 15 + .../(components)/AddDeploymentDialog.svelte | 153 + .../(components)/AddModelConfigDialog.svelte | 498 + .../DeleteDeploymentDialog.svelte | 96 + .../DeleteModelConfigDialog.svelte | 96 + .../(components)/DeploymentDetails.svelte | 208 + .../(components)/DeploymentManagement.svelte | 131 + .../ManageDeploymentDialog.svelte | 108 + .../models/(components)/ModelCatalogue.svelte | 344 + .../(components)/ModelConfigCard.svelte | 117 + .../system/models/(components)/index.ts | 19 + .../(main)/system/models/+layout.server.ts | 32 + .../routes/(main)/system/models/+layout.ts | 77 + .../(main)/system/models/+page.server.ts | 279 + .../routes/(main)/system/models/+page.svelte | 56 + .../(components)/CloudDeployments.svelte | 106 + .../(components)/ModelDetails.svelte | 513 + .../models/[model_id]/(components)/index.ts | 4 + .../system/models/[model_id]/+page.server.ts | 125 + .../system/models/[model_id]/+page.svelte | 97 + services/app/src/routes/+error.svelte | 8 +- services/app/src/routes/+layout.server.ts | 266 +- services/app/src/routes/+layout.svelte | 138 +- services/app/src/routes/+page.ts | 0 services/app/src/routes/_layout.ts | 6 +- .../api/admin/org/v1/projects/+server.ts | 191 - .../org/v1/projects/[project_id]/+server.ts | 93 - .../projects/[project_id]/export/+server.ts | 65 - .../import/[organization_id]/+server.ts | 66 - services/app/src/routes/api/log/+server.ts | 12 +- .../routes/api/v2/projects/export/+server.ts | 35 + .../api/v2/projects/import/parquet/+server.ts | 45 + .../routes/join-organization/+page.server.ts | 80 + .../src/routes/join-organization/+page.svelte | 112 + .../src/routes/join-project/+page.server.ts | 80 + .../app/src/routes/join-project/+page.svelte | 110 + services/app/src/routes/login/+page.svelte | 138 + services/app/src/routes/login/auth-errors.ts | 22 + .../routes/new-organization/+page.server.ts | 7 + .../src/routes/new-organization/+page.svelte | 190 + services/app/src/routes/register/+page.svelte | 177 + .../app/src/routes/register/auth-errors.ts | 22 + .../src/routes/verify-email/+page.server.ts | 301 + .../app/src/routes/verify-email/+page.svelte | 89 + services/app/src/showdown-theme.css | 0 services/app/static/favicon.ico | Bin services/app/static/favicon.png | Bin services/app/static/jamai-onboarding-bg.svg | 0 services/app/static/logo.png | Bin services/app/svelte.config.js | 0 services/app/tailwind.config.js | 17 +- services/app/tests/auth.setup.ts | 13 +- services/app/tests/fixtures/sample-csv.csv | 0 services/app/tests/fixtures/sample-data.json | 122 + services/app/tests/fixtures/sample-doc.txt | 0 services/app/tests/fixtures/sample-img.jpg | Bin services/app/tests/main.setup.ts | 317 +- services/app/tests/main.teardown.ts | 35 +- services/app/tests/pages/layout.page.ts | 6 +- services/app/tests/pages/project.page.ts | 8 +- services/app/tests/pages/table.page.ts | 10 +- services/app/tests/pages/tableList.page.ts | 0 services/app/tests/tableList.spec.ts | 10 +- services/app/tests/tables/actionTable.spec.ts | 24 +- services/app/tests/tables/chatTable.spec.ts | 37 +- .../app/tests/tables/knowledgeTable.spec.ts | 16 +- services/app/tsconfig.json | 0 services/app/vite.config.ts | 71 +- services/docio/.env | 2 - services/docio/MANIFEST.in | 1 - services/docio/README.md | 14 - services/docio/docio.spec | 70 - services/docio/pyproject.toml | 153 - services/docio/scripts/GenerateWinExe.ps1 | 7 - services/docio/scripts/SetupWinExeEnv.ps1 | 5 - services/docio/scripts/validate_exe.py | 39 - services/docio/src/docio/config.py | 21 - services/docio/src/docio/entrypoints/api.py | 117 - .../docio/src/docio/langchain/jsonloader.py | 141 - .../docio/src/docio/langchain/pdfplumber.py | 515 - .../docio/src/docio/langchain/tsvloader.py | 136 - services/docio/src/docio/protocol.py | 8 - services/docio/src/docio/routers/loader.py | 118 - services/docio/src/docio/utils/logging.py | 64 - services/docio/src/docio/version.py | 1 - 857 files changed, 86603 insertions(+), 35286 deletions(-) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/github_bot.yml create mode 100644 clients/python/src/jamaibase/types/__init__.py create mode 100644 clients/python/src/jamaibase/types/billing.py create mode 100644 clients/python/src/jamaibase/types/common.py create mode 100644 clients/python/src/jamaibase/types/compat.py create mode 100644 clients/python/src/jamaibase/types/conversation.py create mode 100644 clients/python/src/jamaibase/types/db.py create mode 100644 clients/python/src/jamaibase/types/file.py create mode 100644 clients/python/src/jamaibase/types/gen_table.py create mode 100644 clients/python/src/jamaibase/types/legacy.py create mode 100644 clients/python/src/jamaibase/types/lm.py create mode 100644 clients/python/src/jamaibase/types/logs.py create mode 100644 clients/python/src/jamaibase/types/mcp.py create mode 100644 clients/python/src/jamaibase/types/model.py create mode 100644 clients/python/src/jamaibase/types/telemetry.py create mode 100644 clients/python/src/jamaibase/utils/background_loop.py create mode 100644 clients/python/src/jamaibase/utils/dates.py rename clients/python/src/jamaibase/{ => utils}/exceptions.py (51%) delete mode 100644 clients/python/tests/cloud/test_admin.py delete mode 100644 clients/python/tests/cloud/test_org_admin.py create mode 100644 clients/typescript/__tests__/zoom-in-audio.mp3 mode change 100644 => 100755 clients/typescript/build create mode 100644 clients/typescript/rollup.config.ts create mode 100644 docker/Dockerfile.cnpg17 delete mode 100644 docker/Dockerfile.docio create mode 100644 docker/Dockerfile.owl.base create mode 100644 docker/Dockerfile.pg17 create mode 100644 docker/build_owl_base_image.sh create mode 100644 docker/ch_configs/clickhouse_config.xml create mode 100644 docker/ch_configs/clickhouse_user_config.xml create mode 100644 docker/ch_configs/create_ch_prom_db.sh delete mode 100644 docker/compose.amd.yml create mode 100644 docker/compose.bake.hcl create mode 100644 docker/compose.base.yml create mode 100644 docker/compose.ci.yml delete mode 100644 docker/compose.cpu.ollama.yml delete mode 100644 docker/compose.cpu.yml create mode 100644 docker/compose.dev.yml delete mode 100644 docker/compose.nvidia.yml create mode 100644 docker/compose.test-llm.yml create mode 100644 docker/infinity.yml create mode 100644 docker/kong.yml delete mode 100644 docker/ollama.yml create mode 100644 docker/otel_configs/otel-collector-config.yaml create mode 100644 docker/override.ci.yml create mode 100644 docker/override.dev.yml rename docker/{nvidia.yml => override.nvidia.yml} (97%) create mode 100644 docker/vmagent/streamingAggregation.yaml create mode 100644 docker/vmauth/config.yml create mode 100644 docs/alert_guide.md create mode 100644 docs/pgaudit_guide.md delete mode 100644 scripts/compile_docio_exe.ps1 create mode 100644 scripts/migrate_v1_to_v2.py create mode 100644 scripts/migration_s3_v1_to_v2.py delete mode 100644 scripts/remove_cloud_modules.ps1 create mode 100644 scripts/update_model_id.py create mode 100644 services/api/Chat.md delete mode 100644 services/api/api.spec create mode 100644 services/api/scripts/recreate_template.py create mode 100644 services/api/src/owl/assets/Roboto-Regular.ttf create mode 100644 services/api/src/owl/assets/icons/csv.webp create mode 100644 services/api/src/owl/assets/icons/docx.webp create mode 100644 services/api/src/owl/assets/icons/html.webp create mode 100644 services/api/src/owl/assets/icons/jpg.webp create mode 100644 services/api/src/owl/assets/icons/json.webp create mode 100644 services/api/src/owl/assets/icons/jsonl.webp create mode 100644 services/api/src/owl/assets/icons/md.webp create mode 100644 services/api/src/owl/assets/icons/pdf.webp create mode 100644 services/api/src/owl/assets/icons/pptx.webp create mode 100644 services/api/src/owl/assets/icons/tsv.webp create mode 100644 services/api/src/owl/assets/icons/txt.webp create mode 100644 services/api/src/owl/assets/icons/xlsx.webp create mode 100644 services/api/src/owl/assets/icons/xml.webp delete mode 100644 services/api/src/owl/billing.py create mode 100644 services/api/src/owl/client.py delete mode 100644 services/api/src/owl/configs/manager.py delete mode 100644 services/api/src/owl/configs/models.json delete mode 100644 services/api/src/owl/configs/models_aipc.json delete mode 100644 services/api/src/owl/configs/models_ci.json delete mode 100644 services/api/src/owl/configs/models_ollama.json create mode 100644 services/api/src/owl/configs/oss.py delete mode 100644 services/api/src/owl/db/gen_table_v2.py create mode 100644 services/api/src/owl/db/models/__init__.py create mode 100644 services/api/src/owl/db/models/oss.py delete mode 100644 services/api/src/owl/db/oss_admin.py delete mode 100644 services/api/src/owl/db/template.py delete mode 100644 services/api/src/owl/docio.py create mode 100644 services/api/src/owl/docparse.py delete mode 100644 services/api/src/owl/entrypoints/chat_echo.py delete mode 100644 services/api/src/owl/entrypoints/chat_python.py create mode 100644 services/api/src/owl/entrypoints/llm.py delete mode 100644 services/api/src/owl/llm.py delete mode 100644 services/api/src/owl/loaders.py delete mode 100644 services/api/src/owl/models.py delete mode 100644 services/api/src/owl/protocol.py create mode 100644 services/api/src/owl/routers/auth.py create mode 100644 services/api/src/owl/routers/conversation.py create mode 100644 services/api/src/owl/routers/gen_table_v1.py delete mode 100644 services/api/src/owl/routers/llm.py create mode 100644 services/api/src/owl/routers/meters.py create mode 100644 services/api/src/owl/routers/models.py delete mode 100644 services/api/src/owl/routers/org_admin.py create mode 100644 services/api/src/owl/routers/organizations/__init__.py create mode 100644 services/api/src/owl/routers/organizations/oss.py delete mode 100644 services/api/src/owl/routers/oss_admin.py create mode 100644 services/api/src/owl/routers/projects/__init__.py create mode 100644 services/api/src/owl/routers/projects/oss.py create mode 100644 services/api/src/owl/routers/projects/v1.py create mode 100644 services/api/src/owl/routers/serving.py create mode 100644 services/api/src/owl/routers/tasks.py delete mode 100644 services/api/src/owl/routers/template.py create mode 100644 services/api/src/owl/routers/templates.py create mode 100644 services/api/src/owl/routers/users/__init__.py create mode 100644 services/api/src/owl/routers/users/oss.py delete mode 100644 services/api/src/owl/scripts/update_db.py create mode 100644 services/api/src/owl/tasks/checks.py create mode 100644 services/api/src/owl/tasks/database.py create mode 100644 services/api/src/owl/tasks/gen_table.py delete mode 100644 services/api/src/owl/tasks/storage.py delete mode 100644 services/api/src/owl/templates/.gitignore delete mode 100644 services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet delete mode 100644 services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet delete mode 100644 services/api/src/owl/templates/f1_due_diligence/template_meta.json create mode 100644 services/api/src/owl/types/__init__.py create mode 100644 services/api/src/owl/types/db.py delete mode 100644 services/api/src/owl/unstructuredio.py delete mode 100644 services/api/src/owl/utils/auth.py create mode 100644 services/api/src/owl/utils/auth/__init__.py create mode 100644 services/api/src/owl/utils/auth/oss.py create mode 100644 services/api/src/owl/utils/billing/__init__.py create mode 100644 services/api/src/owl/utils/billing/oss.py create mode 100644 services/api/src/owl/utils/billing_metrics.py create mode 100644 services/api/src/owl/utils/cache.py create mode 100644 services/api/src/owl/utils/dates.py create mode 100644 services/api/src/owl/utils/handlers.py delete mode 100644 services/api/src/owl/utils/ip_address.py create mode 100644 services/api/src/owl/utils/lm.py create mode 100644 services/api/src/owl/utils/loguru_otlp_handler.py create mode 100644 services/api/src/owl/utils/mcp/__init__.py create mode 100644 services/api/src/owl/utils/mcp/custom_tools.py create mode 100644 services/api/src/owl/utils/mcp/helpers.py create mode 100644 services/api/src/owl/utils/mcp/server.py create mode 100644 services/api/src/owl/utils/metrics.py delete mode 100644 services/api/src/owl/utils/openapi.py delete mode 100644 services/api/src/owl/utils/responses.py delete mode 100644 services/api/src/owl/utils/tasks.py create mode 100644 services/api/src/owl/utils/test.py create mode 100644 services/api/src/owl/utils/types.py create mode 100644 services/api/src/owl/utils/victoriametrics.py create mode 100644 services/api/tests/README.md create mode 100644 "services/api/tests/docling_ground_truth/GitHub \350\241\250\345\215\225\346\236\266\346\236\204\350\257\255\346\263\225 - GitHub \346\226\207\346\241\243.json" create mode 100644 services/api/tests/docling_ground_truth/Swire_AR22_e_230406_sample.json rename {clients/python => services/api}/tests/files/bmp/cifar10-deer.bmp (100%) rename {clients/python => services/api}/tests/files/csv/company-profile.csv (100%) rename {clients/python => services/api}/tests/files/csv/empty.csv (100%) rename {clients/python => services/api}/tests/files/csv/weather_observations_long.csv (100%) rename {clients/python => services/api}/tests/files/doc/Recommendation Letter.doc (100%) rename {clients/python => services/api}/tests/files/docx/Recommendation Letter.docx (100%) rename {clients/python => services/api}/tests/files/gif/rabbit_cifar10-deer.gif (100%) create mode 100644 services/api/tests/files/gif/rabbit_cifar10-deer.gif.thumb.webp rename {clients/python => services/api}/tests/files/html/RAG and LLM Integration Guide.html (100%) rename {clients/python => services/api}/tests/files/html/multilingual-code-examples.html (100%) rename {clients/python => services/api}/tests/files/html/table.html (100%) rename {clients/python => services/api}/tests/files/jpeg/cifar10-deer.jpg (100%) create mode 100644 services/api/tests/files/jpeg/cifar10-deer.jpg.thumb.webp create mode 100644 services/api/tests/files/jpeg/doe.jpg rename {clients/python => services/api}/tests/files/jpeg/rabbit.jpeg (100%) rename {clients/python => services/api}/tests/files/json/company-profile.json (100%) rename {clients/python => services/api}/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl (100%) rename {clients/python => services/api}/tests/files/jsonl/llm-models.jsonl (100%) rename {clients/python => services/api}/tests/files/md/creative-story.md (100%) create mode 100644 services/api/tests/files/mp3/grand-scheme.mp3 create mode 100644 services/api/tests/files/mp3/gutter.mp3 create mode 100644 services/api/tests/files/mp3/gutter.mp3.thumb.mp3 create mode 100644 services/api/tests/files/mp3/stars.mp3 rename {clients/python => services/api}/tests/files/mp3/turning-a4-size-magazine.mp3 (100%) create mode 100644 services/api/tests/files/mp3/turning-a4-size-magazine.mp3.thumb.mp3 rename {clients/python => services/api}/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf (100%) create mode 100644 services/api/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf.thumb.webp rename {clients/python => services/api}/tests/files/pdf/1982_PRB_phonon-assisted_tunnel_ionization.pdf (100%) create mode 100644 "services/api/tests/files/pdf/GitHub \350\241\250\345\215\225\346\236\266\346\236\204\350\257\255\346\263\225 - GitHub \346\226\207\346\241\243.pdf" rename clients/python/tests/files/pdf/Large Language Models as Optimizers [DeepMind ; 2023].pdf => services/api/tests/files/pdf/LLMs as Optimizers [DeepMind ; 2023].pdf (100%) rename {clients/python => services/api}/tests/files/pdf/Swire_AR22_e_230406_sample.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/System Design Blueprint - The Ultimate Guide.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/Vehicle Detail - MyPUSPAKOM.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/ag-energy-round-up-2017-02-24.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/background-checks.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/ca-warn-report.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/empty.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/empty_3pages.pdf (100%) create mode 100644 services/api/tests/files/pdf/hello.pdf rename "clients/python/tests/files/pdf/salary \346\200\273\347\273\223.pdf" => "services/api/tests/files/pdf/salary \346\200\273\347\273\223.pdf" (100%) rename {clients/python => services/api}/tests/files/pdf/sample_tables.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/san-jose-pd-firearm-sample.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/statement_card.pdf (100%) rename {clients/python => services/api}/tests/files/pdf/statement_ewallet.pdf (100%) rename {clients/python => services/api}/tests/files/pdf_mixed/digital_scan_combined.pdf (100%) rename {clients/python => services/api}/tests/files/pdf_scan/1978_APL_FP_detrapping.PDF (100%) create mode 100644 services/api/tests/files/pdf_scan/uuk_bangunan_seragam_pindaan_2017.pdf rename {clients/python => services/api}/tests/files/png/cifar10-deer.png (100%) rename {clients/python => services/api}/tests/files/png/github-mark-white.png (100%) rename {clients/python => services/api}/tests/files/png/rabbit.png (100%) create mode 100644 services/api/tests/files/png/rabbit.png.thumb.webp rename {clients/python => services/api}/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 => services/api/tests/files/pptx/(2017.06.30) NMT in Linear Time (ByteNet).pptx (100%) rename {clients/python => services/api}/tests/files/tiff/cifar10-deer.tiff (100%) rename {clients/python => services/api}/tests/files/tiff/rabbit.tiff (100%) rename {clients/python => services/api}/tests/files/tsv/weather_observations.tsv (100%) rename {clients/python => services/api}/tests/files/txt/creative-story.txt (100%) rename {clients/python => services/api}/tests/files/txt/empty.txt (100%) rename {clients/python => services/api}/tests/files/txt/weather.txt (100%) create mode 100644 services/api/tests/files/wav/gutter.wav create mode 100644 services/api/tests/files/wav/gutter.wav.thumb.mp3 rename {clients/python => services/api}/tests/files/wav/turning-a4-size-magazine.wav (100%) create mode 100644 services/api/tests/files/wav/turning-a4-size-magazine.wav.thumb.mp3 rename {clients/python => services/api}/tests/files/webp/rabbit_cifar10-deer.webp (100%) create mode 100644 services/api/tests/files/webp/rabbit_cifar10-deer.webp.thumb.webp rename {clients/python => services/api}/tests/files/xls/Claims Form.xls (100%) rename {clients/python => services/api}/tests/files/xlsx/Claims Form.xlsx (100%) create mode 100644 services/api/tests/files/xlsx/Claims Form.xlsx.thumb.gen.webp create mode 100644 services/api/tests/files/xlsx/Claims Form.xlsx.thumb.webp rename {clients/python => services/api}/tests/files/xml/weather-forecast-service.xml (100%) create mode 100644 services/api/tests/gen_table/test_empty_db.py create mode 100644 services/api/tests/gen_table/test_import_export.py create mode 100644 services/api/tests/gen_table/test_row_ops.py create mode 100644 services/api/tests/gen_table/test_row_ops_v2.py create mode 100644 services/api/tests/gen_table/test_table_ops.py create mode 100644 services/api/tests/gen_table/test_table_ops_v2.py create mode 100644 services/api/tests/gen_table/test_v1.py create mode 100644 services/api/tests/gen_table_core/test_gen_table_core.py create mode 100644 services/api/tests/gen_table_core/test_manipulation.py create mode 100644 services/api/tests/routers/test_conversation.py create mode 100644 services/api/tests/routers/test_models.py create mode 100644 services/api/tests/routers/test_organizations.py create mode 100644 services/api/tests/routers/test_projects.py create mode 100644 services/api/tests/routers/test_serving.py create mode 100644 services/api/tests/routers/test_templates.py create mode 100644 services/api/tests/routers/test_users.py create mode 100644 services/api/tests/test_db.py create mode 100644 services/api/tests/test_docparse.py delete mode 100644 services/api/tests/test_lance.py create mode 100644 services/api/tests/test_protocol.py delete mode 100644 services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_data.lance delete mode 100644 services/api/tests/test_table.lance/_indices/80c539f0-1c19-4a7c-b273-cfb237733433/page_lookup.lance delete mode 100644 services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_data.lance delete mode 100644 services/api/tests/test_table.lance/_indices/c0e1017d-bc45-4449-860d-91a3e588c23b/page_lookup.lance delete mode 100644 services/api/tests/test_table.lance/_indices/cf06ed0f-70eb-479f-9824-dfee71a61680/page_data.lance delete mode 100644 services/api/tests/test_table.lance/_indices/cf06ed0f-70eb-479f-9824-dfee71a61680/page_lookup.lance delete mode 100644 services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_data.lance delete mode 100644 services/api/tests/test_table.lance/_indices/d722107e-5c23-4c94-b9e1-2eb5cc635c9a/page_lookup.lance delete mode 100644 services/api/tests/test_table.lance/_latest.manifest delete mode 100644 services/api/tests/test_table.lance/_transactions/55-83679c50-04f5-4ad6-a5d3-c90147f82175.txn delete mode 100644 services/api/tests/test_table.lance/_transactions/56-2a19e3d8-6397-4d41-8667-3f8c005bdb47.txn delete mode 100644 services/api/tests/test_table.lance/_transactions/56-c54f1ffd-ae26-4520-9639-0d08015a5dce.txn delete mode 100644 services/api/tests/test_table.lance/_versions/56.manifest delete mode 100644 services/api/tests/test_table.lance/_versions/57.manifest delete mode 100644 services/api/tests/test_table.lance/_versions/58.manifest delete mode 100644 services/api/tests/test_table.lance/data/0218dfb5-7d6a-4594-be2f-b6da21fc2991.lance delete mode 100644 services/api/tests/test_table.lance/data/0841f676-2d34-4a0d-beb2-b66eee322f5b.lance delete mode 100644 services/api/tests/test_table.lance/data/0c4d5f53-dce1-4c49-8095-fe16471dfe92.lance delete mode 100644 services/api/tests/test_table.lance/data/0cab0285-7b8d-4019-8662-662342476266.lance delete mode 100644 services/api/tests/test_table.lance/data/195821c2-10b0-4681-9b60-50f79fa017a9.lance delete mode 100644 services/api/tests/test_table.lance/data/2ac6344b-650c-4991-9bd1-48046519287a.lance delete mode 100644 services/api/tests/test_table.lance/data/2fe78714-ed7b-4dd7-8388-889e58479f7c.lance delete mode 100644 services/api/tests/test_table.lance/data/30fb0867-31d2-4cf6-8d7e-f8c68bcd4882.lance delete mode 100644 services/api/tests/test_table.lance/data/375faaf4-394d-4c1d-a217-dce02e301028.lance delete mode 100644 services/api/tests/test_table.lance/data/3a8ee5fd-c9ae-4015-970b-7a21f4a0d4fa.lance delete mode 100644 services/api/tests/test_table.lance/data/3e6187d4-9f32-4fb1-a0e4-a4e028d98574.lance delete mode 100644 services/api/tests/test_table.lance/data/4038dc14-3f39-4076-b537-d262314ce58e.lance delete mode 100644 services/api/tests/test_table.lance/data/476816e3-7c4a-4b74-aec1-1e5a1a2a9c57.lance delete mode 100644 services/api/tests/test_table.lance/data/4c5cd4e2-3156-4e17-8129-2e600e91d019.lance delete mode 100644 services/api/tests/test_table.lance/data/4fb6cee8-9f15-4070-a709-02db49fb21b1.lance delete mode 100644 services/api/tests/test_table.lance/data/556e81d3-9067-4d1d-b17d-a4cc0a3eeefc.lance delete mode 100644 services/api/tests/test_table.lance/data/5bb527a1-3c37-4cc7-8db9-85e610247143.lance delete mode 100644 services/api/tests/test_table.lance/data/5e04331f-8465-40b0-af53-455928d381a8.lance delete mode 100644 services/api/tests/test_table.lance/data/62661a16-01fe-4513-b58d-9699f70b5ae4.lance delete mode 100644 services/api/tests/test_table.lance/data/67bff393-906c-432b-bf15-5b854effe0a7.lance delete mode 100644 services/api/tests/test_table.lance/data/681de983-0000-4db1-a193-ee04cee80253.lance delete mode 100644 services/api/tests/test_table.lance/data/6b47eefb-b495-444c-aec6-626a0ed8e441.lance delete mode 100644 services/api/tests/test_table.lance/data/70ba4ca1-f11d-4a37-8bf2-33e642d64baf.lance delete mode 100644 services/api/tests/test_table.lance/data/800d2d2e-e5d3-4f6b-86e5-5406ac625504.lance delete mode 100644 services/api/tests/test_table.lance/data/8263336e-11dc-47c1-a0ab-37dced034d86.lance delete mode 100644 services/api/tests/test_table.lance/data/833f82bb-d3bf-45d6-93d1-d81b280d4db5.lance delete mode 100644 services/api/tests/test_table.lance/data/85d3b452-5002-4793-bc66-fced85c77ebd.lance delete mode 100644 services/api/tests/test_table.lance/data/94e60dc4-ca0e-408d-9977-988f7b1fa27e.lance delete mode 100644 services/api/tests/test_table.lance/data/9583d125-a754-4104-ad58-670223a4cfd4.lance delete mode 100644 services/api/tests/test_table.lance/data/9ae5a155-6864-4aee-9622-7c1d04913ae9.lance delete mode 100644 services/api/tests/test_table.lance/data/a20c5619-719e-48c8-a249-607527257890.lance delete mode 100644 services/api/tests/test_table.lance/data/a4a314e7-13e0-4a60-9d6f-b459576cc577.lance delete mode 100644 services/api/tests/test_table.lance/data/abbdcc8c-93da-4e2e-ac61-91be0874a587.lance delete mode 100644 services/api/tests/test_table.lance/data/af0d8632-619d-4e09-bb2f-ba13d0f568e5.lance delete mode 100644 services/api/tests/test_table.lance/data/af70162a-d319-403c-bd26-251353c9966c.lance delete mode 100644 services/api/tests/test_table.lance/data/b25448ee-84a7-46d8-b681-67a7faf2e1fc.lance delete mode 100644 services/api/tests/test_table.lance/data/bc2de3a9-4f96-4bd8-9856-e17e7bc6bcf8.lance delete mode 100644 services/api/tests/test_table.lance/data/beb7ea89-576d-45f7-b8be-cc248d0ccc77.lance delete mode 100644 services/api/tests/test_table.lance/data/c1236cac-af08-4ce5-be1f-ae3e8b2024ef.lance delete mode 100644 services/api/tests/test_table.lance/data/c6729c82-8cb2-44d5-993a-7b09bf678703.lance delete mode 100644 services/api/tests/test_table.lance/data/ca3909ff-b21b-45c5-bf85-4911bba60fde.lance delete mode 100644 services/api/tests/test_table.lance/data/ca71816d-8322-43a4-9502-bca6cd2e020c.lance delete mode 100644 services/api/tests/test_table.lance/data/d532c28c-7d3c-4d22-afb5-92b5a111e17b.lance delete mode 100644 services/api/tests/test_table.lance/data/da9c0e9e-7f8b-44ea-82a4-160c98ca17ae.lance delete mode 100644 services/api/tests/test_table.lance/data/db6054f5-4108-4f23-b508-565b32020f68.lance delete mode 100644 services/api/tests/test_table.lance/data/e4c544bd-eb55-4af4-a2dc-f91d7863e671.lance delete mode 100644 services/api/tests/test_table.lance/data/e628d9da-95ab-4b1f-8d21-47ca21154b81.lance delete mode 100644 services/api/tests/test_table.lance/data/e6541d20-5c18-4b80-8d71-84f899310512.lance delete mode 100644 services/api/tests/test_table.lance/data/e856769d-4dac-492c-bd12-679434292b04.lance delete mode 100644 services/api/tests/test_table.lance/data/ea967c5f-1d05-4069-8fef-a1d090e42730.lance delete mode 100644 services/api/tests/test_table.lance/data/ee2829e0-90b5-416b-8c05-203ee77b75aa.lance delete mode 100644 services/api/tests/test_table.lance/data/fc8dc641-5cba-4570-8ef2-8b10992f2cf8.lance delete mode 100644 services/api/tests/test_table.lance/data/fd13dd8d-4f5c-4040-b8da-af081fa49e9d.lance delete mode 100644 services/api/tests/test_table.lance/data/fe71820f-1a4e-4345-8df1-777443efb74a.lance create mode 100644 services/api/tests/test_types.py create mode 100644 services/api/tests/utils/test_auth.py create mode 100644 services/api/tests/utils/test_billing_event.py create mode 100644 services/api/tests/utils/test_crypt.py create mode 100644 services/api/tests/utils/test_dates.py create mode 100644 services/api/tests/utils/test_file.py create mode 100644 services/api/tests/utils/test_io.py create mode 100644 services/api/tests/utils/test_jwt.py create mode 100644 services/api/tests/utils/test_mcp.py create mode 100644 services/api/tests/utils/test_utils.py mode change 100644 => 100755 services/app/.env.example mode change 100644 => 100755 services/app/.eslintignore mode change 100644 => 100755 services/app/.eslintrc.cjs mode change 100644 => 100755 services/app/.gitignore mode change 100644 => 100755 services/app/.npmrc mode change 100644 => 100755 services/app/.prettierignore mode change 100644 => 100755 services/app/.prettierrc mode change 100644 => 100755 services/app/README.md mode change 100644 => 100755 services/app/build.bat mode change 100644 => 100755 services/app/components.json mode change 100644 => 100755 services/app/electron/icons/icon.icns mode change 100644 => 100755 services/app/electron/icons/icon.ico mode change 100644 => 100755 services/app/electron/icons/icon.png mode change 100644 => 100755 services/app/electron/main.js mode change 100644 => 100755 services/app/forge.config.cjs create mode 100644 services/app/messages/en.json mode change 100644 => 100755 services/app/package-lock.json mode change 100644 => 100755 services/app/package.json mode change 100644 => 100755 services/app/playwright.config.ts mode change 100644 => 100755 services/app/postcss.config.js create mode 100644 services/app/project.inlang/.gitignore create mode 100644 services/app/project.inlang/project_id create mode 100644 services/app/project.inlang/settings.json mode change 100644 => 100755 services/app/server/index.js mode change 100644 => 100755 services/app/src/app.css mode change 100644 => 100755 services/app/src/app.d.ts mode change 100644 => 100755 services/app/src/app.html mode change 100644 => 100755 services/app/src/globalStore.ts mode change 100644 => 100755 services/app/src/hljs-theme.css mode change 100644 => 100755 services/app/src/hooks.server.ts create mode 100644 services/app/src/hooks.ts mode change 100644 => 100755 services/app/src/lib/assets/Black-Long-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/Black-Long.svg mode change 100644 => 100755 services/app/src/lib/assets/Black-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/Black.svg mode change 100644 => 100755 services/app/src/lib/assets/Jamai-Long-Black-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/Jamai-Long-White-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/White-Long-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/White-Long.svg mode change 100644 => 100755 services/app/src/lib/assets/White-Main.svg mode change 100644 => 100755 services/app/src/lib/assets/White.svg mode change 100644 => 100755 services/app/src/lib/assets/dark-mode.svg mode change 100644 => 100755 services/app/src/lib/assets/jamai-onboarding-bg.svg mode change 100644 => 100755 services/app/src/lib/assets/light-mode.svg create mode 100644 services/app/src/lib/assets/model-icons/allenai.png create mode 100644 services/app/src/lib/assets/model-icons/anthropic.png create mode 100644 services/app/src/lib/assets/model-icons/cohere.png create mode 100644 services/app/src/lib/assets/model-icons/deepseek.png create mode 100644 services/app/src/lib/assets/model-icons/gemini.png create mode 100644 services/app/src/lib/assets/model-icons/generic.png create mode 100644 services/app/src/lib/assets/model-icons/generic2.png create mode 100644 services/app/src/lib/assets/model-icons/index.ts create mode 100644 services/app/src/lib/assets/model-icons/meta.png create mode 100644 services/app/src/lib/assets/model-icons/mistral.png create mode 100644 services/app/src/lib/assets/model-icons/openai.png create mode 100644 services/app/src/lib/assets/model-icons/qwen.png mode change 100644 => 100755 services/app/src/lib/assets/system-mode.svg create mode 100644 services/app/src/lib/auth.ts mode change 100644 => 100755 services/app/src/lib/components/Checkbox.svelte mode change 100644 => 100755 services/app/src/lib/components/DraggableList.svelte mode change 100644 => 100755 services/app/src/lib/components/InputText.svelte create mode 100644 services/app/src/lib/components/PermissionGuard.svelte mode change 100644 => 100755 services/app/src/lib/components/Portal.svelte mode change 100644 => 100755 services/app/src/lib/components/Range.svelte mode change 100644 => 100755 services/app/src/lib/components/TextField.svelte mode change 100644 => 100755 services/app/src/lib/components/Tooltip.svelte create mode 100644 services/app/src/lib/components/chat/ChatFilePreview.svelte create mode 100644 services/app/src/lib/components/chat/ChatThumbsFetch.svelte create mode 100644 services/app/src/lib/components/chat/index.ts create mode 100644 services/app/src/lib/components/preset/CodeEditor.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/FoundProjectOrgSwitcher.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/ModelSelect.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/PlanSelect.svelte create mode 100644 services/app/src/lib/components/preset/PromptEditor.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/RowStreamIndicator.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/SearchBar.svelte mode change 100644 => 100755 services/app/src/lib/components/preset/SorterSelect.svelte create mode 100644 services/app/src/lib/components/preset/UserDetailsBtn.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/ConvList.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/Conversations.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/DeleteFileDialog.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/FileColumnView.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/FileSelect.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/NewRow.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/PlaceholderNewCol.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/TablePagination.svelte create mode 100644 services/app/src/lib/components/tables/(sub)/TableSorter.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/(sub)/index.ts mode change 100644 => 100755 services/app/src/lib/components/tables/(svg)/NoRowsGraphic.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/ActionTable.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/ChatTable.svelte mode change 100644 => 100755 services/app/src/lib/components/tables/KnowledgeTable.svelte create mode 100644 services/app/src/lib/components/tables/tablesState.svelte.ts delete mode 100644 services/app/src/lib/components/tables/tablesStore.ts create mode 100644 services/app/src/lib/components/ui/alert/alert-description.svelte create mode 100644 services/app/src/lib/components/ui/alert/alert-title.svelte create mode 100644 services/app/src/lib/components/ui/alert/alert.svelte create mode 100644 services/app/src/lib/components/ui/alert/index.ts create mode 100644 services/app/src/lib/components/ui/badge/badge.svelte create mode 100644 services/app/src/lib/components/ui/badge/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/button/button.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/button/index.ts create mode 100644 services/app/src/lib/components/ui/calendar/calendar-cell.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-day.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-grid-body.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-grid-head.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-grid-row.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-grid.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-head-cell.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-header.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-heading.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-months.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-next-button.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar-prev-button.svelte create mode 100644 services/app/src/lib/components/ui/calendar/calendar.svelte create mode 100644 services/app/src/lib/components/ui/calendar/index.ts create mode 100644 services/app/src/lib/components/ui/checkbox/checkbox.svelte create mode 100644 services/app/src/lib/components/ui/checkbox/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-actions.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-content.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-description.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-footer.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-header.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-overlay.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-portal.svelte delete mode 100644 services/app/src/lib/components/ui/dialog/dialog-root.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/dialog-title.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dialog/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/dropdown-menu/index.ts create mode 100644 services/app/src/lib/components/ui/input-otp/index.ts create mode 100644 services/app/src/lib/components/ui/input-otp/input-otp-group.svelte create mode 100644 services/app/src/lib/components/ui/input-otp/input-otp-separator.svelte create mode 100644 services/app/src/lib/components/ui/input-otp/input-otp-slot.svelte create mode 100644 services/app/src/lib/components/ui/input-otp/input-otp.svelte create mode 100644 services/app/src/lib/components/ui/input/index.ts create mode 100644 services/app/src/lib/components/ui/input/input.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/label/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/label/label.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-content.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-ellipsis.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-item.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-link.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-next-button.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination-prev-button.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/pagination/pagination.svelte create mode 100644 services/app/src/lib/components/ui/popover/index.ts create mode 100644 services/app/src/lib/components/ui/popover/popover-content.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/index.ts create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-cell.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-day.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-grid.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-header.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-heading.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-months.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte create mode 100644 services/app/src/lib/components/ui/range-calendar/range-calendar.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/select/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/select/select-content.svelte create mode 100644 services/app/src/lib/components/ui/select/select-group-heading.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/select/select-item.svelte delete mode 100644 services/app/src/lib/components/ui/select/select-label.svelte create mode 100644 services/app/src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 services/app/src/lib/components/ui/select/select-scroll-up-button.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/select/select-separator.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/select/select-trigger.svelte create mode 100644 services/app/src/lib/components/ui/separator/index.ts create mode 100644 services/app/src/lib/components/ui/separator/separator.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/skeleton/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/skeleton/skeleton.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/sonner/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/sonner/sonner.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/switch/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/switch/switch.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/index.ts mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-body.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-caption.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-cell.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-footer.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-head.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-header.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table-row.svelte mode change 100644 => 100755 services/app/src/lib/components/ui/table/table.svelte create mode 100644 services/app/src/lib/components/ui/tabs/index.ts create mode 100644 services/app/src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 services/app/src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 services/app/src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 services/app/src/lib/components/ui/tooltip/index.ts create mode 100644 services/app/src/lib/components/ui/tooltip/tooltip-content.svelte mode change 100644 => 100755 services/app/src/lib/constants.ts mode change 100644 => 100755 services/app/src/lib/db.ts mode change 100644 => 100755 services/app/src/lib/icons/ActionTableIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/AddColumnIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/AddIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ArrowBackIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ArrowDownIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ArrowFilledRightIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ArrowLeftIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ArrowRightIcon.svelte delete mode 100644 services/app/src/lib/icons/AssignmentIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ChatAgentIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ChatTableIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/CheckDoneIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/CheckIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ChunkEditorIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/CloseIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/CodeIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/CopyIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/DeleteIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/DialogCloseIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/DocumentFilledIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/EditIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ExportIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ExternalLinkIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/EyeOffIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/EyeOnIcon.svelte create mode 100644 services/app/src/lib/icons/FourCircles.svelte mode change 100644 => 100755 services/app/src/lib/icons/HamburgerIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/HomeIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/ImportIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/InfoIcon.svelte create mode 100644 services/app/src/lib/icons/Jambu.svelte mode change 100644 => 100755 services/app/src/lib/icons/KnowledgeTableIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/LeftArrowIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/LoadingSpinner.svelte mode change 100644 => 100755 services/app/src/lib/icons/LockIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/LogoutIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/MoreVertIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/MultiturnChatIcon.svelte create mode 100644 services/app/src/lib/icons/OpenIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/PeopleIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/PersonAddIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/RegenerateIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/RowSearchIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SearchIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SendIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SettingsIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SideBarIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SortAlphabetIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/SortByIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/StarIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/StickyNoteIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/StopIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/TuneIcon.svelte mode change 100644 => 100755 services/app/src/lib/icons/WarningIcon.svelte mode change 100644 => 100755 services/app/src/lib/logger.ts mode change 100644 => 100755 services/app/src/lib/server/nodeCache.ts mode change 100644 => 100755 services/app/src/lib/server/utils.ts mode change 100644 => 100755 services/app/src/lib/showdown/codeblock.ts mode change 100644 => 100755 services/app/src/lib/showdown/codehighlight.ts mode change 100644 => 100755 services/app/src/lib/showdown/index.ts mode change 100644 => 100755 services/app/src/lib/showdown/table.ts mode change 100644 => 100755 services/app/src/lib/types.ts mode change 100644 => 100755 services/app/src/lib/utils.ts mode change 100644 => 100755 services/app/src/routes/(main)/+layout.svelte mode change 100644 => 100755 services/app/src/routes/(main)/BreadcrumbsBar.svelte create mode 100644 services/app/src/routes/(main)/ChatSideDock.svelte mode change 100644 => 100755 services/app/src/routes/(main)/SideDock.svelte mode change 100644 => 100755 services/app/src/routes/(main)/UploadTab.svelte mode change 100644 => 100755 services/app/src/routes/(main)/UserDetails.svelte create mode 100755 services/app/src/routes/(main)/analytics/(charts)/CreditUsageChart.svelte create mode 100755 services/app/src/routes/(main)/analytics/(charts)/EgressUsageChart.svelte create mode 100755 services/app/src/routes/(main)/analytics/(charts)/StorageUsageChart.svelte create mode 100755 services/app/src/routes/(main)/analytics/(charts)/TokenUsageChart.svelte create mode 100755 services/app/src/routes/(main)/analytics/(charts)/index.ts create mode 100755 services/app/src/routes/(main)/analytics/+layout.server.ts create mode 100755 services/app/src/routes/(main)/analytics/+layout.svelte create mode 100755 services/app/src/routes/(main)/analytics/+page.server.ts create mode 100755 services/app/src/routes/(main)/analytics/+page.svelte create mode 100755 services/app/src/routes/(main)/analytics/SelectMonth.svelte create mode 100644 services/app/src/routes/(main)/chat/(components)/ProjectAgents.svelte create mode 100644 services/app/src/routes/(main)/chat/(components)/index.ts create mode 100644 services/app/src/routes/(main)/chat/+page.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/(components)/ChatControls.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/(components)/DeleteConvDialog.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/(components)/PDFViewer.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/(components)/ReferencesSection.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/(components)/index.ts create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/+page.svelte create mode 100644 services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/+page.ts create mode 100644 services/app/src/routes/(main)/chat/chat.svelte.ts create mode 100755 services/app/src/routes/(main)/organization/+layout.server.ts create mode 100755 services/app/src/routes/(main)/organization/+layout.svelte create mode 100755 services/app/src/routes/(main)/organization/+page.server.ts create mode 100755 services/app/src/routes/(main)/organization/general/+page.server.ts create mode 100755 services/app/src/routes/(main)/organization/general/+page.svelte create mode 100644 services/app/src/routes/(main)/organization/secrets/(components)/DeleteExtKeyDialog.svelte create mode 100644 services/app/src/routes/(main)/organization/secrets/(components)/EditExtKeyDialog.svelte create mode 100644 services/app/src/routes/(main)/organization/secrets/(components)/index.ts create mode 100755 services/app/src/routes/(main)/organization/secrets/+page.server.ts create mode 100755 services/app/src/routes/(main)/organization/secrets/+page.svelte create mode 100755 services/app/src/routes/(main)/organization/team/+page.server.ts create mode 100755 services/app/src/routes/(main)/organization/team/+page.svelte create mode 100644 services/app/src/routes/(main)/organization/team/+page.ts create mode 100755 services/app/src/routes/(main)/organization/team/OrgInviteDialog.svelte create mode 100755 services/app/src/routes/(main)/organization/usage/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/+layout.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/+layout.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/ExportProjectButton.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/ProjectDialogs.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(components)/ActionsDropdown.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(components)/index.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddColumnDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/ColumnMatchDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteDialogs.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteTableDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/RenameTableDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/(dialogs)/index.ts create mode 100644 services/app/src/routes/(main)/project/[project_id]/+layout.server.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/+layout.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/action-table/(dialogs)/AddTableDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/action-table/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/action-table/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddAgentDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddConversationDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/index.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatMode.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ModeToggle.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/(dialogs)/AddTableDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/(dialogs)/UploadingFileDialog.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/(dialogs)/index.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/[table_id]/+page.ts mode change 100644 => 100755 services/app/src/routes/(main)/project/[project_id]/knowledge-table/[table_id]/+page@project.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/(components)/EditProjMemberDialog.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/(components)/ProjectInviteDialog.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/(components)/RemoveProjMemberDialog.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/(components)/index.ts create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/+page.server.ts create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/+page.svelte create mode 100644 services/app/src/routes/(main)/project/[project_id]/members/+page.ts create mode 100644 services/app/src/routes/(main)/project/[project_id]/overview/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/settings/+layout.svelte mode change 100644 => 100755 services/app/src/routes/(main)/settings/+layout.ts mode change 100644 => 100755 services/app/src/routes/(main)/settings/+page.ts create mode 100644 services/app/src/routes/(main)/settings/account/(components)/ChangePasswordDialog.svelte create mode 100644 services/app/src/routes/(main)/settings/account/(components)/CreatePATDialog.svelte create mode 100644 services/app/src/routes/(main)/settings/account/(components)/DeleteAccountDialog.svelte create mode 100644 services/app/src/routes/(main)/settings/account/(components)/DeletePATDialog.svelte create mode 100644 services/app/src/routes/(main)/settings/account/(components)/index.ts create mode 100755 services/app/src/routes/(main)/settings/account/+page.server.ts create mode 100755 services/app/src/routes/(main)/settings/account/+page.svelte mode change 100644 => 100755 services/app/src/routes/(main)/settings/theme/page.svelte create mode 100644 services/app/src/routes/(main)/system/+page.server.ts create mode 100644 services/app/src/routes/(main)/system/models/(components)/AddDeploymentDialog.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/AddModelConfigDialog.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/DeleteDeploymentDialog.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/DeleteModelConfigDialog.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/DeploymentDetails.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/DeploymentManagement.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/ManageDeploymentDialog.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/ModelCatalogue.svelte create mode 100644 services/app/src/routes/(main)/system/models/(components)/ModelConfigCard.svelte create mode 100755 services/app/src/routes/(main)/system/models/(components)/index.ts create mode 100644 services/app/src/routes/(main)/system/models/+layout.server.ts create mode 100644 services/app/src/routes/(main)/system/models/+layout.ts create mode 100644 services/app/src/routes/(main)/system/models/+page.server.ts create mode 100644 services/app/src/routes/(main)/system/models/+page.svelte create mode 100644 services/app/src/routes/(main)/system/models/[model_id]/(components)/CloudDeployments.svelte create mode 100644 services/app/src/routes/(main)/system/models/[model_id]/(components)/ModelDetails.svelte create mode 100644 services/app/src/routes/(main)/system/models/[model_id]/(components)/index.ts create mode 100644 services/app/src/routes/(main)/system/models/[model_id]/+page.server.ts create mode 100644 services/app/src/routes/(main)/system/models/[model_id]/+page.svelte mode change 100644 => 100755 services/app/src/routes/+error.svelte mode change 100644 => 100755 services/app/src/routes/+layout.server.ts mode change 100644 => 100755 services/app/src/routes/+layout.svelte mode change 100644 => 100755 services/app/src/routes/+page.ts mode change 100644 => 100755 services/app/src/routes/_layout.ts delete mode 100644 services/app/src/routes/api/admin/org/v1/projects/+server.ts delete mode 100644 services/app/src/routes/api/admin/org/v1/projects/[project_id]/+server.ts delete mode 100644 services/app/src/routes/api/admin/org/v1/projects/[project_id]/export/+server.ts delete mode 100644 services/app/src/routes/api/admin/org/v1/projects/import/[organization_id]/+server.ts mode change 100644 => 100755 services/app/src/routes/api/log/+server.ts create mode 100755 services/app/src/routes/api/v2/projects/export/+server.ts create mode 100755 services/app/src/routes/api/v2/projects/import/parquet/+server.ts create mode 100755 services/app/src/routes/join-organization/+page.server.ts create mode 100644 services/app/src/routes/join-organization/+page.svelte create mode 100755 services/app/src/routes/join-project/+page.server.ts create mode 100644 services/app/src/routes/join-project/+page.svelte create mode 100644 services/app/src/routes/login/+page.svelte create mode 100644 services/app/src/routes/login/auth-errors.ts create mode 100755 services/app/src/routes/new-organization/+page.server.ts create mode 100755 services/app/src/routes/new-organization/+page.svelte create mode 100644 services/app/src/routes/register/+page.svelte create mode 100644 services/app/src/routes/register/auth-errors.ts create mode 100755 services/app/src/routes/verify-email/+page.server.ts create mode 100755 services/app/src/routes/verify-email/+page.svelte mode change 100644 => 100755 services/app/src/showdown-theme.css mode change 100644 => 100755 services/app/static/favicon.ico mode change 100644 => 100755 services/app/static/favicon.png mode change 100644 => 100755 services/app/static/jamai-onboarding-bg.svg mode change 100644 => 100755 services/app/static/logo.png mode change 100644 => 100755 services/app/svelte.config.js mode change 100644 => 100755 services/app/tailwind.config.js mode change 100644 => 100755 services/app/tests/auth.setup.ts mode change 100644 => 100755 services/app/tests/fixtures/sample-csv.csv create mode 100644 services/app/tests/fixtures/sample-data.json mode change 100644 => 100755 services/app/tests/fixtures/sample-doc.txt mode change 100644 => 100755 services/app/tests/fixtures/sample-img.jpg mode change 100644 => 100755 services/app/tests/main.setup.ts mode change 100644 => 100755 services/app/tests/main.teardown.ts mode change 100644 => 100755 services/app/tests/pages/layout.page.ts mode change 100644 => 100755 services/app/tests/pages/project.page.ts mode change 100644 => 100755 services/app/tests/pages/table.page.ts mode change 100644 => 100755 services/app/tests/pages/tableList.page.ts mode change 100644 => 100755 services/app/tests/tableList.spec.ts mode change 100644 => 100755 services/app/tests/tables/actionTable.spec.ts mode change 100644 => 100755 services/app/tests/tables/chatTable.spec.ts mode change 100644 => 100755 services/app/tests/tables/knowledgeTable.spec.ts mode change 100644 => 100755 services/app/tsconfig.json mode change 100644 => 100755 services/app/vite.config.ts delete mode 100644 services/docio/.env delete mode 100644 services/docio/MANIFEST.in delete mode 100644 services/docio/README.md delete mode 100644 services/docio/docio.spec delete mode 100644 services/docio/pyproject.toml delete mode 100644 services/docio/scripts/GenerateWinExe.ps1 delete mode 100644 services/docio/scripts/SetupWinExeEnv.ps1 delete mode 100644 services/docio/scripts/validate_exe.py delete mode 100644 services/docio/src/docio/config.py delete mode 100644 services/docio/src/docio/entrypoints/api.py delete mode 100644 services/docio/src/docio/langchain/jsonloader.py delete mode 100644 services/docio/src/docio/langchain/pdfplumber.py delete mode 100644 services/docio/src/docio/langchain/tsvloader.py delete mode 100644 services/docio/src/docio/protocol.py delete mode 100644 services/docio/src/docio/routers/loader.py delete mode 100644 services/docio/src/docio/utils/logging.py delete mode 100644 services/docio/src/docio/version.py diff --git a/.env.example b/.env.example index 54c752a..3432b0e 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,65 @@ # External API keys -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -COHERE_API_KEY= -TOGETHER_API_KEY= -HYPERBOLIC_API_KEY= -CEREBRAS_API_KEY= -SAMBANOVA_API_KEY= -DEEPSEEK_API_KEY= -CUSTOM_API_KEY= +OWL_ANTHROPIC_API_KEY= +OWL_AZURE_API_KEY= +OWL_AZURE_AI_API_KEY= +OWL_BEDROCK_API_KEY= +OWL_CEREBRAS_API_KEY= +OWL_COHERE_API_KEY= +OWL_DEEPSEEK_API_KEY= +OWL_ELLM_API_KEY= +OWL_GEMINI_API_KEY= +OWL_GROQ_API_KEY= +OWL_HYPERBOLIC_API_KEY= +OWL_JINA_AI_API_KEY= +OWL_OPENAI_API_KEY= +OWL_OPENROUTER_API_KEY= +OWL_SAGEMAKER_API_KEY= +OWL_SAMBANOVA_API_KEY= +OWL_TOGETHER_AI_API_KEY= +OWL_VERTEX_AI_API_KEY= +OWL_VOYAGE_API_KEY= +OWL_STRIPE_API_KEY= +OWL_STRIPE_PUBLISHABLE_KEY_LIVE= +OWL_STRIPE_PUBLISHABLE_KEY_TEST= +OWL_STRIPE_WEBHOOK_SECRET_LIVE= +OWL_STRIPE_WEBHOOK_SECRET_TEST= +OWL_AUTH0_API_KEY= -# Service URLs -DOCIO_URL=http://docio:6979/api/docio -UNSTRUCTUREDIO_URL=http://unstructuredio:6989 -JAMAI_API_BASE=http://owl:6969/api +# CI +JAMAI_TOKEN= +JAMAI_API_BASE=http://localhost:6969/api -# Frontend config -JAMAI_URL=http://owl:6969 -PUBLIC_JAMAI_URL= -PUBLIC_IS_SPA=false -CHECK_ORIGIN=false +# Service connection (dev) +# OWL_DB_PATH=postgresql+psycopg://owlpguser:owlpgpassword@localhost:5432/jamaibase_owl +# OWL_CLICKHOUSE_HOST=localhost +# OWL_CLICKHOUSE_PORT=8123 +# OWL_OPENTELEMETRY_HOST=localhost +# OWL_OPENTELEMETRY_PORT=4317 +# OWL_REDIS_HOST=localhost +# OWL_REDIS_PORT=6379 +# OWL_VICTORIA_METRICS_HOST=localhost +# OWL_VICTORIA_LOGS_HOST=localhost +# OWL_CODE_EXECUTOR_ENDPOINT=http://localhost:5569 +# OWL_DOCLING_URL=http://localhost:5001 +# OWL_TEST_LLM_API_BASE=http://localhost:6970/v1 +# OWL_S3_ENDPOINT=http://localhost:9000 +# OWL_FILE_PROXY_URL=website-url # Configuration OWL_PORT=6969 OWL_WORKERS=3 -DOCIO_WORKERS=1 -DOCIO_DEVICE=cpu -EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 -RERANKER_MODEL=mixedbread-ai/mxbai-rerank-xsmall-v1 OWL_CONCURRENT_ROWS_BATCH_SIZE=5 OWL_CONCURRENT_COLS_BATCH_SIZE=5 OWL_MAX_WRITE_BATCH_SIZE=1000 +PB_MAX_CLIENT_CONN=500 +PB_MAX_CLIENT_CONN=80 +PG_MAX_CONNECTIONS=100 + +# Frontend config +HOST=localhost +ORIGIN=http://localhost:4000 +AUTH_SECRET="changeme" +OWL_URL=http://owl:6969 +PUBLIC_JAMAI_URL= +PUBLIC_IS_SPA=false +CHECK_ORIGIN=false diff --git a/.gitattributes b/.gitattributes index 31b0a04..9c014d8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,12 +33,18 @@ *.db binary *.doc binary *.docx binary +*.gif binary *.gz binary +*.heic* binary +*.heif* binary *.jar binary *.jpeg binary *.jpg binary +*.mov binary +*.mp* binary *.npy binary *.npz binary +*.parquet binary *.pcd binary *.pdf binary *.pkl binary @@ -47,13 +53,16 @@ *.pptx binary *.pth binary *.so binary +*.ttf binary +*.webp binary *.xls binary *.xlsx binary *.zip binary +# Track with LFS +# *.pth filter=lfs diff=lfs merge=lfs -text +# *.parquet filter=lfs diff=lfs merge=lfs -text + # 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/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..581583a --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,51 @@ +name: CD - Build and Push Docker Images + +on: + push: + branches: + - main + +permissions: + contents: read + packages: write + +jobs: + build_and_push: + name: Build and Push Docker Images + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + lfs: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare HCL file + run: | + # Convert repository owner to lowercase + REPO_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + + # Get short SHA + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + + # Replace variables in HCL file + sed -i "s|AZURE_STORAGE_ACCOUNT_URL|${{ secrets.AZURE_STORAGE_ACCOUNT_URL }}|g" docker/compose.bake.hcl + sed -i "s|AZURE_STORAGE_ACCESS_KEY|${{ secrets.AZURE_STORAGE_ACCESS_KEY }}|g" docker/compose.bake.hcl + + # Add tags to HCL file + sed -i "/target \"owl\"/a \ tags = [\"ghcr.io/${REPO_OWNER}/jamaibase-owl:latest\", \"ghcr.io/${REPO_OWNER}/jamaibase-owl:${SHORT_SHA}\"]" docker/compose.bake.hcl + sed -i "/target \"jambu\"/a \ tags = [\"ghcr.io/${REPO_OWNER}/jamaibase-jambu:latest\", \"ghcr.io/${REPO_OWNER}/jamaibase-jambu:${SHORT_SHA}\"]" docker/compose.bake.hcl + + - name: Build and Push Images + run: | + # Build and push JamaiBase image with both latest and commit hash + docker buildx bake --file docker/compose.bake.hcl --push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b69078a..86518b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,15 @@ -name: CI (OSS) +name: CI on: pull_request: branches: - main + - legacy-lancedb push: branches: - main + - legacy-lancedb tags: - "v*" @@ -39,7 +41,8 @@ jobs: if: ${{ !(needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push') }} strategy: matrix: - python-version: ["3.10"] + jamai-mode: ["oss", "cloud"] + test-group: [group1, group2, group3, group4] timeout-minutes: 2 steps: - name: No-op @@ -52,8 +55,9 @@ jobs: if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' strategy: matrix: - python-version: ["3.10"] - timeout-minutes: 60 + jamai-mode: ["oss", "cloud"] + test-group: [group1, group2, group3, group4] + timeout-minutes: 90 steps: - name: Checkout code @@ -61,14 +65,18 @@ jobs: with: lfs: true + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Inspect git version - run: | - git --version + run: git --version - name: Check Docker Version run: docker version @@ -76,26 +84,34 @@ jobs: - name: Check Docker Compose Version run: docker compose version - - name: Remove cloud-only modules and install Python client + - name: Remove cloud-only modules + if: matrix.jamai-mode == 'oss' + run: bash scripts/remove_cloud_modules.sh + + - name: Inspect directory tree + run: tree + + - name: Install jamaibase & owl run: | - set -e - bash scripts/remove_cloud_modules.sh - cd clients/python - python -m pip install .[test] + pushd clients/python + uv pip install --system -e .[test] + popd + pushd services/api + uv pip install --system -e .[test] - - name: Install ffmpeg + - name: Inspect jamaibase environment run: | - set -e - sudo apt-get update -qq && sudo apt-get install ffmpeg libavcodec-extra -y + uv pip list - - name: Authenticating to the Container registry - run: echo $JH_PAT | docker login ghcr.io -u tanjiahuei@gmail.com --password-stdin - env: - JH_PAT: ${{ secrets.JH_PAT }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Edit env file run: | - set -e mv .env.example .env ORGS=$(printenv | grep API_KEY | xargs -I {} echo {} | cut -d '=' -f 1) @@ -111,125 +127,439 @@ jobs: # Replace the org with the key in the .env file sed -i "s/$org=.*/$org=$key/g" .env done - sed -i "s:EMBEDDING_MODEL=.*:EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2:g" .env - sed -i "s:RERANKER_MODEL=.*:RERANKER_MODEL=cross-encoder/ms-marco-TinyBERT-L-2:g" .env - echo 'OWL_MODELS_CONFIG=models_ci.json' >> .env + echo "OWL_DB_INIT=False" >> .env + echo "OWL_COMPUTE_STORAGE_PERIOD_SEC=15" >> .env + echo "OWL_STRIPE_WEBHOOK_SECRET_TEST=${OWL_STRIPE_WEBHOOK_SECRET_TEST}" >> .env + echo "OWL_STRIPE_PUBLISHABLE_KEY_TEST=${OWL_STRIPE_PUBLISHABLE_KEY_TEST}" >> .env + echo 'OWL_SERVICE_KEY=lalala' >> .env + echo 'JAMAI_TOKEN=lalala' >> .env + echo 'JAMAI_API_BASE=http://localhost:6969/api' >> .env + echo 'OWL_FLUSH_CLICKHOUSE_BUFFER_SEC=5' >> .env env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} - COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} - HYPERBOLIC_API_KEY: ${{ secrets.HYPERBOLIC_API_KEY }} - CUSTOM_API_KEY: ${{ secrets.CUSTOM_API_KEY }} - - - name: Launch services (OSS) - id: launch_oss + OWL_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OWL_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} + OWL_HYPERBOLIC_API_KEY: ${{ secrets.HYPERBOLIC_API_KEY }} + OWL_JINA_AI_API_KEY: ${{ secrets.JINA_AI_API_KEY }} + OWL_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OWL_TOGETHER_AI_API_KEY: ${{ secrets.TOGETHER_AI_API_KEY }} + OWL_ELLM_API_KEY: ${{ secrets.CUSTOM_API_KEY }} + OWL_STRIPE_WEBHOOK_SECRET_TEST: ${{ secrets.OWL_STRIPE_WEBHOOK_SECRET_TEST }} + OWL_STRIPE_PUBLISHABLE_KEY_TEST: ${{ secrets.OWL_STRIPE_PUBLISHABLE_KEY_TEST }} + + - name: Launch services + id: launch_services timeout-minutes: 20 - run: | - set -e - docker compose -p jamai -f docker/compose.cpu.yml --profile minio --profile kopi up --quiet-pull -d --wait + if: always() + run: docker compose -p jm -f docker/compose.ci.yml up --quiet-pull -d --wait env: COMPOSE_DOCKER_CLI_BUILD: 1 DOCKER_BUILDKIT: 1 - - name: Inspect owl Python version - run: docker exec jamai-owl-1 python -V + - name: Inspect owl logs if failed to launch + timeout-minutes: 1 + if: failure() && steps.launch_services.outcome == 'failure' + run: docker compose -p jm -f docker/compose.ci.yml logs owl + + - name: Inspect owl UV and Python version + run: | + docker exec jm-owl-1 uv -V + docker exec jm-owl-1 $(docker exec jm-owl-1 uv python find) -V - name: Inspect owl environment - run: docker exec jamai-owl-1 pip list - - - 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 \ - --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' - timeout-minutes: 1 - run: docker exec jamai-owl-1 cat /app/api/logs/owl.log + if: always() + run: docker exec jm-owl-1 uv pip list + + - name: Copy OpenAPI JSON + id: copy_openapi + if: always() && matrix.test-group == 'group1' + run: | + curl localhost:6969/api/public/openapi.json > openapi.json + + - name: Generate OpenAPI Redoc HTML page + id: generate_redoc_html + if: always() && matrix.test-group == 'group1' && steps.copy_openapi.outcome == 'success' + run: | + npx @redocly/cli@latest build-docs openapi.json + mkdir openapi + mv redoc-static.html openapi + mv openapi.json openapi - - name: Upload Pytest Test Results + - name: Upload Redoc HTML + id: upload_redoc_html uses: actions/upload-artifact@v4 + if: always() && matrix.test-group == 'group1' && steps.generate_redoc_html.outcome == 'success' with: - name: pytest-results-${{ matrix.python-version }} - path: junit/test-results-${{ matrix.python-version }}.xml - # Always run this step to publish test results even when there are test failures - if: always() + name: redoc-html-${{ matrix.jamai-mode }} + path: openapi + + - name: Publish Redoc HTML link as PR comment + uses: thollander/actions-comment-pull-request@v3 + if: always() && matrix.test-group == 'group1' && github.event_name == 'pull_request' && steps.upload_redoc_html.outcome == 'success' + with: + message: | + [Link to OpenAPI Redoc HTML (${{ matrix.jamai-mode }})](${{ steps.upload_redoc_html.outputs.artifact-url }}) + comment-tag: redoc_html_comment_${{ matrix.jamai-mode }} + + - name: Python SDK tests + id: python_sdk_test + if: always() && steps.launch_services.outcome == 'success' + run: | + cp .env services/api/.env + cd services/api - - name: TS/JS SDK tests (OSS) - id: ts_sdk_test_oss - if: always() && steps.launch_oss.outcome == 'success' + if [ "${{ matrix.test-group }}" = "group1" ]; then + DIRS=(tests --ignore=tests/gen_table/test_row_ops.py --ignore=tests/gen_table/test_row_ops_v2.py --ignore=tests/routers --ignore=tests/utils) + elif [ "${{ matrix.test-group }}" = "group2" ]; then + DIRS=(tests/gen_table/test_row_ops.py) + elif [ "${{ matrix.test-group }}" = "group3" ]; then + DIRS=(tests/gen_table/test_row_ops_v2.py tests/utils) + else + DIRS=(tests/routers) + fi + + coverage run --data-file=coverage/.coverage.${{ matrix.test-group }} --rcfile=pyproject.toml -m \ + pytest \ + --timeout 300 \ + --no-flaky-report \ + --junitxml=pytest_regular.xml \ + -m "not (${{ matrix.jamai-mode == 'cloud' && 'oss' || 'cloud' }} or stripe)" \ + "${DIRS[@]}" + env: + OWL_DB_PATH: postgresql+psycopg://owlpguser:owlpgpassword@localhost:5432/jamaibase_owl + OWL_CLICKHOUSE_HOST: localhost + OWL_REDIS_HOST: localhost + + - name: Inspect owl logs + if: always() && steps.launch_services.outcome == 'success' + timeout-minutes: 1 + run: mkdir -p logs && docker compose -p jm -f docker/compose.ci.yml logs owl > logs/owl.log + + - name: Inspect starling logs + if: always() && steps.launch_services.outcome == 'success' + timeout-minutes: 1 + run: mkdir -p logs && docker compose -p jm -f docker/compose.ci.yml logs starling > logs/starling.log + + - name: Test Stripe integration (Cloud only) + id: test_stripe + if: matrix.jamai-mode == 'cloud' && matrix.test-group == 'group1' && steps.launch_services.outcome == 'success' run: | - cd clients/typescript - echo "BASEURL=http://localhost:6969" >> __tests__/.env - npm install - npm run test + # Shut down owl to allow coverage data to be flushed + docker compose -p jm -f docker/compose.ci.yml down + # Copy Pytest coverage data + sudo cp -r docker_data docker_data_pytest + sudo rm -rf docker_data + + # Relaunch + echo "OWL_STRIPE_API_KEY=${OWL_STRIPE_API_KEY}" >> .env + docker compose -p jm -f docker/compose.ci.yml up --quiet-pull -d --wait --force-recreate + + # Install Stripe CLI + curl -L https://github.com/stripe/stripe-cli/releases/download/v1.27.0/stripe_1.27.0_linux_x86_64.tar.gz --output stripe.tar.gz + tar -xvf stripe.tar.gz + + # Listen for Stripe events and forward them to local endpoint + nohup ./stripe listen \ + --forward-to http://localhost:6969/api/v2/organizations/webhooks/stripe & + # --events customer.created,invoice.paid + # Give stripe listen a moment to establish the tunnel + sleep 5 + + # Run tests + cd services/api + # Use unique coverage file for Stripe tests + coverage run --data-file=coverage/.coverage.stripe --rcfile=pyproject.toml -m \ + pytest \ + --timeout 300 \ + --no-flaky-report \ + --junitxml=pytest_stripe.xml \ + -m stripe \ + tests + env: + STRIPE_API_KEY: ${{ secrets.OWL_STRIPE_API_KEY }} + OWL_STRIPE_API_KEY: ${{ secrets.OWL_STRIPE_API_KEY }} + OWL_CLICKHOUSE_HOST: localhost + OWL_REDIS_HOST: localhost - - name: Inspect owl logs if TS/JS SDK tests failed - if: failure() && steps.ts_sdk_test_oss.outcome == 'failure' + - name: Inspect owl logs if Stripe integration failed timeout-minutes: 1 - 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 - - lance_tests: - name: Lance tests - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - timeout-minutes: 60 + if: failure() && matrix.jamai-mode == 'cloud' && steps.test_stripe.outcome == 'failure' + run: mkdir -p logs && docker compose -p jm --env-file .env -f docker/compose.ci.yml logs owl > logs/owl_stripe.log + + # - name: TS/JS SDK tests + # id: ts_sdk_test + # if: always() && steps.launch_services.outcome == 'success' + # run: | + # cd clients/typescript + # echo "BASEURL=http://localhost:6969" >> __tests__/.env + # npm install + # npm run test + + - name: Upload logs + id: upload_logs + uses: actions/upload-artifact@v4 + if: always() && steps.launch_services.outcome == 'success' + with: + name: logs-${{ matrix.jamai-mode }}-${{ matrix.test-group }} + path: logs + + - name: Publish logs link as PR comment + uses: thollander/actions-comment-pull-request@v3 + if: always() && github.event_name == 'pull_request' && steps.upload_logs.outcome == 'success' + with: + message: | + [Link to logs (${{ matrix.jamai-mode }}, ${{ matrix.test-group }})](${{ steps.upload_logs.outputs.artifact-url }}) + comment-tag: logs_comment_${{ matrix.jamai-mode }}-${{ matrix.test-group }} + + - name: Upload pytest coverage file + uses: actions/upload-artifact@v4 + if: always() && steps.python_sdk_test.outcome == 'success' + with: + name: pytest-coverage-data-${{ matrix.jamai-mode }}-${{ matrix.test-group }} + path: services/api/coverage + include-hidden-files: true + if-no-files-found: error + + - name: Merge JUnit XML and Coverage data + id: merge_test_data + if: always() && steps.launch_services.outcome == 'success' + run: | + # Shut down owl to allow coverage data to be flushed + docker compose -p jm --env-file .env -f docker/compose.ci.yml down + + # Combine coverage data + if [ "${{ matrix.test-group }}" = "group1" ]; then + mkdir -p docker_data_pytest/owl/db + DIRS="docker_data_pytest/owl/db \ + docker_data/owl/db \ + services/api/coverage" + else + DIRS="docker_data/owl/db \ + services/api/coverage" + fi + + coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ + $DIRS + + # Merge JUnit XML files + mkdir -p services/api/junit_xml + junitparser merge --glob "services/api/pytest_*.xml" services/api/junit_xml/pytest-${{ matrix.jamai-mode }}-${{ matrix.test-group }}.xml + - name: Upload docker data pytest coverage file + uses: actions/upload-artifact@v4 + if: always() && matrix.test-group == 'group1' && steps.test_stripe.outcome == 'success' + with: + name: docker_data_pytest-coverage-data-${{ matrix.jamai-mode }} + path: docker_data_pytest/owl/db + include-hidden-files: true + if-no-files-found: error + + - name: Upload docker data coverage file + uses: actions/upload-artifact@v4 + if: always() && steps.merge_test_data.outcome == 'success' + with: + name: docker_data-coverage-data-${{ matrix.jamai-mode }}-${{ matrix.test-group }} + path: docker_data/owl/db + include-hidden-files: true + if-no-files-found: error + - name: Upload JUnit XML file + uses: actions/upload-artifact@v4 + if: always() && steps.merge_test_data.outcome == 'success' + with: + name: junit-xml-data-${{ matrix.jamai-mode }}-${{ matrix.test-group }} + path: services/api/junit_xml + + - name: Log coverage data files + run: | + if [ "${{ matrix.test-group }}" = "group1" ]; then + find docker_data_pytest/owl/db -type f | head -50 + fi + find docker_data/owl/db -type f | head -50 + find services/api/coverage -type f | head -50 + find services/api/junit_xml -type f | head -50 + + - name: Generate coverage reports + id: generate_coverage_report + if: always() && steps.merge_test_data.outcome == 'success' + run: | + cd services/api + coverage html --data-file=coverage/.coverage -d coverage/html + coverage xml --data-file=coverage/.coverage -o coverage/coverage.xml + coverage report --data-file=coverage/.coverage + + - name: Upload coverage HTML report + id: upload_coverage_html + uses: actions/upload-artifact@v4 + if: always() && steps.merge_test_data.outcome == 'success' + with: + name: pytest-coverage-${{ matrix.jamai-mode }}-${{ matrix.test-group }} + path: services/api/coverage/html + + merge_coverage: + name: Merge Coverage Reports + runs-on: ubuntu-latest + needs: sdk_tests + if: always() && (needs.sdk_tests.result == 'success' || needs.sdk_tests.result == 'skipped') steps: - name: Checkout code uses: actions/checkout@v4 + with: + lfs: true + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - - name: Inspect git version + - name: Install jamaibase & owl + run: | + pushd services/api + uv pip install --system -e .[test] + + - name: Skip coverage merge (no tests ran) + if: needs.sdk_tests.result == 'skipped' + run: echo "Skipping coverage merge - no tests were executed (no changes detected)" + + - name: Download pytest coverage data artifacts + uses: actions/download-artifact@v4 + if: needs.sdk_tests.result == 'success' + with: + pattern: pytest-coverage-data-* + path: ./ + + - name: Download docker data pytest coverage data artifacts + uses: actions/download-artifact@v4 + if: needs.sdk_tests.result == 'success' + with: + pattern: docker_data_pytest-coverage-data-* + path: ./ + + - name: Download docker data coverage data artifacts + uses: actions/download-artifact@v4 + if: needs.sdk_tests.result == 'success' + with: + pattern: docker_data-coverage-data-* + path: ./ + + - name: Download junit xml artifacts + uses: actions/download-artifact@v4 + if: needs.sdk_tests.result == 'success' + with: + pattern: junit-xml-data-* + path: junit-xml-data + + - name: Log coverage data files + if: needs.sdk_tests.result == 'success' + run: | + find docker_data_pytest-coverage-data-* -type f | head -50 + find docker_data-coverage-data-* -type f | head -50 + find pytest-coverage-data-* -type f | head -50 + find junit-xml-data -type f | head -50 + + - name: Merge Cloud JUnit XML and Coverage data + id: merge_cloud_test_data + if: needs.sdk_tests.result == 'success' + run: | + coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ + ./docker_data_pytest-coverage-data-cloud \ + ./docker_data-coverage-data-cloud-group[1-4] \ + ./pytest-coverage-data-cloud-group[1-4] + + # Merge JUnit XML files + junitparser merge --glob "junit-xml-data/junit-xml-data-cloud-*/pytest_*.xml" junit-xml-data/pytest-cloud.xml + + - name: Generate cloud coverage reports + if: always() && steps.merge_cloud_test_data.outcome == 'success' + run: | + cd services/api + coverage xml --data-file=coverage/.coverage -o coverage/coverage-cloud.xml + coverage report --data-file=coverage/.coverage + + - name: Pytest cloud coverage comment + uses: MishaKav/pytest-coverage-comment@main + if: always() && github.event_name == 'pull_request' && steps.merge_cloud_test_data.outcome == 'success' + with: + title: Coverage Report (cloud) + pytest-xml-coverage-path: services/api/coverage/coverage-cloud.xml + junitxml-path: junit-xml-data/pytest-cloud.xml + unique-id-for-comment: coverage_report_comment_cloud + report-only-changed-files: true + + - name: Merge OSS JUnit XML and Coverage data + id: merge_oss_test_data + if: needs.sdk_tests.result == 'success' + run: | + coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ + ./docker_data-coverage-data-oss-group[1-4] \ + ./pytest-coverage-data-oss-group[1-4] + + # Merge JUnit XML files + junitparser merge --glob "junit-xml-data/junit-xml-data-oss-*/pytest_*.xml" junit-xml-data/pytest-oss.xml + + - name: Generate oss coverage reports + if: always() && steps.merge_oss_test_data.outcome == 'success' + run: | + cd services/api + coverage xml --data-file=coverage/.coverage -o coverage/coverage-oss.xml + coverage report --data-file=coverage/.coverage + + - name: Pytest oss coverage comment + uses: MishaKav/pytest-coverage-comment@main + if: always() && github.event_name == 'pull_request' && steps.merge_oss_test_data.outcome == 'success' + with: + title: Coverage Report (oss) + pytest-xml-coverage-path: services/api/coverage/coverage-oss.xml + junitxml-path: junit-xml-data/pytest-oss.xml + unique-id-for-comment: coverage_report_comment_oss + report-only-changed-files: true + + - name: Merge All JUnit XML and Coverage data + id: merge_all_test_data + if: needs.sdk_tests.result == 'success' run: | - git --version + coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ + ./docker_data_pytest-coverage-data-cloud \ + ./docker_data-coverage-data-{cloud,oss}-group[1-4] \ + ./pytest-coverage-data-{cloud,oss}-group[1-4] - - name: Install owl + # Merge JUnit XML files + junitparser merge --glob "junit-xml-data/junit-xml-data-*/pytest_*.xml" junit-xml-data/pytest.xml + + - name: Generate coverage reports + id: generate_coverage_report + if: always() && steps.merge_all_test_data.outcome == 'success' run: | - set -e cd services/api - python -m pip install .[test] + coverage html --data-file=coverage/.coverage -d coverage/html + coverage xml --data-file=coverage/.coverage -o coverage/coverage.xml + coverage report --data-file=coverage/.coverage + + - name: Pytest coverage comment + uses: MishaKav/pytest-coverage-comment@main + if: always() && github.event_name == 'pull_request' && steps.merge_all_test_data.outcome == 'success' + with: + title: Coverage Report (all) + pytest-xml-coverage-path: services/api/coverage/coverage.xml + junitxml-path: junit-xml-data/pytest.xml + unique-id-for-comment: coverage_report_comment_all + report-only-changed-files: true + + - name: Upload all coverage HTML report + id: upload_all_coverage_html + uses: actions/upload-artifact@v4 + if: always() && steps.merge_all_test_data.outcome == 'success' + with: + name: pytest-coverage-all + path: services/api/coverage/html - - name: Run tests - run: pytest services/api/tests/test_lance.py + - name: Publish coverage HTML report link as PR comment + uses: thollander/actions-comment-pull-request@v3 + if: always() && github.event_name == 'pull_request' && steps.upload_all_coverage_html.outcome == 'success' + with: + message: | + [Link to coverage HTML report (All)](${{ steps.upload_all_coverage_html.outputs.artifact-url }}) + comment-tag: coverage_html_report_comment_all diff --git a/.github/workflows/github_bot.yml b/.github/workflows/github_bot.yml new file mode 100644 index 0000000..baa6479 --- /dev/null +++ b/.github/workflows/github_bot.yml @@ -0,0 +1,45 @@ +name: JambuBot + +on: + issues: + types: [opened, edited] + pull_request: + types: [opened, synchronize] + +# Cancel in-progress CI jobs if there is a new push +# https://stackoverflow.com/a/72408109 +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + github-bot: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: true # Ensure submodules are checked out + + + # - name: Set up Go + # uses: actions/setup-go@v2 + # with: + # go-version: "1.18" + + # - name: Launching JambuBot + # run: | + # cd examples/github-bot/github-bot/src + # go build -o github_bot cmd/main.go + # ./github_bot + # env: + # TRIAGE_BOT_APP_ID: ${{ secrets.TRIAGE_BOT_APP_ID }} + # TRIAGE_BOT_INSTALLATION_ID: ${{ secrets.TRIAGE_BOT_INSTALLATION_ID }} + # TRIAGE_BOT_PRIVATE_KEY: ${{ secrets.TRIAGE_BOT_PRIVATE_KEY }} + # TRIAGE_BOT_JAMAI_KEY: ${{ secrets.TRIAGE_BOT_JAMAI_KEY }} + # TRIAGE_BOT_JAMAI_PROJECT_ID: ${{ secrets.TRIAGE_BOT_JAMAI_PROJECT_ID }} + # TRIAGE_BOT_NAME: ${{github.actor}} + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # GITHUB_EVENT_NAME: ${{ github.event_name }} + # GITHUB_EVENT_PATH: ${{ github.event_path }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 85a0754..144cad8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,13 +24,11 @@ jobs: - name: Install linting libraries run: | - set -e cd clients/python python3 -m pip install .[lint] - name: Check Python files using Ruff run: | - set -e ruff check --output-format github --config clients/python/pyproject.toml . ruff format --diff --config clients/python/pyproject.toml . @@ -43,11 +41,9 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "16" # Specify the Node.js version you want to use + node-version: 24 - name: Check files using Prettier - run: | - npm install -g prettier@3.3.2 - prettier --check . + run: npx prettier@3.3.2 --check . diff --git a/.gitignore b/.gitignore index bbb9d49..c598157 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ +# OS +thumbs.db +.DS_Store + +# Internal references, dependencies, temporary folders & files +.env +**/__ref__/ +*.log +*.lock +*.db +*.parquet + +# Docker +/docker_data/ + # Python __pycache__/ *.py[cod] @@ -6,38 +21,19 @@ __pycache__/ .pytest_cache .ipynb_checkpoints venv/ -*.npy -*.geojson -*.laz -*.db -*.parquet -# Internal references, dependencies, temporary folders & files -/db*/ -file/ -/infinity_cache/ -**/__ref__/ -/dependencies/ -logs/ -*.log -/datasets/ -/milvus_data/ -/vespa*/ -*.swp -.env -*.lock +# pip +**/build/ # pytest-cov +**/coverage.xml **/.coverage* /junit /htmlcov /coverage.xml -# jest-cov -**/coverage/* - -# pip -**/build/ +# ruff +.ruff_cache/ # OS thumbs.db diff --git a/.prettierrc b/.prettierrc index 74240b5..95e419e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "printWidth": 150, - "proseWrap": "never" + "proseWrap": "never", + "trailingComma": "all" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a2cc62a..52ea78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,90 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### ADDED + +API + +- Conversation API for JamAI Chat: Chat with agents pre-configured by your organisation admins. +- Table row update endpoint can now update multiple rows in a single call. +- Table row list endpoint `/v1/gen_tables/{table_type}/{table_id}/rows` now accepts: + - `order_by` string parameter that specifies the column to sort rows by. + - `where` string parameter that defines an SQL where clause. Defaults to "" (no filter). + - `search_columns` string parameter to restrict the columns that are searched by `search_query`. + +### CHANGED (BREAKING) + +Python Client + +- Some `JamAI` and `JamAIAsync` client methods are deprecated and/or removed. + +API + +- We unified our DB to migrate from LanceDB + SQLite to Postgres only. For OSS users, please use the provided migration script to migrate your data. +- Changed endpoint `/v1/model_names` -> `/v1/models/ids` +- Renamed external keys: + - `jina_api_key` -> `jina_ai_api_key` + - `together_api_key` -> `together_ai_api_key` +- Added `OWL_` prefix to API server environment variables. +- Major changes to internal APIs which affect OSS users + - Endpoints are now `/v2` + - Most of the path params are converted into query params + - Input and output schemas may have changed. + - List endpoints param changed from `order_descending` with a default of True to `order_ascending` with a default of True. + +### CHANGED + +Python Client + +- `JamAI` is now a wrapper around `JamAIAsync`. +- `JamaiException` classes are now subclasses of `Exception` rather than `RuntimeError`. +- Deprecated `jamaibase.protocol`, use `jamaibase.types` instead. +- Types / protocol: + - Deprecated `AdminOrderBy` enum; use strings instead. + - Deprecated `GenTableOrderBy` enum; use strings instead. + - Deprecated `ModelInfoResponse`; use `ModelInfoListResponse` instead. + - Deprecated `MessageToolCallFunction`; use `ToolCallFunction` instead. + - Deprecated `MessageToolCall`; use `ToolCall` instead. + - Deprecated `ChatCompletionChoiceDelta`; use `ChatCompletionChoice` instead. + - Deprecated `CompletionUsage`; use `ChatCompletionUsage` instead. + - Deprecated `ChatCompletionChunk`; use `ChatCompletionChunkResponse` instead. + - Deprecated `ChatCompletionChoiceOutput`; use `ChatCompletionMessage` instead. + - Deprecated `ChatThread`; use `ChatThreadResponse` instead. + - Deprecated `ChatRequestWithTools`; use `ChatRequest` instead. + - Deprecated `GenTableStreamReferences`; use `CellReferencesResponse` instead. + - Deprecated `GenTableStreamChatCompletionChunk`; use `CellCompletionResponse` instead. + - Deprecated `GenTableChatCompletionChunks`; use `RowCompletionResponse` instead. + - Deprecated `GenTableRowsChatCompletionChunks`; use `MultiRowCompletionResponse` instead. + - Deprecated `RowAddRequest`; use `MultiRowAddRequest` instead. + - Deprecated `RowAddRequestWithLimit`; use `MultiRowAddRequestWithLimit` instead. + - Deprecated `RowRegenRequest`; use `MultiRowRegenRequest` instead. + - Deprecated `RowDeleteRequest`; use `MultiRowDeleteRequest` instead. + - All `reindex` parameters are removed. Reindexing now happens immediately. + +API + +- Improvements: + - All extra Knowledge Table columns are now injected into prompt + - RAG references are now stored alongside model response +- Fixed: + - Streaming responses from table row add endpoint now returns a final chunk with usage data. + - You can now set system prompt and prompt of Generative Tables to empty strings `""` without them being replaced with default prompts. + +### REMOVED + +API + +- Hybrid search endpoint: + - Removed parameters: `where`, `nprobes`, `refine_factor` + +### DEPRECATED + +API + +- Generative table endpoints: + - `order_descending` in table and row lists endpoints is deprecated and replaced with `order_ascending` with a default of True. + - Single row delete and update endpoints are deprecated for their multi-row counterparts. + ## [v0.4.1] (2025-02-26) ### CHANGED / FIXED @@ -39,6 +123,9 @@ Python SDK - jamaibase TS SDK - jamaibase +- Add `CodeGenConfigSchema` for code execution #446 +- Support audio data type + UI - Support chat mode multiturn option in add column and column resize #451 @@ -73,6 +160,7 @@ Python SDK - jamaibase TS SDK - jamaibase - Update the `uploadFile` method in `index.ts` to remove the trailing slash from the API endpoint #462 +- Update client and node enviroment conflict in file upload UI diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 046611a..1bccfd1 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,69 +1 @@ # Migration Guide - -## [v0.4.0] - -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.4.0). - -### Prerequisites - -1. Ensure **owl/jamaibase** has been updated to at least **v0.4.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_v040.py - ``` - -### Expected Output - -- The script will print messages indicating whether the `file` column was renamed to `image` column. -- The script will print messages indicating whether the `Page` column was added to knowledge table or if it already exist. -- 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. - -## [v0.3.0] - -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/clients/python/README.md b/clients/python/README.md index 8e1d122..88f4e39 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -12,10 +12,10 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y 1. First, [sign up for a free account on JamAI Base Cloud!](https://cloud.jamaibase.com/) 2. Create a project and give it any name that you want. -3. Create a Python (>= 3.10) environment and install `jamaibase` (here we use [micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html) but you can use other tools such as [conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html), virtualenv, etc): +3. Create a Python (>= 3.11) environment and install `jamaibase` (here we use [micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html) but you can use other tools such as [conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html), virtualenv, etc): ```shell - $ micromamba create -n jam310 python=3.10 -y + $ micromamba create -n jam310 python=3.11 -y $ micromamba activate jam310 $ pip install jamaibase ``` @@ -26,7 +26,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y - Project ID can be obtained by browsing to any of your projects. ```python - from jamaibase import JamAI, protocol as p + from jamaibase import JamAI, types as t jamai = JamAI(token="your_pat", project_id="your_project_id") ``` @@ -34,7 +34,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y Async is supported too: ```python - from jamaibase import JamAIAsync, protocol as p + from jamaibase import JamAIAsync, types as t jamai = JamAIAsync(token="your_pat", project_id="your_project_id") ``` @@ -54,14 +54,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y - `.env` specifies which model to run on the `infinity` service for locally-hosted embedding and reranking models. - `.env` also specifies all the third party API keys to be used. - For OSS mode, in order for you to see and use the other third party models such as OpenAI, you need to provide your own OpenAI API key in `.env` file. You can add one or more providers: - - ``` - OPENAI_API_KEY=... - ANTHROPIC_API_KEY=... - COHERE_API_KEY=... - TOGETHER_API_KEY=... - ``` + For OSS mode, in order for you to see and use the other third party models such as OpenAI, you need to provide your own OpenAI API key in `.env` file (refer to `.env.example` file). 3. Launch the Docker containers by running one of these: @@ -74,7 +67,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y ``` - By default, frontend and backend are accessible at ports 4000 and 6969. - - You can change the ports exposed to host by setting env var in `.env` or shell like so `API_PORT=6970 FRONTEND_PORT=4001 docker compose -f docker/compose.cpu.yml up --quiet-pull -d` + - You can change the ports exposed to host by setting env var in `.env` or shell like so `API_PORT=6968 FRONTEND_PORT=4001 docker compose -f docker/compose.cpu.yml up --quiet-pull -d` 4. Try the command below in your terminal, or open your browser and go to `localhost:4000`. @@ -89,7 +82,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y - `api_base` should point to the exposed port of `owl` service. ```python - from jamaibase import JamAI, protocol as p + from jamaibase import JamAI, types as t jamai = JamAI(api_base="http://localhost:6969/api") ``` @@ -97,7 +90,7 @@ The recommended way of using JamAI Base is via Cloud 🚀. Did we mention that y Async is supported too: ```python - from jamaibase import JamAIAsync, protocol as p + from jamaibase import JamAIAsync, types as t jamai = JamAIAsync(api_base="http://localhost:6969/api") ``` @@ -157,16 +150,16 @@ Let's start with creating simple tables. Create a table by defining a schema. ```python # Create an Action Table table = jamai.table.create_action_table( - p.ActionTableSchemaCreate( + t.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="image", dtype="image"), # Image input - p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input - p.ColumnSchemaCreate(id="question", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="image", dtype="image"), # Image input + t.ColumnSchemaCreate(id="length", dtype="int"), # Integer input + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate( id="answer", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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.", @@ -182,7 +175,7 @@ print(table) # Create a Knowledge Table table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id="knowledge-simple", cols=[], embedding_model="ellm/BAAI/bge-m3", @@ -192,14 +185,14 @@ print(table) # Create a Chat Table table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate( + t.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="openai/gpt-4o-mini", # Leave this out to use a default model system_prompt="You are a pirate.", temperature=0.001, @@ -228,7 +221,7 @@ text_c = "Identify the subject of the image." # Streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(length=5, question=text_a)], stream=True, @@ -243,7 +236,7 @@ print("") # Non-streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(length=5, question=text_b)], stream=False, @@ -255,7 +248,7 @@ print(completion.rows[0].columns["answer"].text) upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(image=upload_response.uri, length=5, question=text_c)], stream=True, @@ -270,7 +263,7 @@ print("") # Non-streaming (with image input) completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(image=upload_response.uri, length=5, question=text_c)], stream=False, @@ -286,7 +279,7 @@ Next let's try adding to Chat Table: # Streaming completion = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="chat-simple", data=[dict(User="Who directed Arrival (2016)?")], stream=True, @@ -301,7 +294,7 @@ print("") # Non-streaming completion = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="chat-simple", data=[dict(User="Who directed Dune (2024)?")], stream=False, @@ -324,7 +317,7 @@ Finally we can add rows to Knowledge Table too: # Streaming completion = jamai.table.add_table_rows( "knowledge", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="knowledge-simple", data=[dict(Title="Arrival (2016)", Text=text_a)], stream=True, @@ -335,7 +328,7 @@ assert len(list(completion)) == 0 # Non-streaming completion = jamai.table.add_table_rows( "knowledge", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="knowledge-simple", data=[dict(Title="Dune (2024)", Text=text_b)], stream=False, @@ -407,18 +400,18 @@ with TemporaryDirectory() as tmp_dir: # Create an Action Table with RAG table = jamai.table.create_action_table( - p.ActionTableSchemaCreate( + t.ActionTableSchemaCreate( id="action-rag", cols=[ - p.ColumnSchemaCreate(id="question", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate( id="answer", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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( + rag_params=t.RAGParams( table_id="knowledge-simple", k=2, ), @@ -435,7 +428,7 @@ print(table) # Ask a question with streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-rag", data=[dict(question="Where did I go in 2018?")], stream=True, @@ -444,7 +437,7 @@ completion = jamai.table.add_table_rows( for chunk in completion: if chunk.output_column_name != "answer": continue - if isinstance(chunk, p.GenTableStreamReferences): + if isinstance(chunk, t.CellReferencesResponse): # References that are retrieved from KT assert len(chunk.chunks) == 2 # k = 2 print(chunk.chunks) @@ -493,7 +486,7 @@ Now that you know how to add rows into tables, let's see how to delete them inst rows = jamai.table.list_table_rows("action", "action-simple") response = jamai.table.delete_table_rows( "action", - p.RowDeleteRequest( + t.MultiRowDeleteRequest( table_id="action-simple", row_ids=[row["ID"] for row in rows.items], ), @@ -548,22 +541,22 @@ The full script is as follows: ```python from jamaibase import JamAI -from jamaibase import protocol as p +from jamaibase import types as t def create_tables(jamai: JamAI): # Create an Action Table table = jamai.table.create_action_table( - p.ActionTableSchemaCreate( + t.ActionTableSchemaCreate( id="action-simple", cols=[ - p.ColumnSchemaCreate(id="image", dtype="image"), # Image input - p.ColumnSchemaCreate(id="length", dtype="int"), # Integer input - p.ColumnSchemaCreate(id="question", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="image", dtype="image"), # Image input + t.ColumnSchemaCreate(id="length", dtype="int"), # Integer input + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate( id="answer", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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.", @@ -579,7 +572,7 @@ def create_tables(jamai: JamAI): # Create a Knowledge Table table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id="knowledge-simple", cols=[], embedding_model="ellm/BAAI/bge-m3", @@ -589,14 +582,14 @@ def create_tables(jamai: JamAI): # Create a Chat Table table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate( + t.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="openai/gpt-4o-mini", # Leave this out to use a default model system_prompt="You are a pirate.", temperature=0.001, @@ -619,7 +612,7 @@ def add_rows(jamai: JamAI): # Streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(length=5, question=text_a)], stream=True, @@ -634,7 +627,7 @@ def add_rows(jamai: JamAI): # Non-streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(length=5, question=text_b)], stream=False, @@ -646,7 +639,7 @@ def add_rows(jamai: JamAI): upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(image=upload_response.uri, length=5, question=text_c)], stream=True, @@ -661,7 +654,7 @@ def add_rows(jamai: JamAI): # Non-streaming (with image input) completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-simple", data=[dict(image=upload_response.uri, length=5, question=text_c)], stream=False, @@ -673,7 +666,7 @@ def add_rows(jamai: JamAI): # Streaming completion = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="chat-simple", data=[dict(User="Who directed Arrival (2016)?")], stream=True, @@ -688,7 +681,7 @@ def add_rows(jamai: JamAI): # Non-streaming completion = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="chat-simple", data=[dict(User="Who directed Dune (2024)?")], stream=False, @@ -700,7 +693,7 @@ def add_rows(jamai: JamAI): # Streaming completion = jamai.table.add_table_rows( "knowledge", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="knowledge-simple", data=[dict(Title="Arrival (2016)", Text=text_a)], stream=True, @@ -711,7 +704,7 @@ def add_rows(jamai: JamAI): # Non-streaming completion = jamai.table.add_table_rows( "knowledge", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="knowledge-simple", data=[dict(Title="Dune (2024)", Text=text_b)], stream=False, @@ -776,18 +769,18 @@ def rag(jamai: JamAI): # Create an Action Table with RAG table = jamai.table.create_action_table( - p.ActionTableSchemaCreate( + t.ActionTableSchemaCreate( id="action-rag", cols=[ - p.ColumnSchemaCreate(id="question", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate( id="answer", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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( + rag_params=t.RAGParams( table_id="knowledge-simple", k=2, ), @@ -804,7 +797,7 @@ def rag(jamai: JamAI): # Ask a question with streaming completion = jamai.table.add_table_rows( "action", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="action-rag", data=[dict(question="Where did I went in 2018?")], stream=True, @@ -813,7 +806,7 @@ def rag(jamai: JamAI): for chunk in completion: if chunk.output_column_name != "answer": continue - if isinstance(chunk, p.GenTableStreamReferences): + if isinstance(chunk, t.CellReferencesResponse): # References that are retrieved from KT assert len(chunk.chunks) == 2 # k = 2 print(chunk.chunks) @@ -854,7 +847,7 @@ def delete_rows(jamai: JamAI): rows = jamai.table.list_table_rows("action", "action-simple") response = jamai.table.delete_table_rows( "action", - p.RowDeleteRequest( + t.MultiRowDeleteRequest( table_id="action-simple", row_ids=[row["ID"] for row in rows.items], ), @@ -971,11 +964,11 @@ Generate chat completions using various models. Supports streaming and non-strea ```python # Streaming -request = p.ChatRequest( +request = t.ChatRequest( model="openai/gpt-4o-mini", messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("What is a llama?"), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.user("What is a llama?"), ], temperature=0.001, top_p=0.001, @@ -988,11 +981,11 @@ for chunk in completion: print("") # Non-streaming -request = p.ChatRequest( +request = t.ChatRequest( model="openai/gpt-4o-mini", messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("What is a llama?"), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.user("What is a llama?"), ], temperature=0.001, top_p=0.001, @@ -1010,7 +1003,7 @@ Generate embeddings for given input text. ```python texts = ["What is love?", "What is a llama?"] embeddings = jamai.generate_embeddings( - p.EmbeddingRequest( + t.EmbeddingRequest( model="ellm/BAAI/bge-m3", input=texts, ) @@ -1038,7 +1031,7 @@ print(f"Model: {model.id} Context length: {model.context_length}") # 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=128000 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=None # Filter based on capability: "chat", "embed", "rerank" models = jamai.model_info(capabilities=["chat"]) @@ -1094,14 +1087,14 @@ st.title("Simple chat") try: # Create a Chat Table jamai.table.create_chat_table( - p.ChatTableSchemaCreate( + t.ChatTableSchemaCreate( id="chat-simple", cols=[ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="openai/gpt-4o-mini", # Leave this out to use a default model system_prompt="You are a pirate.", temperature=0.001, @@ -1128,7 +1121,7 @@ for message in st.session_state.messages: def response_generator(_prompt): completion = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id="chat-simple", data=[dict(User=_prompt)], stream=True, diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 64b37f3..c902ed0 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -5,11 +5,11 @@ # https://docs.pytest.org/en/latest/customize.html?highlight=pyproject#pyproject-toml [tool.pytest.ini_options] -timeout = 90 +timeout = 120 log_cli = true asyncio_mode = "auto" # log_cli_level = "DEBUG" -addopts = "--cov=jamaibase --doctest-modules" +addopts = "--doctest-modules -vv -ra --strict-markers --no-flaky-report" testpaths = ["tests"] filterwarnings = [ "ignore::DeprecationWarning:tensorflow.*", @@ -17,6 +17,20 @@ filterwarnings = [ "ignore::DeprecationWarning:matplotlib.*", "ignore::DeprecationWarning:flatbuffers.*", ] +markers = ["oss: Cloud-only tests", "cloud: Cloud-only tests"] + +# ----------------------------------------------------------------------------- +# Coverage configuration +# https://coverage.readthedocs.io/en + +[tool.coverage.run] +source = ["owl"] +relative_files = true +concurrency = ["multiprocessing", "thread", "greenlet"] +parallel = true + +# [tool.coverage.paths] +# source = ["services/api/src", "src"] # ----------------------------------------------------------------------------- # Ruff configuration @@ -25,7 +39,7 @@ filterwarnings = [ [tool.ruff] line-length = 99 indent-width = 4 -target-version = "py310" +target-version = "py312" extend-include = [".pyi?$", ".ipynb"] extend-exclude = ["archive/*"] respect-gitignore = true @@ -57,7 +71,7 @@ unfixable = ["B"] "**/{tests,docs,tools}/*" = ["E402"] [tool.ruff.lint.isort] -known-first-party = ["jamaibase", "owl", "docio"] +known-first-party = ["jamaibase", "owl"] [tool.ruff.lint.flake8-bugbear] # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. @@ -74,16 +88,16 @@ extend-immutable-calls = [ # https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html [build-system] -requires = ["setuptools>=61.0", "setuptools-scm"] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "jamaibase" description = "JamAI Base: Let Your Database Orchestrate LLMs and RAG" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" # keywords = ["one", "two"] -license = { text = "Apache 2.0" } +license = "Apache-2.0" classifiers = [ # https://pypi.org/classifiers/ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3 :: Only", @@ -91,40 +105,40 @@ classifiers = [ # https://pypi.org/classifiers/ "Operating System :: Unix", ] # Sort your dependencies https://sortmylist.com/ +# In general, for v1 and above, we pin to minor version using ~= dependencies = [ - "filetype~=1.2.0", + "filetype~=1.2", "httpx>=0.25.0", "loguru>=0.7.2", - "numpy>=1.26.0,<2.0.0", - "orjson>=3.9.7", + "natsort[fast]~=8.4", + "numpy>=1.26.0", + "orjson~=3.9", "pandas", - "Pillow>=10.0.1", - "pydantic-settings>=2.0.3", - "pydantic>=2.4.2", - "srsly>=2.4.8", - "toml>=0.10.2", - "typing_extensions>=4.10.0", + "Pillow>=10.0", + "pydantic-extra-types~=2.9", + "pydantic-settings~=2.4", + "pydantic[email,timezone]~=2.10", + "pyyaml~=6.0", + "toml~=0.10.2", + "typing_extensions~=4.10", + "uuid-utils~=0.9", + "uuid7~=0.1", ] dynamic = ["version"] [project.optional-dependencies] -lint = ["ruff~=0.5.7"] +lint = ["ruff~=0.12.9"] test = [ "flaky~=3.8.1", + "locust~=2.39.1", "mypy~=1.11.1", - "pydub~=0.25.1", "pytest-asyncio>=0.23.8", "pytest-cov~=5.0.0", "pytest-timeout>=2.3.1", "pytest~=8.2.2", -] -docs = [ - "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.0.0", - "sphinx_rtd_theme~=2.0.0", # Sphinx theme + "sphinx_rtd_theme~=2.0.0", # Sphinx theme ] build = [ "build", @@ -137,6 +151,12 @@ all = [ # [project.scripts] # jamaibase = "jamaibase.scripts.example:main_cli" +[project.urls] +Homepage = "https://www.jamaibase.com/" +Documentation = "https://docs.jamaibase.com/" +Repository = "https://github.com/EmbeddedLLM/JamAIBase" +Changelog = "https://github.com/EmbeddedLLM/JamAIBase/blob/main/CHANGELOG.md" + [tool.setuptools.dynamic] version = { attr = "jamaibase.version.__version__" } diff --git a/clients/python/src/jamaibase/client.py b/clients/python/src/jamaibase/client.py index d5df736..c5edd35 100644 --- a/clients/python/src/jamaibase/client.py +++ b/clients/python/src/jamaibase/client.py @@ -1,79 +1,112 @@ import platform -from mimetypes import guess_type -from os.path import split +import warnings +from contextlib import contextmanager +from datetime import datetime +from os.path import basename, split +from time import perf_counter from typing import Any, AsyncGenerator, BinaryIO, Generator, Literal, Type from urllib.parse import quote from warnings import warn -import filetype import httpx +import orjson +from loguru import logger 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 ( +from jamaibase.types import ( ActionTableSchemaCreate, AddActionColumnSchema, AddChatColumnSchema, AddKnowledgeColumnSchema, - AdminOrderBy, - ApiKeyCreate, - ApiKeyRead, - ChatCompletionChunk, + AgentMetaResponse, + CellCompletionResponse, + CellReferencesResponse, + ChatCompletionChunkResponse, + ChatCompletionResponse, ChatRequest, ChatTableSchemaCreate, - ChatThread, + ChatThreadResponse, + ChatThreadsResponse, ColumnDropRequest, ColumnRenameRequest, ColumnReorderRequest, + ConversationCreateRequest, + ConversationMetaResponse, + ConversationThreadsResponse, + DeploymentCreate, + DeploymentRead, + DeploymentUpdate, EmbeddingRequest, EmbeddingResponse, - EventCreate, - EventRead, - FileUploadRequest, FileUploadResponse, GenConfigUpdateRequest, - GenTableOrderBy, - GenTableRowsChatCompletionChunks, - GenTableStreamChatCompletionChunk, - GenTableStreamReferences, GetURLRequest, GetURLResponse, KnowledgeTableSchemaCreate, - ModelInfoResponse, - ModelListConfig, + MessageAddRequest, + MessagesRegenRequest, + MessageUpdateRequest, + ModelConfigCreate, + ModelConfigRead, + ModelConfigUpdate, + ModelInfoListResponse, ModelPrice, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + MultiRowRegenRequest, + MultiRowUpdateRequest, OkResponse, OrganizationCreate, OrganizationRead, OrganizationUpdate, - OrgMemberCreate, OrgMemberRead, Page, - PATCreate, - PATRead, - Price, + PasswordChangeRequest, + PasswordLoginRequest, + PricePlanCreate, + PricePlanRead, + PricePlanUpdate, + ProgressState, ProjectCreate, + ProjectKeyCreate, + ProjectKeyRead, + ProjectKeyUpdate, + ProjectMemberRead, ProjectRead, ProjectUpdate, References, - RowAddRequest, - RowDeleteRequest, - RowRegenRequest, + RerankingRequest, + RerankingResponse, + Role, RowUpdateRequest, SearchRequest, - StringResponse, + StripePaymentInfo, TableDataImportRequest, TableImportRequest, TableMetaResponse, - TableType, - Template, + UsageResponse, UserCreate, UserRead, UserUpdate, + VerificationCodeRead, ) -from jamaibase.utils.io import json_loads +from jamaibase.utils import uuid7_str +from jamaibase.utils.background_loop import LOOP +from jamaibase.utils.exceptions import ( + AuthorizationError, + BadInputError, + ForbiddenError, + JamaiException, + RateLimitExceedError, + ResourceExistsError, + ResourceNotFoundError, + ServerBusyError, + UnexpectedError, +) +from jamaibase.utils.io import guess_mime, json_loads from jamaibase.version import __version__ USER_AGENT = f"SDK/{__version__} (Python/{platform.python_version()}; {platform.system()} {platform.release()}; {platform.machine()})" @@ -82,54 +115,64 @@ class EnvConfig(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - jamai_token: SecretStr = "" - jamai_api_key: SecretStr = "" - 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 + model_config = SettingsConfigDict( + env_prefix="jamai_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + token: SecretStr = "" + api_base: str = "https://api.jamaibase.com/api" + project_id: str = "default" + timeout_sec: float = 60.0 * 5 # Default to 5 minutes + file_upload_timeout_sec: float = 60.0 * 15 # Default to 15 minutes @property - 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 + def token_plain(self): + return self.token.get_secret_value().strip() ENV_CONFIG = EnvConfig() GenTableChatResponseType = ( - GenTableRowsChatCompletionChunks - | Generator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None, None] + MultiRowCompletionResponse + | Generator[CellReferencesResponse | CellCompletionResponse, None, None] ) -class _Client: +class _ClientAsync: def __init__( self, + user_id: str, project_id: str, token: str, api_base: str, headers: dict | None, http_client: httpx.Client | httpx.AsyncClient, - file_upload_timeout: float | None, + timeout: float | None, + file_upload_timeout: float | None = None, ) -> None: """ Base client. Args: - project_id (str): The project ID. + user_id (str): User ID. + project_id (str): 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.user_id = user_id self.project_id = project_id self.token = token self.api_base = api_base - self.headers = {"X-PROJECT-ID": project_id, "User-Agent": USER_AGENT} + self.headers = { + "X-USER-ID": user_id, + "X-PROJECT-ID": project_id, + "User-Agent": USER_AGENT, + } if token != "": self.headers["Authorization"] = f"Bearer {token}" if headers is not None: @@ -137,20 +180,64 @@ def __init__( raise TypeError("`headers` must be None or a dict.") self.headers.update(headers) self.http_client = http_client + self.timeout = timeout self.file_upload_timeout = file_upload_timeout - @property - def api_key(self) -> str: - return self.token + async def close(self) -> None: + """ + Close the HTTP async client. + """ + await self.http_client.aclose() + + @staticmethod + def _filter_params(params: dict[str, Any] | BaseModel | None) -> dict[str, Any] | None: + """ + Filter out None values from query parameters dictionary or Pydantic model. + + Args: + params (dict[str, Any] | BaseModel | None): Query parameters dictionary or Pydantic model. - def close(self) -> None: + Returns: + params (dict[str, Any] | None): Filtered query parameters dictionary. + """ + if isinstance(params, BaseModel): + params = params.model_dump() + if params is not None: + params = {k: v for k, v in params.items() if v is not None} + return params + + @staticmethod + def _process_body( + body: dict[str, Any] | BaseModel | None, + **kwargs, + ) -> dict[str, Any] | None: """ - Close the HTTP client. + Create a dictionary from request body. + + Args: + body (dict[str, Any] | BaseModel | None): JSON body dictionary or Pydantic model. + **kwargs: Keyword arguments to be pass into `model_dump`. + + Returns: + params (dict[str, Any] | None): JSON body dictionary. """ - self.http_client.close() + if body is not None: + body = body if isinstance(body, dict) else body.model_dump(mode="json", **kwargs) + return body + + @contextmanager + def _log_call(self): + request_id = uuid7_str() + self.headers["X-REQUEST-ID"] = request_id + try: + yield + except JamaiException: + raise + except Exception as e: + raise JamaiException(f"Request {request_id} failed. {repr(e)}") from e @staticmethod - def raise_exception( + async def _raise_exception( response: httpx.Response, *, ignore_code: int | None = None, @@ -176,2232 +263,2202 @@ def raise_exception( try: error = response.text except httpx.ResponseNotRead: - error = response.read().decode() - error = json_loads(error) - err_mssg = error.get("message", error.get("detail", str(error))) - if code == 404: - exc = ResourceNotFoundError + error = (await response.aread()).decode() + try: + error = json_loads(error) + err_mssg = error.get("message", error.get("detail", str(error))) + except Exception: + err_mssg = error + request_id = response.headers.get("x-request-id", "") + err_mssg = f"Request {request_id} failed. {err_mssg}" + if code == 401: + exc_class = AuthorizationError + elif code == 403: + exc_class = ForbiddenError + elif code == 404: + exc_class = ResourceNotFoundError + elif code == 409: + exc_class = ResourceExistsError + elif code == 422: + exc_class = BadInputError + elif code == 429: + _headers = response.headers + used = _headers.get("x-ratelimit-used", None) + retry_after = _headers.get("retry-after", None) + meta = _headers.get("x-ratelimit-meta", None) + raise RateLimitExceedError( + err_mssg, + limit=int(_headers.get("x-ratelimit-limit", 0)), + remaining=int(_headers.get("x-ratelimit-remaining", 0)), + reset_at=int(_headers.get("x-ratelimit-reset", 0)), + used=None if used is None else int(used), + retry_after=None if retry_after is None else int(retry_after), + meta=None if meta is None else orjson.loads(meta), + ) + elif code == 500: + exc_class = UnexpectedError + elif code == 503: + exc_class = ServerBusyError else: - exc = RuntimeError - raise exc(err_mssg) + exc_class = JamaiException + raise exc_class(err_mssg) - @staticmethod - def _filter_params(params: dict[str, Any] | None): + async def _request( + self, + method: str, + address: str, + endpoint: str, + *, + headers: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, + body: BaseModel | dict[str, Any] | None = None, + response_model: Type[BaseModel] | None = None, + timeout: float | None = None, + ignore_code: int | None = None, + process_body_kwargs: dict[str, Any] | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: """ - Filter out None values from the parameters dictionary. + Make an asynchronous request to the specified endpoint. Args: - params (dict[str, Any] | None): The parameters dictionary. + method (str): The HTTP method to use (e.g., "GET", "POST"). + address (str): The base address of the API. + endpoint (str): The API endpoint. + headers (dict[str, Any] | None, optional): Headers to include in the request. Defaults to None. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + body (BaseModel | dict[str, Any] | None, optional): The body to send in the request. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + ignore_code (int | None, optional): HTTP error code to ignore. + process_body_kwargs (dict[str, Any] | None, optional): Additional keyword arguments for processing the body. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: - params (dict[str, Any] | None): The filtered parameters dictionary. + response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - if params is not None: - params = {k: v for k, v in params.items() if v is not None} - return params + with self._log_call(): + if process_body_kwargs is None: + process_body_kwargs = {} + response = await self.http_client.request( + method, + f"{address}{endpoint}", + headers=headers, + params=self._filter_params(params), + json=self._process_body(body, **process_body_kwargs), + timeout=timeout or self.timeout, + **kwargs, + ) + response = await self._raise_exception(response, ignore_code=ignore_code) + if response_model is None: + return response + try: + return response_model.model_validate_json(response.text) + except Exception as e: + raise JamaiException( + f"Failed to parse response (code={response.status_code}): {response.text}" + ) from e - def _get( + async def _get( self, - address: str, endpoint: str, *, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, response_model: Type[BaseModel] | None = None, + timeout: float | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ - Make a GET request to the specified endpoint. + 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. Defaults to None. - response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. - **kwargs (Any): Keyword arguments for `httpx.get`. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - response = self.http_client.get( - f"{address}{endpoint}", - params=self._filter_params(params), + return await self._request( + "GET", + self.api_base, + endpoint, headers=self.headers, + params=params, + body=None, + response_model=response_model, + timeout=timeout, **kwargs, ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) - def _post( + async def _post( self, - address: str, endpoint: str, *, - body: BaseModel | None, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, + body: BaseModel | None = None, response_model: Type[BaseModel] | None = None, + timeout: float | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ - Make a POST request to the specified endpoint. + 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`. + body (BaseModel | None, optional): The body to send in the request. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - if body is not None: - body = body.model_dump() - response = self.http_client.post( - f"{address}{endpoint}", - json=body, + return await self._request( + "POST", + self.api_base, + endpoint, headers=self.headers, - params=self._filter_params(params), + params=params, + body=body, + response_model=response_model, + timeout=timeout, **kwargs, ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) - def _options( + async def _options( self, - address: str, endpoint: str, *, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, response_model: Type[BaseModel] | None = None, + timeout: float | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ - Make an OPTIONS request to the specified endpoint. + 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`. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: response (httpx.Response | BaseModel): The response or Pydantic response object. """ - response = self.http_client.options( - f"{address}{endpoint}", - params=self._filter_params(params), + return await self._request( + "OPTIONS", + self.api_base, + endpoint, headers=self.headers, + params=params, + body=None, + response_model=response_model, + timeout=timeout, **kwargs, ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) - def _patch( + async def _patch( self, - address: str, endpoint: str, *, - body: BaseModel | None, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, + body: BaseModel | None = None, response_model: Type[BaseModel] | None = None, + timeout: float | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ - Make a PATCH request to the specified endpoint. + 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. + params (dict[str, Any] | None, optional): Query parameters. Defaults to None. + body (BaseModel | None, optional): The body to send in the request. Defaults to None. response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.request`. + + Returns: + response (httpx.Response | BaseModel): The response text or Pydantic response object. + """ + return await self._request( + "PATCH", + self.api_base, + endpoint, + headers=self.headers, + params=params, + body=body, + response_model=response_model, + timeout=timeout, + process_body_kwargs={"exclude_unset": True}, + **kwargs, + ) + + async def _put( + self, + endpoint: str, + *, + params: dict[str, Any] | BaseModel | None = None, + body: BaseModel | None = None, + response_model: Type[BaseModel] | None = None, + timeout: float | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + """ + Make an asynchronous PUT request to the specified endpoint. + + Args: + endpoint (str): The API endpoint. params (dict[str, Any] | None, optional): Query parameters. Defaults to None. - **kwargs (Any): Keyword arguments for `httpx.patch`. + body (BaseModel | None, optional): The body to send in the request. Defaults to None. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - if body is not None: - body = body.model_dump() - response = self.http_client.patch( - f"{address}{endpoint}", - json=body, + return await self._request( + "PUT", + self.api_base, + endpoint, headers=self.headers, - params=self._filter_params(params), + params=params, + body=body, + response_model=response_model, + timeout=timeout, + process_body_kwargs={"exclude_unset": True}, **kwargs, ) - response = self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) - def _stream( + async def _stream( self, - address: str, endpoint: str, *, body: BaseModel | None, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, + timeout: float | None = None, **kwargs, - ) -> Generator[str, None, None]: + ) -> AsyncGenerator[str, None]: """ - Make a streaming POST request to the specified endpoint. + 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. + body (BaseModel | None): The body 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() - with self.http_client.stream( - "POST", - f"{address}{endpoint}", - json=body, - headers=self.headers, - params=self._filter_params(params), - **kwargs, - ) as response: - response = self.raise_exception(response) - for chunk in response.iter_lines(): - chunk = chunk.strip() - if chunk == "" or chunk == "data: [DONE]": - continue - yield chunk + with self._log_call(): + async with self.http_client.stream( + "POST", + f"{self.api_base}{endpoint}", + headers=self.headers, + params=self._filter_params(params), + json=self._process_body(body), + timeout=timeout or self.timeout, + **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 - def _delete( + async def _delete( self, - address: str, endpoint: str, *, - params: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, response_model: Type[BaseModel] | None = None, + timeout: float | None = None, ignore_code: int | None = None, **kwargs, ) -> httpx.Response | BaseModel: """ - Make a DELETE request to the specified endpoint. + Make an asynchronous 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`. + response_model (Type[pydantic.BaseModel] | None, optional): The response model to return. Defaults to None. + timeout (float | None, optional): Timeout for the request. Defaults to None. + ignore_code (int | None, optional): HTTP error code to ignore. + **kwargs (Any): Keyword arguments for `httpx.request`. Returns: response (httpx.Response | BaseModel): The response text or Pydantic response object. """ - response = self.http_client.delete( - f"{address}{endpoint}", - params=self._filter_params(params), + return await self._request( + "DELETE", + self.api_base, + endpoint, headers=self.headers, + params=params, + body=None, + response_model=response_model, + timeout=timeout, + ignore_code=ignore_code, **kwargs, ) - response = self.raise_exception(response, ignore_code=ignore_code) - if response_model is None: - return response + + @staticmethod + async def _empty_async_generator(): + """Returns an empty asynchronous generator.""" + return + # This line is never reached, but makes it an async generator + yield + + @staticmethod + def _empty_sync_generator(): + """Returns an empty synchronous generator.""" + return + # This line is never reached, but makes it a sync generator + yield + + async def _return_async_iterator( + self, + agen: AsyncGenerator[Any, None], + stream_models: list[Type[BaseModel]] | None = None, + ) -> AsyncGenerator[Any, None]: + # Get the first chunk outside of the loop so that errors can be raised immediately + try: + chunk = await anext(agen) + except StopAsyncIteration: + # Return empty async generator + return self._empty_async_generator() + + def _process(_chunk: str) -> BaseModel | str: + if stream_models is None: + return _chunk + for m in stream_models: + try: + return m.model_validate_json(_chunk[5:]) + except Exception: + pass + raise RuntimeError(f"Unexpected SSE chunk: {chunk}") + + # For streaming responses, return an asynchronous generator + async def gen(): + nonlocal chunk + yield _process(chunk) + async for chunk in agen: + yield _process(chunk) + + # Directly return the asynchronous generator + return gen() + + def _return_iterator( + self, + agen: AsyncGenerator[Any, None] | Any, + stream: bool, + ) -> Generator[Any, None, None] | Any: + if stream: + # Get the first chunk outside of the loop so that errors can be raised immediately + try: + chunk = LOOP.run(anext(agen)) + except StopAsyncIteration: + # Return empty sync generator + return self._empty_sync_generator() + + def gen(): + nonlocal chunk + yield chunk + while True: + try: + yield LOOP.run(anext(agen)) + except StopAsyncIteration: + break + + return gen() else: - return response_model.model_validate_json(response.text) + return agen -class _BackendAdminClient(_Client): - """Backend administration methods.""" +class _AuthAsync(_ClientAsync): + """Auth methods.""" - def create_user(self, request: UserCreate) -> UserRead: - return self._post( - self.api_base, - "/admin/backend/v1/users", - body=request, + async def register_password(self, body: UserCreate, **kwargs) -> UserRead: + return await self._post( + "/v2/auth/register/password", + body=body, response_model=UserRead, + **kwargs, ) - def update_user(self, request: UserUpdate) -> UserRead: - return self._patch( - self.api_base, - "/admin/backend/v1/users", - body=request, + async def login_password(self, body: PasswordLoginRequest, **kwargs) -> UserRead: + return await self._post( + "/v2/auth/login/password", + body=body, response_model=UserRead, + **kwargs, ) - def list_users( + async def change_password(self, body: PasswordChangeRequest, **kwargs) -> UserRead: + return await self._patch( + "/v2/auth/login/password", + body=body, + response_model=UserRead, + **kwargs, + ) + + +class _PricesAsync(_ClientAsync): + """Prices methods.""" + + async def create_price_plan(self, body: PricePlanCreate, **kwargs) -> PricePlanRead: + return await self._post( + "/v2/prices/plans", + body=body, + response_model=PricePlanRead, + **kwargs, + ) + + async def list_price_plans( self, + *, offset: int = 0, limit: int = 100, - order_by: str = AdminOrderBy.UPDATED_AT, - order_descending: bool = True, - ) -> Page[UserRead]: - """ - List users. - - Args: - 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 (Page[UserRead]): The paginated user metadata response. - """ - return self._get( - self.api_base, - "/admin/backend/v1/users", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[PricePlanRead]: + return await self._get( + "/v2/prices/plans/list", params=dict( offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, ), - response_model=Page[UserRead], - ) - - 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, + response_model=Page[PricePlanRead], + **kwargs, ) - def delete_user( + async def get_price_plan( 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) - - def create_pat(self, request: PATCreate) -> PATRead: - return self._post( - self.api_base, - "/admin/backend/v1/pats", - body=request, - response_model=PATRead, + plan_id: str, + **kwargs, + ) -> PricePlanRead: + return await self._get( + "/v2/prices/plans", + params=dict(price_plan_id=plan_id), + response_model=PricePlanRead, + **kwargs, ) - 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, + async def update_price_plan( + self, + plan_id: str, + body: PricePlanUpdate, + **kwargs, + ) -> PricePlanRead: + return await self._patch( + "/v2/prices/plans", + params=dict(price_plan_id=plan_id), + body=body, + response_model=PricePlanRead, + **kwargs, ) - def delete_pat( + async def delete_price_plan( self, - pat: str, + price_plan_id: str, *, missing_ok: bool = True, + **kwargs, ) -> OkResponse: - response = self._delete( - self.api_base, - f"/admin/backend/v1/pats/{quote(pat)}", - params=None, + response = await self._delete( + "/v2/prices/plans", + params=dict(price_plan_id=price_plan_id), response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) if response.status_code == 404 and missing_ok: return OkResponse() else: return OkResponse.model_validate_json(response.text) - def create_organization(self, request: OrganizationCreate) -> OrganizationRead: - return self._post( - self.api_base, - "/admin/backend/v1/organizations", - body=request, - response_model=OrganizationRead, + async def list_model_prices(self, **kwargs) -> ModelPrice: + return await self._get( + "/v2/prices/models/list", + response_model=ModelPrice, + **kwargs, ) - def update_organization(self, request: OrganizationUpdate) -> OrganizationRead: - return self._patch( - self.api_base, - "/admin/backend/v1/organizations", - body=request, - response_model=OrganizationRead, + +class _UsersAsync(_ClientAsync): + """Users methods.""" + + async def create_user(self, body: UserCreate, **kwargs) -> UserRead: + return await self._post( + "/v2/users", + body=body, + response_model=UserRead, + process_body_kwargs={"exclude_unset": True}, + **kwargs, ) - def list_organizations( + async def list_users( 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", + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + search_columns: list[str] | None = None, + after: str | None = None, + **kwargs, + ) -> Page[UserRead]: + params = dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + after=after, + ) + if search_columns: + params["search_columns"] = search_columns + return await self._get( + "/v2/users/list", + params=params, + response_model=Page[UserRead], + **kwargs, + ) + + async def get_user( + self, + user_id: str | None = None, + **kwargs, + ) -> UserRead: + return await self._get( + "/v2/users", + params=dict(user_id=user_id), + response_model=UserRead, + **kwargs, + ) + + async def update_user( + self, + body: UserUpdate, + **kwargs, + ) -> UserRead: + return await self._patch( + "/v2/users", + body=body, + response_model=UserRead, + **kwargs, + ) + + async def delete_user( + self, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + response = await self._delete( + "/v2/users", + response_model=None, + ignore_code=404 if missing_ok else None, + **kwargs, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) + + async def create_pat(self, body: ProjectKeyCreate, **kwargs) -> ProjectKeyRead: + return await self._post( + "/v2/pats", + body=body, + response_model=ProjectKeyRead, + **kwargs, + ) + + async def list_pats( + self, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ProjectKeyRead]: + return await self._get( + "/v2/pats/list", params=dict( offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, ), - response_model=Page[OrganizationRead], + response_model=Page[ProjectKeyRead], + **kwargs, ) - 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, + async def update_pat( + self, + pat_id: str, + body: ProjectKeyUpdate, + **kwargs, + ) -> ProjectKeyRead: + return await self._patch( + "/v2/pats", + params=dict(pat_id=pat_id), + body=body, + response_model=ProjectKeyRead, + **kwargs, ) - def delete_organization( + async def delete_pat( self, - organization_id: str, + pat_id: str, *, missing_ok: bool = True, + **kwargs, ) -> OkResponse: - response = self._delete( - self.api_base, - f"/admin/backend/v1/organizations/{quote(organization_id)}", - params=None, + response = await self._delete( + "/v2/pats", + params=dict(pat_id=pat_id), response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) if response.status_code == 404 and missing_ok: return OkResponse() else: return OkResponse.model_validate_json(response.text) - def generate_invite_token( + async def create_email_verification_code( self, - organization_id: str, - user_email: str = "", - user_role: str = "", + *, valid_days: int = 7, - ) -> str: + **kwargs, + ) -> VerificationCodeRead: """ - Generates an invite token to join an organization. + Generates an email verification code. 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 "". - user_role (str, optional): User role. - Leave blank to default to guest. Defaults to "". - valid_days (int, optional): How many days should this link be valid for. Defaults to 7. + valid_days (int, optional): Code validity in days. Defaults to 7. Returns: - token (str): _description_ + code (InviteCodeRead): Verification code. """ - response = self._get( - self.api_base, - "/admin/backend/v1/invite_tokens", - params=dict( - organization_id=organization_id, - user_email=user_email, - user_role=user_role, - valid_days=valid_days, - ), - response_model=None, - ) - return response.text - - def join_organization(self, request: OrgMemberCreate) -> OrgMemberRead: - return self._post( - self.api_base, - "/admin/backend/v1/organizations/link", - body=request, - response_model=OrgMemberRead, + return await self._post( + "/v2/users/verify/email/code", + params=dict(valid_days=valid_days), + body=None, + response_model=VerificationCodeRead, + **kwargs, ) - 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, + async def list_email_verification_codes( + self, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + search_columns: list[str] | None = None, + after: str | None = None, + **kwargs, + ) -> Page[VerificationCodeRead]: + params = dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + after=after, ) - - def create_api_key(self, request: ApiKeyCreate) -> ApiKeyRead: - warn(ORG_API_KEY_DEPRECATE, FutureWarning, stacklevel=2) - return self._post( - self.api_base, - "/admin/backend/v1/api_keys", - body=request, - response_model=ApiKeyRead, + if search_columns: + params["search_columns"] = search_columns + return await self._get( + "/v2/users/verify/email/code/list", + params=params, + response_model=Page[VerificationCodeRead], + **kwargs, ) - 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, + async def get_email_verification_code( + self, + verification_code: str, + **kwargs, + ) -> VerificationCodeRead: + return await self._get( + "/v2/users/verify/email/code", + params=dict(verification_code=verification_code), + response_model=VerificationCodeRead, + **kwargs, ) - def delete_api_key( + async def revoke_email_verification_code( self, - api_key: str, + verification_code: str, *, missing_ok: bool = True, + **kwargs, ) -> 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 = await self._delete( + "/v2/users/verify/email/code", + params=dict(verification_code=verification_code), response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) if response.status_code == 404 and missing_ok: return OkResponse() else: return OkResponse.model_validate_json(response.text) - def refresh_quota( + @deprecated( + "`delete_email_verification_code` is deprecated, use `revoke_email_verification_code` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def delete_email_verification_code( 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, - ) - - def get_event(self, event_id: str) -> EventRead: - return self._get( - self.api_base, - f"/admin/backend/v1/events/{quote(event_id)}", - params=None, - response_model=EventRead, - ) - - 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, + verification_code: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return await self.revoke_email_verification_code( + verification_code=verification_code, + missing_ok=missing_ok, + **kwargs, ) - def add_template( + async def verify_email( self, - source: str | BinaryIO, - template_id_dst: str, - exist_ok: bool = False, + verification_code: str, + **kwargs, ) -> 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. + Verify and update user email. Args: - timeout (float, optional): Timeout in seconds, must be >= 0. Defaults to 30.0. + verification_code (str): Verification code. Returns: - response (OkResponse): The response indicating success. + ok (OkResponse): 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, + return await self._post( + "/v2/users/verify/email", + params=dict(verification_code=verification_code), response_model=OkResponse, + **kwargs, ) - 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, - ) +class _ModelsAsync(_ClientAsync): + """Models methods.""" - 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, + async def create_model_config(self, body: ModelConfigCreate, **kwargs) -> ModelConfigRead: + return await self._post( + "/v2/models/configs", + body=body, + response_model=ModelConfigRead, + **kwargs, ) - def list_projects( + async def list_model_configs( self, - organization_id: str = "default", - search_query: str = "", + *, + organization_id: str | None = None, 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", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ModelConfigRead]: + return await self._get( + "/v2/models/configs/list", params=dict( - organization_id=organization_id, - search_query=search_query, offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, + organization_id=organization_id, ), - response_model=Page[ProjectRead], + response_model=Page[ModelConfigRead], + **kwargs, ) - 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, + async def get_model_config( + self, + model_id: str, + **kwargs, + ) -> ModelConfigRead: + return await self._get( + "/v2/models/configs", + params=dict(model_id=model_id), + response_model=ModelConfigRead, + **kwargs, ) - def delete_project( + async def update_model_config( self, - project_id: str, - *, + model_id: str, + body: ModelConfigUpdate, + **kwargs, + ) -> ModelConfigRead: + return await self._patch( + "/v2/models/configs", + params=dict(model_id=model_id), + body=body, + response_model=ModelConfigRead, + **kwargs, + ) + + async def delete_model_config( + self, + model_id: str, missing_ok: bool = True, + **kwargs, ) -> OkResponse: - response = self._delete( - self.api_base, - f"/admin/org/v1/projects/{quote(project_id)}", - params=None, + response = await self._delete( + "/v2/models/configs", + params=dict(model_id=model_id), response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) if response.status_code == 404 and missing_ok: return OkResponse() else: return OkResponse.model_validate_json(response.text) - def import_project( + async def create_deployment( 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, + body: DeploymentCreate, + timeout: float | None = 300.0, + **kwargs, + ) -> DeploymentRead: + return await self._post( + "/v2/models/deployments/cloud", + body=body, + response_model=DeploymentRead, + timeout=self.timeout if timeout is None else timeout, + **kwargs, ) - 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( + async def list_deployments( 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, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[DeploymentRead]: + return await self._get( + "/v2/models/deployments/list", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + ), + response_model=Page[DeploymentRead], + **kwargs, ) - return response.content - def import_project_from_template( + async def get_deployment( 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, + deployment_id: str, + **kwargs, + ) -> DeploymentRead: + return await self._get( + "/v2/models/deployments", + params=dict(deployment_id=deployment_id), + response_model=DeploymentRead, + **kwargs, ) - def export_project_as_template( + async def update_deployment( 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, + deployment_id: str, + body: DeploymentUpdate, + **kwargs, + ) -> DeploymentRead: + return await self._patch( + "/v2/models/deployments", + params=dict(deployment_id=deployment_id), + body=body, + response_model=DeploymentRead, + **kwargs, ) - 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], + async def delete_deployment(self, deployment_id: str, **kwargs) -> OkResponse: + return await self._delete( + "/v2/models/deployments", + params=dict(deployment_id=deployment_id), + response_model=OkResponse, + **kwargs, ) - def get_template(self, template_id: str) -> Template: - """ - Get a template by its ID. - Args: - template_id (str): Template ID. +class _OrganizationsAsync(_ClientAsync): + """Organization methods.""" - Returns: - template (Template): The template. - """ - return self._get( - self.api_base, - f"/public/v1/templates/{quote(template_id)}", - params=None, - response_model=Template, + async def create_organization( + self, + body: OrganizationCreate, + **kwargs, + ) -> OrganizationRead: + return await self._post( + "/v2/organizations", + body=body, + response_model=OrganizationRead, + **kwargs, ) - def list_tables( + async def list_organizations( 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)}", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[OrganizationRead]: + return await self._get( + "/v2/organizations/list", params=dict( offset=offset, limit=limit, - search_query=search_query, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, ), - response_model=Page[TableMetaResponse], + response_model=Page[OrganizationRead], + **kwargs, ) - def get_table(self, template_id: str, table_type: str, table_id: str) -> TableMetaResponse: - """ - Get a table in a template. + async def get_organization( + self, + organization_id: str, + **kwargs, + ) -> OrganizationRead: + return await self._get( + "/v2/organizations", + params=dict(organization_id=organization_id), + response_model=OrganizationRead, + **kwargs, + ) - Args: - template_id (str): Template ID. - table_type (str): Table type. - table_id (str): Table ID. + async def update_organization( + self, + organization_id: str, + body: OrganizationUpdate, + **kwargs, + ) -> OrganizationRead: + return await self._patch( + "/v2/organizations", + body=body, + params=dict(organization_id=organization_id), + response_model=OrganizationRead, + **kwargs, + ) - 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, + async def delete_organization( + self, + organization_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + response = await self._delete( + "/v2/organizations", + params=dict(organization_id=organization_id), + response_model=None, + ignore_code=404 if missing_ok else None, + **kwargs, ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - def list_table_rows( + async def join_organization( self, - template_id: str, - table_type: str, - table_id: str, + user_id: str, + *, + invite_code: str | None = None, + organization_id: str | None = None, + role: str | None = None, + **kwargs, + ) -> OrgMemberRead: + return await self._post( + "/v2/organizations/members", + params=dict( + user_id=user_id, + organization_id=organization_id, + role=role, + invite_code=invite_code, + ), + body=None, + response_model=OrgMemberRead, + **kwargs, + ) + + async def list_members( + self, + organization_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", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[OrgMemberRead]: + return await self._get( + "/v2/organizations/members/list", 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, + order_ascending=order_ascending, + organization_id=organization_id, ), - response_model=Page[dict[str, Any]], + response_model=Page[OrgMemberRead], + **kwargs, ) + async def get_member( + self, + *, + user_id: str, + organization_id: str, + **kwargs, + ) -> OrgMemberRead: + return await self._get( + "/v2/organizations/members", + params=dict(user_id=user_id, organization_id=organization_id), + response_model=OrgMemberRead, + **kwargs, + ) -class _FileClient(_Client): - """File methods.""" - - def upload_file(self, file_path: str) -> FileUploadResponse: - """ - Uploads a file to the server. + async def update_member_role( + self, + *, + user_id: str, + organization_id: str, + role: Role, + **kwargs, + ) -> OrgMemberRead: + return await self._patch( + "/v2/organizations/members/role", + params=dict(user_id=user_id, organization_id=organization_id, role=role), + response_model=OrgMemberRead, + **kwargs, + ) - 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, + async def leave_organization( + self, + user_id: str, + organization_id: str, + **kwargs, + ) -> OkResponse: + return await self._delete( + "/v2/organizations/members", + params=dict( + user_id=user_id, + organization_id=organization_id, + ), + response_model=OkResponse, + **kwargs, ) - 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, + async def model_catalogue( + self, + *, + organization_id: str, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ModelConfigRead]: + return await self._get( + "/v2/organizations/models/catalogue", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + organization_id=organization_id, + ), + response_model=Page[ModelConfigRead], + **kwargs, ) - def get_table( + async def create_invite( self, - table_type: str | TableType, - table_id: str, - ) -> TableMetaResponse: + *, + user_email: str, + organization_id: str, + role: str, + valid_days: int = 7, + **kwargs, + ) -> VerificationCodeRead: """ - Get metadata for a specific Generative Table. + Generates an invite token to join an organization. Args: - table_type (str | TableType): The type of the table. - table_id (str): The ID of the table. + user_email (str): User email. + organization_id (str): Organization ID. + role (str): Organization role. + valid_days (int, optional): Code validity in days. Defaults to 7. Returns: - response (TableMetaResponse): The table metadata response. + code (InviteCodeRead): Invite code. """ - return self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}", - params=None, - response_model=TableMetaResponse, + return await self._post( + "/v2/organizations/invites", + params=dict( + user_email=user_email, + organization_id=organization_id, + role=role, + valid_days=valid_days, + ), + body=None, + response_model=VerificationCodeRead, + **kwargs, ) - def list_tables( + async def generate_invite_token(self, *_, **__): + raise NotImplementedError("This method is deprecated, use `create_invite` instead.") + + async def list_invites( self, - table_type: str | TableType, + organization_id: 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, - 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}", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[VerificationCodeRead]: + return await self._get( + "/v2/organizations/invites/list", 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, + order_ascending=order_ascending, + organization_id=organization_id, ), - response_model=Page[TableMetaResponse], + response_model=Page[VerificationCodeRead], + **kwargs, ) - def delete_table( + async def revoke_invite( self, - table_type: str | TableType, - table_id: str, + invite_id: str, *, missing_ok: bool = True, + **kwargs, ) -> 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 = await self._delete( + "/v2/organizations/invites", + params=dict(invite_id=invite_id), response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) if response.status_code == 404 and missing_ok: return OkResponse() else: return OkResponse.model_validate_json(response.text) - def duplicate_table( + @deprecated( + "`delete_invite` is deprecated, use `revoke_invite` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def delete_invite( self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str | None = None, + invite_id: str, *, - include_data: bool = True, - create_as_child: bool = False, + missing_ok: bool = True, **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. + ) -> OkResponse: + return await self.revoke_invite(invite_id=invite_id, missing_ok=missing_ok, **kwargs) - 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)}", + async def subscribe_plan( + self, + organization_id: str, + price_plan_id: str, + **kwargs, + ) -> StripePaymentInfo: + return await self._patch( + "/v2/organizations/plan", + params=dict(organization_id=organization_id, price_plan_id=price_plan_id), body=None, - params=dict( - table_id_dst=table_id_dst, - include_data=include_data, - create_as_child=create_as_child, - ), - response_model=TableMetaResponse, + response_model=StripePaymentInfo, + **kwargs, ) - def rename_table( + async def refresh_quota( 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)}", + organization_id: str, + **kwargs, + ) -> OrganizationRead: + return await self._post( + "/v2/organizations/plan/refresh", + params=dict(organization_id=organization_id), body=None, - response_model=TableMetaResponse, + response_model=OrganizationRead, + **kwargs, ) - def update_gen_config( + async def purchase_credits( 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, + organization_id: str, + amount: float, + *, + confirm: bool = False, + off_session: bool = False, + **kwargs, + ) -> StripePaymentInfo: + return await self._post( + "/v2/organizations/credits", + params=dict( + organization_id=organization_id, + amount=amount, + confirm=confirm, + off_session=off_session, + ), + body=None, + response_model=StripePaymentInfo, + **kwargs, ) - 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, + async def set_credit_grant( + self, + organization_id: str, + amount: float, + **kwargs, + ) -> OkResponse: + return await self._put( + "/v2/organizations/credit_grant", + params=dict(organization_id=organization_id, amount=amount), + body=None, + response_model=OkResponse, + **kwargs, ) - 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, + async def add_credit_grant( + self, + organization_id: str, + amount: float, + **kwargs, + ) -> OkResponse: + return await self._patch( + "/v2/organizations/credit_grant", + params=dict(organization_id=organization_id, amount=amount), + body=None, + response_model=OkResponse, + **kwargs, ) - 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, + async def get_organization_metrics( + self, + metric_id: str, + from_: datetime, + org_id: str, + window_size: str | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + data_source: Literal["clickhouse", "victoriametrics"] = "clickhouse", + **kwargs, + ) -> UsageResponse: + params = { + "metricId": metric_id, + "from": from_.isoformat(), # Use string key to avoid keyword conflict + "orgId": org_id, + "windowSize": window_size, + "projIds": proj_ids, + "to": to.isoformat() if to else None, + "groupBy": group_by, + "dataSource": data_source, + } + return await self._get( + "/v2/organizations/meters/query", + params=params, + response_model=UsageResponse, + ) + + # async def get_billing_metrics( + # self, + # from_: datetime, + # window_size: str, + # org_id: str, + # proj_ids: list[str] | None = None, + # to: datetime | None = None, + # group_by: list[str] | None = None, + # **kwargs, + # ) -> dict: + # params = { + # "from": from_.isoformat(), + # "window_size": window_size, + # "org_id": org_id, + # "proj_ids": proj_ids, + # "to": to, + # "group_by": group_by, + # } + # return await self._get( + # "/v2/organizations/meters/billings", + # params=params, + # **kwargs, + # ) + + +class _ProjectsAsync(_ClientAsync): + """Project methods.""" + + async def create_project(self, body: ProjectCreate, **kwargs) -> ProjectRead: + return await self._post( + "/v2/projects", + body=body, + response_model=ProjectRead, + **kwargs, ) - def drop_columns( + async def list_projects( 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, + organization_id: str, + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + order_by: str = "updated_at", + order_ascending: bool = True, + list_chat_agents: bool = False, + **kwargs, + ) -> Page[ProjectRead]: + return await self._get( + "/v2/projects/list", + params=dict( + offset=offset, + limit=limit, + search_query=search_query, + order_by=order_by, + order_ascending=order_ascending, + organization_id=organization_id, + list_chat_agents=list_chat_agents, + ), + response_model=Page[ProjectRead], + **kwargs, ) - def rename_columns( + async def get_project( self, - table_type: str | TableType, - request: ColumnRenameRequest, - ) -> TableMetaResponse: - """ - Rename columns in a table. + project_id: str, + **kwargs, + ) -> ProjectRead: + return await self._get( + "/v2/projects", + params=dict(project_id=project_id), + response_model=ProjectRead, + **kwargs, + ) - Args: - table_type (str | TableType): The type of the table. - request (ColumnRenameRequest): The column rename request. + async def update_project( + self, + project_id: str, + body: ProjectUpdate, + **kwargs, + ) -> ProjectRead: + return await self._patch( + "/v2/projects", + body=body, + params=dict(project_id=project_id), + response_model=ProjectRead, + **kwargs, + ) - 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, + async def delete_project( + self, + project_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + response = await self._delete( + "/v2/projects", + params=dict(project_id=project_id), + response_model=None, + ignore_code=404 if missing_ok else None, + **kwargs, ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - def reorder_columns( + async def create_invite( self, - table_type: str | TableType, - request: ColumnReorderRequest, - ) -> TableMetaResponse: + *, + user_email: str, + project_id: str, + role: str, + valid_days: int = 7, + **kwargs, + ) -> VerificationCodeRead: """ - Reorder columns in a table. + Generates an invite token to join a project. Args: - table_type (str | TableType): The type of the table. - request (ColumnReorderRequest): The column reorder request. + user_email (str): User email. + project_id (str): Project ID. + role (str): Project role. + valid_days (int, optional): Code validity in days. Defaults to 7. Returns: - response (TableMetaResponse): The table metadata response. + code (InviteCodeRead): Invite code. """ - return self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/reorder", - body=request, - response_model=TableMetaResponse, + return await self._post( + "/v2/projects/invites", + params=dict( + user_email=user_email, + project_id=project_id, + role=role, + valid_days=valid_days, + ), + body=None, + response_model=VerificationCodeRead, + **kwargs, ) - def list_table_rows( + async def list_invites( self, - table_type: str | TableType, - table_id: str, + project_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", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[VerificationCodeRead]: + return await self._get( + "/v2/projects/invites/list", params=dict( offset=offset, limit=limit, - search_query=search_query, - columns=columns, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - order_descending=order_descending, + order_by=order_by, + order_ascending=order_ascending, + project_id=project_id, ), - response_model=Page[dict[str, Any]], + response_model=Page[VerificationCodeRead], + **kwargs, ) - def get_table_row( + async def revoke_invite( self, - table_type: str | TableType, - table_id: str, - row_id: str, + invite_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, - ), + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + response = await self._delete( + "/v2/projects/invites", + params=dict(invite_id=invite_id), response_model=None, + ignore_code=404 if missing_ok else None, + **kwargs, ) - return json_loads(response.text) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - def add_table_rows( + @deprecated( + "`delete_invite` is deprecated, use `revoke_invite` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def delete_invite( 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, - ) + invite_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return await self.revoke_invite(invite_id, missing_ok=missing_ok, **kwargs) - def regen_table_rows( + async def join_project( 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. + user_id: str, + *, + invite_code: str | None = None, + project_id: str | None = None, + role: str | None = None, + **kwargs, + ) -> ProjectMemberRead: + return await self._post( + "/v2/projects/members", + params=dict( + user_id=user_id, + project_id=project_id, + role=role, + invite_code=invite_code, + ), + body=None, + response_model=ProjectMemberRead, + **kwargs, + ) - 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: + async def list_members( + self, + project_id: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ProjectMemberRead]: + return await self._get( + "/v2/projects/members/list", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + project_id=project_id, + ), + response_model=Page[ProjectMemberRead], + **kwargs, + ) - 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}") + async def get_member( + self, + *, + user_id: str, + project_id: str, + **kwargs, + ) -> ProjectMemberRead: + return await self._get( + "/v2/projects/members", + params=dict(user_id=user_id, project_id=project_id), + response_model=ProjectMemberRead, + **kwargs, + ) - return gen() - else: - return self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/regen", - body=request, - response_model=GenTableRowsChatCompletionChunks, - ) + async def update_member_role( + self, + *, + user_id: str, + project_id: str, + role: Role, + **kwargs, + ) -> ProjectMemberRead: + return await self._patch( + "/v2/projects/members/role", + params=dict(user_id=user_id, project_id=project_id, role=role), + response_model=ProjectMemberRead, + **kwargs, + ) - def update_table_row( + async def leave_project( self, - table_type: str | TableType, - request: RowUpdateRequest, + user_id: str, + project_id: str, + **kwargs, ) -> 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, + return await self._delete( + "/v2/projects/members", + params=dict( + user_id=user_id, + project_id=project_id, + ), response_model=OkResponse, + **kwargs, ) - def delete_table_rows( + async def import_project( self, - table_type: str | TableType, - request: RowDeleteRequest, - ) -> OkResponse: + source: str | BinaryIO, + *, + project_id: str = "", + organization_id: str = "", + **kwargs, + ) -> ProjectRead: """ - Delete rows from a table. + Import a project. Args: - table_type (str | TableType): The type of the table. - request (RowDeleteRequest): The row delete request. + source (str | BinaryIO): The parquet file path or file-like object. + It can be a Project or Template file. + project_id (str, optional): If given, import tables into this project. + Defaults to "" (create new project). + organization_id (str): Organization ID of the new project. + Only required if creating a new project. Returns: - response (OkResponse): The response indicating success. + response (ProjectRead): The imported project. """ - return self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/delete", - body=request, - response_model=OkResponse, + migrate = kwargs.pop("migrate", False) # Temporary, may be removed anytime + timeout = None if migrate else (kwargs.pop("timeout", None) or self.file_upload_timeout) + kw = dict( + endpoint=f"/v2/projects/import/parquet{'/migration' if migrate else ''}", + body=None, + response_model=ProjectRead, + data=dict(project_id=project_id, organization_id=organization_id), + timeout=timeout, + **kwargs, ) + 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)}, + **kw, + ) + else: + filename = "import.parquet" + return await self._post( + files={"file": (filename, source, mime_type)}, + **kw, + ) - def delete_table_row( + async def export_project( self, - table_type: str | TableType, - table_id: str, - row_id: str, - ) -> OkResponse: + project_id: str, + **kwargs, + ) -> bytes: """ - Delete a specific row from a table. + Export a project as a Project Parquet file. 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. + project_id (str): Project ID "proj_xxx". Returns: - response (OkResponse): The response indicating success. + response (bytes): The Parquet file. """ - return self._delete( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", - params=None, - response_model=OkResponse, + response = await self._get( + "/v2/projects/export", + params=dict(project_id=project_id), + response_model=None, + **kwargs, ) + return response.content - def get_conversation_thread( + async def import_template( self, - table_type: str | TableType, - table_id: str, - column_id: str, + template_id: str, *, - row_id: str = "", - include: bool = True, - ) -> ChatThread: + project_id: str = "", + organization_id: str = "", + **kwargs, + ) -> ProjectRead: """ - Get the conversation thread for a chat table. + Import a Template. 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. + template_id (str): Template ID "proj_xxx". + project_id (str, optional): If given, import tables into this project. + Defaults to "" (create new project). + organization_id (str): Organization ID of the new project. + Only required if creating a new project. Returns: - response (ChatThread): The conversation thread. + response (ProjectRead): The imported project. """ - 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, + return await self._post( + "/v2/projects/import/template", + body=None, + params=dict( + template_id=template_id, + project_id=project_id, + organization_id=organization_id, + ), + response_model=ProjectRead, + **kwargs, ) - def hybrid_search( - self, - table_type: str | TableType, - request: SearchRequest, - ) -> list[dict[str, Any]]: - """ - Perform a hybrid search on a table. + # async def get_usage_metrics( + # self, + # type: str, + # from_: datetime, + # window_size: str, + # proj_id: str, + # to: datetime | None = None, + # group_by: list[str] | None = None, + # **kwargs, + # ) -> dict: + # params = { + # "type": type, + # "from": from_.isoformat(), + # "window_size": window_size, + # "proj_id": proj_id, + # "to": to, + # "group_by": group_by, + # } + # return await self._get( + # "/v2/projects/meters/usages", + # params=params, + # **kwargs, + # ) + + # async def get_billing_metrics( + # self, + # from_: datetime, + # window_size: str, + # proj_id: str, + # to: datetime | None = None, + # group_by: list[str] | None = None, + # **kwargs, + # ) -> dict: + # params = { + # "from": from_.isoformat(), + # "window_size": window_size, + # "proj_id": proj_id, + # "to": to, + # "group_by": group_by, + # } + # return await self._get( + # "/v2/projects/meters/billings", + # params=params, + # **kwargs, + # ) + + +class _TemplatesAsync(_ClientAsync): + """Template methods.""" - Args: - table_type (str | TableType): The type of the table. - request (SearchRequest): The search request. + async def list_templates( + self, + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ProjectRead]: + return await self._get( + "/v2/templates/list", + params=dict( + offset=offset, + limit=limit, + search_query=search_query, + order_by=order_by, + order_ascending=order_ascending, + ), + response_model=Page[ProjectRead], + **kwargs, + ) - 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, + async def get_template(self, template_id: str, **kwargs) -> ProjectRead: + return await self._get( + "/v2/templates", + params=dict(template_id=template_id), + response_model=ProjectRead, + **kwargs, ) - return json_loads(response.text) - def embed_file_options(self) -> httpx.Response: - """ - Get options for embedding a file to a Knowledge Table. + async def list_tables( + self, + template_id: str, + table_type: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + parent_id: str | None = None, + count_rows: bool = False, + **kwargs, + ) -> Page[TableMetaResponse]: + return await self._get( + f"/v2/templates/gen_tables/{table_type}/list", + params=dict( + template_id=template_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + parent_id=parent_id, + count_rows=count_rows, + ), + response_model=Page[TableMetaResponse], + **kwargs, + ) - Returns: - response (httpx.Response): The response containing options information. - """ - response = self._options( - self.api_base, - "/v1/gen_tables/knowledge/embed_file", + async def get_table( + self, + template_id: str, + table_type: str, + table_id: str, + **kwargs, + ) -> TableMetaResponse: + return await self._get( + f"/v2/templates/gen_tables/{table_type}", + params=dict( + template_id=template_id, + table_id=table_id, + ), + response_model=TableMetaResponse, + **kwargs, ) - return response - def embed_file( + async def list_table_rows( self, - file_path: str, + template_id: str, + table_type: 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: + offset: int = 0, + limit: int = 100, + order_by: str = "ID", + order_ascending: bool = True, + columns: list[str] | None = None, + search_query: str = "", + search_columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> Page[dict[str, Any]]: """ - Imports CSV or TSV data into a table. + List rows in 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. + template_id (str): The ID of the template. + table_type (str): 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. + order_by (str, optional): Column name to order by. Defaults to "ID". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + search_query (str, optional): A string to search for within the rows as a filter. + Defaults to "" (no filter). + search_columns (list[str] | None, optional): A list of column names to search for `search_query`. + Defaults to None (search 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). """ - # 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, - ) + if columns is not None and not isinstance(columns, list): + raise TypeError("`columns` must be None or a list.") + return await self._get( + f"/v2/templates/gen_tables/{table_type}/rows/list", + params=dict( + template_id=template_id, + table_id=table_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + columns=columns, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=Page[dict[str, Any]], + **kwargs, + ) - def export_table_data( + async def get_table_row( self, - table_type: str | TableType, + template_id: str, + table_type: str, table_id: str, + row_id: str, *, columns: list[str] | None = None, - delimiter: Literal[",", "\t"] = ",", - ) -> bytes: + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> dict[str, Any]: """ - Exports the row data of a table as a CSV or TSV file. + Get a specific row in a table. 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). + template_id (str): The ID of the template. + table_type (str): 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 (list[dict[str, Any]]): The search results. + response (dict[str, Any]): The row data. """ - response = self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data", - params=dict(delimiter=delimiter, columns=columns), + if columns is not None and not isinstance(columns, list): + raise TypeError("`columns` must be None or a list.") + response = await self._get( + f"/v2/templates/gen_tables/{table_type}/rows", + params=dict( + template_id=template_id, + table_id=table_id, + row_id=row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), response_model=None, + **kwargs, ) - return response.content + return json_loads(response.text) - def import_table( - self, - table_type: str | TableType, - request: TableImportRequest, - ) -> TableMetaResponse: + +class _FileClientAsync(_ClientAsync): + """File methods.""" + + async def upload_file(self, file_path: str, **kwargs) -> FileUploadResponse: """ - Imports a table (data and schema) from a parquet file. + Uploads a file to the server. Args: - file_path (str): The parquet file path. - table_type (str | TableType): Table type. - request (TableImportRequest): Table import request. + file_path (str): Path to the file to be uploaded. Returns: - response (TableMetaResponse): The table metadata response. + response (FileUploadResponse): The response containing the file URI. """ - 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", + with open(file_path, "rb") as f: + return await self._post( + "/v2/files/upload", body=None, - response_model=TableMetaResponse, + response_model=FileUploadResponse, files={ - "file": (filename, f, mime_type), + "file": (basename(file_path), f, guess_mime(file_path)), }, - data=data, timeout=self.file_upload_timeout, + **kwargs, ) - def export_table( - self, - table_type: str | TableType, - table_id: str, - ) -> bytes: + async def get_raw_urls(self, uris: list[str], **kwargs) -> GetURLResponse: """ - Exports a table (data and schema) as a parquet file. + Get download URLs for raw files. Args: - table_type (str | TableType): Table type. - table_id (str): ID or name of the table to be exported. + uris (List[str]): List of file URIs to download. Returns: - response (list[dict[str, Any]]): The search results. + response (GetURLResponse): The response containing download information for the files. """ - response = self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/export", - params=None, - response_model=None, + return await self._post( + "/v2/files/url/raw", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + **kwargs, ) - 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: + async def get_thumbnail_urls(self, uris: list[str], **kwargs) -> GetURLResponse: """ - Initialize the JamAI client. + Get download URLs for file thumbnails. 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. + uris (List[str]): List of file URIs to get thumbnails for. Returns: - response (dict[str, Any]): Health status. + response (GetURLResponse): The response containing download information for the thumbnails. """ - response = self._get(self.api_base, "/health", response_model=None) - return json_loads(response.text) + return await self._post( + "/v2/files/url/thumb", + body=GetURLRequest(uris=uris), + response_model=GetURLResponse, + **kwargs, + ) - # --- Models and chat --- # - def model_info( +class _GenTableClientAsync(_ClientAsync): + """Generative Table methods.""" + + # Table CRUD + async def create_action_table( self, - name: str = "", - capabilities: list[ - Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] - ] - | None = None, - ) -> ModelInfoResponse: + request: ActionTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Get information about available models. + Create an Action Table. Args: - name (str, optional): The model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): - List of model capabilities to filter by. Defaults to None. + request (ActionTableSchemaCreate): The action table schema. Returns: - response (ModelInfoResponse): The model information response. + response (TableMetaResponse): The table metadata response. """ - params = {"model": name, "capabilities": capabilities} - return self._get( - self.api_base, - "/v1/models", - params=params, - response_model=ModelInfoResponse, + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/action", + body=request, + response_model=TableMetaResponse, + **kwargs, ) - def model_names( + async def create_knowledge_table( self, - prefer: str = "", - capabilities: list[ - Literal["completion", "chat", "image", "audio", "tool", "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", "audio", "tool", "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: + request: KnowledgeTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Generate embeddings for the given input. + Create a Knowledge Table. Args: - request (EmbeddingRequest): The embedding request. + request (KnowledgeTableSchemaCreate): The knowledge table schema. Returns: - response (EmbeddingResponse): The embedding response. + response (TableMetaResponse): The table metadata response. """ - return self._post( - self.api_base, - "/v1/embeddings", + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/knowledge", body=request, - response_model=EmbeddingResponse, + response_model=TableMetaResponse, + **kwargs, ) - # --- 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: + async def create_chat_table( + self, + request: ChatTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Create a Knowledge Table. + Create a Chat Table. Args: - request (KnowledgeTableSchemaCreate): The knowledge table schema. + request (ChatTableSchemaCreate): The chat table schema. Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.create_knowledge_table(request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/chat", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: + async def duplicate_table( + self, + table_type: str, + table_id_src: str, + table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, + ) -> TableMetaResponse: """ - Create a Chat Table. + Duplicate a table. Args: - request (ChatTableSchemaCreate): The chat table schema. + table_type (str): 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.create_chat_table(request) + if (deploy := kwargs.pop("deploy", None)) is not None: + warn( + 'The "deploy" argument is deprecated, use "create_as_child" instead.', + FutureWarning, + stacklevel=2, + ) + create_as_child = create_as_child or deploy + return await self._post( + f"/v1/gen_tables/{table_type}/duplicate/{quote(table_id_src)}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/duplicate", + body=None, + params=dict( + table_id_src=table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + ), + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def get_table( + async def get_table( self, - table_type: str | TableType, + table_type: str, table_id: str, + **kwargs, ) -> TableMetaResponse: """ Get metadata for a specific Generative Table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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) + return await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}", + params=dict(table_id=table_id), + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def list_tables( + async def list_tables( self, - table_type: str | TableType, + table_type: str, + *, offset: int = 0, limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + created_by: str | None = None, parent_id: str | None = None, search_query: str = "", - order_by: str = GenTableOrderBy.UPDATED_AT, - order_descending: bool = True, count_rows: bool = False, + **kwargs, ) -> Page[TableMetaResponse]: """ List Generative Tables of a specific type. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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. + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. + created_by (str | None, optional): Return tables created by this user. + Defaults to None (return all tables). 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 @@ -2409,120 +2466,106 @@ 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 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, + if (order_descending := kwargs.pop("order_descending", None)) is not None: + warn( + 'The "order_descending" argument is deprecated, use "order_ascending" instead.', + FutureWarning, + stacklevel=2, + ) + order_ascending = not order_descending + return await self._get( + f"/v1/gen_tables/{table_type}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/list", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + created_by=created_by, + parent_id=parent_id, + search_query=search_query, + count_rows=count_rows, + ), + response_model=Page[TableMetaResponse], + **kwargs, ) - @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( + async def rename_table( self, - table_type: str | TableType, + table_type: str, table_id_src: str, - table_id_dst: str | None = None, - *, - include_data: bool = True, - create_as_child: bool = False, + table_id_dst: str, **kwargs, ) -> TableMetaResponse: """ - Duplicate a table. + Rename a table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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. + table_id_dst (str): The destination / new table ID. 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, + return await self._post( + f"/v1/gen_tables/{table_type}/rename/{quote(table_id_src)}/{quote(table_id_dst)}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/rename", + params=dict( + table_id_src=table_id_src, + table_id_dst=table_id_dst, + ), + body=None, + response_model=TableMetaResponse, **kwargs, ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def rename_table( + async def delete_table( self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str, - ) -> TableMetaResponse: + table_type: str, + table_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: """ - Rename 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. + table_type (str): The type of the table. + 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.table.rename_table(table_type, table_id_src, table_id_dst) + response = await self._delete( + f"/v1/gen_tables/{table_type}/{quote(table_id)}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}", + params=dict(table_id=table_id), + response_model=None, + ignore_code=404 if missing_ok else None, + **kwargs, + ) + if response.status_code == 404 and missing_ok: + return OkResponse() + else: + return OkResponse.model_validate_json(response.text) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def update_gen_config( + # Column CRUD + async def add_action_columns( self, - table_type: str | TableType, - request: GenConfigUpdateRequest, + request: AddActionColumnSchema, + **kwargs, ) -> 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. @@ -2532,10 +2575,19 @@ def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaRespons Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.add_action_columns(request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/action/columns/add", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: + async def add_knowledge_columns( + self, + request: AddKnowledgeColumnSchema, + **kwargs, + ) -> TableMetaResponse: """ Add columns to a Knowledge Table. @@ -2545,10 +2597,19 @@ def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaR Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.add_knowledge_columns(request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/knowledge/columns/add", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: + async def add_chat_columns( + self, + request: AddChatColumnSchema, + **kwargs, + ) -> TableMetaResponse: """ Add columns to a Chat Table. @@ -2558,122 +2619,246 @@ def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.add_chat_columns(request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/chat/columns/add", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def drop_columns( + async def rename_columns( self, - table_type: str | TableType, - request: ColumnDropRequest, + table_type: str, + request: ColumnRenameRequest, + **kwargs, ) -> TableMetaResponse: """ - Drop columns from a table. + Rename columns in a table. Args: - table_type (str | TableType): The type of the table. - request (ColumnDropRequest): The column drop request. + table_type (str): The type of the table. + request (ColumnRenameRequest): The column rename request. Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.drop_columns(table_type, request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/{table_type}/columns/rename", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def rename_columns( + async def update_gen_config( self, - table_type: str | TableType, - request: ColumnRenameRequest, + table_type: str, + request: GenConfigUpdateRequest, + **kwargs, ) -> TableMetaResponse: """ - Rename columns in a table. + Update the generation configuration for a table. Args: - table_type (str | TableType): The type of the table. - request (ColumnRenameRequest): The column rename request. + table_type (str): The type of the table. + request (GenConfigUpdateRequest): The generation configuration update request. Returns: response (TableMetaResponse): The table metadata response. """ - return self.table.rename_columns(table_type, request) + if kwargs.pop("v1", False): + return await self._post( + f"/v1/gen_tables/{table_type}/gen_config/update", + body=request, + response_model=TableMetaResponse, + process_body_kwargs={"exclude_unset": True}, + **kwargs, + ) + return await self._patch( + f"/v2/gen_tables/{table_type}/gen_config", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def reorder_columns( + async def reorder_columns( self, - table_type: str | TableType, + table_type: str, request: ColumnReorderRequest, + **kwargs, ) -> TableMetaResponse: """ Reorder columns in a table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/{table_type}/columns/reorder", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def list_table_rows( + async def drop_columns( + self, + table_type: str, + request: ColumnDropRequest, + **kwargs, + ) -> TableMetaResponse: + """ + Drop columns from a table. + + Args: + table_type (str): The type of the table. + request (ColumnDropRequest): The column drop request. + + Returns: + response (TableMetaResponse): The table metadata response. + """ + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/{table_type}/columns/drop", + body=request, + response_model=TableMetaResponse, + **kwargs, + ) + + # Row CRUD + async def add_table_rows( + self, + table_type: str, + request: MultiRowAddRequest, + **kwargs, + ) -> ( + MultiRowCompletionResponse + | AsyncGenerator[CellReferencesResponse | CellCompletionResponse, None] + ): + """ + Add rows to a table. + + Args: + table_type (str): The type of the table. + request (MultiRowAddRequest): The row add request. + + Returns: + response (MultiRowCompletionResponse | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `CellReferencesResponse` object + followed by zero or more `CellCompletionResponse` objects. + In non-streaming mode, it is a `MultiRowCompletionResponse` object. + """ + v = "v1" if kwargs.pop("v1", False) else "v2" + if request.stream: + agen = self._stream( + f"/{v}/gen_tables/{table_type}/rows/add", + body=request, + **kwargs, + ) + return await self._return_async_iterator( + agen, [CellCompletionResponse, CellReferencesResponse] + ) + else: + return await self._post( + f"/{v}/gen_tables/{table_type}/rows/add", + body=request, + response_model=MultiRowCompletionResponse, + **kwargs, + ) + + async def list_table_rows( self, - table_type: str | TableType, + table_type: str, table_id: str, *, offset: int = 0, limit: int = 100, - search_query: str = "", + order_by: str = "ID", + order_ascending: bool = True, columns: list[str] | None = None, + where: str = "", + search_query: str = "", + search_columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, - order_descending: bool = True, + **kwargs, ) -> Page[dict[str, Any]]: """ List rows in a table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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). + order_by (str, optional): Column name to order by. Defaults to "ID". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. columns (list[str] | None, optional): List of column names to include in the response. Defaults to None (all columns). + where (str, optional): SQL where clause. Can be nested ie `x = '1' AND ("y (1)" = 2 OR z = '3')`. + It will be combined other filters using `AND`. Defaults to "" (no filter). + search_query (str, optional): A string to search for within the rows as a filter. + Defaults to "" (no filter). + search_columns (list[str] | None, optional): A list of column names to search for `search_query`. + Defaults to None (search 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, + if (order_descending := kwargs.pop("order_descending", None)) is not None: + warn( + 'The "order_descending" argument is deprecated, use "order_ascending" instead.', + FutureWarning, + stacklevel=2, + ) + order_ascending = not order_descending + if columns is not None and not isinstance(columns, list): + raise TypeError("`columns` must be None or a list.") + if search_columns is not None and not isinstance(search_columns, list): + raise TypeError("`search_columns` must be None or a list.") + return await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/rows/list", + params=dict( + table_id=table_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + columns=columns, + where=where, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=Page[dict[str, Any]], + **kwargs, ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def get_table_row( + async def get_table_row( self, - table_type: str | TableType, + table_type: str, table_id: str, row_id: str, *, columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, + **kwargs, ) -> dict[str, Any]: """ Get a specific row in a table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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. @@ -2686,228 +2871,401 @@ def get_table_row( 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, + if columns is not None and not isinstance(columns, list): + raise TypeError("`columns` must be None or a list.") + response = await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/rows", + params=dict( + table_id=table_id, + row_id=row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + response_model=None, + **kwargs, ) + return json_loads(response.text) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def add_table_rows( + @deprecated( + "This method is deprecated, use `get_conversation_threads` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def get_conversation_thread( self, - table_type: str | TableType, - request: RowAddRequest, - ) -> ( - GenTableRowsChatCompletionChunks - | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] - ): + table_type: str, + table_id: str, + column_id: str, + *, + row_id: str = "", + include: bool = True, + **kwargs, + ) -> ChatThreadResponse: """ - Add rows to a table. + Get the conversation thread for a column in a table. Args: - table_type (str | TableType): The type of the table. - request (RowAddRequest): The row add request. + table_type (str): 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 (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. + response (ChatThreadResponse): The conversation thread. """ - return self.table.add_table_rows(table_type, request) + return await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/thread", + params=dict( + table_id=table_id, + column_id=column_id, + row_id=row_id, + include=include, + ), + response_model=ChatThreadResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def regen_table_rows( + async def get_conversation_threads( self, - table_type: str | TableType, - request: RowRegenRequest, - ) -> ( - GenTableRowsChatCompletionChunks - | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] - ): + table_type: str, + table_id: str, + column_ids: list[str] | None = None, + *, + row_id: str = "", + include_row: bool = True, + **kwargs, + ) -> ChatThreadsResponse: """ - Regenerate rows in a table. + Get all multi-turn / conversation threads from a table. Args: - table_type (str | TableType): The type of the table. - request (RowRegenRequest): The row regenerate request. + table_type (str): The type of the table. + table_id (str): ID / name of the chat table. + column_ids (list[str] | None): Columns to fetch as conversation threads. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include_row (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. 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. + response (ChatThreadsResponse): The conversation threads. """ - return self.table.regen_table_rows(table_type, request) + return await self._get( + f"/v2/gen_tables/{table_type}/threads", + params=dict( + table_id=table_id, + column_ids=column_ids, + row_id=row_id, + include_row=include_row, + ), + response_model=ChatThreadsResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def update_table_row( + async def hybrid_search( self, - table_type: str | TableType, - request: RowUpdateRequest, - ) -> OkResponse: + table_type: str, + request: SearchRequest, + **kwargs, + ) -> list[dict[str, Any]]: """ - Update a specific row in a table. + Perform a hybrid search on a table. Args: - table_type (str | TableType): The type of the table. - request (RowUpdateRequest): The row update request. + table_type (str): The type of the table. + request (SearchRequest): The search request. Returns: - response (OkResponse): The response indicating success. + response (list[dict[str, Any]]): The search results. """ - return self.table.update_table_row(table_type, request) + v = "v1" if kwargs.pop("v1", False) else "v2" + response = await self._post( + f"/{v}/gen_tables/{table_type}/hybrid_search", + body=request, + response_model=None, + **kwargs, + ) + return json_loads(response.text) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def delete_table_rows( + async def regen_table_rows( self, - table_type: str | TableType, - request: RowDeleteRequest, - ) -> OkResponse: + table_type: str, + request: MultiRowRegenRequest, + **kwargs, + ) -> ( + MultiRowCompletionResponse + | AsyncGenerator[CellReferencesResponse | CellCompletionResponse, None] + ): """ - Delete rows from a table. + Regenerate rows in a table. Args: - table_type (str | TableType): The type of the table. - request (RowDeleteRequest): The row delete request. + table_type (str): The type of the table. + request (MultiRowRegenRequest): The row regenerate request. Returns: - response (OkResponse): The response indicating success. + response (MultiRowCompletionResponse | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `CellReferencesResponse` object + followed by zero or more `CellCompletionResponse` objects. + In non-streaming mode, it is a `MultiRowCompletionResponse` object. """ - return self.table.delete_table_rows(table_type, request) + v = "v1" if kwargs.pop("v1", False) else "v2" + if request.stream: + agen = self._stream( + f"/{v}/gen_tables/{table_type}/rows/regen", + body=request, + **kwargs, + ) + return await self._return_async_iterator( + agen, [CellCompletionResponse, CellReferencesResponse] + ) + else: + return await self._post( + f"/{v}/gen_tables/{table_type}/rows/regen", + body=request, + response_model=MultiRowCompletionResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def delete_table_row( + async def update_table_rows( self, - table_type: str | TableType, - table_id: str, - row_id: str, + table_type: str, + request: MultiRowUpdateRequest, + **kwargs, ) -> OkResponse: """ - Delete a specific row from a table. + Update rows 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. + table_type (str): The type of the table. + request (MultiRowUpdateRequest): The row update request. Returns: response (OkResponse): The response indicating success. """ - return self.table.delete_table_row(table_type, table_id, row_id) + return await self._patch( + f"/v2/gen_tables/{table_type}/rows", + body=request, + response_model=OkResponse, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def get_conversation_thread( + @deprecated( + "This method is deprecated, use `update_table_rows` instead.", + category=FutureWarning, + stacklevel=1, + ) + async def update_table_row( self, - table_type: str | TableType, - table_id: str, - column_id: str, - row_id: str = "", - include: bool = True, - ) -> ChatThread: + table_type: str, + request: RowUpdateRequest, + **kwargs, + ) -> OkResponse: """ - Get the conversation thread for a chat table. + Update a specific row in a 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. + table_type (str): The type of the table. + request (RowUpdateRequest): The row update request. Returns: - response (ChatThread): The conversation thread. + response (OkResponse): The response indicating success. """ - return self.table.get_conversation_thread( - table_type, table_id, column_id, row_id=row_id, include=include + return await self._post( + f"/v1/gen_tables/{table_type}/rows/update", + body=request, + response_model=OkResponse, + **kwargs, ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def hybrid_search( + async def delete_table_rows( self, - table_type: str | TableType, - request: SearchRequest, - ) -> list[dict[str, Any]]: + table_type: str, + request: MultiRowDeleteRequest, + **kwargs, + ) -> OkResponse: """ - Perform a hybrid search on a table. + Delete rows from a table. Args: - table_type (str | TableType): The type of the table. - request (SearchRequest): The search request. + table_type (str): The type of the table. + request (MultiRowDeleteRequest): The row delete request. Returns: - response (list[dict[str, Any]]): The search results. + response (OkResponse): The response indicating success. """ - return self.table.hybrid_search(table_type, request) + v = "v1" if kwargs.pop("v1", False) else "v2" + return await self._post( + f"/{v}/gen_tables/{table_type}/rows/delete", + body=request, + response_model=OkResponse, + **kwargs, + ) @deprecated( - "This method is deprecated, use `client.table.embed_file_options` instead.", + "This method is deprecated, use `delete_table_rows` instead.", category=FutureWarning, stacklevel=1, ) - def upload_file_options(self) -> httpx.Response: - """ - Get options for uploading a file to a Knowledge Table. + async def delete_table_row( + self, + table_type: str, + table_id: str, + row_id: str, + **kwargs, + ) -> OkResponse: + """ + Delete a specific row from a table. + + Args: + table_type (str): 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 await self._delete( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", + params=None, + response_model=OkResponse, + **kwargs, + ) + + async def embed_file_options(self, **kwargs) -> httpx.Response: + """ + Get CORS preflight options for file embedding endpoint. Returns: response (httpx.Response): The response containing options information. """ - return self.table.embed_file_options() + v = "v1" if kwargs.pop("v1", False) else "v2" + response = await self._options( + f"/{v}/gen_tables/knowledge/embed_file", + **kwargs, + ) + return response - @deprecated( - "This method is deprecated, use `client.table.embed_file` instead.", - category=FutureWarning, - stacklevel=1, - ) - def upload_file(self, request: FileUploadRequest) -> OkResponse: + async def embed_file( + self, + file_path: str, + table_id: str, + *, + chunk_size: int = 1000, + chunk_overlap: int = 200, + **kwargs, + ) -> OkResponse: """ - Upload a file to a Knowledge Table. + 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. """ - return self.table.embed_file(request) + v = "v1" if kwargs.pop("v1", False) else "v2" + # Open the file in binary mode + with open(file_path, "rb") as f: + response = await self._post( + f"/{v}/gen_tables/knowledge/embed_file", + body=None, + response_model=OkResponse, + files={ + "file": (basename(file_path), f, guess_mime(file_path)), + }, + data={ + "table_id": table_id, + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, + # "overwrite": request.overwrite, + }, + timeout=self.file_upload_timeout, + **kwargs, + ) + return response - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def import_table_data( + # Import export + async def import_table_data( self, - table_type: str | TableType, + table_type: str, request: TableDataImportRequest, + **kwargs, ) -> GenTableChatResponseType: """ Imports CSV or TSV data into a table. Args: file_path (str): CSV or TSV file path. - table_type (str | TableType): Table type. + table_type (str): Table type. request (TableDataImportRequest): Data import request. Returns: response (OkResponse): The response indicating success. """ - return self.table.import_table_data(table_type, request) + v = "v1" if kwargs.pop("v1", False) else "v2" + data = { + "table_id": request.table_id, + "stream": request.stream, + # "column_names": request.column_names, + # "columns": request.columns, + "delimiter": request.delimiter, + } + file_path = request.file_path + if request.stream: + # Open the file in binary mode + with open(file_path, "rb") as f: + agen = self._stream( + f"/{v}/gen_tables/{table_type}/import_data", + body=None, + files={"file": (basename(file_path), f, guess_mime(file_path))}, + data=data, + timeout=self.file_upload_timeout, + **kwargs, + ) + return await self._return_async_iterator( + agen, [CellCompletionResponse, CellReferencesResponse] + ) + else: + # Open the file in binary mode + with open(request.file_path, "rb") as f: + return await self._post( + f"/{v}/gen_tables/{table_type}/import_data", + body=None, + response_model=MultiRowCompletionResponse, + files={ + "file": (basename(file_path), f, guess_mime(file_path)), + }, + data=data, + timeout=self.file_upload_timeout, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def export_table_data( + async def export_table_data( self, - table_type: str | TableType, + table_type: str, table_id: str, + *, columns: list[str] | None = None, delimiter: Literal[",", "\t"] = ",", + **kwargs, ) -> bytes: """ Exports the row data of a table as a CSV or TSV file. Args: - table_type (str | TableType): Table type. + table_type (str): 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). @@ -2915,2824 +3273,3154 @@ def export_table_data( Returns: response (list[dict[str, Any]]): The search results. """ - return self.table.export_table_data( - table_type, table_id, columns=columns, delimiter=delimiter + if columns is not None and not isinstance(columns, list): + raise TypeError("`columns` must be None or a list.") + response = await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export_data" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/export_data", + params=dict(table_id=table_id, delimiter=delimiter, columns=columns), + response_model=None, + **kwargs, ) + return response.content - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def import_table( + async def import_table( self, - table_type: str | TableType, + table_type: str, request: TableImportRequest, - ) -> TableMetaResponse: + **kwargs, + ) -> TableMetaResponse | OkResponse: """ Imports a table (data and schema) from a parquet file. Args: file_path (str): The parquet file path. - table_type (str | TableType): Table type. + table_type (str): Table type. request (TableImportRequest): Table import request. Returns: - response (TableMetaResponse): The table metadata response. + response (TableMetaResponse | OkResponse): The table metadata response if blocking is True, + otherwise OkResponse. """ - return self.table.import_table(table_type, request) + migrate = kwargs.pop("migrate", False) # Temporary, may be removed anytime + timeout = None if migrate else (kwargs.pop("timeout", None) or self.file_upload_timeout) + v = "v1" if kwargs.pop("v1", False) else "v2" + mime_type = "application/octet-stream" + filename = split(request.file_path)[-1] + # Open the file in binary mode + with open(request.file_path, "rb") as f: + return await self._post( + f"/{v}/gen_tables/{table_type}/import", + body=None, + response_model=TableMetaResponse if request.blocking else OkResponse, + files={ + "file": (filename, f, mime_type), + }, + data=dict(**self._process_body(request), migrate=migrate), + timeout=timeout, + **kwargs, + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - def export_table( + async def export_table( self, - table_type: str | TableType, + table_type: str, table_id: str, + **kwargs, ) -> bytes: """ Exports a table (data and schema) as a parquet file. Args: - table_type (str | TableType): Table type. + table_type (str): 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() + response = await self._get( + f"/v1/gen_tables/{table_type}/{quote(table_id)}/export" + if kwargs.pop("v1", False) + else f"/v2/gen_tables/{table_type}/export", + params=dict(table_id=table_id), + response_model=None, + **kwargs, + ) + return response.content - @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. +class _MeterClientAsync(_ClientAsync): + """Meter methods.""" + + async def get_usage_metrics( + self, + type: Literal["llm", "embedding", "reranking"], + from_: datetime, + window_size: str, + org_ids: list[str] | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + data_source: Literal["clickhouse", "victoriametrics"] = "clickhouse", + ) -> UsageResponse: + params = { + "type": type, + "from": from_.isoformat(), # Use string key to avoid keyword conflict + "orgIds": org_ids, + "windowSize": window_size, + "projIds": proj_ids, + "to": to.isoformat() if to else None, + "groupBy": group_by, + "dataSource": data_source, + } + return await self._get( + "/v2/meters/usages", + params=params, + response_model=UsageResponse, + ) + + async def get_billing_metrics( + self, + from_: datetime, + window_size: str, + org_ids: list[str] | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + data_source: Literal["clickhouse", "victoriametrics"] = "clickhouse", + ) -> UsageResponse: + params = { + "from": from_.isoformat(), # Use string key to avoid keyword conflict + "orgIds": org_ids, + "windowSize": window_size, + "projIds": proj_ids, + "to": to.isoformat() if to else None, + "groupBy": group_by, + "dataSource": data_source, + } + return await self._get( + "/v2/meters/billings", + params=params, + response_model=UsageResponse, + ) + + async def get_bandwidth_metrics( + self, + from_: datetime, + window_size: str, + org_ids: list[str] | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + data_source: Literal["clickhouse", "victoriametrics"] = "clickhouse", + ) -> UsageResponse: + params = { + "from": from_.isoformat(), # Use string key to avoid keyword conflict + "orgIds": org_ids, + "windowSize": window_size, + "projIds": proj_ids, + "to": to.isoformat() if to else None, + "groupBy": group_by, + "dataSource": data_source, + } + return await self._get( + "/v2/meters/bandwidths", + params=params, + response_model=UsageResponse, + ) + + async def get_storage_metrics( + self, + from_: datetime, + window_size: str, + org_ids: list[str] | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + ) -> UsageResponse: + params = { + "from": from_.isoformat(), # Use string key to avoid keyword conflict + "orgIds": org_ids, + "windowSize": window_size, + "projIds": proj_ids, + "to": to.isoformat() if to else None, + "groupBy": group_by, + } + return await self._get( + "/v2/meters/storages", + params=params, + response_model=UsageResponse, + ) - 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) +class _TaskClientAsync(_ClientAsync): + """Task methods.""" - async def _get( + async def get_progress( self, - address: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - response_model: Type[BaseModel] | None = None, + key: str, **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, + ) -> dict[str, Any]: + response = await self._get( + "/v2/progress", + params=dict(key=key), + response_model=None, **kwargs, ) - response = await self.raise_exception(response) - if response_model is None: - return response - else: - return response_model.model_validate_json(response.text) + return json_loads(response.text) - async def _post( + async def poll_progress( self, - address: str, - endpoint: str, + key: str, *, - body: BaseModel | None, - response_model: Type[BaseModel] | None = None, - params: dict[str, Any] | None = None, + initial_wait: float = 0.5, + max_wait: float = 30 * 60.0, + verbose: bool = False, **kwargs, - ) -> httpx.Response | BaseModel: - """ - Make an asynchronous POST request to the specified endpoint. + ) -> dict[str, Any] | None: + from asyncio import sleep + + i = 1 + t0 = perf_counter() + while (perf_counter() - t0) < max_wait: + await sleep(min(initial_wait * i, 5.0)) + prog = await self.get_progress(key, **kwargs) + state = prog.get("state", None) + error = prog.get("error", None) + if verbose: + logger.info( + f"{self.__class__.__name__}: Progress: key={key} state={state}" + + (f" error={error}" if error else "") + ) + if state == ProgressState.COMPLETED: + return prog + elif state == ProgressState.FAILED: + raise JamaiException(prog.get("error", "Unknown error")) + i += 1 + return None - 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) +class _ConversationClientAsync(_ClientAsync): + """Conversation methods.""" - async def _options( + async def create_conversation( self, - address: str, - endpoint: str, - *, - params: dict[str, Any] | None = None, - response_model: Type[BaseModel] | None = None, + request: ConversationCreateRequest, **kwargs, - ) -> httpx.Response | BaseModel: + ) -> AsyncGenerator[ + ConversationMetaResponse | CellReferencesResponse | CellCompletionResponse, None + ]: """ - Make an asynchronous OPTIONS request to the specified endpoint. + Creates a new conversation and sends the first message. + Yields metadata first, then the message stream. + """ + agen = self._stream("/v2/conversations", body=request, **kwargs) + current_event = None + # Get the first chunk outside of the loop so that errors can be raised immediately + try: + chunk = await anext(agen) + except StopAsyncIteration: + # Return empty async generator + return self._empty_async_generator() + + def _process( + _chunk: str, + ) -> ConversationMetaResponse | CellCompletionResponse | CellReferencesResponse | None: + nonlocal current_event + if _chunk.startswith("event:"): + current_event = _chunk[6:].strip() + return None + + if _chunk.startswith("data:"): + data_obj = json_loads(_chunk[5:]) + + if current_event == "metadata": + # This is the special metadata event + current_event = None # Reset for next events + return ConversationMetaResponse.model_validate(data_obj) + else: + # This is a standard gen_table chunk + if data_obj.get("object") == "gen_table.completion.chunk": + return CellCompletionResponse.model_validate(data_obj) + elif data_obj.get("object") == "gen_table.references": + return CellReferencesResponse.model_validate(data_obj) + else: + pass - 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`. + async def gen(): + nonlocal chunk + res = _process(chunk) + if res is not None: + yield res + async for chunk in agen: + res = _process(chunk) + if res is not None: + yield res - 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) + return gen() - async def _patch( + async def list_conversations( 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, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", **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, + ) -> Page[ConversationMetaResponse]: + """Lists all conversations for the authenticated user.""" + return await self._get( + "/v2/conversations/list", + params=dict( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + ), + response_model=Page[ConversationMetaResponse], **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( + async def list_agents( self, offset: int = 0, limit: int = 100, - order_by: str = AdminOrderBy.UPDATED_AT, - order_descending: bool = True, - ) -> Page[UserRead]: + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + **kwargs, + ) -> Page[ConversationMetaResponse]: + """Lists all available agents for the authenticated user.""" return await self._get( - self.api_base, - "/admin/backend/v1/users", + "/v2/conversations/agents/list", params=dict( offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, + search_query=search_query, ), - response_model=Page[UserRead], + response_model=Page[ConversationMetaResponse], + **kwargs, ) - async def get_user(self, user_id: str) -> UserRead: + async def get_conversation(self, conversation_id: str, **kwargs) -> ConversationMetaResponse: + """Fetches metadata for a single conversation.""" return await self._get( - self.api_base, - f"/admin/backend/v1/users/{quote(user_id)}", - params=None, - response_model=UserRead, + "/v2/conversations", + params={"conversation_id": conversation_id}, + response_model=ConversationMetaResponse, + **kwargs, ) - 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, + async def get_agent(self, agent_id: str, **kwargs) -> AgentMetaResponse: + """Fetches metadata for a single agent.""" + return await self._get( + "/v2/conversations/agents", + params={"agent_id": agent_id}, + response_model=AgentMetaResponse, + **kwargs, ) - 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: + async def generate_title( + self, + conversation_id: str, + **kwargs, + ) -> ConversationMetaResponse: + """Generates a title for a conversation.""" return await self._post( - self.api_base, - "/admin/backend/v1/pats", - body=request, - response_model=PATRead, + "/v2/conversations/title", + params=dict(conversation_id=conversation_id), + body=None, + response_model=ConversationMetaResponse, + **kwargs, ) - 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 rename_conversation_title( + self, + conversation_id: str, + title: str, + **kwargs, + ) -> ConversationMetaResponse: + """Renames conversation title.""" + return await self._patch( + "/v2/conversations/title", + params=dict(conversation_id=conversation_id, title=title), + body=None, + response_model=ConversationMetaResponse, + **kwargs, ) - async def delete_pat( + async def delete_conversation( self, - pat: str, + conversation_id: str, *, missing_ok: bool = True, + **kwargs, ) -> OkResponse: + """Deletes a conversation permanently.""" response = await self._delete( - self.api_base, - f"/admin/backend/v1/pats/{quote(pat)}", - params=None, + "/v2/conversations", + params={"conversation_id": conversation_id}, response_model=None, ignore_code=404 if missing_ok else None, + **kwargs, ) 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", + async def send_message( + self, + request: MessageAddRequest, + **kwargs, + ) -> AsyncGenerator[CellReferencesResponse | CellCompletionResponse, None]: + """ + Sends a message to a conversation and streams back the response. + Note: This endpoint currently only supports streaming responses from the server. + """ + agen = self._stream( + "/v2/conversations/messages", body=request, - response_model=OrganizationRead, + **kwargs, ) - - async def update_organization(self, request: OrganizationUpdate) -> OrganizationRead: - return await self._patch( - self.api_base, - "/admin/backend/v1/organizations", - body=request, - response_model=OrganizationRead, + return await self._return_async_iterator( + agen, [CellCompletionResponse, CellReferencesResponse] ) - async def list_organizations( + async def list_messages( self, + conversation_id: str, offset: int = 0, limit: int = 100, - order_by: str = AdminOrderBy.UPDATED_AT, - order_descending: bool = True, - ) -> Page[OrganizationRead]: + order_by: str = "ID", + order_ascending: bool = True, + columns: list[str] | None = None, + search_query: str = "", + search_columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> Page[dict[str, Any]]: + """Fetches all messages in a conversation.""" return await self._get( - self.api_base, - "/admin/backend/v1/organizations", + "/v2/conversations/messages/list", params=dict( + conversation_id=conversation_id, offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, + order_ascending=order_ascending, + columns=columns, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, ), - response_model=Page[OrganizationRead], + response_model=Page[dict[str, Any]], + **kwargs, ) - 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 regen_message( + self, + request: MessagesRegenRequest, + **kwargs, + ) -> AsyncGenerator[CellReferencesResponse | CellCompletionResponse, None]: + """ + Regenerates a message in a conversation and streams back the response. + """ + agen = self._stream( + "/v2/conversations/messages/regen", + body=request, + **kwargs, + ) + return await self._return_async_iterator( + agen, [CellCompletionResponse, CellReferencesResponse] ) - async def delete_organization( + async def update_message( self, - organization_id: str, - *, - missing_ok: bool = True, + request: MessageUpdateRequest, + **kwargs, ) -> 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, + """Updates a specific message within a conversation.""" + return await self._patch( + "/v2/conversations/messages", + body=request, + response_model=OkResponse, + **kwargs, ) - if response.status_code == 404 and missing_ok: - return OkResponse() - else: - return OkResponse.model_validate_json(response.text) - async def generate_invite_token( + async def get_threads( self, - organization_id: str, - user_email: str = "", - valid_days: int = 7, - ) -> str: + conversation_id: str, + column_ids: list[str] | None = None, + **kwargs, + ) -> ConversationThreadsResponse: """ - Generates an invite token to join an organization. + Get all threads from a conversation. 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. + conversation_id (str): Conversation ID. + column_ids (list[str] | None): Columns to fetch as conversation threads. Returns: - token (str): _description_ + response (ConversationThreadsResponse): The conversation threads. """ - response = await self._get( - self.api_base, - "/admin/backend/v1/invite_tokens", + return await self._get( + "/v2/conversations/threads", params=dict( - organization_id=organization_id, user_email=user_email, valid_days=valid_days + conversation_id=conversation_id, + column_ids=column_ids, ), - 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, + response_model=ConversationThreadsResponse, + **kwargs, ) - 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( +class JamAIAsync(_ClientAsync): + def __init__( self, - api_key: str, + project_id: str = ENV_CONFIG.project_id, + token: str = ENV_CONFIG.token_plain, + api_base: str = ENV_CONFIG.api_base, + headers: dict | None = None, + timeout: float | None = ENV_CONFIG.timeout_sec, + file_upload_timeout: float | None = ENV_CONFIG.file_upload_timeout_sec, *, - 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) + user_id: str = "", + ) -> None: + """ + Initialize the JamAI async client. - 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, + 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. + user_id (str, optional): User ID. For development purposes. + Defaults to "". + """ + if not isinstance(project_id, str): + raise TypeError("`project_id` must be a string.") + if not isinstance(token, str): + raise TypeError("`token` must be a string.") + if not isinstance(api_base, str): + raise TypeError("`api_base` must be a string.") + if not (isinstance(headers, dict) or headers is None): + raise TypeError("`headers` must be a dict or None.") + if not (isinstance(timeout, (float, int)) or timeout is None): + raise TypeError("`timeout` must be a float, int or None.") + if not (isinstance(file_upload_timeout, (float, int)) or file_upload_timeout is None): + raise TypeError("`file_upload_timeout` must be a float, int or None.") + if not isinstance(user_id, str): + raise TypeError("`user_id` must be a string.") + http_client = httpx.AsyncClient( + timeout=timeout, + transport=httpx.AsyncHTTPTransport(retries=3), ) - - 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, + kwargs = dict( + user_id=user_id, + project_id=project_id, + token=token, + api_base=api_base, + headers=headers, + http_client=http_client, + timeout=timeout, + file_upload_timeout=file_upload_timeout, ) + super().__init__(**kwargs) + self.auth = _AuthAsync(**kwargs) + self.prices = _PricesAsync(**kwargs) + self.users = _UsersAsync(**kwargs) + self.models = _ModelsAsync(**kwargs) + self.organizations = _OrganizationsAsync(**kwargs) + self.projects = _ProjectsAsync(**kwargs) + self.templates = _TemplatesAsync(**kwargs) + self.file = _FileClientAsync(**kwargs) + self.table = _GenTableClientAsync(**kwargs) + self.meters = _MeterClientAsync(**kwargs) + self.tasks = _TaskClientAsync(**kwargs) + self.conversations = _ConversationClientAsync(**kwargs) - 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 health(self) -> dict[str, Any]: + """ + Get health status. - 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, - ) + Returns: + response (dict[str, Any]): Health status. + """ + response = await self._get("/health", response_model=None) + return json_loads(response.text) - 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, - ) + # --- Models and chat --- # - 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 model_info( + self, + model: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> ModelInfoListResponse: + """ + Get information about available models. + + Args: + name (str, optional): The model name. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. - async def get_pricing(self) -> Price: + Returns: + response (ModelInfoListResponse): The model information response. + """ + if (name := kwargs.pop("name", None)) is not None: + warnings.warn( + "'name' parameter is deprecated, use 'model' instead.", + DeprecationWarning, + stacklevel=2, + ) + model = name return await self._get( - self.api_base, - "/public/v1/prices/plans", - params=None, - response_model=Price, + "/v1/models", + params=dict(model=model, capabilities=capabilities), + response_model=ModelInfoListResponse, + **kwargs, ) - 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 model_ids( + self, + prefer: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> list[str]: + """ + Get the IDs of available models. - async def get_model_pricing(self) -> ModelPrice: - return await self._get( - self.api_base, - "/public/v1/prices/models", - params=None, - response_model=ModelPrice, - ) + Args: + prefer (str, optional): Preferred model ID. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. - async def get_model_config(self) -> ModelListConfig: - return await self._get( - self.api_base, - "/admin/backend/v1/models", - params=None, - response_model=ModelListConfig, + Returns: + response (list[str]): List of model IDs. + """ + params = {"prefer": prefer, "capabilities": capabilities} + response = await self._get( + "/v1/models/ids", + params=params, + response_model=None, + **kwargs, ) + return json_loads(response.text) - 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, - ) + @deprecated( + "This method is deprecated, use `model_ids` instead.", category=FutureWarning, stacklevel=1 + ) + async def model_names( + self, + prefer: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> list[str]: + return await self.model_ids(prefer=prefer, capabilities=capabilities, **kwargs) - async def add_template( + async def generate_chat_completions( self, - source: str | BinaryIO, - template_id_dst: str, - exist_ok: bool = False, - ) -> OkResponse: + request: ChatRequest, + **kwargs, + ) -> ChatCompletionResponse | AsyncGenerator[References | ChatCompletionChunkResponse, None]: """ - Upload a template Parquet file to add a new template into gallery. + Generates chat completions. 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. + request (ChatRequest): The request. Returns: - response (OkResponse): The response indicating success. + completion (ChatCompletionChunkResponse | AsyncGenerator): The chat completion. + In streaming mode, it is an async generator that yields a `References` object + followed by zero or more `ChatCompletionChunkResponse` objects. + In non-streaming mode, it is a `ChatCompletionChunkResponse` object. """ - 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) + body = self._process_body(request) + if request.stream: + agen = self._stream("/v1/chat/completions", body=body, **kwargs) + return await self._return_async_iterator( + agen, [ChatCompletionChunkResponse, References] + ) else: - filename = "import.parquet" - return await self._post(files={"file": (filename, source, mime_type)}, **kwargs) + return await self._post( + "/v1/chat/completions", + body=body, + response_model=ChatCompletionResponse, + **kwargs, + ) - async def populate_templates(self, timeout: float = 30.0) -> OkResponse: + async def generate_embeddings( + self, + request: EmbeddingRequest, + **kwargs, + ) -> EmbeddingResponse: """ - Re-populates the template gallery. + Generate embeddings for the given input. Args: - timeout (float, optional): Timeout in seconds, must be >= 0. Defaults to 30.0. + request (EmbeddingRequest): The embedding request. Returns: - response (OkResponse): The response indicating success. + response (EmbeddingResponse): The embedding response. """ return await self._post( - self.api_base, - "/admin/backend/v1/templates/populate", - body=None, - params=dict(timeout=timeout), - response_model=OkResponse, + "/v1/embeddings", + body=request, + response_model=EmbeddingResponse, + **kwargs, ) + async def rerank(self, request: RerankingRequest, **kwargs) -> RerankingResponse: + """ + Generate similarity rankings for the given query and documents. -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, - ) + Args: + request (RerankingRequest): The reranking request body. - async def create_project(self, request: ProjectCreate) -> ProjectRead: + Returns: + RerankingResponse: The reranking response. + """ return await self._post( - self.api_base, - "/admin/org/v1/projects", + "/v1/rerank", body=request, - response_model=ProjectRead, + response_model=RerankingResponse, + **kwargs, ) - 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( +class _Auth(_AuthAsync): + """Auth methods.""" + + def register_password(self, body: UserCreate, **kwargs) -> UserRead: + return LOOP.run(super().register_password(body, **kwargs)) + + def login_password(self, body: PasswordLoginRequest, **kwargs) -> UserRead: + return LOOP.run(super().login_password(body, **kwargs)) + + def change_password(self, body: PasswordChangeRequest, **kwargs) -> UserRead: + return LOOP.run(super().change_password(body, **kwargs)) + + +class _Prices(_PricesAsync): + """Prices methods.""" + + def create_price_plan( 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, - ) + body: PricePlanCreate, + **kwargs, + ) -> PricePlanRead: + return LOOP.run(super().create_price_plan(body, **kwargs)) - async def list_projects( + def list_price_plans( 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, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[PricePlanRead]: + return LOOP.run( + super().list_price_plans( offset=offset, limit=limit, order_by=order_by, - order_descending=order_descending, - ), - response_model=Page[ProjectRead], + order_ascending=order_ascending, + **kwargs, + ) ) - 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, - ) + def get_price_plan( + self, + plan_id: str, + **kwargs, + ) -> PricePlanRead: + return LOOP.run(super().get_price_plan(plan_id=plan_id, **kwargs)) - async def delete_project( + def update_price_plan( self, - project_id: str, + plan_id: str, + body: PricePlanUpdate, + **kwargs, + ) -> PricePlanRead: + return LOOP.run(super().update_price_plan(plan_id, body, **kwargs)) + + def delete_price_plan( + self, + price_plan_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) + **kwargs, + ) -> None: + return LOOP.run(super().delete_price_plan(price_plan_id, missing_ok=missing_ok, **kwargs)) - async def import_project( - self, - source: str | BinaryIO, - organization_id: str, - project_id_dst: str = "", - ) -> ProjectRead: - """ - Imports a project. + def list_model_prices(self, **kwargs) -> ModelPrice: + return LOOP.run(super().list_model_prices(**kwargs)) - 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) +class _Users(_UsersAsync): + """Users methods.""" - async def export_project( + def create_user(self, body: UserCreate, **kwargs) -> UserRead: + return LOOP.run(super().create_user(body, **kwargs)) + + def list_users( self, - project_id: str, - compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", - ) -> bytes: - """ - Exports a project as a Project Parquet file. + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + search_columns: list[str] | None = None, + after: str | None = None, + **kwargs, + ) -> Page[UserRead]: + return LOOP.run( + super().list_users( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + after=after, + **kwargs, + ) + ) - Args: - project_id (str): Project ID "proj_xxx". - compression (str, optional): Parquet compression codec. Defaults to "ZSTD". + def get_user( + self, + user_id: str | None = None, + **kwargs, + ) -> UserRead: + return LOOP.run(super().get_user(user_id, **kwargs)) - 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 + def update_user( + self, + body: UserUpdate, + **kwargs, + ) -> UserRead: + return LOOP.run(super().update_user(body, **kwargs)) - async def import_project_from_template( + def delete_user( self, - organization_id: str, - template_id: str, - project_id_dst: str = "", - ) -> ProjectRead: - """ - Imports a project from a template. + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run(super().delete_user(missing_ok=missing_ok, **kwargs)) - 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. + def create_pat(self, body: ProjectKeyCreate, **kwargs) -> ProjectKeyRead: + return LOOP.run(super().create_pat(body, **kwargs)) - 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, + def list_pats( + self, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ProjectKeyRead]: + return LOOP.run( + super().list_pats( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) ) - async def export_project_as_template( + def update_pat( self, - project_id: str, + pat_id: str, + body: ProjectKeyUpdate, + **kwargs, + ) -> ProjectKeyRead: + return LOOP.run(super().update_pat(pat_id, body, **kwargs)) + + def delete_pat( + self, + pat_id: str, *, - name: str, - tags: list[str], - description: str, - compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", - ) -> bytes: + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run(super().delete_pat(pat_id, missing_ok=missing_ok, **kwargs)) + + def create_email_verification_code( + self, + *, + valid_days: int = 7, + **kwargs, + ) -> VerificationCodeRead: """ - Exports a project as a template Parquet file. + Generates an email verification code. 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". + valid_days (int, optional): Code validity in days. Defaults to 7. Returns: - response (bytes): The template Parquet file. + code (InviteCodeRead): Verification code. """ - 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 + return LOOP.run(super().create_email_verification_code(valid_days=valid_days, **kwargs)) + def list_email_verification_codes( + self, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + search_columns: list[str] | None = None, + after: str | None = None, + **kwargs, + ) -> Page[VerificationCodeRead]: + return LOOP.run( + super().list_email_verification_codes( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + after=after, + **kwargs, + ) + ) -class _AdminClientAsync(_ClientAsync): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.backend = _BackendAdminClientAsync(*args, **kwargs) - self.organization = _OrgAdminClientAsync(*args, **kwargs) + def get_email_verification_code( + self, + verification_code: str, + **kwargs, + ) -> VerificationCodeRead: + return LOOP.run( + super().get_email_verification_code( + verification_code=verification_code, + **kwargs, + ) + ) + def revoke_email_verification_code( + self, + verification_code: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().revoke_email_verification_code( + verification_code=verification_code, + missing_ok=missing_ok, + **kwargs, + ) + ) -class _TemplateClientAsync(_ClientAsync): - """Template methods.""" + @deprecated( + "`delete_email_verification_code` is deprecated, use `revoke_email_verification_code` instead.", + category=FutureWarning, + stacklevel=1, + ) + def delete_email_verification_code( + self, + verification_code: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().delete_email_verification_code( + verification_code=verification_code, + missing_ok=missing_ok, + **kwargs, + ) + ) - async def list_templates(self, search_query: str = "") -> Page[Template]: + def verify_email( + self, + verification_code: str, + **kwargs, + ) -> OkResponse: """ - List all templates. + Verify and update user email. Args: - search_query (str, optional): A string to search for within template names. + verification_code (str): Verification code. Returns: - templates (Page[Template]): A page of templates. + ok (OkResponse): Success. """ - return await self._get( - self.api_base, - "/public/v1/templates", - params=dict(search_query=search_query), - response_model=Page[Template], - ) + return LOOP.run(super().verify_email(verification_code=verification_code, **kwargs)) - async def get_template(self, template_id: str) -> Template: - """ - Get a template by its ID. - Args: - template_id (str): Template ID. +class _Models(_ModelsAsync): + """Models methods.""" - Returns: - template (Template): The template. - """ - return await self._get( - self.api_base, - f"/public/v1/templates/{quote(template_id)}", - params=None, - response_model=Template, - ) + def create_model_config(self, body: ModelConfigCreate, **kwargs) -> ModelConfigRead: + return LOOP.run(super().create_model_config(body, **kwargs)) - async def list_tables( + def list_model_configs( self, - template_id: str, - table_type: str, *, + organization_id: str | None = None, 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 await self._get( - self.api_base, - f"/public/v1/templates/{quote(template_id)}/gen_tables/{quote(table_type)}", - params=dict( + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ModelConfigRead]: + return LOOP.run( + super().list_model_configs( + organization_id=organization_id, offset=offset, limit=limit, - search_query=search_query, order_by=order_by, - order_descending=order_descending, - ), - response_model=Page[TableMetaResponse], + order_ascending=order_ascending, + **kwargs, + ) ) - async def get_table( - self, template_id: str, table_type: str, table_id: str - ) -> TableMetaResponse: - """ - Get a table in a template. + def get_model_config( + self, + model_id: str, + **kwargs, + ) -> ModelConfigRead: + return LOOP.run(super().get_model_config(model_id, **kwargs)) - Args: - template_id (str): Template ID. - table_type (str): Table type. - table_id (str): Table ID. + def update_model_config( + self, + model_id: str, + body: ModelConfigUpdate, + **kwargs, + ) -> ModelConfigRead: + return LOOP.run(super().update_model_config(model_id, body, **kwargs)) - 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, - ) + def delete_model_config( + self, + model_id: str, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run(super().delete_model_config(model_id, missing_ok=missing_ok, **kwargs)) - async def list_table_rows( + def create_deployment( + self, + body: DeploymentCreate, + timeout: float | None = 300.0, + **kwargs, + ) -> DeploymentRead: + return LOOP.run(super().create_deployment(body, timeout=timeout, **kwargs)) + + def list_deployments( 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, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[DeploymentRead]: + return LOOP.run( + super().list_deployments( 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]], + order_ascending=order_ascending, + **kwargs, + ) ) + def get_deployment( + self, + deployment_id: str, + **kwargs, + ) -> DeploymentRead: + return LOOP.run(super().get_deployment(deployment_id, **kwargs)) -class _FileClientAsync(_ClientAsync): - """File methods.""" + def update_deployment( + self, + deployment_id: str, + body: DeploymentUpdate, + **kwargs, + ) -> DeploymentRead: + return LOOP.run(super().update_deployment(deployment_id, body, **kwargs)) - async def upload_file(self, file_path: str) -> FileUploadResponse: - """ - Uploads a file to the server. + def delete_deployment(self, deployment_id: str, **kwargs) -> OkResponse: + return LOOP.run(super().delete_deployment(deployment_id, **kwargs)) - 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 +class _Organizations(_OrganizationsAsync): + """Organization methods.""" - 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, + def create_organization( + self, + body: OrganizationCreate, + **kwargs, + ) -> OrganizationRead: + return LOOP.run(super().create_organization(body, **kwargs)) + + def list_organizations( + self, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[OrganizationRead]: + return LOOP.run( + super().list_organizations( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, ) + ) - async def get_raw_urls(self, uris: list[str]) -> GetURLResponse: - """ - Get download URLs for raw files. + def get_organization( + self, + organization_id: str, + **kwargs, + ) -> OrganizationRead: + return LOOP.run(super().get_organization(organization_id, **kwargs)) - Args: - uris (List[str]): List of file URIs to download. + def update_organization( + self, + organization_id: str, + body: OrganizationUpdate, + **kwargs, + ) -> OrganizationRead: + return LOOP.run(super().update_organization(organization_id, body, **kwargs)) - 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, + def delete_organization( + self, + organization_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().delete_organization(organization_id, missing_ok=missing_ok, **kwargs) ) - 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. + def join_organization( + self, + user_id: str, + *, + invite_code: str | None = None, + organization_id: str | None = None, + role: str | None = None, + **kwargs, + ) -> OrgMemberRead: + return LOOP.run( + super().join_organization( + user_id=user_id, + invite_code=invite_code, + organization_id=organization_id, + role=role, + **kwargs, + ) + ) - 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, + def list_members( + self, + organization_id: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[OrgMemberRead]: + return LOOP.run( + super().list_members( + organization_id=organization_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) ) + def get_member( + self, + *, + user_id: str, + organization_id: str, + **kwargs, + ) -> OrgMemberRead: + return LOOP.run( + super().get_member( + user_id=user_id, + organization_id=organization_id, + **kwargs, + ) + ) -class _GenTableClientAsync(_ClientAsync): - """Generative Table methods.""" + def update_member_role( + self, + *, + user_id: str, + organization_id: str, + role: Role, + **kwargs, + ) -> OrgMemberRead: + return LOOP.run( + super().update_member_role( + user_id=user_id, + organization_id=organization_id, + role=role, + **kwargs, + ) + ) - async def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: - """ - Create an Action Table. + def leave_organization( + self, + user_id: str, + organization_id: str, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().leave_organization( + user_id=user_id, + organization_id=organization_id, + **kwargs, + ) + ) - Args: - request (ActionTableSchemaCreate): The action table schema. + def model_catalogue( + self, + *, + organization_id: str, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ModelConfigRead]: + return LOOP.run( + super().model_catalogue( + organization_id=organization_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/action", - body=request, - response_model=TableMetaResponse, + def create_invite( + self, + *, + user_email: str, + organization_id: str, + role: str, + valid_days: int = 7, + **kwargs, + ) -> VerificationCodeRead: + return LOOP.run( + super().create_invite( + user_email=user_email, + organization_id=organization_id, + role=role, + valid_days=valid_days, + **kwargs, + ) ) - async def create_knowledge_table( - self, request: KnowledgeTableSchemaCreate - ) -> TableMetaResponse: - """ - Create a Knowledge Table. + def generate_invite_token(self, *_, **__): + raise NotImplementedError("This method is deprecated, use `create_invite` instead.") - Args: - request (KnowledgeTableSchemaCreate): The knowledge table schema. + def list_invites( + self, + organization_id: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[VerificationCodeRead]: + return LOOP.run( + super().list_invites( + organization_id=organization_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/knowledge", - body=request, - response_model=TableMetaResponse, + def revoke_invite( + self, + invite_id: str, + *, + missing_ok: bool = True, + **kwargs, + ): + return LOOP.run( + super().revoke_invite(invite_id=invite_id, missing_ok=missing_ok, **kwargs) ) - async def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: - """ - Create a Chat Table. + @deprecated( + "`delete_invite` is deprecated, use `revoke_invite` instead.", + category=FutureWarning, + stacklevel=1, + ) + def delete_invite( + self, + invite_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().delete_invite( + invite_id=invite_id, + missing_ok=missing_ok, + **kwargs, + ) + ) - Args: - request (ChatTableSchemaCreate): The chat table schema. + def subscribe_plan( + self, + organization_id: str, + price_plan_id: str, + **kwargs, + ) -> StripePaymentInfo: + return LOOP.run( + super().subscribe_plan( + organization_id=organization_id, + price_plan_id=price_plan_id, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/chat", - body=request, - response_model=TableMetaResponse, + def refresh_quota( + self, + organization_id: str, + **kwargs, + ) -> OrganizationRead: + return LOOP.run( + super().refresh_quota( + organization_id=organization_id, + **kwargs, + ) ) - async def get_table( + def purchase_credits( self, - table_type: str | TableType, - table_id: str, - ) -> TableMetaResponse: - """ - Get metadata for a specific Generative Table. + organization_id: str, + amount: float, + *, + confirm: bool = False, + off_session: bool = False, + **kwargs, + ) -> StripePaymentInfo: + return LOOP.run( + super().purchase_credits( + organization_id=organization_id, + amount=amount, + confirm=confirm, + off_session=off_session, + **kwargs, + ) + ) - Args: - table_type (str | TableType): The type of the table. - table_id (str): The ID of the table. + def set_credit_grant( + self, + organization_id: str, + amount: float, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().set_credit_grant( + organization_id=organization_id, + amount=amount, + **kwargs, + ) + ) - 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, + def add_credit_grant( + self, + organization_id: str, + amount: float, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().add_credit_grant( + organization_id=organization_id, + amount=amount, + **kwargs, + ) ) - async def list_tables( + def get_organization_metrics( + self, + metric_id: str, + from_: datetime, + org_id: str, + window_size: str | None = None, + proj_ids: list[str] | None = None, + to: datetime | None = None, + group_by: list[str] | None = None, + data_source: Literal["clickhouse", "victoriametrics"] = "clickhouse", + **kwargs, + ) -> UsageResponse: + return LOOP.run( + super().get_organization_metrics( + metric_id=metric_id, + from_=from_, + org_id=org_id, + window_size=window_size, + proj_ids=proj_ids, + to=to, + group_by=group_by, + data_source=data_source, + **kwargs, + ) + ) + + # def get_billing_metrics( + # self, + # from_: datetime, + # window_size: str, + # org_id: str, + # proj_ids: list[str] | None = None, + # to: datetime | None = None, + # group_by: list[str] | None = None, + # **kwargs, + # ) -> dict: + # return LOOP.run( + # super().get_billing_metrics( + # from_=from_, + # window_size=window_size, + # org_id=org_id, + # proj_ids=proj_ids, + # to=to, + # group_by=group_by, + # **kwargs, + # ) + # ) + + +class _Projects(_ProjectsAsync): + """Project methods.""" + + def create_project(self, body: ProjectCreate, **kwargs) -> ProjectRead: + return LOOP.run(super().create_project(body, **kwargs)) + + def list_projects( self, - table_type: str | TableType, + organization_id: 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, - 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( + order_by: str = "updated_at", + order_ascending: bool = True, + list_chat_agents: bool = False, + **kwargs, + ) -> Page[ProjectRead]: + return LOOP.run( + super().list_projects( + organization_id=organization_id, 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], + order_ascending=order_ascending, + list_chat_agents=list_chat_agents, + **kwargs, + ) ) - async def delete_table( + def get_project( self, - table_type: str | TableType, - table_id: str, + project_id: str, + **kwargs, + ) -> ProjectRead: + return LOOP.run(super().get_project(project_id, **kwargs)) + + def update_project( + self, + project_id: str, + body: ProjectUpdate, + **kwargs, + ) -> ProjectRead: + return LOOP.run(super().update_project(project_id, body, **kwargs)) + + def delete_project( + self, + project_id: str, *, missing_ok: bool = True, + **kwargs, ) -> 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. + return LOOP.run(super().delete_project(project_id, missing_ok=missing_ok, **kwargs)) - Returns: - response (OkResponse): The response indicating success. - """ - response = await 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, + def join_project( + self, + user_id: str, + *, + invite_code: str | None = None, + project_id: str | None = None, + role: str | None = None, + **kwargs, + ) -> ProjectMemberRead: + return LOOP.run( + super().join_project( + user_id=user_id, + invite_code=invite_code, + project_id=project_id, + role=role, + **kwargs, + ) ) - if response.status_code == 404 and missing_ok: - return OkResponse() - else: - return OkResponse.model_validate_json(response.text) - async def duplicate_table( + def list_members( self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str | None = None, + project_id: str, *, - include_data: bool = True, - create_as_child: bool = False, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, **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, + ) -> Page[ProjectMemberRead]: + return LOOP.run( + super().list_members( + project_id=project_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, ) - create_as_child = create_as_child or kwargs.pop("deploy") - return await 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, ) - async def rename_table( + def get_member( 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 await 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, + *, + user_id: str, + project_id: str, + **kwargs, + ) -> ProjectMemberRead: + return LOOP.run( + super().get_member( + user_id=user_id, + project_id=project_id, + **kwargs, + ) ) - async def update_gen_config( + def update_member_role( 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 await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/gen_config/update", - body=request, - response_model=TableMetaResponse, + *, + user_id: str, + project_id: str, + role: Role, + **kwargs, + ) -> ProjectMemberRead: + return LOOP.run( + super().update_member_role( + user_id=user_id, + project_id=project_id, + role=role, + **kwargs, + ) ) - async def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: - """ - Add columns to an Action Table. - - Args: - request (AddActionColumnSchema): The action column schema. + def leave_project( + self, + user_id: str, + project_id: str, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().leave_project( + user_id=user_id, + project_id=project_id, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/action/columns/add", - body=request, - response_model=TableMetaResponse, + def import_project( + self, + source: str | BinaryIO, + *, + project_id: str = "", + organization_id: str = "", + **kwargs, + ) -> ProjectRead: + return LOOP.run( + super().import_project( + source=source, + project_id=project_id, + organization_id=organization_id, + **kwargs, + ) ) - async def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: - """ - Add columns to a Knowledge Table. + def export_project( + self, + project_id: str, + **kwargs, + ) -> bytes: + return LOOP.run( + super().export_project( + project_id=project_id, + **kwargs, + ) + ) - Args: - request (AddKnowledgeColumnSchema): The knowledge column schema. + def import_template( + self, + template_id: str, + *, + project_id: str = "", + organization_id: str = "", + **kwargs, + ) -> ProjectRead: + return LOOP.run( + super().import_template( + template_id=template_id, + project_id=project_id, + organization_id=organization_id, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/knowledge/columns/add", - body=request, - response_model=TableMetaResponse, + def create_invite( + self, + *, + user_email: str, + project_id: str, + role: str, + valid_days: int = 7, + **kwargs, + ) -> VerificationCodeRead: + return LOOP.run( + super().create_invite( + user_email=user_email, + project_id=project_id, + role=role, + valid_days=valid_days, + **kwargs, + ) ) - async def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: - """ - Add columns to a Chat Table. - - Args: - request (AddChatColumnSchema): The chat column schema. + def list_invites( + self, + project_id: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[VerificationCodeRead]: + return LOOP.run( + super().list_invites( + project_id=project_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) + ) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - "/v1/gen_tables/chat/columns/add", - body=request, - response_model=TableMetaResponse, + def revoke_invite( + self, + invite_id: str, + *, + missing_ok: bool = True, + **kwargs, + ): + return LOOP.run( + super().revoke_invite(invite_id=invite_id, missing_ok=missing_ok, **kwargs) ) - async def drop_columns( + @deprecated( + "`delete_invite` is deprecated, use `revoke_invite` instead.", + category=FutureWarning, + stacklevel=1, + ) + def delete_invite( self, - table_type: str | TableType, - request: ColumnDropRequest, - ) -> TableMetaResponse: - """ - Drop columns from a table. + invite_id: str, + *, + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: + return LOOP.run( + super().delete_invite( + invite_id=invite_id, + missing_ok=missing_ok, + **kwargs, + ) + ) - Args: - table_type (str | TableType): The type of the table. - request (ColumnDropRequest): The column drop request. - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/drop", - body=request, - response_model=TableMetaResponse, - ) +class _Templates(_TemplatesAsync): + """Template methods.""" - async def rename_columns( + def list_templates( self, - table_type: str | TableType, - request: ColumnRenameRequest, - ) -> TableMetaResponse: - """ - Rename columns in a table. + *, + offset: int = 0, + limit: int = 100, + search_query: str = "", + order_by: str = "updated_at", + order_ascending: bool = True, + **kwargs, + ) -> Page[ProjectRead]: + return LOOP.run( + super().list_templates( + offset=offset, + limit=limit, + search_query=search_query, + order_by=order_by, + order_ascending=order_ascending, + **kwargs, + ) + ) - Args: - table_type (str | TableType): The type of the table. - request (ColumnRenameRequest): The column rename request. + def get_template(self, template_id: str, **kwargs) -> ProjectRead: + return LOOP.run(super().get_template(template_id, **kwargs)) - Returns: - response (TableMetaResponse): The table metadata response. - """ - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/rename", - body=request, - response_model=TableMetaResponse, + def list_tables( + self, + template_id: str, + table_type: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + parent_id: str | None = None, + count_rows: bool = False, + **kwargs, + ) -> Page[TableMetaResponse]: + return LOOP.run( + super().list_tables( + template_id=template_id, + table_type=table_type, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + parent_id=parent_id, + count_rows=count_rows, + **kwargs, + ) ) - async def reorder_columns( + def get_table( self, - table_type: str | TableType, - request: ColumnReorderRequest, + template_id: str, + table_type: str, + table_id: str, + **kwargs, ) -> 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 await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/columns/reorder", - body=request, - response_model=TableMetaResponse, + return LOOP.run( + super().get_table( + template_id=template_id, + table_type=table_type, + table_id=table_id, + **kwargs, + ) ) - async def list_table_rows( + def list_table_rows( self, - table_type: str | TableType, + template_id: str, + table_type: str, table_id: str, *, offset: int = 0, limit: int = 100, - search_query: str = "", + order_by: str = "ID", + order_ascending: bool = True, columns: list[str] | None = None, + search_query: str = "", + search_columns: list[str] | None = None, float_decimals: int = 0, vec_decimals: int = 0, - order_descending: bool = True, + **kwargs, ) -> Page[dict[str, Any]]: """ List rows in a table. Args: - table_type (str | TableType): The type of the table. + template_id (str): The ID of the template. + table_type (str): 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). + order_by (str, optional): Column name to order by. Defaults to "ID". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. columns (list[str] | None, optional): List of column names to include in the response. Defaults to None (all columns). + search_query (str, optional): A string to search for within the rows as a filter. + Defaults to "" (no filter). + search_columns (list[str] | None, optional): A list of column names to search for `search_query`. + Defaults to None (search 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 await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows", - params=dict( + return LOOP.run( + super().list_table_rows( + template_id=template_id, + table_type=table_type, + table_id=table_id, 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]], - ) - - 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. - - 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 = await self._get( - self.api_base, - f"/v1/gen_tables/{table_type}/{quote(table_id)}/rows/{quote(row_id)}", - params=dict( + order_by=order_by, + order_ascending=order_ascending, columns=columns, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ), - response_model=None, - ) - return json_loads(response.text) - - async 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 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: - - async def gen(): - async 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) - - return gen() - else: - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/add", - body=request, - response_model=GenTableRowsChatCompletionChunks, - ) - - async 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 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: - - async def gen(): - async 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) - - return gen() - else: - return await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/regen", - body=request, - response_model=GenTableRowsChatCompletionChunks, - ) - - async 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 await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/update", - body=request, - response_model=OkResponse, - ) - - async 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 await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/rows/delete", - body=request, - response_model=OkResponse, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) ) - async def delete_table_row( + def get_table_row( self, - table_type: str | TableType, + template_id: str, + table_type: str, table_id: str, row_id: str, - ) -> OkResponse: + *, + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> dict[str, Any]: """ - Delete a specific row from a table. + Get a specific row in a table. Args: - table_type (str | TableType): The type of the table. + template_id (str): The ID of the template. + table_type (str): 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 (OkResponse): The response indicating success. + response (dict[str, Any]): The row data. """ - 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 LOOP.run( + super().get_table_row( + template_id=template_id, + table_type=table_type, + table_id=table_id, + row_id=row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) ) - 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. - 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. +class _FileClient(_FileClientAsync): + """File methods (synchronous version).""" - Returns: - response (ChatThread): The conversation thread. - """ - return await 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 upload_file(self, file_path: str, **kwargs) -> FileUploadResponse: + return LOOP.run(super().upload_file(file_path, **kwargs)) - async def hybrid_search( + def get_raw_urls(self, uris: list[str], **kwargs) -> GetURLResponse: + return LOOP.run(super().get_raw_urls(uris, **kwargs)) + + def get_thumbnail_urls(self, uris: list[str], **kwargs) -> GetURLResponse: + return LOOP.run(super().get_thumbnail_urls(uris, **kwargs)) + + +class _GenTableClient(_GenTableClientAsync): + """Generative Table methods (synchronous version).""" + + # Table CRUD + def create_action_table( self, - table_type: str | TableType, - request: SearchRequest, - ) -> list[dict[str, Any]]: + request: ActionTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Perform a hybrid search on a table. + Create an Action Table. Args: - table_type (str | TableType): The type of the table. - request (SearchRequest): The search request. + request (ActionTableSchemaCreate): The action table schema. Returns: - response (list[dict[str, Any]]): The search results. + response (TableMetaResponse): The table metadata response. """ - response = await self._post( - self.api_base, - f"/v1/gen_tables/{table_type}/hybrid_search", - body=request, - response_model=None, - ) - return json_loads(response.text) + return LOOP.run(super().create_action_table(request, **kwargs)) - async def embed_file_options(self) -> httpx.Response: + def create_knowledge_table( + self, + request: KnowledgeTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Get options for embedding a file to a Knowledge Table. + Create a Knowledge Table. + + Args: + request (KnowledgeTableSchemaCreate): The knowledge table schema. Returns: - response (httpx.Response): The response containing options information. + response (TableMetaResponse): The table metadata response. """ - response = await self._options( - self.api_base, - "/v1/gen_tables/knowledge/embed_file", - ) - return response + return LOOP.run(super().create_knowledge_table(request, **kwargs)) - async def embed_file( + def create_chat_table( self, - file_path: str, - table_id: str, - *, - chunk_size: int = 1000, - chunk_overlap: int = 200, - ) -> OkResponse: + request: ChatTableSchemaCreate, + **kwargs, + ) -> TableMetaResponse: """ - Embed a file into a Knowledge Table. + Create a Chat 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. + request (ChatTableSchemaCreate): The chat table schema. Returns: - response (OkResponse): The response indicating success. + response (TableMetaResponse): The table metadata response. """ - # 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 = await 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 + return LOOP.run(super().create_chat_table(request, **kwargs)) - async def import_table_data( + def duplicate_table( self, - table_type: str | TableType, - request: TableDataImportRequest, - ) -> GenTableChatResponseType: + table_type: str, + table_id_src: str, + table_id_dst: str | None = None, + *, + include_data: bool = True, + create_as_child: bool = False, + **kwargs, + ) -> TableMetaResponse: """ - Imports CSV or TSV data into a table. + Duplicate a table. Args: - file_path (str): CSV or TSV file path. - table_type (str | TableType): Table type. - request (TableDataImportRequest): Data import request. + table_type (str): 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 (OkResponse): The response indicating success. + response (TableMetaResponse): The table metadata response. """ - # 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: - - 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", - 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 await 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, - ) + return LOOP.run( + super().duplicate_table( + table_type, + table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + **kwargs, + ) + ) - async def export_table_data( + def get_table( self, - table_type: str | TableType, + table_type: str, table_id: str, - *, - columns: list[str] | None = None, - delimiter: Literal[",", "\t"] = ",", - ) -> bytes: + **kwargs, + ) -> TableMetaResponse: """ - Exports the row data of a table as a CSV or TSV file. + Get metadata for a specific Generative Table. 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). + table_type (str): The type of the table. + table_id (str): The ID of the table. Returns: - response (list[dict[str, Any]]): The search results. + response (TableMetaResponse): The table metadata response. """ - 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 response.content + return LOOP.run(super().get_table(table_type, table_id, **kwargs)) - async def import_table( + def list_tables( self, - table_type: str | TableType, - request: TableImportRequest, - ) -> TableMetaResponse: + table_type: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + created_by: str | None = None, + parent_id: str | None = None, + search_query: str = "", + count_rows: bool = False, + **kwargs, + ) -> Page[TableMetaResponse]: """ - Imports a table (data and schema) from a parquet file. + List Generative Tables of a specific type. Args: - file_path (str): The parquet file path. - table_type (str | TableType): Table type. - request (TableImportRequest): Table import request. + table_type (str): 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. + order_by (str, optional): Sort tables by this attribute. Defaults to "updated_at". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. + created_by (str | None, optional): Return tables created by this user. + Defaults to None (return all tables). + 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). + count_rows (bool, optional): Whether to count the rows of the tables. Defaults to False. Returns: - response (TableMetaResponse): The table metadata response. + response (Page[TableMetaResponse]): The paginated 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 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, + return LOOP.run( + super().list_tables( + table_type, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + created_by=created_by, + parent_id=parent_id, + search_query=search_query, + count_rows=count_rows, + **kwargs, ) + ) - async def export_table( + def rename_table( self, - table_type: str | TableType, - table_id: str, - ) -> bytes: + table_type: str, + table_id_src: str, + table_id_dst: str, + **kwargs, + ) -> TableMetaResponse: """ - Exports a table (data and schema) as a parquet file. + Rename a table. Args: - table_type (str | TableType): Table type. - table_id (str): ID or name of the table to be exported. + table_type (str): The type of the table. + table_id_src (str): The source table ID. + table_id_dst (str): The destination / new table ID. Returns: - response (list[dict[str, Any]]): The search results. + response (TableMetaResponse): The table metadata response. """ - 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 + return LOOP.run(super().rename_table(table_type, table_id_src, table_id_dst, **kwargs)) - -class JamAIAsync(_ClientAsync): - def __init__( + def delete_table( 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, + table_type: str, + table_id: str, *, - api_key: str = "", - ) -> None: + missing_ok: bool = True, + **kwargs, + ) -> OkResponse: """ - Initialize the JamAI client. + Delete a specific table. 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.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. + table_type (str): The type of the table. + table_id (str): The ID of the table. + missing_ok (bool, optional): Ignore resource not found error. Returns: - response (dict[str, Any]): Health status. + response (OkResponse): The response indicating success. """ - response = await self._get(self.api_base, "/health", response_model=None) - return json_loads(response.text) - - # --- Models and chat --- # + return LOOP.run( + super().delete_table( + table_type, + table_id, + missing_ok=missing_ok, + **kwargs, + ) + ) - async def model_info( + # Column CRUD + def add_action_columns( self, - name: str = "", - capabilities: list[ - Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] - ] - | None = None, - ) -> ModelInfoResponse: + request: AddActionColumnSchema, + **kwargs, + ) -> TableMetaResponse: """ - Get information about available models. + Add columns to an Action Table. Args: - name (str, optional): The model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): - List of model capabilities to filter by. Defaults to None. + request (AddActionColumnSchema): The action column schema. Returns: - response (ModelInfoResponse): The model information response. + response (TableMetaResponse): The table metadata response. """ - params = {"model": name, "capabilities": capabilities} - return await self._get( - self.api_base, - "/v1/models", - params=params, - response_model=ModelInfoResponse, - ) + return LOOP.run(super().add_action_columns(request, **kwargs)) - async def model_names( + def add_knowledge_columns( self, - prefer: str = "", - capabilities: list[ - Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] - ] - | None = None, - ) -> list[str]: + request: AddKnowledgeColumnSchema, + **kwargs, + ) -> TableMetaResponse: """ - Get the names of available models. + Add columns to a Knowledge Table. Args: - prefer (str, optional): Preferred model name. Defaults to "". - capabilities (list[Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"]] | None, optional): - List of model capabilities to filter by. Defaults to None. + request (AddKnowledgeColumnSchema): The knowledge column schema. Returns: - response (list[str]): List of model names. + response (TableMetaResponse): The table metadata response. """ - params = {"prefer": prefer, "capabilities": capabilities} - response = await self._get( - self.api_base, - "/v1/model_names", - params=params, - response_model=None, - ) - return json_loads(response.text) + return LOOP.run(super().add_knowledge_columns(request, **kwargs)) - async def generate_chat_completions( - self, request: ChatRequest - ) -> ChatCompletionChunk | AsyncGenerator[References | ChatCompletionChunk, None]: + def add_chat_columns( + self, + request: AddChatColumnSchema, + **kwargs, + ) -> TableMetaResponse: """ - Generates chat completions. + Add columns to a Chat Table. Args: - request (ChatRequest): The request. + request (AddChatColumnSchema): The chat column schema. Returns: - completion (ChatCompletionChunk | AsyncGenerator): The chat completion. - In streaming mode, it is an async generator that yields a `References` object - followed by zero or more `ChatCompletionChunk` objects. - In non-streaming mode, it is a `ChatCompletionChunk` object. + response (TableMetaResponse): The table metadata response. """ - if request.stream: - - async def gen(): - async 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) + return LOOP.run(super().add_chat_columns(request, **kwargs)) - return gen() - else: - return await self._post( - self.api_base, - "/v1/chat/completions", - body=request, - response_model=ChatCompletionChunk, - ) - - async def generate_embeddings(self, request: EmbeddingRequest) -> EmbeddingResponse: + def rename_columns( + self, + table_type: str, + request: ColumnRenameRequest, + **kwargs, + ) -> TableMetaResponse: """ - Generate embeddings for the given input. + Rename columns in a table. Args: - request (EmbeddingRequest): The embedding request. + table_type (str): The type of the table. + request (ColumnRenameRequest): The column rename request. Returns: - response (EmbeddingResponse): The embedding response. + response (TableMetaResponse): The table metadata response. """ - return await self._post( - self.api_base, - "/v1/embeddings", - body=request, - response_model=EmbeddingResponse, - ) + return LOOP.run(super().rename_columns(table_type, request, **kwargs)) - # --- Gen Table --- # - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def create_action_table(self, request: ActionTableSchemaCreate) -> TableMetaResponse: + def update_gen_config( + self, + table_type: str, + request: GenConfigUpdateRequest, + **kwargs, + ) -> TableMetaResponse: """ - Create an Action Table. + Update the generation configuration for a table. Args: - request (ActionTableSchemaCreate): The action table schema. + table_type (str): The type of the table. + request (GenConfigUpdateRequest): The generation configuration update request. Returns: response (TableMetaResponse): The table metadata response. """ - return await self.table.create_action_table(request) + return LOOP.run(super().update_gen_config(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def create_knowledge_table( - self, request: KnowledgeTableSchemaCreate + def reorder_columns( + self, + table_type: str, + request: ColumnReorderRequest, + **kwargs, ) -> TableMetaResponse: """ - Create a Knowledge Table. + Reorder columns in a table. Args: - request (KnowledgeTableSchemaCreate): The knowledge table schema. + table_type (str): The type of the table. + request (ColumnReorderRequest): The column reorder request. Returns: response (TableMetaResponse): The table metadata response. """ - return await self.table.create_knowledge_table(request) + return LOOP.run(super().reorder_columns(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def create_chat_table(self, request: ChatTableSchemaCreate) -> TableMetaResponse: + def drop_columns( + self, + table_type: str, + request: ColumnDropRequest, + **kwargs, + ) -> TableMetaResponse: """ - Create a Chat Table. + Drop columns from a table. Args: - request (ChatTableSchemaCreate): The chat table schema. + table_type (str): The type of the table. + request (ColumnDropRequest): The column drop request. Returns: response (TableMetaResponse): The table metadata response. """ - return await self.table.create_chat_table(request) + return LOOP.run(super().drop_columns(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def get_table( + # Row CRUD + def add_table_rows( self, - table_type: str | TableType, - table_id: str, - ) -> TableMetaResponse: + table_type: str, + request: MultiRowAddRequest, + **kwargs, + ) -> ( + MultiRowCompletionResponse + | Generator[CellReferencesResponse | CellCompletionResponse, None, None] + ): """ - Get metadata for a specific Generative Table. + Add rows to a table. Args: - table_type (str | TableType): The type of the table. - table_id (str): The ID of the table. + table_type (str): The type of the table. + request (MultiRowAddRequest): The row add request. Returns: - response (TableMetaResponse): The table metadata response. + response (MultiRowCompletionResponse | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `CellReferencesResponse` object + followed by zero or more `CellCompletionResponse` objects. + In non-streaming mode, it is a `MultiRowCompletionResponse` object. """ - return await self.table.get_table(table_type, table_id) + agen = LOOP.run(super().add_table_rows(table_type, request, **kwargs)) + return self._return_iterator(agen, request.stream) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def list_tables( + def list_table_rows( self, - table_type: str | TableType, + table_type: str, + table_id: str, + *, offset: int = 0, limit: int = 100, - parent_id: str | None = None, + order_by: str = "ID", + order_ascending: bool = True, + columns: list[str] | None = None, + where: str = "", search_query: str = "", - order_by: str = GenTableOrderBy.UPDATED_AT, - order_descending: bool = True, - count_rows: bool = False, - ) -> Page[TableMetaResponse]: + search_columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> Page[dict[str, Any]]: """ - List Generative Tables of a specific type. + List rows in a table. Args: - table_type (str | TableType): The type of the table. + table_type (str): 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 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. + limit (int, optional): Number of rows to return (min 1, max 100). Defaults to 100. + order_by (str, optional): Column name to order by. Defaults to "ID". + order_ascending (bool, optional): Whether to sort by ascending order. Defaults to True. + columns (list[str] | None, optional): List of column names to include in the response. + Defaults to None (all columns). + where (str, optional): SQL where clause. Can be nested ie `x = '1' AND ("y (1)" = 2 OR z = '3')`. + It will be combined other filters using `AND`. Defaults to "" (no filter). + search_query (str, optional): A string to search for within the rows 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. + search_columns (list[str] | None, optional): A list of column names to search for `search_query`. + Defaults to None (search 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). + """ + return LOOP.run( + super().list_table_rows( + table_type, + table_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + columns=columns, + where=where, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) + ) + + def get_table_row( + self, + table_type: str, + table_id: str, + row_id: str, + *, + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> dict[str, Any]: + """ + Get a specific row in a table. + + Args: + table_type (str): 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 (Page[TableMetaResponse]): The paginated table metadata response. + response (dict[str, Any]): The row data. """ - 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, + return LOOP.run( + super().get_table_row( + table_type, + table_id, + row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def delete_table( + @deprecated( + "This method is deprecated, use `get_conversation_threads` instead.", + category=FutureWarning, + stacklevel=1, + ) + def get_conversation_thread( self, - table_type: str | TableType, + table_type: str, table_id: str, + column_id: str, *, - missing_ok: bool = True, - ) -> OkResponse: + row_id: str = "", + include: bool = True, + **kwargs, + ) -> ChatThreadResponse: """ - Delete a specific table. + Get the conversation thread for a column in a 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. + table_type (str): 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 (OkResponse): The response indicating success. + response (ChatThreadResponse): The conversation thread. """ - return await self.table.delete_table(table_type, table_id, missing_ok=missing_ok) + return LOOP.run( + super().get_conversation_thread( + table_type, + table_id, + column_id, + row_id=row_id, + include=include, + **kwargs, + ) + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def duplicate_table( + def get_conversation_threads( self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str | None = None, + table_type: str, + table_id: str, + column_ids: list[str] | None = None, *, - include_data: bool = True, - create_as_child: bool = False, + row_id: str = "", + include_row: bool = True, **kwargs, - ) -> TableMetaResponse: + ) -> ChatThreadsResponse: """ - Duplicate a table. + Get all multi-turn / conversation threads from 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. + table_type (str): The type of the table. + table_id (str): ID / name of the chat table. + column_ids (list[str] | None): Columns to fetch as conversation threads. + row_id (str, optional): ID / name of the last row in the thread. + Defaults to "" (export all rows). + include_row (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. Returns: - response (TableMetaResponse): The table metadata response. + response (ChatThreadsResponse): The conversation threads. """ - 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, + return LOOP.run( + super().get_conversation_threads( + table_type, + table_id, + column_ids, + row_id=row_id, + include_row=include_row, + **kwargs, + ) ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def rename_table( + def hybrid_search( self, - table_type: str | TableType, - table_id_src: str, - table_id_dst: str, - ) -> TableMetaResponse: + table_type: str, + request: SearchRequest, + **kwargs, + ) -> list[dict[str, Any]]: """ - Rename a table. + Perform a hybrid search on 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. + table_type (str): The type of the table. + request (SearchRequest): The search request. Returns: - response (TableMetaResponse): The table metadata response. + response (list[dict[str, Any]]): The search results. """ - return await self.table.rename_table(table_type, table_id_src, table_id_dst) + return LOOP.run(super().hybrid_search(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def update_gen_config( + def regen_table_rows( self, - table_type: str | TableType, - request: GenConfigUpdateRequest, - ) -> TableMetaResponse: + table_type: str, + request: MultiRowRegenRequest, + **kwargs, + ) -> ( + MultiRowCompletionResponse + | Generator[CellReferencesResponse | CellCompletionResponse, None, None] + ): """ - Update the generation configuration for a table. + Regenerate rows in a table. Args: - table_type (str | TableType): The type of the table. - request (GenConfigUpdateRequest): The generation configuration update request. + table_type (str): The type of the table. + request (MultiRowRegenRequest): The row regenerate request. Returns: - response (TableMetaResponse): The table metadata response. + response (MultiRowCompletionResponse | AsyncGenerator): The row completion. + In streaming mode, it is an async generator that yields a `CellReferencesResponse` object + followed by zero or more `CellCompletionResponse` objects. + In non-streaming mode, it is a `MultiRowCompletionResponse` object. """ - return await self.table.update_gen_config(table_type, request) + agen = LOOP.run(super().regen_table_rows(table_type, request, **kwargs)) + return self._return_iterator(agen, request.stream) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def add_action_columns(self, request: AddActionColumnSchema) -> TableMetaResponse: + def update_table_rows( + self, + table_type: str, + request: MultiRowUpdateRequest, + **kwargs, + ) -> OkResponse: """ - Add columns to an Action Table. + Update rows in a table. Args: - request (AddActionColumnSchema): The action column schema. + table_type (str): The type of the table. + request (MultiRowUpdateRequest): The row update request. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.add_action_columns(request) + return LOOP.run(super().update_table_rows(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def add_knowledge_columns(self, request: AddKnowledgeColumnSchema) -> TableMetaResponse: + @deprecated( + "This method is deprecated, use `update_table_rows` instead.", + category=FutureWarning, + stacklevel=1, + ) + def update_table_row( + self, + table_type: str, + request: RowUpdateRequest, + **kwargs, + ) -> OkResponse: """ - Add columns to a Knowledge Table. + Update a specific row in a table. Args: - request (AddKnowledgeColumnSchema): The knowledge column schema. + table_type (str): The type of the table. + request (RowUpdateRequest): The row update request. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.add_knowledge_columns(request) + return LOOP.run(super().update_table_row(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def add_chat_columns(self, request: AddChatColumnSchema) -> TableMetaResponse: + def delete_table_rows( + self, + table_type: str, + request: MultiRowDeleteRequest, + **kwargs, + ) -> OkResponse: """ - Add columns to a Chat Table. + Delete rows from a table. Args: - request (AddChatColumnSchema): The chat column schema. + table_type (str): The type of the table. + request (MultiRowDeleteRequest): The row delete request. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.add_chat_columns(request) + return LOOP.run(super().delete_table_rows(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def drop_columns( + @deprecated( + "This method is deprecated, use `delete_table_rows` instead.", + category=FutureWarning, + stacklevel=1, + ) + def delete_table_row( self, - table_type: str | TableType, - request: ColumnDropRequest, - ) -> TableMetaResponse: + table_type: str, + table_id: str, + row_id: str, + **kwargs, + ) -> OkResponse: """ - Drop columns from a table. + Delete a specific row from a table. Args: - table_type (str | TableType): The type of the table. - request (ColumnDropRequest): The column drop request. + table_type (str): The type of the table. + table_id (str): The ID of the table. + row_id (str): The ID of the row. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.drop_columns(table_type, request) + return LOOP.run(super().delete_table_row(table_type, table_id, row_id, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def rename_columns( + def embed_file_options(self, **kwargs) -> httpx.Response: + """ + Get CORS preflight options for file embedding endpoint. + + Returns: + response (httpx.Response): The response containing options information. + """ + return LOOP.run(super().embed_file_options(**kwargs)) + + def embed_file( self, - table_type: str | TableType, - request: ColumnRenameRequest, - ) -> TableMetaResponse: + file_path: str, + table_id: str, + *, + chunk_size: int = 1000, + chunk_overlap: int = 200, + **kwargs, + ) -> OkResponse: """ - Rename columns in a table. + Embed a file into a Knowledge Table. Args: - table_type (str | TableType): The type of the table. - request (ColumnRenameRequest): The column rename 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 (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.rename_columns(table_type, request) + return LOOP.run( + super().embed_file( + file_path, table_id, chunk_size=chunk_size, chunk_overlap=chunk_overlap, **kwargs + ) + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def reorder_columns( + # Import export + def import_table_data( self, - table_type: str | TableType, - request: ColumnReorderRequest, - ) -> TableMetaResponse: + table_type: str, + request: TableDataImportRequest, + **kwargs, + ) -> GenTableChatResponseType: """ - Reorder columns in a table. + Imports CSV or TSV data into a table. Args: - table_type (str | TableType): The type of the table. - request (ColumnReorderRequest): The column reorder request. + file_path (str): CSV or TSV file path. + table_type (str): Table type. + request (TableDataImportRequest): Data import request. Returns: - response (TableMetaResponse): The table metadata response. + response (OkResponse): The response indicating success. """ - return await self.table.reorder_columns(table_type, request) + agen = LOOP.run(super().import_table_data(table_type, request, **kwargs)) + return self._return_iterator(agen, request.stream) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def list_table_rows( + def export_table_data( self, - table_type: str | TableType, + table_type: str, 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]]: + delimiter: Literal[",", "\t"] = ",", + **kwargs, + ) -> bytes: """ - List rows in a table. + Exports the row data of a table as a CSV or TSV file. 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. + table_type (str): 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 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, + return LOOP.run( + super().export_table_data( + table_type, table_id, columns=columns, delimiter=delimiter, **kwargs + ) ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def get_table_row( + def import_table( 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]: + table_type: str, + request: TableImportRequest, + **kwargs, + ) -> TableMetaResponse | OkResponse: """ - Get a specific row in a table. + Imports a table (data and schema) from a parquet file. 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). + file_path (str): The parquet file path. + table_type (str): Table type. + request (TableImportRequest): Table import request. Returns: - response (dict[str, Any]): The row data. + response (TableMetaResponse | OkResponse): The table metadata response if blocking is True, + otherwise OkResponse. """ - return await self.table.get_table_row( - table_type, - table_id, - row_id, - columns=columns, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ) + return LOOP.run(super().import_table(table_type, request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def add_table_rows( + def export_table( self, - table_type: str | TableType, - request: RowAddRequest, - ) -> ( - GenTableRowsChatCompletionChunks - | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] - ): + table_type: str, + table_id: str, + **kwargs, + ) -> bytes: """ - Add rows to a table. + Exports a table (data and schema) as a parquet file. Args: - table_type (str | TableType): The type of the table. - request (RowAddRequest): The row add request. + table_type (str): Table type. + table_id (str): ID or name of the table to be exported. Returns: - 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. + response (list[dict[str, Any]]): The search results. """ - return await self.table.add_table_rows(table_type, request) + return LOOP.run(super().export_table(table_type, table_id, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def regen_table_rows( + +class _MeterClient(_MeterClientAsync): + def get_usage_metrics( self, - table_type: str | TableType, - request: RowRegenRequest, - ) -> ( - GenTableRowsChatCompletionChunks - | AsyncGenerator[GenTableStreamReferences | GenTableStreamChatCompletionChunk, None] - ): - """ - Regenerate rows in a table. + type, + from_, + window_size, + org_ids=None, + proj_ids=None, + to=None, + group_by=None, + data_source=None, + ) -> UsageResponse: + return LOOP.run( + super().get_usage_metrics( + type, from_, window_size, org_ids, proj_ids, to, group_by, data_source + ) + ) - Args: - table_type (str | TableType): The type of the table. - request (RowRegenRequest): The row regenerate request. + def get_billing_metrics( + self, + from_, + window_size, + org_ids=None, + proj_ids=None, + to=None, + group_by=None, + data_source=None, + ) -> UsageResponse: + return LOOP.run( + super().get_billing_metrics( + from_, window_size, org_ids, proj_ids, to, group_by, data_source + ) + ) - Returns: - 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. - """ - return await self.table.regen_table_rows(table_type, request) + def get_bandwidth_metrics( + self, + from_, + window_size, + org_ids=None, + proj_ids=None, + to=None, + group_by=None, + data_source=None, + ) -> UsageResponse: + return LOOP.run( + super().get_bandwidth_metrics( + from_, window_size, org_ids, proj_ids, to, group_by, data_source + ) + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def update_table_row( + def get_storage_metrics( + self, from_, window_size, org_ids=None, proj_ids=None, to=None, group_by=None + ) -> UsageResponse: + return LOOP.run( + super().get_storage_metrics(from_, window_size, org_ids, proj_ids, to, group_by) + ) + + +class _TaskClient(_TaskClientAsync): + """Task methods.""" + + def get_progress( self, - table_type: str | TableType, - request: RowUpdateRequest, - ) -> OkResponse: - """ - Update a specific row in a table. + key: str, + **kwargs, + ) -> dict[str, Any]: + return LOOP.run(super().get_progress(key, **kwargs)) - Args: - table_type (str | TableType): The type of the table. - request (RowUpdateRequest): The row update request. + def poll_progress( + self, + key: str, + *, + initial_wait: float = 0.5, + max_wait: float = 30 * 60.0, + verbose: bool = False, + **kwargs, + ) -> dict[str, Any] | None: + from time import sleep + + i = 1 + t0 = perf_counter() + while (perf_counter() - t0) < max_wait: + sleep(min(initial_wait * i, 5.0)) + prog = self.get_progress(key, **kwargs) + state = prog.get("state", None) + error = prog.get("error", None) + if verbose: + logger.info( + f"{self.__class__.__name__}: Progress: key={key} state={state}" + + (f" error={error}" if error else "") + ) + if state == ProgressState.COMPLETED: + return prog + elif state == ProgressState.FAILED: + raise JamaiException(prog.get("error", "Unknown error")) + i += 1 + return None + + # def poll_progress( + # self, + # key: str, + # *, + # initial_wait: float = 0.5, + # max_wait: float = 30 * 60.0, + # **kwargs, + # ) -> dict[str, Any] | None: + # return LOOP.run( + # super().poll_progress( + # key, + # initial_wait=initial_wait, + # max_wait=max_wait, + # **kwargs, + # ) + # ) + + +class _ConversationClient(_ConversationClientAsync): + """Conversation methods (synchronous version).""" + + def create_conversation( + self, + request: ConversationCreateRequest, + **kwargs, + ) -> Generator[ + ConversationMetaResponse | CellReferencesResponse | CellCompletionResponse, None, None + ]: + agen = LOOP.run(super().create_conversation(request, **kwargs)) + return self._return_iterator(agen, True) - Returns: - response (OkResponse): The response indicating success. - """ - return await self.table.update_table_row(table_type, request) + def list_conversations( + self, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + **kwargs, + ) -> Page[ConversationMetaResponse]: + return LOOP.run( + super().list_conversations( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + **kwargs, + ) + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def delete_table_rows( + def list_agents( self, - table_type: str | TableType, - request: RowDeleteRequest, - ) -> OkResponse: - """ - Delete rows from a table. + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + search_query: str = "", + **kwargs, + ) -> Page[ConversationMetaResponse]: + return LOOP.run( + super().list_agents( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + **kwargs, + ) + ) - Args: - table_type (str | TableType): The type of the table. - request (RowDeleteRequest): The row delete request. + def get_conversation(self, conversation_id: str, **kwargs) -> ConversationMetaResponse: + return LOOP.run(super().get_conversation(conversation_id, **kwargs)) - Returns: - response (OkResponse): The response indicating success. - """ - return await self.table.delete_table_rows(table_type, request) + def get_agent(self, agent_id: str, **kwargs) -> AgentMetaResponse: + return LOOP.run(super().get_agent(agent_id, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def delete_table_row( + def generate_title( self, - table_type: str | TableType, - table_id: str, - row_id: str, + conversation_id: str, + **kwargs, + ) -> ConversationMetaResponse: + """Generates a title for a conversation.""" + return LOOP.run(super().generate_title(conversation_id, **kwargs)) + + def rename_conversation_title( + self, + conversation_id: str, + title: str, + **kwargs, + ) -> ConversationMetaResponse: + return LOOP.run(super().rename_conversation_title(conversation_id, title, **kwargs)) + + def delete_conversation( + self, + conversation_id: str, + *, + missing_ok: bool = True, + **kwargs, ) -> OkResponse: - """ - Delete a specific row from a table. + return LOOP.run( + super().delete_conversation(conversation_id, missing_ok=missing_ok, **kwargs) + ) - 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. + def send_message( + self, + request: MessageAddRequest, + **kwargs, + ) -> Generator[CellReferencesResponse | CellCompletionResponse, None, None]: + agen = LOOP.run(super().send_message(request, **kwargs)) + return self._return_iterator(agen, True) - Returns: - response (OkResponse): The response indicating success. - """ - return await self.table.delete_table_row(table_type, table_id, row_id) + def list_messages( + self, + conversation_id: str, + offset: int = 0, + limit: int = 100, + order_by: str = "ID", + order_ascending: bool = True, + columns: list[str] | None = None, + search_query: str = "", + search_columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, + ) -> Page[dict[str, Any]]: + return LOOP.run( + super().list_messages( + conversation_id=conversation_id, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + columns=columns, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) + ) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def get_conversation_thread( + def regen_message( self, - table_type: str | TableType, - table_id: str, - column_id: str, - row_id: str = "", - include: bool = True, - ) -> ChatThread: + request: MessagesRegenRequest, + **kwargs, + ) -> Generator[CellReferencesResponse | CellCompletionResponse, None, None]: + """Regenerates a message in a conversation and streams back the response.""" + agen = LOOP.run(super().regen_message(request, **kwargs)) + return self._return_iterator(agen, True) + + def update_message( + self, + request: MessageUpdateRequest, + **kwargs, + ) -> OkResponse: + """Updates a specific message within a conversation.""" + return LOOP.run(super().update_message(request, **kwargs)) + + def get_threads( + self, + conversation_id: str, + column_ids: list[str] | None = None, + **kwargs, + ) -> ConversationThreadsResponse: """ - Get the conversation thread for a chat table. + Get all threads from a conversation. 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. + conversation_id (str): Conversation ID. + column_ids (list[str] | None): Columns to fetch as conversation threads. Returns: - response (ChatThread): The conversation thread. + response (ConversationThreadsResponse): The conversation threads. """ - return await self.table.get_conversation_thread( - table_type, table_id, column_id, row_id=row_id, include=include - ) + return LOOP.run(super().get_threads(conversation_id, column_ids, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def hybrid_search( + +class JamAI(JamAIAsync): + def __init__( self, - table_type: str | TableType, - request: SearchRequest, - ) -> list[dict[str, Any]]: + project_id: str = ENV_CONFIG.project_id, + token: str = ENV_CONFIG.token_plain, + api_base: str = ENV_CONFIG.api_base, + headers: dict | None = None, + timeout: float | None = ENV_CONFIG.timeout_sec, + file_upload_timeout: float | None = ENV_CONFIG.file_upload_timeout_sec, + *, + user_id: str = "", + ) -> None: """ - Perform a hybrid search on a table. + Initialize the JamAI client. Args: - table_type (str | TableType): The type of the table. - request (SearchRequest): The search request. - - Returns: - response (list[dict[str, Any]]): The search results. + 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. + user_id (str, optional): User ID. For development purposes. + Defaults to "". """ - return await self.table.hybrid_search(table_type, request) + super().__init__( + project_id=project_id, + token=token, + api_base=api_base, + headers=headers, + timeout=timeout, + file_upload_timeout=file_upload_timeout, + user_id=user_id, + ) + kwargs = dict( + user_id=self.user_id, + project_id=self.project_id, + token=self.token, + api_base=self.api_base, + headers=self.headers, + http_client=self.http_client, + timeout=self.timeout, + file_upload_timeout=self.file_upload_timeout, + ) + self.auth = _Auth(**kwargs) + self.prices = _Prices(**kwargs) + self.users = _Users(**kwargs) + self.models = _Models(**kwargs) + self.organizations = _Organizations(**kwargs) + self.projects = _Projects(**kwargs) + self.templates = _Templates(**kwargs) + self.file = _FileClient(**kwargs) + self.table = _GenTableClient(**kwargs) + self.meters = _MeterClient(**kwargs) + self.tasks = _TaskClient(**kwargs) + self.conversations = _ConversationClient(**kwargs) - @deprecated( - "This method is deprecated, use `client.table.embed_file_options` instead.", - category=FutureWarning, - stacklevel=1, - ) - async def upload_file_options(self) -> httpx.Response: + def health(self) -> dict[str, Any]: """ - Get options for uploading a file to a Knowledge Table. + Get health status. Returns: - response (httpx.Response): The response containing options information. + response (dict[str, Any]): Health status. """ - return await self.table.embed_file_options() + return LOOP.run(super().health()) - @deprecated( - "This method is deprecated, use `client.table.embed_file` instead.", - category=FutureWarning, - stacklevel=1, - ) - async def upload_file(self, request: FileUploadRequest) -> OkResponse: + # --- Models and chat --- # + + def model_info( + self, + model: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> ModelInfoListResponse: """ - Upload a file to a Knowledge Table. + Get information about available models. Args: - request (FileUploadRequest): The file upload request. + name (str, optional): The model name. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. Returns: - response (OkResponse): The response indicating success. + response (ModelInfoListResponse): The model information response. """ - return await self.table.embed_file(request) + return LOOP.run(super().model_info(model=model, capabilities=capabilities, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def import_table_data( + def model_ids( self, - table_type: str | TableType, - request: TableDataImportRequest, - ) -> GenTableChatResponseType: + prefer: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> list[str]: """ - Imports CSV or TSV data into a table. + Get the IDs of available models. Args: - file_path (str): CSV or TSV file path. - table_type (str | TableType): Table type. - request (TableDataImportRequest): Data import request. + prefer (str, optional): Preferred model ID. Defaults to "". + capabilities (list[Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"]] | None, optional): + List of model capabilities to filter by. Defaults to None. Returns: - response (OkResponse): The response indicating success. + response (list[str]): List of model IDs. """ - return await self.table.import_table_data(table_type, request) + return LOOP.run(super().model_ids(prefer=prefer, capabilities=capabilities, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def export_table_data( + @deprecated( + "This method is deprecated, use `model_ids` instead.", category=FutureWarning, stacklevel=1 + ) + def model_names( self, - table_type: str | TableType, - table_id: str, - columns: list[str] | None = None, - delimiter: Literal[",", "\t"] = ",", - ) -> bytes: + prefer: str = "", + capabilities: list[ + Literal["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"] + ] + | None = None, + **kwargs, + ) -> list[str]: + return self.model_ids(prefer=prefer, capabilities=capabilities, **kwargs) + + def generate_chat_completions( + self, + request: ChatRequest, + **kwargs, + ) -> ChatCompletionResponse | Generator[References | ChatCompletionChunkResponse, None, None]: """ - Exports the row data of a table as a CSV or TSV file. + Generates chat completions. 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). + request (ChatRequest): The request. Returns: - response (list[dict[str, Any]]): The search results. + completion (ChatCompletionChunkResponse | AsyncGenerator): The chat completion. + In streaming mode, it is an async generator that yields a `References` object + followed by zero or more `ChatCompletionChunkResponse` objects. + In non-streaming mode, it is a `ChatCompletionChunkResponse` object. """ - return await self.table.export_table_data( - table_type, table_id, columns=columns, delimiter=delimiter - ) + agen = LOOP.run(super().generate_chat_completions(request=request, **kwargs)) + return self._return_iterator(agen, request.stream) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def import_table( + def generate_embeddings( self, - table_type: str | TableType, - request: TableImportRequest, - ) -> TableMetaResponse: + request: EmbeddingRequest, + **kwargs, + ) -> EmbeddingResponse: """ - Imports a table (data and schema) from a parquet file. + Generate embeddings for the given input. Args: - file_path (str): The parquet file path. - table_type (str | TableType): Table type. - request (TableImportRequest): Table import request. + request (EmbeddingRequest): The embedding request. Returns: - response (TableMetaResponse): The table metadata response. + response (EmbeddingResponse): The embedding response. """ - return await self.table.import_table(table_type, request) + return LOOP.run(super().generate_embeddings(request=request, **kwargs)) - @deprecated(TABLE_METHOD_DEPRECATE, category=FutureWarning, stacklevel=1) - async def export_table( - self, - table_type: str | TableType, - table_id: str, - ) -> bytes: + def rerank(self, request: RerankingRequest, **kwargs) -> RerankingResponse: """ - Exports a table (data and schema) as a parquet file. + Generate similarity rankings for the given query and documents. Args: - table_type (str | TableType): Table type. - table_id (str): ID or name of the table to be exported. + request (RerankingRequest): The reranking request body. Returns: - response (list[dict[str, Any]]): The search results. + RerankingResponse: The reranking response. """ - return await self.table.export_table(table_type, table_id) + return LOOP.run(super().rerank(request=request, **kwargs)) diff --git a/clients/python/src/jamaibase/protocol.py b/clients/python/src/jamaibase/protocol.py index 1cc79ec..25f4014 100644 --- a/clients/python/src/jamaibase/protocol.py +++ b/clients/python/src/jamaibase/protocol.py @@ -1,2385 +1,9 @@ -""" -NOTES: - -- Pydantic supports setting mutable values as default. - This is in contrast to native `dataclasses` where it is not supported. - -- Pydantic supports setting default fields in any order. - This is in contrast to native `dataclasses` where fields with default values must be defined after non-default fields. -""" - -from __future__ import annotations - -import re -from datetime import datetime -from decimal import Decimal -from enum import Enum, EnumMeta -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, - Discriminator, - Field, - Tag, - computed_field, - field_validator, - model_validator, -) -from pydantic.functional_validators import AfterValidator -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.")] - - -def sanitise_document_id(v: str) -> str: - if v.startswith('"') and v.endswith('"'): - v = v[1:-1] - return v - - -def sanitise_document_id_list(v: list[str]) -> list[str]: - return [sanitise_document_id(vv) for vv in v] - - -DocumentID = Annotated[str, AfterValidator(sanitise_document_id)] -DocumentIDList = Annotated[list[str], AfterValidator(sanitise_document_id_list)] - -EXAMPLE_CHAT_MODEL_IDS = ["openai/gpt-4o-mini"] -# for openai embedding models doc: https://platform.openai.com/docs/guides/embeddings -# for cohere embedding models doc: https://docs.cohere.com/reference/embed -# for jina embedding models doc: https://jina.ai/embeddings/ -# for voyage embedding models doc: https://docs.voyageai.com/docs/embeddings -# for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_EMBEDDING_MODEL_IDS = [ - "openai/text-embedding-3-small-512", - "ellm/sentence-transformers/all-MiniLM-L6-v2", -] -# for cohere reranking models doc: https://docs.cohere.com/reference/rerank-1 -# for jina reranking models doc: https://jina.ai/reranker -# for colbert reranking models doc: https://docs.voyageai.com/docs/reranker -# for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_RERANKING_MODEL_IDS = [ - "cohere/rerank-multilingual-v3.0", - "ellm/cross-encoder/ms-marco-TinyBERT-L-2", -] - -IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] -AUDIO_FILE_EXTENSIONS = [".mp3", ".wav"] -DOCUMENT_FILE_EXTENSIONS = [ - ".pdf", - ".txt", - ".md", - ".docx", - ".xml", - ".html", - ".json", - ".csv", - ".tsv", - ".jsonl", - ".xlsx", - ".xls", -] - - -class OkResponse(BaseModel): - ok: bool = True - - -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_IDS[0], - EXAMPLE_EMBEDDING_MODEL_IDS[0], - EXAMPLE_RERANKING_MODEL_IDS[0], - ], - ) - 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 input / prompt token.", - ) - output_cost_per_mtoken: float = Field( - description="Cost in USD per million 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 (kilo) 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.") - - -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="Chunk text.") - title: str = Field(default="", description='Document title. Defaults to "".') - page: int | None = Field(default=None, description="Document page the chunk text from.") - 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( - default_factory=dict, - description="Arbitrary metadata about the page content (e.g., source, relationships to other documents, etc.).", - ) - - -class SplitChunksParams(BaseModel): - method: str = Field( - default="RecursiveCharacterTextSplitter", - description="Name of the splitter.", - examples=["RecursiveCharacterTextSplitter"], - ) - chunk_size: PositiveNonZeroInt = Field( - default=1000, - description="Maximum chunk size (number of characters). Must be > 0.", - examples=[1000], - ) - chunk_overlap: PositiveInt = Field( - default=200, - description="Overlap in characters between chunks. Must be >= 0.", - examples=[200], - ) - - -class SplitChunksRequest(BaseModel): - id: str = Field( - default="", - description="Request ID for logging purposes.", - examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], - ) - chunks: list[Chunk] = Field( - description="List of `Chunk` where each will be further split into chunks.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - params: SplitChunksParams = Field( - default=SplitChunksParams(), - description="How to split each document. Defaults to `RecursiveCharacterTextSplitter` with chunk_size = 1000 and chunk_overlap = 200.", - examples=[SplitChunksParams()], - ) - - def str_trunc(self) -> str: - return f"id={self.id} len(chunks)={len(self.chunks)} params={self.params}" - - -class RAGParams(BaseModel): - table_id: str = Field(description="Knowledge Table ID", examples=["my-dataset"], min_length=2) - reranking_model: str | None = Field( - default=None, - description="Reranking model to use for hybrid search.", - examples=[EXAMPLE_RERANKING_MODEL_IDS[0], 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.", - ) - k: Annotated[int, Field(gt=0, le=1024)] = Field( - default=3, - gt=0, - le=1024, - description="Top-k closest text in terms of embedding distance. Must be in [1, 1024]. Defaults to 3.", - examples=[3], - ) - rerank: bool = Field( - default=True, - description="Flag to perform rerank on the retrieved results. Defaults to True.", - examples=[True, False], - ) - concat_reranker_input: bool = Field( - default=False, - description="Flag to concat title and content as reranker input. Defaults to False.", - examples=[True, False], - ) - - -class VectorSearchRequest(RAGParams): - id: str = Field( - default="", - description="Request ID for logging purposes.", - examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], - ) - search_query: str = Field(description="Query used to retrieve items from the KB database.") - - -class VectorSearchResponse(BaseModel): - object: str = Field( - default="kb.search_response", - description="Type of API response object.", - examples=["kb.search_response"], - ) - chunks: list[Chunk] = Field( - default=[], - description="A list of `Chunk`.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - - -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_IDS, - ) - 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( - description="The organization that owns the model.", - examples=["openai"], - ) - capabilities: list[ - Literal["completion", "chat", "image", "audio", "tool", "embed", "rerank"] - ] = Field( - description="List of capabilities of model.", - examples=[["chat"]], - ) - - -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_IDS, - ) - api_base: str = Field( - default="", - description="Hosting url for the model.", - ) - provider: str = Field( - default="", - description="Provider of the model.", - ) - - -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.", - min_length=1, - ) - - -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.", - ) - - @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=EXAMPLE_EMBEDDING_MODEL_IDS, - ) - 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", - ) - 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(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=EXAMPLE_RERANKING_MODEL_IDS, - ) - 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] = [] - - @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): - """Represents who said a chat message.""" - - SYSTEM = "system" - """The message is from the system (usually a steering prompt).""" - USER = "user" - """The message is from the user.""" - ASSISTANT = "assistant" - """The message is from the language model.""" - # FUNCTION = "function" - # """The message is the result of a function call.""" - - def __str__(self) -> str: - return self.value - - -def sanitise_name(v: str) -> str: - """Replace any non-alphanumeric and dash characters with space. - - Args: - v (str): Raw name string. - - Returns: - out (str): Sanitised name string that is safe for OpenAI. - """ - return re.sub(r"[^a-zA-Z0-9_-]", "_", v).strip() - - -MessageName = Annotated[str, AfterValidator(sanitise_name)] - - -class MessageToolCallFunction(BaseModel): - arguments: str - name: str | None - - -class MessageToolCall(BaseModel): - id: str | None - function: MessageToolCallFunction - type: str - - -class ChatEntry(BaseModel): - """Represents a message in the chat context.""" - - model_config = ConfigDict(use_enum_values=True) - - role: ChatRole - """Who said the message?""" - 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).""" - - @classmethod - def system(cls, content: str, **kwargs): - """Create a new system message.""" - return cls(role=ChatRole.SYSTEM, content=content, **kwargs) - - @classmethod - def user(cls, content: str, **kwargs): - """Create a new user message.""" - return cls(role=ChatRole.USER, content=content, **kwargs) - - @classmethod - 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 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 ChatCompletionChoiceOutput(ChatEntry): - tool_calls: list[MessageToolCall] | None = None - """List of tool calls if the message includes tool call responses.""" - - -class ChatThread(BaseModel): - object: str = Field( - default="chat.thread", - description="Type of API response object.", - examples=["chat.thread"], - ) - thread: list[ChatEntry] = Field( - default=[], - description="List of chat messages.", - examples=[ - [ - ChatEntry.system(content="You are an assistant."), - ChatEntry.user(content="Hello."), - ] - ], - ) - - -class CompletionUsage(BaseModel): - prompt_tokens: int = Field(default=0, description="Number of tokens in the prompt.") - completion_tokens: int = Field( - default=0, description="Number of tokens in the generated completion." - ) - total_tokens: int = Field( - default=0, description="Total number of tokens used in the request (prompt + completion)." - ) - - -class ChatCompletionChoice(BaseModel): - message: ChatEntry | ChatCompletionChoiceOutput = Field( - description="A chat completion message generated by the model." - ) - index: int = Field(description="The index of the choice in the list of choices.") - finish_reason: str | None = Field( - default=None, - 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." - ), - ) - - @property - def text(self) -> str: - """The text of the most recent chat completion.""" - return self.message.content - - -class ChatCompletionChoiceDelta(ChatCompletionChoice): - @computed_field - @property - def delta(self) -> ChatEntry | ChatCompletionChoiceOutput: - return self.message - - -class References(BaseModel): - object: str = Field( - default="chat.references", - description="Type of API response object.", - examples=["chat.references"], - ) - chunks: list[Chunk] = Field( - default=[], - description="A list of `Chunk`.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - search_query: str = Field(description="Query used to retrieve items from the KB database.") - finish_reason: Literal["stop", "context_overflow"] | None = Field( - default=None, - description=""" -In streaming mode, reference chunk will be streamed first. -However, if the model's context length is exceeded, then there will be no further completion chunks. -In this case, "finish_reason" will be set to "context_overflow". -Otherwise, it will be None or null. -""", - ) - - def remove_contents(self): - copy = self.model_copy(deep=True) - for d in copy.documents: - d.page_content = "" - return copy +from jamaibase.types import * # noqa: F403 - -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 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="Type of API response object.", - examples=["chat.completion.chunk"], - ) - created: int = Field( - description="The Unix timestamp (in seconds) of when the chat completion was created." - ) - model: str = Field(description="The model used for the chat completion.") - usage: CompletionUsage | None = Field( - description="Number of tokens consumed for the completion request.", - examples=[CompletionUsage(), None], - ) - choices: list[ChatCompletionChoice | ChatCompletionChoiceDelta] = Field( - description="A list of chat completion choices. Can be more than one if `n` is greater than 1." - ) - references: References | None = Field( - default=None, - description="Contains the references retrieved from database when performing chat completion with RAG.", - ) - - @property - def message(self) -> ChatEntry | ChatCompletionChoiceOutput | None: - return self.choices[0].message if len(self.choices) > 0 else None - - @property - def prompt_tokens(self) -> int: - return self.usage.prompt_tokens - - @property - def completion_tokens(self) -> int: - return self.usage.completion_tokens - - @property - def text(self) -> str: - """The text of the most recent chat completion.""" - 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 GenTableStreamChatCompletionChunk(ChatCompletionChunk): - object: str = Field( - default="gen_table.completion.chunk", - description="Type of API response object.", - examples=["gen_table.completion.chunk"], - ) - output_column_name: str - row_id: str - - -class FunctionParameter(BaseModel): - type: str = Field( - default="", description="The type of the parameter, e.g., 'string', 'number'." - ) - description: str = Field(default="", description="A description of the parameter.") - enum: list[str] = Field( - default=[], description="An optional list of allowed values for the parameter." - ) - - -class FunctionParameters(BaseModel): - type: str = Field( - default="object", description="The type of the parameters object, usually 'object'." - ) - properties: dict[str, FunctionParameter] = Field( - description="The properties of the parameters object." - ) - required: list[str] = Field(description="A list of required parameter names.") - additionalProperties: bool = Field( - default=False, description="Whether additional properties are allowed." - ) - - -class Function(BaseModel): - name: str = Field(default="", description="The name of the function.") - description: str = Field(default="", description="A description of what the function does.") - parameters: FunctionParameters = Field(description="The parameters for the function.") - - -class Tool(BaseModel): - type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") - function: Function = Field(description="The function details of the tool.") - - -class ToolChoiceFunction(BaseModel): - name: str = Field(default="", description="The name of the function.") - - -class ToolChoice(BaseModel): - type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") - function: ToolChoiceFunction = Field(description="Select a tool for the chat model to use.") - - -class ChatRequest(BaseModel): - id: str = Field( - default="", - description='Chat ID. Will be replaced with request ID. Defaults to "".', - ) - 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.", - ) - messages: list[ChatEntry] = Field( - description="A list of messages comprising the conversation so far.", - min_length=1, - ) - 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], - ) - n: int = Field( - default=1, - description="How many chat completion choices to generate for each input message.", - examples=[1], - ) - stream: bool = Field( - default=True, - description=""" -If set, partial message deltas will be sent, like in ChatGPT. -Tokens will be sent as server-sent events as they become available, -with the stream terminated by a 'data: [DONE]' message. -""", - examples=[True], - ) - 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=[{}], - ) - user: str = Field( - default="", - description="A unique identifier representing your end-user. For monitoring and debugging purposes.", - examples=[""], - ) - - @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 ChatRequestWithTools(ChatRequest): - tools: list[Tool] = Field( - description="A list of tools available for the chat model to use.", - min_length=1, - examples=[ - # --- [Tool Function] --- - # def get_delivery_date(order_id: str) -> datetime: - # # Connect to the database - # conn = sqlite3.connect('ecommerce.db') - # cursor = conn.cursor() - # # ... - [ - Tool( - type="function", - function=Function( - name="get_delivery_date", - description="Get the delivery date for a customer's order.", - parameters=FunctionParameters( - type="object", - properties={ - "order_id": FunctionParameter( - type="string", description="The customer's order ID." - ) - }, - required=["order_id"], - additionalProperties=False, - ), - ), - ) - ], - ], - ) - tool_choice: str | ToolChoice = Field( - default="auto", - description="Set `auto` to let chat model pick a tool or select a tool for the chat model to use.", - examples=[ - "auto", - ToolChoice(type="function", function=ToolChoiceFunction(name="get_delivery_date")), - ], - ) - - -class EmbeddingRequest(BaseModel): - input: str | list[str] = Field( - description=( - "Input text to embed, encoded as a string or array of strings " - "(to embed multiple inputs in a single request). " - "The input must not exceed the max input tokens for the model, and cannot contain empty string." - ), - examples=["What is a llama?", ["What is a llama?", "What is an alpaca?"]], - ) - model: str = Field( - description=( - "The ID of the model to use. " - "You can use the List models API to see all of your available models." - ), - examples=EXAMPLE_EMBEDDING_MODEL_IDS, - ) - type: Literal["query", "document"] = Field( - default="document", - description=( - 'Whether the input text is a "query" (used to retrieve) or a "document" (to be retrieved).' - ), - examples=["query", "document"], - ) - encoding_format: Literal["float", "base64"] = Field( - default="float", - description=( - '_Optional_. The format to return the embeddings in. Can be either "float" or "base64". ' - "`base64` string should be decoded as a `float32` array. " - "Example: `np.frombuffer(base64.b64decode(response), dtype=np.float32)`" - ), - examples=["float", "base64"], - ) - - -class EmbeddingResponseData(BaseModel): - object: str = Field( - default="embedding", - description="Type of API response object.", - examples=["embedding"], - ) - embedding: list[float] | str = Field( - description=( - "The embedding vector, which is a list of floats or a base64-encoded string. " - "The length of vector depends on the model." - ), - examples=[[0.0, 1.0, 2.0], []], - ) - index: int = Field( - default=0, - description="The index of the embedding in the list of embeddings.", - examples=[0, 1], - ) - - -class EmbeddingResponse(BaseModel): - object: str = Field( - default="list", - description="Type of API response object.", - examples=["list"], - ) - data: list[EmbeddingResponseData] = Field( - description="List of `EmbeddingResponseData`.", - examples=[[EmbeddingResponseData(embedding=[0.0, 1.0, 2.0])]], - ) - model: str = Field( - description="The ID of the model used.", - examples=["openai/text-embedding-3-small-512"], - ) - usage: CompletionUsage = Field( - default=CompletionUsage(), - description="The number of tokens consumed.", - examples=[CompletionUsage()], - ) - - -class ClipInputData(BaseModel): - """Data model for Clip input data, assume if image_filename is None then it have to be text, otherwise, the input is an image with bytes content""" - - content: str | bytes - """content of this input data, either be str of text or an """ - image_filename: str | None - """image filename of the content, None if the content is text""" - - -T = TypeVar("T") - - -class Page(BaseModel, Generic[T]): - items: Annotated[ - Sequence[T], Field(description="List of items paginated items.", examples=[[]]) - ] = [] - 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 - - -def nd_array_before_validator(x): - return np.array(x) if isinstance(x, list) else x - - -def datetime_str_before_validator(x): - return x.isoformat() if isinstance(x, datetime) else str(x) - - -ODD_SINGLE_QUOTE = r"(? "int".' +warn( + "`jamaibase.protocol` is deprecated, use `jamaibase.types` instead.", + FutureWarning, + stacklevel=2, ) - - -@deprecated(ENUM_DEPRECATE_MSSG, category=FutureWarning, stacklevel=1) -class DtypeCreateEnum(str, Enum, metaclass=MetaEnum): - int_ = "int" - float_ = "float" - bool_ = "bool" - str_ = "str" - image_ = "image" - audio_ = "audio" - - def __getattribute__(cls, *args, **kwargs): - warn(ENUM_DEPRECATE_MSSG, FutureWarning, stacklevel=1) - return super().__getattribute__(*args, **kwargs) - - 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): - action = "action" - """Action table.""" - knowledge = "knowledge" - """Knowledge table.""" - 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: - 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_IDS, - ) - source_column: str = Field( - description="The source column for embedding.", - examples=["text_column"], - ) - - -class CodeGenConfig(BaseModel): - object: Literal["gen_config.code"] = Field( - default="gen_config.code", - description='The object type, which is always "gen_config.code".', - examples=["gen_config.code"], - ) - source_column: str = Field( - description="The source column for python code to execute.", - examples=["code_column"], - ) - - -def _gen_config_discriminator(x: Any) -> str | None: - object_attr = getattr(x, "object", None) - if object_attr: - 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 | CodeGenConfig -DiscriminatedGenConfig = Annotated[ - Union[ - Annotated[CodeGenConfig, Tag("gen_config.code")], - Annotated[LLMGenConfig, Tag("gen_config.llm")], - Annotated[LLMGenConfig, Tag("gen_config.chat")], - Annotated[EmbedGenConfig, Tag("gen_config.embed")], - ], - Discriminator(_gen_config_discriminator), -] - - -class ColumnSchema(BaseModel): - id: str = Field(description="Column name.") - dtype: str = Field( - default="str", - description="Column data type.", - ) - vlen: PositiveInt = Field( # type: ignore - default=0, - description=( - "_Optional_. Vector length. " - "If this is larger than zero, then `dtype` must be one of the floating data types. Defaults to zero." - ), - ) - index: bool = Field( - default=True, - description=( - "_Optional_. Whether to build full-text-search (FTS) or vector index for this column. " - "Only applies to string and vector columns. Defaults to True." - ), - ) - gen_config: DiscriminatedGenConfig | None = Field( - default=None, - description=( - '_Optional_. Generation config. If provided, then this column will be an "Output Column". ' - "Table columns on its left can be referenced by `${column-name}`." - ), - ) - - -class ColumnSchemaCreate(ColumnSchema): - id: str = Field(description="Column name.") - dtype: Literal["int", "float", "bool", "str", "file", "image", "audio"] = Field( - default="str", - description=( - 'Column data type, one of ["int", "float", "bool", "str", "file", "image", "audio"]' - ". Data type 'file' is deprecated, use 'image' instead." - ), - ) - - @model_validator(mode="before") - @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: 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.") - - -class TableSchemaCreate(TableSchema): - id: str = Field(description="Table name.") - cols: list[ColumnSchemaCreate] = Field(description="List of column schema.") - - @model_validator(mode="after") - def check_cols(self) -> Self: - if len(set(c.id.lower() for c in self.cols)) != len(self.cols): - raise ValueError("There are repeated column names (case-insensitive) in the schema.") - if sum(c.id.lower() in ("id", "updated at") for c in self.cols) > 0: - raise ValueError("Schema cannot contain column names: 'ID' or 'Updated at'.") - if sum(c.vlen > 0 for c in self.cols) > 0: - raise ValueError("Schema cannot contain columns with `vlen` > 0.") - return self - - -class ActionTableSchemaCreate(TableSchemaCreate): - pass - - -class AddActionColumnSchema(ActionTableSchemaCreate): - # TODO: Deprecate this - pass - - -class KnowledgeTableSchemaCreate(TableSchemaCreate): - # TODO: Maybe deprecate this and use EmbedGenConfig instead ? - embedding_model: str - - -class AddKnowledgeColumnSchema(TableSchemaCreate): - # TODO: Deprecate this - pass - - -class ChatTableSchemaCreate(TableSchemaCreate): - pass - - -class AddChatColumnSchema(TableSchemaCreate): - # TODO: Deprecate this - pass - - -class TableMeta(TableBase): - cols: list[dict[str, Any]] = Field(description="List of column schema.") - parent_id: str | None = Field( - default=None, - description="The parent table ID. If None (default), it means this is a template table.", - ) - title: str = Field( - default="", - description="Chat title. Defaults to ''.", - ) - updated_at: str = Field( - default_factory=datetime_now_iso, - description="Table last update timestamp (ISO 8601 UTC).", - ) # SQLite does not support TZ - indexed_at_fts: str | None = Field( - default=None, description="Table last FTS index timestamp (ISO 8601 UTC)." - ) - indexed_at_vec: str | None = Field( - default=None, description="Table last vector index timestamp (ISO 8601 UTC)." - ) - indexed_at_sca: str | None = Field( - default=None, description="Table last scalar index timestamp (ISO 8601 UTC)." - ) - - @property - def cols_schema(self) -> list[ColumnSchema]: - return [ColumnSchema.model_validate(c) for c in self.cols] - - @property - def regular_cols(self) -> list[ColumnSchema]: - return [c for c in self.cols_schema if not c.id.endswith("_")] - - -class TableMetaResponse(TableSchema): - parent_id: str | None = Field( - description="The parent table ID. If None (default), it means this is a template table.", - ) - title: str = Field(description="Chat title. Defaults to ''.") - updated_at: str = Field( - description="Table last update timestamp (ISO 8601 UTC).", - ) # SQLite does not support TZ - indexed_at_fts: str | None = Field( - description="Table last FTS index timestamp (ISO 8601 UTC)." - ) - indexed_at_vec: str | None = Field( - description="Table last vector index timestamp (ISO 8601 UTC)." - ) - 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.") - - @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 GenConfigUpdateRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - column_map: dict[str, DiscriminatedGenConfig | None] = Field( - description=( - "Mapping of column ID to generation config JSON in the form of `GenConfig`. " - "Table columns on its left can be referenced by `${column-name}`." - ) - ) - - @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'.") - return self - - -class ColumnRenameRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - column_map: dict[str, str] = Field( - description="Mapping of old column names to new column names." - ) - - @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'.") - return self - - -class ColumnReorderRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - column_names: list[str] = Field(description="List of column ID in the desired order.") - - -class ColumnDropRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - column_names: list[str] = Field(description="List of column ID to drop.") - - @model_validator(mode="after") - def check_column_names(self) -> Self: - if sum(n.lower() in ("id", "updated at") for n in self.column_names) > 0: - raise ValueError("`column_names` cannot contain keys: 'ID' or 'Updated at'.") - return self - - -class RowAddRequest(BaseModel): - table_id: str = Field( - description="Table name or ID.", - ) - data: list[dict[str, Any]] = Field( - min_length=1, - description=( - "List of mapping of column names to its value. " - "In other words, each item in the list is a row, and each item is a mapping. " - "Minimum 1 row, maximum 100 rows." - ), - ) - stream: bool = Field( - default=True, - description="Whether or not to stream the LLM generation.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - description="_Optional_. Whether or not to concurrently generate the output rows and columns.", - ) - - def __repr__(self): - _data = [ - { - k: ( - {"type": type(v), "shape": v.shape, "dtype": v.dtype} - if isinstance(v, np.ndarray) - else v - ) - } - for k, v in self.data.items() - ] - return ( - f"{self.__class__.__name__}(" - 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 + AUDIO_FILE_EXTENSIONS: - raise ValueError( - "Unsupported file type. Make sure the file belongs to " - "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" - f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" - ) - return self - - -class RowAddRequestWithLimit(RowAddRequest): - data: list[dict[str, Any]] = Field( - min_length=1, - max_length=100, - description=( - "List of mapping of column names to its value. " - "In other words, each item in the list is a row, and each item is a mapping. " - "Minimum 1 row, maximum 100 rows." - ), - ) - - -class RowUpdateRequest(BaseModel): - table_id: str = Field( - description="Table name or ID.", - ) - row_id: str = Field( - description="ID of the row to update.", - ) - data: dict[str, Any] = Field( - description="Mapping of column names to its value.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - - @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 + AUDIO_FILE_EXTENSIONS: - raise ValueError( - "Unsupported file type. Make sure the file belongs to " - "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" - f"[Audio File Types]: \n{AUDIO_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( - description="Table name or ID.", - ) - 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.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - description="_Optional_. Whether or not to concurrently generate the output columns.", - ) - - -class RowRegenRequest(BaseModel): - table_id: str = Field( - description="Table name or ID.", - ) - row_ids: list[str] = Field( - min_length=1, - 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.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - 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.") - row_ids: list[str] | None = Field( - min_length=1, - max_length=100, - default=None, - description="List of ID of the row to delete. Minimum 1 row, maximum 100 rows.", - ) - where: str | None = Field( - default=None, - description="_Optional_. SQL where clause. If not provided, will match all rows and thus deleting all table content.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - - -class EmbedFileRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - file_id: str = Field(description="ID of the file.") - chunk_size: Annotated[ - int, Field(description="Maximum chunk size (number of characters). Must be > 0.", gt=0) - ] = 1000 - chunk_overlap: Annotated[ - int, Field(description="Overlap in characters between chunks. Must be >= 0.", ge=0) - ] = 200 - # stream: Annotated[bool, Field(description="Whether or not to stream the LLM generation.")] = ( - # True - # ) - - -class SearchRequest(BaseModel): - table_id: str = Field(description="Table name or ID.") - query: str = Field( - min_length=1, - description="Query for full-text-search (FTS) and vector search. Must not be empty.", - ) - where: str | None = Field( - default=None, - description="_Optional_. SQL where clause. If not provided, will match all rows.", - ) - limit: Annotated[int, Field(gt=0, le=1_000)] = Field( - default=100, description="_Optional_. Min 1, max 1000. Number of rows to return." - ) - metric: str = Field( - default="cosine", - description='_Optional_. Vector search similarity metric. Defaults to "cosine".', - ) - nprobes: Annotated[int, Field(gt=0, le=1000)] = Field( - default=50, - description=( - "_Optional_. Set the number of partitions to search (probe)." - "This argument is only used when the vector column has an IVF PQ index. If there is no index then this value is ignored. " - "The IVF stage of IVF PQ divides the input into partitions (clusters) of related values. " - "The partition whose centroids are closest to the query vector will be exhaustively searched to find matches. " - "This parameter controls how many partitions should be searched. " - "Increasing this value will increase the recall of your query but will also increase the latency of your query. Defaults to 50." - ), - ) - refine_factor: Annotated[int, Field(gt=0, le=1000)] = Field( - default=20, - description=( - "_Optional_. A multiplier to control how many additional rows are taken during the refine step. " - "This argument is only used when the vector column has an IVF PQ index. " - "If there is no index then this value is ignored. " - "An IVF PQ index stores compressed (quantized) values. " - "They query vector is compared against these values and, since they are compressed, the comparison is inaccurate. " - "This parameter can be used to refine the results. " - "It can improve both improve recall and correct the ordering of the nearest results. " - "To refine results LanceDb will first perform an ANN search to find the nearest limit * refine_factor results. " - "In other words, if refine_factor is 3 and limit is the default (10) then the first 30 results will be selected. " - "LanceDb then fetches the full, uncompressed, values for these 30 results. " - "The results are then reordered by the true distance and only the nearest 10 are kept. Defaults to 50." - ), - ) - float_decimals: int = Field( - default=0, - description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", - ) - vec_decimals: int = Field( - default=0, - 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.") - ] = None - - -class FileUploadRequest(BaseModel): - file_path: Annotated[str, Field(description="File path of the document to be uploaded.")] - table_id: Annotated[str, Field(description="Knowledge Table name / ID.")] - chunk_size: Annotated[ - int, Field(description="Maximum chunk size (number of characters). Must be > 0.", gt=0) - ] = 1000 - chunk_overlap: Annotated[ - int, Field(description="Overlap in characters between chunks. Must be >= 0.", ge=0) - ] = 200 - # overwrite: Annotated[ - # bool, - # Field( - # description="Whether to overwrite the file.", - # examples=[True, False], - # ), - # ] = False - - -class TableDataImportRequest(BaseModel): - file_path: Annotated[str, Field(description="CSV or TSV file path.")] - 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 - ) - # column_names: Annotated[ - # list[str] | None, - # Field( - # description="A list of columns names if the CSV does not have header row. Defaults to None (read from CSV)." - # ), - # ] = None - # columns: Annotated[ - # list[str] | None, - # Field( - # description="A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at')." - # ), - # ] = None - delimiter: Annotated[ - 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/types/__init__.py b/clients/python/src/jamaibase/types/__init__.py new file mode 100644 index 0000000..68ab44e --- /dev/null +++ b/clients/python/src/jamaibase/types/__init__.py @@ -0,0 +1,508 @@ +import re +from decimal import Decimal +from typing import Annotated, Generic, Self, TypeVar + +from pydantic import BaseModel, EmailStr, Field, computed_field + +from jamaibase.types.billing import ( # noqa: F401 + DBStorageUsageData, + EgressUsageData, + EmbedUsageData, + FileStorageUsageData, + LlmUsageData, + RerankUsageData, + UsageData, +) +from jamaibase.types.common import ( # noqa: F401 + DEFAULT_MUL_LANGUAGES, + EXAMPLE_CHAT_MODEL_IDS, + EXAMPLE_EMBEDDING_MODEL_IDS, + EXAMPLE_RERANKING_MODEL_IDS, + DatetimeUTC, + EmptyIfNoneStr, + FilePath, + JSONInput, + JSONInputBin, + JSONOutput, + JSONOutputBin, + LanguageCodeList, + NullableStr, + PositiveInt, + PositiveNonZeroInt, + Progress, + ProgressStage, + ProgressState, + SanitisedMultilineStr, + SanitisedNonEmptyStr, + SanitisedStr, + TableImportProgress, + YAMLInput, + YAMLOutput, + empty_string_to_none, + none_to_empty_string, +) +from jamaibase.types.compat import ( # noqa: F401 + AdminOrderBy, + ChatCompletionChoiceDelta, + ChatCompletionChoiceOutput, + ChatCompletionChunk, + ChatRequestWithTools, + ChatThread, + CompletionUsage, + GenTableChatCompletionChunks, + GenTableOrderBy, + GenTableRowsChatCompletionChunks, + GenTableStreamChatCompletionChunk, + GenTableStreamReferences, + MessageToolCall, + MessageToolCallFunction, + ModelInfoResponse, + RowAddRequest, + RowDeleteRequest, + RowRegenRequest, + ToolFunction, +) +from jamaibase.types.conversation import ( # noqa: F401 + AgentMetaResponse, + ConversationCreateRequest, + ConversationMetaResponse, + MessageAddRequest, + MessagesRegenRequest, + MessageUpdateRequest, +) +from jamaibase.types.db import ( # noqa: F401 + CloudProvider, + Deployment_, + DeploymentCreate, + DeploymentRead, + DeploymentUpdate, + ModelCapability, + ModelConfig_, + ModelConfigCreate, + ModelConfigRead, + ModelConfigUpdate, + ModelInfo, + ModelInfoRead, + ModelProvider, + ModelType, + OnPremProvider, + Organization_, + OrganizationCreate, + OrganizationRead, + OrganizationUpdate, + OrgMember_, + OrgMemberCreate, + OrgMemberRead, + OrgMemberUpdate, + PaymentState, + PricePlan_, + PricePlanCreate, + PricePlanRead, + PricePlanUpdate, + PriceTier, + Product, + Products, + ProductType, + Project_, + ProjectCreate, + ProjectKey_, + ProjectKeyCreate, + ProjectKeyRead, + ProjectKeyUpdate, + ProjectMember_, + ProjectMemberCreate, + ProjectMemberRead, + ProjectMemberUpdate, + ProjectRead, + ProjectUpdate, + RankedRole, + Role, + User_, + UserAuth, + UserCreate, + UserRead, + UserReadObscured, + UserUpdate, + VerificationCode_, + VerificationCodeCreate, + VerificationCodeRead, + VerificationCodeUpdate, +) +from jamaibase.types.file import ( # noqa: F401 + FileUploadResponse, + GetURLRequest, + GetURLResponse, +) +from jamaibase.types.gen_table import ( # noqa: F401 + ActionTableSchemaCreate, + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + CellCompletionResponse, + CellReferencesResponse, + ChatTableSchemaCreate, + CodeGenConfig, + ColumnDropRequest, + ColumnRenameRequest, + ColumnReorderRequest, + ColumnSchema, + ColumnSchemaCreate, + CSVDelimiter, + DiscriminatedGenConfig, + EmbedGenConfig, + GenConfigUpdateRequest, + KnowledgeTableSchemaCreate, + LLMGenConfig, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + MultiRowRegenRequest, + MultiRowUpdateRequest, + MultiRowUpdateRequestWithLimit, + PythonGenConfig, + RowCompletionResponse, + RowRegen, + RowUpdateRequest, + SearchRequest, + TableDataImportRequest, + TableImportRequest, + TableMeta, + TableMetaResponse, + TableSchemaCreate, + TableType, +) +from jamaibase.types.legacy import ( # noqa: F401 + VectorSearchRequest, + VectorSearchResponse, +) +from jamaibase.types.lm import ( # noqa: F401 + CITATION_PATTERN, + AudioContent, + AudioContentData, + AudioResponse, + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionDelta, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, + ChatContent, + ChatContentS3, + ChatEntry, + ChatRequest, + ChatRole, + ChatThreadEntry, + ChatThreadResponse, + ChatThreadsResponse, + Chunk, + CodeInterpreterTool, + CompletionUsageDetails, + ConversationThreadsResponse, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingResponseData, + EmbeddingUsage, + Function, + FunctionCall, + FunctionParameters, + ImageContent, + ImageContentData, + LogProbs, + LogProbToken, + PromptUsageDetails, + RAGParams, + References, + RerankingApiVersion, + RerankingBilledUnits, + RerankingData, + RerankingMeta, + RerankingMetaUsage, + RerankingRequest, + RerankingResponse, + RerankingUsage, + S3Content, + SplitChunksParams, + SplitChunksRequest, + TextContent, + ToolCall, + ToolChoice, + ToolChoiceFunction, + ToolUsageDetails, + WebSearchTool, +) +from jamaibase.types.logs import LogQueryResponse # noqa: F401 +from jamaibase.types.model import ( # noqa: F401 + EmbeddingModelPrice, + LLMModelPrice, + ModelInfoListResponse, + ModelPrice, + RerankingModelPrice, +) +from jamaibase.types.telemetry import ( # noqa: F401 + Host, + Metric, + Usage, + UsageResponse, +) + + +class OkResponse(BaseModel): + ok: bool = True + progress_key: str = "" + + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: Annotated[ + list[T], Field(description="List of items paginated items.", examples=[[]]) + ] = [] + 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 + # start_cursor: Annotated[ + # str | None, + # Field( + # description=( + # "Opaque token for the first item in this page. " + # "Pass it as `before=` to request the page that precedes the current window." + # ) + # ), + # ] = None + end_cursor: Annotated[ + str | None, + Field( + description=( + "Opaque cursor token for the last item in this page. " + "Pass it as `after=` to request the page that follows the current window." + ) + ), + ] = None + + +class UserAgent(BaseModel): + is_browser: bool = Field( + 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( + "", + description="The agent version, or an empty string if it cannot be determined.", + examples=["", "5.0", "0.3.0"], + ) + os: str = Field( + "", + 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( + "", + 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( + "", + description="The SDK language, such as 'TypeScript', 'Python', or an empty string if it is not applicable.", + examples=["", "TypeScript", "Python"], + ) + language_version: str = Field( + "", + 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 PasswordLoginRequest(BaseModel): + email: EmailStr = Field(min_length=1, description="Email.") + password: str = Field(min_length=1, max_length=72, description="Password.") + + +class PasswordChangeRequest(BaseModel): + email: EmailStr = Field(min_length=1, description="Email.") + password: str = Field(min_length=1, max_length=72, description="Password.") + new_password: str = Field(min_length=1, max_length=72, description="New password.") + + +class StripePaymentInfo(BaseModel): + status: str = Field( + description="Stripe invoice payment status.", + ) + subscription_id: str | None = Field( + pattern=r"^sub_.+", + description="Stripe subscription ID.", + ) + payment_intent_id: str | None = Field( + pattern=r"^pi_.+", + description="Stripe payment intent ID.", + ) + client_secret: str | None = Field( + description="Stripe client secret.", + ) + amount_due: Decimal = Field( + decimal_places=2, + description="Amount due.", + ) + amount_overpaid: Decimal = Field( + decimal_places=2, + description="Amount overpaid.", + ) + amount_paid: Decimal = Field( + decimal_places=2, + description="Amount paid.", + ) + amount_remaining: Decimal = Field( + decimal_places=2, + description="Amount remaining.", + ) + currency: str = Field( + description="Currency.", + ) + + +class StripeEventData(BaseModel): + event_type: str = Field( + description="Stripe event type.", + ) + event_id: str = Field( + pattern=r"^evt_.+", + description="Stripe event ID.", + ) + invoice_id: str | None = Field( + pattern=r"^in_.+", + description="Stripe invoice ID.", + ) + subscription_id: str | None = Field( + pattern=r"^sub_.+", + description="Stripe subscription ID.", + ) + price_id: str | None = Field( + pattern=r"^price_.+", + description="Stripe price ID.", + ) + payment_method: str | None = Field( + pattern=r"^pm_.+", + description="Stripe payment method.", + ) + customer_id: str = Field( + pattern=r"^cus_.+", + description="Stripe customer ID.", + ) + organization_id: str = Field( + description="Organization ID.", + ) + collection_method: str = Field( + description="Stripe collection method.", + ) + billing_reason: str = Field( + description="Stripe billing reason.", + ) + amount_paid: Decimal = Field( + decimal_places=2, + description="Amount paid.", + ) + currency: str = Field( + description="Currency.", + ) + status: str = Field( + description="Stripe subscription status.", + ) + receipt_url: str = Field( + "", + description="Stripe receipt URL.", + ) + invoice_url: str = Field( + "", + description="Stripe invoice URL.", + ) + invoice_pdf: str = Field( + "", + description="Stripe invoice PDF URL.", + ) diff --git a/clients/python/src/jamaibase/types/billing.py b/clients/python/src/jamaibase/types/billing.py new file mode 100644 index 0000000..56b87f3 --- /dev/null +++ b/clients/python/src/jamaibase/types/billing.py @@ -0,0 +1,136 @@ +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + +from jamaibase.utils import uuid7_str + + +class _BaseUsageData(BaseModel): + id: str = Field( + default_factory=uuid7_str, + description="UUID of the insert row.", + ) + org_id: str = Field( + description="Organization ID.", + ) + proj_id: str = Field( + description="Project ID.", + ) + user_id: str = Field( + description="User ID.", + ) + timestamp: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="UTC Timestamp (microsecond precision) of the insert.", + ) + cost: float = Field( + description="Usage cost (per_million_tokens for LLM and Embedding, per_thousand_searches for Rerank).", + ) + + def as_list(self): + """Convert the instance to a list, including all fields.""" + return list(self.model_dump().values()) + + +class LlmUsageData(_BaseUsageData): + model: str = Field( + description="Model used.", + ) + input_token: int = Field( + description="Number of input tokens used.", + ) + output_token: int = Field( + description="Number of output tokens used.", + ) + input_cost: float = Field( + description="Cost in USD per million input tokens.", + ) + output_cost: float = Field( + description="Cost in USD per million output tokens.", + ) + + +class EmbedUsageData(_BaseUsageData): + model: str = Field( + description="Model used.", + ) + token: int = Field( + description="Number of tokens used.", + ) + + +class RerankUsageData(_BaseUsageData): + model: str = Field( + description="Model used.", + ) + number_of_search: int = Field( + description="Number of searches.", + ) + + +class EgressUsageData(_BaseUsageData): + amount_gib: float = Field( + description="Amount in GiB.", + ) + + +class FileStorageUsageData(_BaseUsageData): + amount_gib: float = Field( + description="Chargeable Amount in GiB.", + ) + snapshot_gib: float = Field( + description="Snapshot of amount in GiB.", + ) + + +class DBStorageUsageData(_BaseUsageData): + amount_gib: float = Field( + description="Chargeable Amount in GiB.", + ) + snapshot_gib: float = Field( + description="Snapshot of amount in GiB.", + ) + + +class UsageData(BaseModel): + llm_usage: list[LlmUsageData] = [] + embed_usage: list[EmbedUsageData] = [] + rerank_usage: list[RerankUsageData] = [] + egress_usage: list[EgressUsageData] = [] + file_storage_usage: list[FileStorageUsageData] = [] + db_storage_usage: list[DBStorageUsageData] = [] + + # A computed field to get the per type list + def as_list_by_type(self) -> dict[str, list[list]]: + """Returns a dictionary of lists, where each key is a usage type and the value is a list of lists.""" + return { + "llm_usage": [usage.as_list() for usage in self.llm_usage], + "embed_usage": [usage.as_list() for usage in self.embed_usage], + "rerank_usage": [usage.as_list() for usage in self.rerank_usage], + "egress_usage": [usage.as_list() for usage in self.egress_usage], + "file_storage_usage": [usage.as_list() for usage in self.file_storage_usage], + "db_storage_usage": [usage.as_list() for usage in self.db_storage_usage], + } + + @property + def total_usage_events(self) -> int: + """Returns the total number of usage events across all types.""" + return ( + len(self.llm_usage) + + len(self.embed_usage) + + len(self.rerank_usage) + + len(self.egress_usage) + + len(self.file_storage_usage) + + len(self.db_storage_usage) + ) + + def __add__(self, other: "UsageData") -> "UsageData": + """Overload the + operator to combine two UsageData objects.""" + combined = UsageData() + combined.llm_usage = self.llm_usage + other.llm_usage + combined.embed_usage = self.embed_usage + other.embed_usage + combined.rerank_usage = self.rerank_usage + other.rerank_usage + combined.egress_usage = self.egress_usage + other.egress_usage + combined.file_storage_usage = self.file_storage_usage + other.file_storage_usage + combined.db_storage_usage = self.db_storage_usage + other.db_storage_usage + return combined diff --git a/clients/python/src/jamaibase/types/common.py b/clients/python/src/jamaibase/types/common.py new file mode 100644 index 0000000..bd64cf4 --- /dev/null +++ b/clients/python/src/jamaibase/types/common.py @@ -0,0 +1,263 @@ +import unicodedata +from collections import OrderedDict +from datetime import timezone +from functools import partial +from pathlib import Path +from typing import Annotated, Any, Dict, List, Tuple, Union + +from pydantic import AfterValidator, BaseModel, BeforeValidator, Field +from pydantic.types import AwareDatetime +from pydantic_extra_types.country import _index_by_alpha2 as iso_3166 +from pydantic_extra_types.language_code import _index_by_alpha2 as iso_639 + +from jamaibase.utils.types import StrEnum + +PositiveInt = Annotated[int, Field(ge=0, description="Positive integer.")] +PositiveNonZeroInt = Annotated[int, Field(gt=0, description="Positive non-zero integer.")] + + +def none_to_empty_string(v: str | None) -> str: + if v is None: + return "" + return v + + +def empty_string_to_none(v: str | None) -> str | None: + if not v: + return None + return v + + +NullableStr = Annotated[str | None, BeforeValidator(empty_string_to_none)] +EmptyIfNoneStr = Annotated[str, BeforeValidator(none_to_empty_string)] + +EXAMPLE_CHAT_MODEL_IDS = ["openai/gpt-4o-mini"] +EXAMPLE_EMBEDDING_MODEL_IDS = [ + "openai/text-embedding-3-small-512", + "ellm/sentence-transformers/all-MiniLM-L6-v2", +] +EXAMPLE_RERANKING_MODEL_IDS = [ + "cohere/rerank-multilingual-v3.0", + "ellm/cross-encoder/ms-marco-TinyBERT-L-2", +] + +# fmt: off +FilePath = Union[str, Path] +# Superficial JSON input/output types +# https://github.com/python/typing/issues/182#issuecomment-186684288 +JSONOutput = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] +JSONOutputBin = Union[bytes, str, int, float, bool, None, Dict[str, Any], List[Any]] +# For input, we also accept tuples, ordered dicts etc. +JSONInput = Union[str, int, float, bool, None, Dict[str, Any], List[Any], Tuple[Any, ...], OrderedDict] +JSONInputBin = Union[bytes, str, int, float, bool, None, Dict[str, Any], List[Any], Tuple[Any, ...], OrderedDict] +YAMLInput = JSONInput +YAMLOutput = JSONOutput +# fmt: on + + +def _to_utc(d: AwareDatetime) -> AwareDatetime: + return d.astimezone(timezone.utc) + + +DatetimeUTC = Annotated[AwareDatetime, AfterValidator(_to_utc)] + +### --- String Validator --- ### + + +def _is_bad_char(char: str, *, allow_newline: bool) -> bool: + """ + Checks if a character is disallowed. + """ + # 1. Handle newlines based on the flag + if char == "\n": + return not allow_newline # Bad if newlines are NOT allowed + + # 2. Check for other non-printable characters (like tabs, control codes) + # str.isprintable() is False for all non-printing chars except space. + if not char.isprintable(): + return True + + # 3. Check for specific disallowed Unicode categories and blocks + category = unicodedata.category(char) + # Combining marks (e.g., for Zalgo text) + if category.startswith("M"): + return True + # Box drawing + if "\u2500" <= char <= "\u257f": + return True + # Block elements + if "\u2580" <= char <= "\u259f": + return True + # Braille patterns + if "\u2800" <= char <= "\u28ff": + return True + + return False + + +def _str_pre_validator( + value: Any, *, disallow_empty_string: bool = False, allow_newline: bool = False +) -> str: + if not isinstance(value, str): + value = str(value) + value = value.strip() + if disallow_empty_string and len(value) == 0: + raise ValueError("Text is empty.") + + # --- Simplified and Consolidated Character Validation --- + # The generator expression is efficient as `any()` will short-circuit + # on the first bad character found. + value = "".join(char for char in value if not unicodedata.category(char).startswith("M")) + if any(_is_bad_char(char, allow_newline=allow_newline) for char in value): + raise ValueError("Text contains disallowed or non-printable characters.") + + return value + + +SanitisedStr = Annotated[ + str, + BeforeValidator(_str_pre_validator), + # Cannot use Field here due to conflict with SQLModel +] +SanitisedMultilineStr = Annotated[ + str, + BeforeValidator(partial(_str_pre_validator, disallow_empty_string=False, allow_newline=True)), + # Cannot use Field here due to conflict with SQLModel +] +SanitisedNonEmptyStr = Annotated[ + str, + BeforeValidator(partial(_str_pre_validator, disallow_empty_string=True)), + # Cannot use Field here due to conflict with SQLModel +] +SanitisedNonEmptyMultilineStr = Annotated[ + str, + BeforeValidator(partial(_str_pre_validator, disallow_empty_string=True, allow_newline=True)), + # Cannot use Field here due to conflict with SQLModel +] + +### --- Language Code Validator --- ### + + +WILDCARD_LANG_CODES = {"*", "mul"} +DEFAULT_MUL_LANGUAGES = [ + # ChatGPT supported languages + # "sq", # Albanian + # "am", # Amharic + # "ar", # Arabic + # "hy", # Armenian + # "bn", # Bengali + # "bs", # Bosnian + # "bg", # Bulgarian + # "my", # Burmese + # "ca", # Catalan + "zh", # Chinese + # "hr", # Croatian + # "cs", # Czech + # "da", # Danish + # "nl", # Dutch + "en", # English + # "et", # Estonian + # "fi", # Finnish + "fr", # French + # "ka", # Georgian + # "de", # German + # "el", # Greek + # "gu", # Gujarati + # "hi", # Hindi + # "hu", # Hungarian + # "is", # Icelandic + # "id", # Indonesian + "it", # Italian + "ja", # Japanese + # "kn", # Kannada + # "kk", # Kazakh + "ko", # Korean + # "lv", # Latvian + # "lt", # Lithuanian + # "mk", # Macedonian + # "ms", # Malay + # "ml", # Malayalam + # "mr", # Marathi + # "mn", # Mongolian + # "no", # Norwegian + # "fa", # Persian + # "pl", # Polish + # "pt", # Portuguese + # "pa", # Punjabi + # "ro", # Romanian + # "ru", # Russian + # "sr", # Serbian + # "sk", # Slovak + # "sl", # Slovenian + # "so", # Somali + "es", # Spanish + # "sw", # Swahili + # "sv", # Swedish + # "tl", # Tagalog + # "ta", # Tamil + # "te", # Telugu + # "th", # Thai + # "tr", # Turkish + # "uk", # Ukrainian + # "ur", # Urdu + # "vi", # Vietnamese +] + + +def _validate_lang(s: str) -> str: + try: + code = s.split("-") + lang = code[0] + lang = lang.lower().strip() + if lang not in iso_639(): + raise ValueError + if len(code) == 2: + country = code[1] + country = country.upper().strip() + if country not in iso_3166(): + raise ValueError + return f"{lang}-{country}" + elif len(code) == 1: + return lang + else: + raise ValueError + except Exception as e: + raise ValueError( + f'Language code "{s}" is not ISO 639-1 alpha-2 or BCP-47 ([ISO 639-1 alpha-2]-[ISO 3166-1 alpha-2]).' + ) from e + + +def _validate_lang_list(s: list[str]) -> list[str]: + s = {lang.strip() for lang in s} + if len(s & WILDCARD_LANG_CODES) > 0: + s = list((s - WILDCARD_LANG_CODES) | set(DEFAULT_MUL_LANGUAGES)) + return [_validate_lang(lang) for lang in s] + + +LanguageCodeList = Annotated[list[str], AfterValidator(_validate_lang_list)] + + +class ProgressState(StrEnum): + STARTED = "STARTED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class Progress(BaseModel): + key: str + data: dict[str, Any] = {} + state: ProgressState = ProgressState.STARTED + error: str | None = None + + +class ProgressStage(BaseModel): + name: str + progress: int = 0 + + +class TableImportProgress(Progress): + load_data: ProgressStage = ProgressStage(name="Load data") + parse_data: ProgressStage = ProgressStage(name="Parse data") + upload_files: ProgressStage = ProgressStage(name="Upload files") + add_rows: ProgressStage = ProgressStage(name="Add rows") + index: ProgressStage = ProgressStage(name="Indexing") diff --git a/clients/python/src/jamaibase/types/compat.py b/clients/python/src/jamaibase/types/compat.py new file mode 100644 index 0000000..f352394 --- /dev/null +++ b/clients/python/src/jamaibase/types/compat.py @@ -0,0 +1,210 @@ +from pydantic import Field +from typing_extensions import deprecated + +from jamaibase.types.gen_table import ( + CellCompletionResponse, + CellReferencesResponse, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + MultiRowRegenRequest, + RowCompletionResponse, +) +from jamaibase.types.lm import ( + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionMessage, + ChatCompletionUsage, + ChatRequest, + ChatThreadResponse, + Function, + ToolCall, + ToolCallFunction, +) +from jamaibase.types.model import ModelInfoListResponse +from jamaibase.utils.types import StrEnum + + +@deprecated( + "AdminOrderBy is deprecated, use string instead.", + category=FutureWarning, + stacklevel=1, +) +class AdminOrderBy(StrEnum): + 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.""" + + +@deprecated( + "GenTableOrderBy is deprecated, use string instead.", + category=FutureWarning, + stacklevel=1, +) +class GenTableOrderBy(StrEnum): + ID = "id" + """Sort by `id` column.""" + UPDATED_AT = "updated_at" + """Sort by `updated_at` column.""" + + +@deprecated( + "ModelInfoResponse is deprecated, use ModelInfoListResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class ModelInfoResponse(ModelInfoListResponse): + object: str = Field( + default="chat.model_info", + description="Type of API response object.", + examples=["chat.model_info"], + ) + + +@deprecated( + "MessageToolCallFunction is deprecated, use ToolCallFunction instead.", + category=FutureWarning, + stacklevel=1, +) +class MessageToolCallFunction(ToolCallFunction): + pass + + +@deprecated( + "MessageToolCall is deprecated, use ToolCall instead.", + category=FutureWarning, + stacklevel=1, +) +class MessageToolCall(ToolCall): + pass + + +@deprecated( + "ChatCompletionChoiceDelta is deprecated, use ChatCompletionChoice instead.", + category=FutureWarning, + stacklevel=1, +) +class ChatCompletionChoiceDelta(ChatCompletionChoice): + pass + + +@deprecated( + "CompletionUsage is deprecated, use ChatCompletionUsage instead.", + category=FutureWarning, + stacklevel=1, +) +class CompletionUsage(ChatCompletionUsage): + pass + + +@deprecated( + "ChatCompletionChunk is deprecated, use ChatCompletionChunkResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class ChatCompletionChunk(ChatCompletionChunkResponse): + pass + + +@deprecated( + "ChatCompletionChoiceOutput is deprecated, use ChatCompletionMessage instead.", + category=FutureWarning, + stacklevel=1, +) +class ChatCompletionChoiceOutput(ChatCompletionMessage): + pass + + +@deprecated( + "ChatThread is deprecated, use ChatThreadResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class ChatThread(ChatThreadResponse): + pass + + +@deprecated( + "ToolFunction is deprecated, use Function instead.", + category=FutureWarning, + stacklevel=1, +) +class ToolFunction(Function): + pass + + +@deprecated( + "ChatRequestWithTools is deprecated, use ChatRequest instead.", + category=FutureWarning, + stacklevel=1, +) +class ChatRequestWithTools(ChatRequest): + pass + + +@deprecated( + "GenTableStreamReferences is deprecated, use CellReferencesResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class GenTableStreamReferences(CellReferencesResponse): + pass + + +@deprecated( + "GenTableStreamChatCompletionChunk is deprecated, use CellCompletionResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class GenTableStreamChatCompletionChunk(CellCompletionResponse): + pass + + +@deprecated( + "GenTableChatCompletionChunks is deprecated, use RowCompletionResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class GenTableChatCompletionChunks(RowCompletionResponse): + pass + + +@deprecated( + "GenTableRowsChatCompletionChunks is deprecated, use MultiRowCompletionResponse instead.", + category=FutureWarning, + stacklevel=1, +) +class GenTableRowsChatCompletionChunks(MultiRowCompletionResponse): + pass + + +@deprecated( + "RowAddRequest is deprecated, use MultiRowAddRequest instead.", + category=FutureWarning, + stacklevel=1, +) +class RowAddRequest(MultiRowAddRequest): + pass + + +@deprecated( + "RowRegenRequest is deprecated, use MultiRowRegenRequest instead.", + category=FutureWarning, + stacklevel=1, +) +class RowRegenRequest(MultiRowRegenRequest): + pass + + +@deprecated( + "RowDeleteRequest is deprecated, use MultiRowDeleteRequest instead.", + category=FutureWarning, + stacklevel=1, +) +class RowDeleteRequest(MultiRowDeleteRequest): + pass diff --git a/clients/python/src/jamaibase/types/conversation.py b/clients/python/src/jamaibase/types/conversation.py new file mode 100644 index 0000000..cba63f5 --- /dev/null +++ b/clients/python/src/jamaibase/types/conversation.py @@ -0,0 +1,105 @@ +from typing import Any, Self + +from pydantic import BaseModel, Field, model_validator + +from jamaibase.types.common import DatetimeUTC, SanitisedNonEmptyStr, SanitisedStr +from jamaibase.types.gen_table import ColumnSchema, TableMetaResponse + + +class _MetaResponse(BaseModel): + meta: dict[SanitisedNonEmptyStr, Any] | None = Field( + None, + description="Additional metadata about the table.", + ) + cols: list[ColumnSchema] = Field( + description="List of column schema.", + ) + title: SanitisedStr = Field( + description="Conversation title.", + ) + created_by: SanitisedNonEmptyStr = Field( + description="ID of the user that created this table.", + ) + updated_at: DatetimeUTC = Field( + description="Table last update datetime (UTC).", + ) + num_rows: int = Field( + -1, + description="Number of rows in the table. Defaults to -1 (not counted).", + ) + version: str = Field( + description="Version.", + ) + + @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 AgentMetaResponse(_MetaResponse): + agent_id: SanitisedNonEmptyStr = Field( + description="Agent ID.", + ) + + @classmethod + def from_table_meta(cls, meta: TableMetaResponse) -> Self: + """Returns an instance from TableMetaResponse.""" + return cls(agent_id=meta.id, **meta.model_dump(exclude={"id"})) + + +class ConversationMetaResponse(_MetaResponse): + conversation_id: SanitisedNonEmptyStr = Field( + description="Conversation ID.", + ) + parent_id: SanitisedNonEmptyStr | None = Field( + description="The parent table ID. If None, it means this is a parent table.", + ) + + @classmethod + def from_table_meta(cls, meta: TableMetaResponse) -> Self: + """Returns an instance from TableMetaResponse.""" + return cls(conversation_id=meta.id, **meta.model_dump(exclude={"id"})) + + +class _MessageBase(BaseModel): + data: dict[str, Any] = Field( + description="Mapping of column names to its value.", + ) + + +class ConversationCreateRequest(_MessageBase): + """Request to create a new conversation.""" + + agent_id: SanitisedNonEmptyStr = Field( + description="Agent ID (parent Chat Table ID).", + ) + title: SanitisedStr | None = Field( + None, + min_length=1, + description="The title of the conversation.", + ) + + +class MessageAddRequest(_MessageBase): + conversation_id: SanitisedNonEmptyStr = Field( + description="Conversation ID.", + ) + + +class MessageUpdateRequest(BaseModel): + """Request to update a single message in a conversation.""" + + conversation_id: str = Field(description="Unique ID of the conversation (table_id).") + row_id: str = Field(description="The ID of the message (row) to update.") + data: dict[str, Any] = Field( + description="The new data for the message, e.g. `{'User': 'new content'}`.", + min_length=1, + ) + + +class MessagesRegenRequest(BaseModel): + """Request to regenerate the current message (and the rest of the messages) in a conversation.""" + + conversation_id: str = Field(description="Unique ID of the conversation (table_id).") + row_id: str = Field(description="Message IDs (rows) to regenerate.") diff --git a/clients/python/src/jamaibase/types/db.py b/clients/python/src/jamaibase/types/db.py new file mode 100644 index 0000000..95f3273 --- /dev/null +++ b/clients/python/src/jamaibase/types/db.py @@ -0,0 +1,1284 @@ +from enum import IntEnum +from typing import Annotated, Any + +from fastapi.exceptions import RequestValidationError +from pydantic import ( + AnyUrl, + BaseModel, + BeforeValidator, + EmailStr, + Field, + ValidationError, + computed_field, + field_validator, + model_validator, +) +from pydantic_extra_types.currency_code import ISO4217 +from pydantic_extra_types.timezone_name import TimeZoneName +from typing_extensions import Self + +from jamaibase.types.common import ( + DEFAULT_MUL_LANGUAGES, + DatetimeUTC, + LanguageCodeList, + PositiveNonZeroInt, + SanitisedMultilineStr, + SanitisedNonEmptyStr, + SanitisedStr, +) +from jamaibase.types.gen_table import TableMetaResponse +from jamaibase.utils import uuid7_str +from jamaibase.utils.dates import now +from jamaibase.utils.exceptions import BadInputError +from jamaibase.utils.types import StrEnum, get_enum_validator + + +class _BaseModel(BaseModel, from_attributes=True, str_strip_whitespace=True): + meta: dict[str, Any] = Field( + {}, + description="Metadata.", + ) + + @classmethod + def validate_updates( + cls, + base: Self, + updates: dict[str, Any], + *, + raise_request_error: bool = True, + ) -> Self: + try: + updates = {k: v for k, v in updates.items() if k in cls.model_fields} + new = cls.model_validate(base.model_dump() | updates) + except ValidationError as e: + if raise_request_error: + raise RequestValidationError(errors=e.errors()) from e + else: + raise + return new + + +class _TableBase(BaseModel): + created_at: DatetimeUTC = Field( + description="Creation datetime (UTC).", + ) + updated_at: DatetimeUTC = Field( + description="Update datetime (UTC).", + ) + + def allowed( + self, + filter_id: str, + *, + allow_list_attr: str = "allowed_orgs", + block_list_attr: str = "blocked_orgs", + ) -> bool: + allow_list: list[str] = getattr(self, allow_list_attr) + block_list: list[str] | None = getattr(self, block_list_attr, None) + # Allow list + allowed = len(allow_list) == 0 or filter_id in allow_list + if block_list is None: + # No block list, just allow list + return allowed + else: + # Block list + return allowed and filter_id not in block_list + + +# TODO: Perhaps need to implement OveragePolicy + + +class PriceTier(BaseModel): + """ + https://docs.stripe.com/api/prices/object#price_object-tiers + """ + + unit_cost: float = 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." + ), + ) + + @classmethod + def null(cls): + return cls( + unit_cost=0.0, + up_to=0.0, + ) + + @classmethod + def unlimited(cls, unit_cost: float = 0.0): + return cls( + unit_cost=unit_cost, + up_to=None, + ) + + +class Product(BaseModel): + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Product name.", + ) + included: PriceTier = Field( + description="Free tier. The `unit_cost` of this tier will always be `0.0`.", + ) + tiers: list[PriceTier] = Field( + description=( + "Additional tiers so that we may charge a different price for the first usage band versus the next. " + "For example, `included=PriceTier(unit_cost=0.0, up_to=0.5), " + "tiers=[PriceTier(unit_cost=1.0, up_to=1.0), PriceTier(unit_cost=2.0, up_to=None)]` " + "would be free for the first `0.5` units, `$1.0` per unit for the next `1.0` units, and `$2.0` per unit for the rest. " + "In this case, a usage of `2.0` units would cost `$2.0`." + ), + ) + unit: SanitisedNonEmptyStr = Field( + description="Unit of measurement for reference.", + ) + + @model_validator(mode="after") + def check_included_cost(self) -> Self: + # Included tier should be free + self.included.unit_cost = 0.0 + return self + + @classmethod + def null(cls, name: str, unit: str): + return cls( + name=name, + included=PriceTier.null(), + tiers=[], + unit=unit, + ) + + @classmethod + def unlimited(cls, name: str, unit: str, unit_cost: float = 0.0): + return cls( + name=name, + included=PriceTier.unlimited(unit_cost=unit_cost), + tiers=[], + unit=unit, + ) + + +class Products(BaseModel): + llm_tokens: Product = Field( + description="LLM token quota to this plan or tier.", + ) + embedding_tokens: Product = Field( + description="Embedding token quota to this plan or tier.", + ) + reranker_searches: Product = Field( + description="Reranker search quota to this plan or tier.", + ) + db_storage: Product = Field( + description="Database storage quota to this plan or tier.", + ) + file_storage: Product = Field( + description="File storage quota to this plan or tier.", + ) + egress: Product = Field( + description="Egress bandwidth quota to this plan or tier.", + ) + + @classmethod + def null(cls): + return cls( + llm_tokens=Product.null("ELLM tokens", "Million Tokens"), + embedding_tokens=Product.null("Embedding tokens", "Million Tokens"), + reranker_searches=Product.null("Reranker searches", "Thousand Searches"), + db_storage=Product.null("Database storage", "GiB"), + file_storage=Product.null("File storage", "GiB"), + egress=Product.null("Egress bandwidth", "GiB"), + ) + + @classmethod + def unlimited(cls, unit_cost: float = 0.0): + return cls( + llm_tokens=Product.unlimited("ELLM tokens", "Million Tokens", unit_cost), + embedding_tokens=Product.unlimited("Embedding tokens", "Million Tokens", unit_cost), + reranker_searches=Product.unlimited( + "Reranker searches", "Thousand Searches", unit_cost + ), + db_storage=Product.unlimited("Database storage", "GiB", unit_cost), + file_storage=Product.unlimited("File storage", "GiB", unit_cost), + egress=Product.unlimited("Egress bandwidth", "GiB", unit_cost), + ) + + +_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(StrEnum): + 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" + + @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 PricePlanUpdate(_BaseModel): + stripe_price_id_live: SanitisedNonEmptyStr = Field( + "", + description="Stripe price ID (live mode).", + ) + stripe_price_id_test: SanitisedNonEmptyStr = Field( + "", + description="Stripe price ID (test mode).", + ) + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Price plan name.", + ) + flat_cost: float = Field( + 0.0, + ge=0.0, + description="Base price for the entire tier.", + ) + credit_grant: float = Field( + 0.0, + ge=0.0, + description="Credit amount included in USD.", + ) + max_users: int | None = Field( + 0, + ge=1, + description="Maximum number of users per organization. `None` means no limit.", + ) + products: Products = Field( + Products.null(), + description="Mapping of product ID to product.", + ) + allowed_orgs: list[str] = Field( + [], + description=( + "List of IDs of organizations allowed to use this price plan. " + "If empty, all orgs are allowed." + ), + ) + + @classmethod + def free( + cls, + stripe_price_id_live: str = "price_123", + stripe_price_id_test: str = "price_1RT2CqCcpbd72IcYEvy6U3GR", + ): + return cls( + name="Free plan", + stripe_price_id_live=stripe_price_id_live, + stripe_price_id_test=stripe_price_id_test, + flat_cost=0.0, + credit_grant=0.0, + max_users=2, # For ease of testing + products=Products( + llm_tokens=Product( + name="ELLM tokens", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Million Tokens", + ), + embedding_tokens=Product( + name="Embedding tokens", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Million Tokens", + ), + reranker_searches=Product( + name="Reranker searches", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Thousand Searches", + ), + db_storage=Product( + name="Database storage", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="GiB", + ), + file_storage=Product( + name="File storage", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="GiB", + ), + egress=Product( + name="Egress bandwidth", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="GiB", + ), + ), + ) + + +class PricePlanCreate(PricePlanUpdate): + id: str = Field( + "", + description="Price plan ID.", + ) + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Price plan name.", + ) + stripe_price_id_live: SanitisedNonEmptyStr = Field( + description="Stripe price ID (live mode).", + ) + stripe_price_id_test: SanitisedNonEmptyStr = Field( + description="Stripe price ID (test mode).", + ) + flat_cost: float = Field( + ge=0.0, + description="Base price for the entire tier.", + ) + credit_grant: float = Field( + ge=0.0, + description="Credit amount included in USD.", + ) + max_users: int | None = Field( + ge=1, + description="Maximum number of users per organization. `None` means no limit.", + ) + products: Products = Field( + description="Mapping of product ID to product.", + ) + + +class PricePlan_(PricePlanCreate, _TableBase): + # Computed fields + is_private: bool = Field( + description="Whether this is a private price plan visible only to select organizations.", + ) + stripe_price_id: str = Field( + description="Stripe Price ID (either live or test based on API key).", + ) + + +class PricePlanRead(PricePlan_): + pass + + +class OnPremProvider(StrEnum): + VLLM = "vllm" + VLLM_AMD = "vllm_amd" + OLLAMA = "ollama" + INFINITY = "infinity" + INFINITY_CPU = "infinity_cpu" + + @classmethod + def list_(cls) -> list[str]: + return list(map(str, cls)) + + +class CloudProvider(StrEnum): + ANTHROPIC = "anthropic" + AZURE = "azure" + AZURE_AI = "azure_ai" + BEDROCK = "bedrock" + CEREBRAS = "cerebras" + COHERE = "cohere" + DEEPSEEK = "deepseek" + ELLM = "ellm" + FIREWORKS_AI = "fireworks_ai" + GEMINI = "gemini" + GROQ = "groq" + HYPERBOLIC = "hyperbolic" + INFINITY_CLOUD = "infinity_cloud" + JINA_AI = "jina_ai" + OPENAI = "openai" + OPENROUTER = "openrouter" + SAGEMAKER = "sagemaker" + SAMBANOVA = "sambanova" + TOGETHER_AI = "together_ai" + # VERTEX_AI = "vertex_ai" + VLLM_CLOUD = "vllm_cloud" + VOYAGE = "voyage" + + @classmethod + def list_(cls) -> list[str]: + return list(map(str, cls)) + + +class ModelProvider(StrEnum): + ANTHROPIC = "anthropic" + COHERE = "cohere" + DEEPSEEK = "deepseek" + GEMINI = "gemini" + JINA_AI = "jina_ai" + OPENAI = "openai" + + @classmethod + def list_(cls) -> list[str]: + return list(map(str, cls)) + + +class DeploymentStatus(StrEnum): + ACTIVE = "active" + + +class DeploymentUpdate(_BaseModel): + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Name for the deployment.", + ) + routing_id: SanitisedNonEmptyStr = Field( + "", + description=( + "Model ID that the inference provider expects (whereas `model_id` is what the users will see). " + "OpenAI example: `model_id` CAN be `openai/gpt-5` but `routing_id` SHOULD be `gpt-5`." + ), + ) + api_base: str = Field( + "", + description=( + "(Optional) Hosting url. " + "Required for creating external cloud deployment using custom providers. " + "Example: `http://vllm-endpoint.xyz/v1`." + ), + ) + provider: SanitisedNonEmptyStr = Field( + "", + description=( + f"Inference provider of the model. " + f"Standard cloud providers are {CloudProvider.list_()}." + ), + ) + weight: int = Field( + 1, + description="Routing weight. Must be >= 0. A deployment is selected according to its relative weight.", + ) + cooldown_until: DatetimeUTC = Field( + default_factory=now, + description="Cooldown until datetime (UTC).", + ) + + +class DeploymentCreate(DeploymentUpdate): + model_id: SanitisedNonEmptyStr = Field( + description="Model ID.", + ) + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Name for the deployment.", + ) + + +class Deployment_(DeploymentCreate, _TableBase): + id: str = Field( + description="Deployment ID.", + ) + + +class DeploymentRead(Deployment_): + model: "ModelConfig_" = Field( + description="Model config.", + ) + + @computed_field(description='Status of the deployment. Will always be "ACTIVE".') + @property + def status(self) -> str: + return DeploymentStatus.ACTIVE + + +class ModelType(StrEnum): + COMPLETION = "completion" + LLM = "llm" + EMBED = "embed" + RERANK = "rerank" + + +# This is needed because DB stores Enums as keys but Pydantic loads via values +_ModelType = Annotated[ModelType, BeforeValidator(get_enum_validator(ModelType))] + + +class ModelCapability(StrEnum): + COMPLETION = "completion" + CHAT = "chat" + TOOL = "tool" + IMAGE = "image" # TODO: Maybe change to "image_in" & "image_out" + AUDIO = "audio" + EMBED = "embed" + RERANK = "rerank" + REASONING = "reasoning" + + +_ModelCapability = Annotated[ModelCapability, BeforeValidator(get_enum_validator(ModelCapability))] + + +class ModelInfo(_BaseModel): + id: SanitisedNonEmptyStr = Field( + description=( + "Unique identifier. " + "Users will specify this to select a model. " + "Must follow the following format: `{provider}/{model_id}`. " + "Examples=['openai/gpt-4o-mini', 'Qwen/Qwen2.5-0.5B']" + ), + examples=["openai/gpt-4o-mini", "Qwen/Qwen2.5-0.5B"], + ) + type: _ModelType = Field( + "", + description="Model type. Can be completion, llm, embed, or rerank.", + examples=[ModelType.LLM], + ) + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Model name that is more user friendly.", + examples=["OpenAI GPT-4o Mini"], + ) + owned_by: SanitisedStr = Field( + "", + description="Model provider (usually organization that trained the model).", + ) + capabilities: list[_ModelCapability] = Field( + [], + min_length=1, + description="List of capabilities of model.", + examples=[[ModelCapability.CHAT], [ModelCapability.CHAT, ModelCapability.AUDIO]], + ) + context_length: int = Field( + 4096, + gt=0, + description="Context length of model.", + examples=[4096], + ) + languages: LanguageCodeList = Field( + ["en"], + description=f'List of languages which the model is well-versed in. "*" and "mul" resolves to {DEFAULT_MUL_LANGUAGES}.', + examples=[["en"], ["en", "zh-CN"]], + ) + max_output_tokens: int | None = Field( + None, + gt=0, + description="Maximum number of output tokens, if not specified, will be based on context length.", + # examples=[8192], + ) + + @field_validator("id", mode="after") + @classmethod + def validate_id(cls, v: str) -> str: + if len(v.split("/")) < 2: + raise ValueError( + "Model `id` must follow the following format: `{provider}/{model_id}`." + ) + return v + + @property + def capabilities_set(self) -> set[str]: + return set(map(str, self.capabilities)) + + +class ModelInfoRead(ModelInfo, _TableBase): + pass + + +class ModelConfigUpdate(ModelInfo): + # --- All models --- # + id: SanitisedNonEmptyStr = Field( + "", + description=( + "Unique identifier. " + "Users will specify this to select a model. " + "Must follow the following format: `{provider}/{model_id}`. " + "Examples=['openai/gpt-4o-mini', 'Qwen/Qwen2.5-0.5B']" + ), + ) + timeout: float = Field( + 30 * 60 * 60, + description="Timeout in seconds. Must be greater than 0. Defaults to 30 minutes.", + ) + priority: int = Field( + 0, + description="Priority for fallback model selection. The larger the number, the higher the priority.", + ) + allowed_orgs: list[str] = Field( + [], + description=( + "List of IDs of organizations allowed to use this model. " + "If empty, all orgs are allowed. Allow list is applied first, followed by block list." + ), + ) + blocked_orgs: list[str] = Field( + [], + description=( + "List of IDs of organizations NOT allowed to use this model. " + "If empty, no org is blocked. Allow list is applied first, followed by block list." + ), + ) + # --- LLM models --- # + llm_input_cost_per_mtoken: float = Field( + -1.0, + description="Cost in USD per million (mega) input / prompt token.", + ) + llm_output_cost_per_mtoken: float = Field( + -1.0, + description="Cost in USD per million (mega) output / completion token.", + ) + # --- Embedding models --- # + embedding_size: PositiveNonZeroInt | None = Field( + None, + description=( + "The default embedding size of the model. " + "For example: `openai/text-embedding-3-large` has `embedding_size` of 3072 " + "but can be shortened to `embedding_dimensions` of 256; " + "`cohere/embed-v4.0` has `embedding_size` of 1536 " + "but can be shortened to `embedding_dimensions` of 256." + ), + ) + # Matryoshka embedding dimension + embedding_dimensions: PositiveNonZeroInt | None = Field( + None, + description=( + "The number of dimensions the resulting output embeddings should have. " + "Can be overridden by `dimensions` for each request. " + "Defaults to None (no reduction). " + "Note that this parameter will only be used when using models that support Matryoshka embeddings. " + "For example: `openai/text-embedding-3-large` has `embedding_size` of 3072 " + "but can be shortened to `embedding_dimensions` of 256; " + "`cohere/embed-v4.0` has `embedding_size` of 1536 " + "but can be shortened to `embedding_dimensions` of 256." + ), + ) + # Most likely only useful for HuggingFace models + embedding_transform_query: SanitisedNonEmptyStr | None = Field( + None, + description="Transform query that might be needed, especially for HuggingFace models.", + ) + embedding_cost_per_mtoken: float = Field( + -1.0, + description="Cost in USD per million embedding tokens.", + ) + # --- Reranking models --- # + reranking_cost_per_ksearch: float = Field( + -1.0, + description="Cost in USD for a thousand searches.", + ) + + @property + def final_embedding_size(self) -> int: + embed_size = self.embedding_dimensions or self.embedding_size + if embed_size is None: + raise BadInputError( + f'Both `embedding_dimensions` and `embedding_size` are None for embedding model "{self.id}".' + ) + return embed_size + + @model_validator(mode="after") + def check_chat_cost_per_mtoken(self) -> Self: + # GPT-4o-mini pricing (2024-08-10) + if self.llm_input_cost_per_mtoken < 0: + self.llm_input_cost_per_mtoken = 0.150 + if self.llm_output_cost_per_mtoken < 0: + self.llm_output_cost_per_mtoken = 0.600 + return self + + @model_validator(mode="after") + def check_embed_cost_per_mtoken(self) -> Self: + # OpenAI text-embedding-3-small pricing (2024-09-09) + if self.embedding_cost_per_mtoken < 0: + self.embedding_cost_per_mtoken = 0.022 + return self + + @model_validator(mode="after") + def check_rerank_cost_per_ksearch(self) -> Self: + # Cohere rerank-multilingual-v3.0 pricing (2024-09-09) + if self.reranking_cost_per_ksearch < 0: + self.reranking_cost_per_ksearch = 2.0 + return self + + +class ModelConfigCreate(ModelConfigUpdate): + # Overrides to make these field required in ModelConfigCreate. + type: _ModelType = Field( + description="Model type. Can be completion, chat, embed, or rerank.", + ) + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Model name that is more user friendly.", + ) + context_length: int = Field( + gt=0, + description="Context length of model. Examples=[4096]", + ) + capabilities: list[_ModelCapability] = Field( + description="List of capabilities of model.", + ) + owned_by: SanitisedStr = Field( + "", + description="Model provider (usually organization that trained the model).", + ) + + @model_validator(mode="after") + def validate_owned_by_ellm_id_match(self) -> Self: + ellm_owned = self.owned_by == "ellm" + ellm_id = self.id.startswith("ellm/") + if (ellm_owned and not ellm_id) or (ellm_id and not ellm_owned): + raise ValueError('ELLM models must have `owned_by="ellm"` and `id="ellm/..."`.') + return self + + +class ModelConfig_(ModelConfigCreate, _TableBase): + # Computed fields + is_private: bool = Field( + False, + description="Whether this is a private model visible only to select organizations.", + ) + + @model_validator(mode="after") + def validate_owned_by_ellm_id_match(self) -> Self: + # Don't validate when reading from DB + return self + + +class ModelConfigRead(ModelConfig_): + deployments: list[Deployment_] = Field( + description="List of model deployment configs.", + ) + # Computed fields + # Since this depends on Deployment, we put here to avoid circular dependency + is_active: bool = Field( + description="Whether this model is active and ready for inference.", + ) + + +class Role(StrEnum): + ADMIN = "ADMIN" + MEMBER = "MEMBER" + GUEST = "GUEST" + + @property + def rank(self) -> "RankedRole": + return RankedRole[self.value] + + +class RankedRole(IntEnum): + GUEST = 0 + MEMBER = 1 + ADMIN = 2 + + @classmethod + def get(cls, role: str) -> int: + try: + return int(RankedRole[role]) + except KeyError: + return -1 + + +_Role = Annotated[Role, BeforeValidator(get_enum_validator(Role))] + + +class OrgMemberUpdate(_BaseModel): + role: _Role = Field( + description="Organization role.", + ) + + +class OrgMemberCreate(OrgMemberUpdate): + user_id: SanitisedNonEmptyStr = Field( + description="User ID.", + ) + organization_id: SanitisedNonEmptyStr = Field( + description="Organization ID.", + ) + + +class OrgMember_(OrgMemberCreate, _TableBase): + pass + + +class OrgMemberRead(OrgMember_): + user: "User_" = Field(description="User.") + organization: "Organization_" = Field(description="Organization.") + + +class ProjectMemberUpdate(_BaseModel): + role: _Role = Field( + description="Project role.", + ) + + +class ProjectMemberCreate(ProjectMemberUpdate): + user_id: SanitisedNonEmptyStr = Field( + description="User ID.", + ) + project_id: SanitisedNonEmptyStr = Field( + description="Project ID.", + ) + + +class ProjectMember_(ProjectMemberCreate, _TableBase): + pass + + +class ProjectMemberRead(ProjectMember_): + user: "User_" = Field(description="User.") + project: "Project_" = Field(description="Project.") + + +class _UserBase(_BaseModel): + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="User's preferred name.", + ) + email: EmailStr = Field( + "", + description="User's email.", + ) + picture_url: AnyUrl | None = Field( + None, + description="User picture URL.", + ) + google_id: SanitisedNonEmptyStr | None = Field( + None, + description="Google user ID.", + ) + google_name: SanitisedNonEmptyStr | None = Field( + None, + description="Google user's preferred name.", + ) + google_username: SanitisedNonEmptyStr | None = Field( + None, + description="Google username.", + ) + google_email: EmailStr | None = Field( + None, + description="Google email.", + ) + google_picture_url: SanitisedNonEmptyStr | None = Field( + None, + description="Google user picture URL.", + ) + google_updated_at: DatetimeUTC | None = Field( + None, + description="Google user info update datetime (UTC).", + ) + github_id: SanitisedNonEmptyStr | None = Field( + None, + description="GitHub user ID.", + ) + github_name: SanitisedNonEmptyStr | None = Field( + None, + description="GitHub user's preferred name.", + ) + github_username: SanitisedNonEmptyStr | None = Field( + None, + description="GitHub username.", + ) + github_email: EmailStr | None = Field( + None, + description="GitHub email.", + ) + github_picture_url: SanitisedNonEmptyStr | None = Field( + None, + description="GitHub user picture URL.", + ) + github_updated_at: DatetimeUTC | None = Field( + None, + description="GitHub user info update datetime (UTC).", + ) + + +class UserUpdate(_UserBase): + password: SanitisedNonEmptyStr = Field( + "", + max_length=72, + description="Password in plain text.", + ) + + +class UserCreate(UserUpdate): + id: SanitisedNonEmptyStr = Field( + default_factory=uuid7_str, + description="User ID.", + ) + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="User's preferred name.", + ) + email: EmailStr = Field( + description="User's email.", + ) + + +def _obscure_password_hash(value: Any) -> Any: + if value is not None: + return "***" + else: + return value + + +class User_(_UserBase, _TableBase): + id: SanitisedNonEmptyStr = Field( + default_factory=uuid7_str, + description="User ID.", + ) + email_verified: bool = Field( + description="Whether the email address is verified.", + ) + password_hash: Annotated[str | None, BeforeValidator(_obscure_password_hash)] = Field( + description="Password hash.", + ) + refresh_counter: int = Field( + 0, + description="Counter used as refresh token version for invalidation.", + ) + # Computed fields + preferred_name: str = Field( + "", + description="Name for display.", + ) + preferred_email: str = Field( + "", + description="Email for display.", + ) + preferred_picture_url: str | None = Field( + None, + description="Picture URL for display.", + ) + preferred_username: str | None = Field( + None, + description="Username for display.", + ) + + +class UserAuth(User_): + org_memberships: list[OrgMember_] = Field( + description="List of organization memberships.", + ) + proj_memberships: list[ProjectMember_] = Field( + description="List of project memberships.", + ) + + +class UserRead(UserAuth): + organizations: list["Organization_"] = Field( + description="List of organizations that this user is a member of.", + ) + projects: list["Project_"] = Field( + description="List of projects that this user is a member of.", + ) + + +class UserReadObscured(UserRead): + password_hash: Annotated[str | None, BeforeValidator(_obscure_password_hash)] = Field( + description="Password hash.", + ) + + +class PaymentState(StrEnum): + NONE = "NONE" # When an organization is created + SUCCESS = "SUCCESS" # Payment is completed + PROCESSING = "PROCESSING" # Payment is initiated but yet to complete + FAILED = "FAILED" # Payment failed + + +class OrganizationUpdate(_BaseModel): + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Organization name.", + ) + currency: ISO4217 = Field( + "USD", + description="Currency of the organization.", + ) + timezone: TimeZoneName | None = Field( + None, + description="Timezone specifier.", + ) + external_keys: dict[SanitisedNonEmptyStr, str] = Field( + {}, + description="Mapping of external service provider to its API key.", + ) + + @field_validator("external_keys", mode="before") + @classmethod + def validate_external_keys(cls, v: dict[str, str]) -> dict[str, str]: + # Remove empty API keys, and ensure provider is lowercase + v = {k.strip().lower(): v.strip() for k, v in v.items() if v.strip()} + return v + + @field_validator("currency", mode="after") + @classmethod + def validate_currency(cls, v: str) -> str: + if v != "USD": + raise ValueError("Currently only USD is supported.") + return v + + +class OrganizationCreate(OrganizationUpdate): + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Organization name.", + ) + + +class Organization_(OrganizationCreate, _TableBase): + id: str = Field( + description="Organization ID.", + ) + created_by: str = Field( + description="ID of the user that created this organization.", + ) + owner: str = Field( + description="ID of the user that owns this organization.", + ) + stripe_id: SanitisedNonEmptyStr | None = Field( + description="Stripe Customer ID.", + ) + # stripe_subscription_id: SanitisedNonEmptyStr = Field( + # "", + # description="Stripe Subscription ID.", + # ) + price_plan_id: SanitisedNonEmptyStr | None = Field( + description="Price plan ID.", + ) + payment_state: PaymentState = Field( + description="Payment state of the organization.", + ) + last_subscription_payment_at: DatetimeUTC | None = Field( + description="Datetime of the last successful subscription payment (UTC).", + ) + quota_reset_at: DatetimeUTC = Field( + description="Quota reset datetime (UTC).", + ) + credit: float = Field( + description="Credit paid by the customer. Unused credit will be carried forward to the next billing cycle.", + ) + credit_grant: float = Field( + description="Credit granted to the customer. Unused credit will NOT be carried forward.", + ) + llm_tokens_quota_mtok: float | None = Field( + description="LLM token quota in millions of tokens.", + ) + llm_tokens_usage_mtok: float = Field( + description="LLM token usage in millions of tokens.", + ) + embedding_tokens_quota_mtok: float | None = Field( + description="Embedding token quota in millions of tokens.", + ) + embedding_tokens_usage_mtok: float = Field( + description="Embedding token quota in millions of tokens.", + ) + reranker_quota_ksearch: float | None = Field( + description="Reranker quota for every thousand searches.", + ) + reranker_usage_ksearch: float = Field( + description="Reranker usage for every thousand searches.", + ) + db_quota_gib: float | None = Field( + description="DB storage quota in GiB.", + ) + db_usage_gib: float = Field( + description="DB storage usage in GiB.", + ) + db_usage_updated_at: DatetimeUTC = Field( + description="Datetime of the last successful DB usage update (UTC).", + ) + file_quota_gib: float | None = Field( + description="File storage quota in GiB.", + ) + file_usage_gib: float = Field( + description="File storage usage in GiB.", + ) + file_usage_updated_at: DatetimeUTC = Field( + description="Datetime of the last successful File usage update (UTC).", + ) + egress_quota_gib: float | None = Field( + description="Egress quota in GiB.", + ) + egress_usage_gib: float = Field( + description="Egress usage in GiB.", + ) + # Computed fields + active: bool = Field( + description="Whether the organization's quota is active (paid).", + ) + quotas: dict[str, dict[str, float | None]] = Field( + description="Quota snapshot.", + ) + + +class OrganizationRead(Organization_): + price_plan: PricePlan_ | None = Field( + description="Subscribed plan.", + ) + + +class ProjectUpdate(_BaseModel): + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Project name.", + ) + description: SanitisedMultilineStr = Field( + "", + description="Project description.", + ) + tags: list[str] = Field( + [], + description="Project tags.", + ) + profile_picture_url: str | None = Field( + None, + description="URL of the profile picture.", + ) + cover_picture_url: str | None = Field( + None, + description="URL of the cover picture.", + ) + + +class ProjectCreate(ProjectUpdate): + organization_id: SanitisedNonEmptyStr = Field( + description="Organization ID.", + ) + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Project name.", + ) + + +class Project_(ProjectCreate, _TableBase): + id: str = Field( + description="Project ID.", + ) + created_by: str = Field( + description="ID of the user that created this project.", + ) + owner: str = Field( + description="ID of the user that owns this project.", + ) + + +class ProjectRead(Project_): + organization: "Organization_" = Field( + description="Organization.", + ) + chat_agents: list[TableMetaResponse] | None = Field( + None, + description=( + "List of ID of chat agents in this project. " + "Empty list means no chat agents are available in this project. " + "Note that by default, the list is not populated will be None." + ), + ) + + +class VerificationCodeUpdate(_BaseModel): + name: SanitisedStr = Field( + "", + max_length=255, + description="Code name.", + ) + role: SanitisedNonEmptyStr | None = Field( + None, + description="Organization or project role.", + ) + + +class VerificationCodeCreate(VerificationCodeUpdate): + user_email: EmailStr = Field( + description="User email.", + ) + expiry: DatetimeUTC = Field( + description="Code expiry datetime (UTC).", + ) + organization_id: SanitisedNonEmptyStr | None = Field( + None, + description="Organization ID.", + ) + project_id: SanitisedNonEmptyStr | None = Field( + None, + description="Project ID.", + ) + + +class VerificationCode_(VerificationCodeCreate, _TableBase): + id: str = Field( + description="The code.", + ) + purpose: str | None = Field( + None, + description="Code purpose.", + ) + used_at: DatetimeUTC | None = Field( + None, + description="Code usage datetime (UTC).", + ) + revoked_at: DatetimeUTC | None = Field( + None, + description="Code revocation datetime (UTC).", + ) + + +class VerificationCodeRead(VerificationCode_): + pass + + +class ProjectKeyUpdate(_BaseModel): + name: SanitisedNonEmptyStr = Field( + "", + max_length=255, + description="Name.", + ) + expiry: DatetimeUTC | None = Field( + None, + description="Expiry datetime (UTC). If None, never expires.", + ) + + +class ProjectKeyCreate(ProjectKeyUpdate): + name: SanitisedNonEmptyStr = Field( + max_length=255, + description="Name.", + ) + project_id: SanitisedNonEmptyStr | None = Field( + None, + description="Project ID.", + ) + + +class ProjectKey_(ProjectKeyCreate, _TableBase): + id: str = Field( + description="The token.", + ) + user_id: str = Field( + description="User ID.", + ) + + +class ProjectKeyRead(ProjectKey_): + pass diff --git a/clients/python/src/jamaibase/types/file.py b/clients/python/src/jamaibase/types/file.py new file mode 100644 index 0000000..51c209d --- /dev/null +++ b/clients/python/src/jamaibase/types/file.py @@ -0,0 +1,42 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class FileUploadResponse(BaseModel): + object: Literal["file.upload"] = Field( + "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( + "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/types/gen_table.py b/clients/python/src/jamaibase/types/gen_table.py new file mode 100644 index 0000000..8db753b --- /dev/null +++ b/clients/python/src/jamaibase/types/gen_table.py @@ -0,0 +1,667 @@ +from functools import cached_property +from typing import Annotated, Any, Literal, Self, Union + +import numpy as np +from pydantic import ( + BaseModel, + Discriminator, + Field, + Tag, + field_validator, + model_validator, +) + +from jamaibase.types.common import ( + EXAMPLE_EMBEDDING_MODEL_IDS, + DatetimeUTC, + EmptyIfNoneStr, + PositiveInt, + SanitisedNonEmptyMultilineStr, + SanitisedNonEmptyStr, +) +from jamaibase.types.lm import ( + ChatCompletionChunkResponse, + ChatCompletionResponse, + ChatRequestBase, + References, +) +from jamaibase.utils.types import StrEnum + + +class CSVDelimiter(StrEnum): + COMMA = "," + TAB = "\t" + + +class TableType(StrEnum): + ACTION = "action" + KNOWLEDGE = "knowledge" + CHAT = "chat" + + +class CellReferencesResponse(References): + object: Literal["gen_table.references"] = Field( + "gen_table.references", + description="Type of API response object.", + examples=["gen_table.references"], + ) + output_column_name: str + row_id: str + + +class CellCompletionResponse(ChatCompletionChunkResponse): + object: Literal["gen_table.completion.chunk"] = Field( + "gen_table.completion.chunk", + description="Type of API response object.", + examples=["gen_table.completion.chunk"], + ) + output_column_name: str + row_id: str + + +class RowCompletionResponse(BaseModel): + object: Literal["gen_table.completion.chunks"] = Field( + "gen_table.completion.chunks", + description="Type of API response object.", + examples=["gen_table.completion.chunks"], + ) + # Union just to satisfy "object" discriminator + # columns: dict[str, ChatCompletionResponse | ChatCompletionChunkResponse] + columns: dict[str, ChatCompletionResponse] + row_id: str + + +class MultiRowCompletionResponse(BaseModel): + object: Literal["gen_table.completion.rows"] = Field( + "gen_table.completion.rows", + description="Type of API response object.", + examples=["gen_table.completion.rows"], + ) + rows: list[RowCompletionResponse] + + +class LLMGenConfig(ChatRequestBase): + object: Literal["gen_config.llm"] = Field( + "gen_config.llm", + description='The object type, which is always "gen_config.llm".', + examples=["gen_config.llm"], + ) + system_prompt: str = Field( + "", + description="System prompt for the LLM.", + ) + prompt: str = Field( + "", + description="Prompt for the LLM.", + ) + multi_turn: bool = Field( + False, + description="Whether this column is a multi-turn chat with history along the entire column.", + ) + + @model_validator(mode="before") + @classmethod + def compat(cls, data: dict[str, Any] | BaseModel) -> dict[str, 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 + + +class EmbedGenConfig(BaseModel): + object: Literal["gen_config.embed"] = Field( + "gen_config.embed", + description='The object type, which is always "gen_config.embed".', + examples=["gen_config.embed"], + ) + embedding_model: SanitisedNonEmptyStr = Field( + description="The embedding model to use.", + examples=EXAMPLE_EMBEDDING_MODEL_IDS, + ) + source_column: SanitisedNonEmptyStr = Field( + description="The source column for embedding.", + examples=["text_column"], + ) + + +class CodeGenConfig(BaseModel): + object: Literal["gen_config.code"] = Field( + "gen_config.code", + description='The object type, which is always "gen_config.code".', + examples=["gen_config.code"], + ) + source_column: SanitisedNonEmptyStr = Field( + description="The source column for python code to execute.", + examples=["code_column"], + ) + + +class PythonGenConfig(BaseModel): + object: Literal["gen_config.python"] = Field( + "gen_config.python", + description='The object type, which is always "gen_config.python".', + examples=["gen_config.python"], + ) + python_code: SanitisedNonEmptyMultilineStr = Field( + description="The python code to execute.", + examples=["row['output_column']='Hello World!'"], + ) + + +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" + if "source_column" in x: + return "gen_config.code" + if "python_code" in x: + return "gen_config.python" + else: + return "gen_config.llm" + return None + + +DiscriminatedGenConfig = Annotated[ + Union[ + # Annotated[CodeGenConfig, Tag("gen_config.code")], + Annotated[PythonGenConfig, Tag("gen_config.python")], + 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: str = Field( + "str", + description="Column data type.", + ) + vlen: PositiveInt = Field( # type: ignore + 0, + description=( + "_Optional_. Vector length. " + "If this is larger than zero, then `dtype` must be one of the floating data types. Defaults to zero." + ), + ) + index: bool = Field( + True, + description=( + "_Optional_. Whether to build full-text-search (FTS) or vector index for this column. " + "Only applies to string and vector columns. Defaults to True." + ), + ) + gen_config: DiscriminatedGenConfig | None = Field( + None, + description=( + '_Optional_. Generation config. If provided, then this column will be an "Output Column". ' + "Table columns on its left can be referenced by `${column-name}`." + ), + ) + + +class ColumnSchemaCreate(ColumnSchema): + id: SanitisedNonEmptyStr = Field(description="Column name.") + dtype: Literal["int", "float", "bool", "str", "file", "image", "audio", "document"] = Field( + "str", + description=( + 'Column data type, one of ["int", "float", "bool", "str", "file", "image", "audio", "document"]' + ". Data type 'file' is deprecated, use 'image' instead." + ), + ) + + +class _TableBase(BaseModel): + id: str = Field( + description="Table name.", + ) + + +class TableSchemaCreate(_TableBase): + id: SanitisedNonEmptyStr = Field( + description="Table name.", + ) + cols: list[ColumnSchemaCreate] = Field( + description="List of column schema.", + ) + + +class ActionTableSchemaCreate(TableSchemaCreate): + pass + + +class AddActionColumnSchema(ActionTableSchemaCreate): + # TODO: Deprecate this + pass + + +class KnowledgeTableSchemaCreate(TableSchemaCreate): + # TODO: Maybe deprecate this and use EmbedGenConfig instead ? + embedding_model: str + + +class AddKnowledgeColumnSchema(TableSchemaCreate): + # TODO: Deprecate this + pass + + +class ChatTableSchemaCreate(TableSchemaCreate): + pass + + +class AddChatColumnSchema(TableSchemaCreate): + # TODO: Deprecate this + pass + + +class TableMeta(_TableBase): + meta: dict[str, Any] | None = Field( + None, + description="Additional metadata about the table.", + ) + cols: list[ColumnSchema] = Field( + description="List of column schema.", + ) + parent_id: str | None = Field( + description="The parent table ID. If None (default), it means this is a parent table.", + ) + title: str = Field( + description='Chat title. Defaults to "".', + ) + created_by: str | None = Field( + None, + description="ID of the user that created this table. Defaults to None.", + ) + updated_at: DatetimeUTC = Field( + description="Table last update datetime (UTC).", + ) + num_rows: int = Field( + -1, + description="Number of rows in the table. Defaults to -1 (not counted).", + ) + version: str = Field( + description="Version.", + ) + + @cached_property + def col_map(self) -> dict[str, ColumnSchema]: + return {c.id: c for c in self.cols} + + @cached_property + def cfg_map(self) -> dict[str, DiscriminatedGenConfig | None]: + return {c.id: c.gen_config for c in self.cols} + + +class TableMetaResponse(TableMeta): + # Legacy, for backwards compatibility + indexed_at_fts: str | None = Field( + None, + description="Table last FTS index timestamp (ISO 8601 UTC).", + ) + indexed_at_vec: str | None = Field( + None, + description="Table last vector index timestamp (ISO 8601 UTC).", + ) + indexed_at_sca: str | None = Field( + None, + description="Table last scalar index timestamp (ISO 8601 UTC).", + ) + + @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 GenConfigUpdateRequest(BaseModel): + table_id: str = Field(description="Table name or ID.") + column_map: dict[str, DiscriminatedGenConfig | None] = Field( + description=( + "Mapping of column ID to generation config JSON in the form of `GenConfig`. " + "Table columns on its left can be referenced by `${column-name}`." + ) + ) + + @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'.") + return self + + +class ColumnRenameRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + column_map: dict[str, str] = Field( + min_length=1, + description="Mapping of old column names to new column names.", + ) + + @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'.") + return self + + +class ColumnReorderRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + column_names: list[str] = Field( + min_length=1, + description="List of column ID in the desired order.", + ) + + @field_validator("column_names", mode="after") + @classmethod + def check_column_order(cls, values: list[str]) -> list[str]: + if values[0].lower() != "id": + values.insert(0, "ID") + if values[1].lower() != "updated at": + values.insert(1, "Updated at") + return values + + @field_validator("column_names", mode="after") + @classmethod + def check_unique_column_names(cls, values: list[str]) -> list[str]: + if len(set(n.lower() for n in values)) != len(values): + raise ValueError("Column names must be unique (case-insensitive).") + return values + + @field_validator("column_names", mode="after") + @classmethod + def check_state_column(cls, values: list[str]) -> list[str]: + if len(invalid_cols := [n for n in values if n.endswith("_")]) > 0: + raise ValueError(f"State columns cannot be reordered: {invalid_cols}") + return values + + +class ColumnDropRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + column_names: list[str] = Field( + min_length=1, + description="List of column ID to drop.", + ) + + @model_validator(mode="after") + def check_column_names(self) -> Self: + if sum(n.lower() in ("id", "updated at") for n in self.column_names) > 0: + raise ValueError("`column_names` cannot contain keys: 'ID' or 'Updated at'.") + return self + + +class MultiRowAddRequest(BaseModel): + table_id: SanitisedNonEmptyStr = Field( + description="Table name or ID.", + ) + data: list[dict[str, Any]] = Field( + min_length=1, + description=( + "List of mapping of column names to its value. " + "In other words, each item in the list is a row, and each item is a mapping. " + "Minimum 1 row, maximum 100 rows." + ), + ) + stream: bool = Field( + True, + description="Whether or not to stream the LLM generation.", + ) + concurrent: bool = Field( + True, + description="_Optional_. Whether or not to concurrently generate the output rows and columns.", + ) + + def __repr__(self): + _data = [ + { + k: ( + {"type": type(v), "shape": v.shape, "dtype": v.dtype} + if isinstance(v, np.ndarray) + else v + ) + for k, v in d.items() + } + for d in self.data + ] + return ( + f"{self.__class__.__name__}(" + f"table_id={self.table_id} stream={self.stream} " + f"concurrent={self.concurrent} data={_data}" + ")" + ) + + +class RowUpdateRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + row_id: str = Field( + description="ID of the row to update.", + ) + data: dict[str, Any] = Field( + description="Mapping of column names to its value.", + ) + + +class MultiRowUpdateRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + data: dict[str, dict[str, Any]] = Field( + min_length=1, + description="Mapping of row IDs to row data, where each row data is a mapping of column names to its value.", + ) + + +class MultiRowUpdateRequestWithLimit(MultiRowUpdateRequest): + data: dict[str, dict[str, Any]] = Field( + min_length=1, + max_length=100, + description="Mapping of row IDs to row data, where each row data is a mapping of column names to its value.", + ) + + +class RowRegen(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + row_id: str = Field( + description="ID of the row to regenerate.", + ) + regen_strategy: str = Field( + "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( + None, + min_length=1, + 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.", + ) + concurrent: bool = Field( + True, + description="_Optional_. Whether or not to concurrently generate the output columns.", + ) + + +class MultiRowRegenRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + row_ids: list[str] = Field( + min_length=1, + max_length=100, + description="List of ID of the row to regenerate. Minimum 1 row, maximum 100 rows.", + ) + regen_strategy: str = Field( + "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( + None, + min_length=1, + 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( + True, + description="Whether or not to stream the LLM generation.", + ) + concurrent: bool = Field( + True, + description="Whether or not to concurrently generate the output rows and columns. Defaults to True.", + ) + + +class MultiRowDeleteRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + row_ids: list[str] | None = Field( + None, + min_length=1, + max_length=100, + description="List of row IDs to be deleted. Maximum 100 rows. Defaults to None (match rows using `where`).", + ) + where: EmptyIfNoneStr = Field( + "", + description=( + "SQL where clause. " + "Can be nested ie `x = '1' AND (\"y (1)\" = 2 OR z = '3')`. " + "It will be combined with `row_ids` using `AND`. " + 'Defaults to "" (no filter).' + ), + ) + + +class SearchRequest(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + query: str = Field( + min_length=1, + description="Query for full-text-search (FTS) and vector search. Must not be empty.", + ) + limit: Annotated[int, Field(gt=0, le=1_000)] = Field( + 100, + description="_Optional_. Min 1, max 1000. Number of rows to return.", + ) + metric: str = Field( + "cosine", + description='_Optional_. Vector search similarity metric. Defaults to "cosine".', + ) + float_decimals: int = Field( + 0, + description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", + ) + vec_decimals: int = Field( + 0, + 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.") + ] = None + + +class TableDataImportRequest(BaseModel): + file_path: Annotated[str, Field(description="CSV or TSV file path.")] + 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 + ) + # column_names: Annotated[ + # list[str] | None, + # Field( + # description="A list of columns names if the CSV does not have header row. Defaults to None (read from CSV)." + # ), + # ] = None + # columns: Annotated[ + # list[str] | None, + # Field( + # description="A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at')." + # ), + # ] = None + delimiter: Annotated[ + 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 + blocking: Annotated[ + bool, + Field( + description=( + "If True, waits until import finishes. " + "If False, the task is submitted to a task queue and returns immediately." + ), + ), + ] = True diff --git a/clients/python/src/jamaibase/types/legacy.py b/clients/python/src/jamaibase/types/legacy.py new file mode 100644 index 0000000..f9b5741 --- /dev/null +++ b/clients/python/src/jamaibase/types/legacy.py @@ -0,0 +1,49 @@ +from pydantic import BaseModel, Field + +from jamaibase.types.lm import ( + Chunk, + RAGParams, +) + + +class VectorSearchRequest(RAGParams): + id: str = Field( + default="", + description="Request ID for logging purposes.", + examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], + ) + search_query: str = Field(description="Query used to retrieve items from the Knowledge Table.") + + +class VectorSearchResponse(BaseModel): + object: str = Field( + default="kb.search_response", + description="Type of API response object.", + examples=["kb.search_response"], + ) + chunks: list[Chunk] = Field( + default=[], + description="A list of `Chunk`.", + examples=[ + [ + Chunk( + text="The Name of the Title is Hope\n\n...", + title="The Name of the Title is Hope", + page=0, + file_name="sample_tables.pdf", + file_path="amagpt/sample_tables.pdf", + metadata={ + "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)", + "Trapped": "False", + }, + ) + ] + ], + ) diff --git a/clients/python/src/jamaibase/types/lm.py b/clients/python/src/jamaibase/types/lm.py new file mode 100644 index 0000000..3289640 --- /dev/null +++ b/clients/python/src/jamaibase/types/lm.py @@ -0,0 +1,1433 @@ +import re +from time import time +from typing import Annotated, Any, Literal, Union + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from jamaibase.types.common import ( + EXAMPLE_EMBEDDING_MODEL_IDS, + EXAMPLE_RERANKING_MODEL_IDS, + EmptyIfNoneStr, + PositiveInt, + PositiveNonZeroInt, +) +from jamaibase.utils.types import StrEnum + +CITATION_PATTERN = r"\[(@[0-9]+)[; ]*\]" + + +class Chunk(BaseModel): + """Class for storing a piece of text and associated metadata.""" + + text: str = Field( + description="Chunk text.", + ) + title: str = Field( + "", + description='Document title. Defaults to "".', + ) + page: int | None = Field( + None, + description="Document page the chunk text is from. Defaults to None.", + ) + file_name: str = Field( + "", + description='File name. Defaults to "".', + ) + file_path: str = Field( + "", + description='File path. Defaults to "".', + ) + document_id: str = Field( + "", + description='Document ID. Defaults to "".', + ) + chunk_id: str = Field( + "", + description='Chunk ID. Defaults to "".', + ) + context: dict[str, str] = Field( + {}, + description="Additional context that should be included in the RAG prompt. Defaults to an empty dictionary.", + ) + metadata: dict = Field( + {}, + description=( + "Arbitrary metadata about the page content (e.g., source, relationships to other documents, etc.). " + "Defaults to an empty dictionary." + ), + ) + + +class SplitChunksParams(BaseModel): + method: str = Field( + "RecursiveCharacterTextSplitter", + description="Name of the splitter.", + examples=["RecursiveCharacterTextSplitter"], + ) + chunk_size: PositiveNonZeroInt = Field( + 1000, + description="Maximum chunk size (number of characters). Must be > 0.", + examples=[1000], + ) + chunk_overlap: PositiveInt = Field( + 200, + description="Overlap in characters between chunks. Must be >= 0.", + examples=[200], + ) + + +class SplitChunksRequest(BaseModel): + id: str = Field( + "", + description="Request ID for logging purposes.", + examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], + ) + chunks: list[Chunk] = Field( + description="List of `Chunk` where each will be further split into chunks.", + examples=[ + [ + Chunk( + text="The Name of the Title is Hope\n\n...", + title="The Name of the Title is Hope", + page=0, + file_name="sample_tables.pdf", + file_path="amagpt/sample_tables.pdf", + metadata={ + "total_pages": 3, + "Author": "Ben Trovato", + "CreationDate": "D:20231031072817Z", + "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles", + "Keywords": "Image Captioning, Deep Learning", + "ModDate": "D:20231031073146Z", + }, + ) + ] + ], + ) + params: SplitChunksParams = Field( + SplitChunksParams(), + description=( + "How to split each document. " + "Defaults to `RecursiveCharacterTextSplitter` with chunk_size = 1000 and chunk_overlap = 200." + ), + examples=[SplitChunksParams()], + ) + + def str_trunc(self) -> str: + return f"id={self.id} len(chunks)={len(self.chunks)} params={self.params}" + + +class References(BaseModel): + object: Literal["chat.references"] = Field( + "chat.references", + description="Type of API response object.", + examples=["chat.references"], + ) + chunks: list[Chunk] = Field( + [], + description="A list of `Chunk`.", + examples=[ + [ + Chunk( + text="The Name of the Title is Hope\n\n...", + title="The Name of the Title is Hope", + page=0, + file_name="sample_tables.pdf", + file_path="amagpt/sample_tables.pdf", + metadata={ + "total_pages": 3, + "Author": "Ben Trovato", + "CreationDate": "D:20231031072817Z", + "Creator": "LaTeX with acmart 2023/10/14 v1.92 Typesetting articles", + "Keywords": "Image Captioning, Deep Learning", + "ModDate": "D:20231031073146Z", + }, + ) + ] + ], + ) + search_query: str = Field(description="Query used to retrieve items from the Knowledge Table.") + finish_reason: Literal["stop", "context_overflow"] | None = Field( + None, + deprecated=True, + description=""" +In streaming mode, reference chunk will be streamed first. +However, if the model's context length is exceeded, then there will be no further completion chunks. +In this case, "finish_reason" will be set to "context_overflow". +Otherwise, it will be None or null. +""", + ) + + def remove_contents(self): + copy = self.model_copy(deep=True) + for d in copy.documents: + d.page_content = "" + return copy + + +class RAGParams(BaseModel): + table_id: str = Field( + "", + description="Knowledge Table ID", + examples=["my-dataset"], + ) + reranking_model: str | None = Field( + None, + description="Reranking model to use for hybrid search. Defaults to None (no reranking).", + examples=[EXAMPLE_RERANKING_MODEL_IDS[0], None], + ) + search_query: str = Field( + "", + description=( + "Query used to retrieve items from the Knowledge Table. " + "If not provided (default), it will be generated using LLM." + ), + ) + k: Annotated[int, Field(gt=0, le=1024)] = Field( + 3, + gt=0, + le=1024, + description="Top-k closest text in terms of embedding distance. Must be in [1, 1024]. Defaults to 3.", + examples=[3], + ) + rerank: bool = Field( + True, + deprecated=True, + description="(Deprecated) Flag to perform rerank on the retrieved results. Defaults to True.", + examples=[True, False], + ) + concat_reranker_input: bool = Field( + False, + description="Flag to concat title and content as reranker input. Defaults to False.", + examples=[True, False], + ) + inline_citations: bool = Field( + True, + description=( + "If True, the model will cite sources as it writes using Pandoc-style in the form of `[@]`. " + "The number is the index of the source in the reference list, ie `[@0; @3]` means the 1st and 4th source in `References.chunks`. " + "Defaults to True." + ), + examples=[True, False], + ) + + +class FunctionCall(BaseModel): + name: str = Field( + description="The name of the function to call.", + ) + arguments: str = Field( + description="The arguments to call the function with, as generated by the model in JSON format.", + ) + + +class ToolCallFunction(BaseModel): + arguments: str + name: str | None + + +class ToolCall(BaseModel): + id: str = Field( + description="The ID of the tool call.", + ) + type: Literal["function"] = Field( + "function", + description="The type of the tool. Currently, only `function` is supported.", + ) + function: ToolCallFunction + + +class AudioResponse(BaseModel): + id: str = Field( + description="Unique identifier for this audio response.", + ) + expires_at: int = Field( + description="The Unix timestamp (in seconds) for when this audio response will no longer be accessible.", + ) + data: str = Field( + description="Base64 encoded audio bytes generated by the model.", + ) + transcript: str = Field( + description="Transcript of the audio generated by the model.", + ) + + +class ChatCompletionDelta(BaseModel): + role: str = Field( + "assistant", + description="The role of the author of this message.", + ) + content: str | None = Field( + None, + description="The contents of the chunk message.", + ) + reasoning_content: str | None = Field( + None, + description="The reasoning contents generated by the model.", + ) + refusal: str | None = Field( + None, + description="The refusal message generated by the model.", + ) + tool_calls: list[ToolCall] | None = Field( + None, + description="The tool calls generated by the model, such as function calls.", + ) + function_call: FunctionCall | None = Field( + None, + deprecated=True, + description=( + "Deprecated and replaced by `tool_calls`. " + "The name and arguments of a function that should be called." + ), + ) + + +class ChatCompletionMessage(ChatCompletionDelta): + # content: str = Field( + # description="The contents of the message.", + # ) + audio: AudioResponse | None = Field( + None, + description="If the audio output modality is requested, this object contains data about the audio response from the model.", + ) + + +class LogProbToken(BaseModel): + token: str = Field( + description="The token.", + ) + logprob: float = Field( + description=( + "The log probability of this token, if it is within the top 20 most likely tokens. " + "Otherwise, the value `-9999.0` is used to signify that the token is very unlikely." + ), + ) + bytes: list[int] | None = Field( + description="A list of integers representing the UTF-8 bytes representation of the token.", + ) + + +class LogProbs(BaseModel): + content: list[LogProbToken] | None = Field( + None, + description="A list of message content tokens with log probability information.", + ) + refusal: list[LogProbToken] | None = Field( + None, + description="A list of message refusal tokens with log probability information.", + ) + + +class ChatCompletionChoice(BaseModel): + index: int = Field( + description="The index of the choice in the list of choices.", + ) + message: ChatCompletionMessage | None = Field( + None, + description="A chat completion message generated by the model.", + ) + delta: ChatCompletionDelta | None = Field( + None, + description="A chat completion delta generated by streamed model responses.", + ) + logprobs: LogProbs | None = Field( + None, + description="Log probability information for the choice.", + ) + finish_reason: str | None = Field( + 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." + ), + ) + + @property + def text(self) -> str: + """The text of the most recent chat completion.""" + message = self.message or self.delta + return getattr(message, "content", None) or "" + + @model_validator(mode="after") + def validate_message_delta(self): + if self.delta is not None: + self.message = ChatCompletionMessage.model_validate(self.delta.model_dump()) + return self + + +def _none_to_zero(v: int | None) -> int: + if v is None: + return 0 + return v + + +ZeroIfNoneInt = Annotated[int, BeforeValidator(_none_to_zero)] + + +class PromptUsageDetails(BaseModel): + cached_tokens: ZeroIfNoneInt = Field( + 0, + description="Cached tokens present in the prompt.", + ) + audio_tokens: ZeroIfNoneInt = Field( + 0, + description="Audio input tokens present in the prompt or generated by the model.", + ) + + +class CompletionUsageDetails(BaseModel): + audio_tokens: ZeroIfNoneInt = Field( + 0, + description="Audio input tokens present in the prompt or generated by the model.", + ) + reasoning_tokens: ZeroIfNoneInt = Field( + 0, + description="Tokens generated by the model for reasoning.", + ) + accepted_prediction_tokens: ZeroIfNoneInt = Field( + 0, + description="When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion.", + ) + rejected_prediction_tokens: ZeroIfNoneInt = Field( + 0, + description="When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion.", + ) + + +class ToolUsageDetails(BaseModel): + web_search_calls: ZeroIfNoneInt = Field( + 0, + description="Number of web search calls.", + ) + code_interpreter_calls: ZeroIfNoneInt = Field( + 0, + description="Number of code interpreter calls.", + ) + + +class ChatCompletionUsage(BaseModel): + prompt_tokens: ZeroIfNoneInt = Field( + 0, + description="Number of tokens in the prompt.", + ) + completion_tokens: ZeroIfNoneInt = Field( + 0, + description="Number of tokens in the generated completion.", + ) + total_tokens: ZeroIfNoneInt = Field( + 0, + description="Total number of tokens used in the request (prompt + completion).", + ) + prompt_tokens_details: PromptUsageDetails | None = Field( + None, + description="Breakdown of tokens used in the prompt.", + ) + completion_tokens_details: CompletionUsageDetails | None = Field( + None, + description="Breakdown of tokens used in a completion.", + ) + tool_usage_details: ToolUsageDetails | None = Field( + None, + description="Breakdown of tool usage details, such as web search and code interpreter calls.", + ) + + @property + def reasoning_tokens(self) -> int: + return getattr(self.completion_tokens_details, "reasoning_tokens", 0) + + +class ChatCompletionResponse(BaseModel): + id: str = Field( + description="A unique identifier for the chat completion.", + ) + object: Literal["chat.completion"] = Field( + "chat.completion", + description="The object type, which is always `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] = Field( + description=( + "A list of chat completion choices. " + "Can contain more than one elements if `n` is greater than 1." + ), + ) + usage: ChatCompletionUsage = Field( + description="Usage statistics for the completion request.", + ) + references: References | None = Field( + None, + description="References of this Retrieval Augmented Generation (RAG) response.", + ) + service_tier: str | None = Field( + None, + description="The service tier used for processing the request.", + ) + system_fingerprint: str | None = Field( + None, + description="This fingerprint represents the backend configuration that the model runs with.", + ) + + @field_validator("choices", mode="after") + @classmethod + def validate_choices(cls, v: list[ChatCompletionChoice]) -> list[ChatCompletionChoice]: + if len(v) > 0 and v[0].message is None: + raise ValueError("`message` must be defined.") + return v + + @property + def finish_reason(self) -> str | None: + return self.choices[0].finish_reason if len(self.choices) > 0 else None + + @property + def delta(self) -> ChatCompletionMessage | None: + """The delta of the first chat completion choice.""" + return self.message + + @property + def message(self) -> ChatCompletionMessage | None: + """The message of the first chat completion choice.""" + return self.choices[0].message if len(self.choices) > 0 else None + + @property + def reasoning_content(self) -> str: + """The reasoning text of the first chat completion choice message.""" + return getattr(self.message, "reasoning_content", None) or "" + + @property + def content(self) -> str: + """The text of the first chat completion choice message.""" + return getattr(self.message, "content", None) or "" + + @property + def text(self) -> str: + """The text of the most recent chat completion.""" + return self.content + + @property + def prompt_tokens(self) -> int: + return getattr(self.usage, "prompt_tokens", 0) + + @property + def completion_tokens(self) -> int: + return getattr(self.usage, "completion_tokens", 0) + + @property + def reasoning_tokens(self) -> int: + return getattr(self.usage, "reasoning_tokens", 0) + + @property + def total_tokens(self) -> int: + return getattr(self.usage, "total_tokens", 0) + + +class ChatCompletionChunkResponse(ChatCompletionResponse): + object: Literal["chat.completion.chunk"] = Field( + "chat.completion.chunk", + description="The object type, which is always `chat.completion.chunk`.", + ) + choices: list[ChatCompletionChoice] = Field( + description=( + "A list of chat completion choices. " + "Can contain more than one elements if `n` is greater than 1. " + 'Can also be empty for the last chunk if you set stream_options: `{"include_usage": true}`.' + ), + ) + usage: ChatCompletionUsage | None = Field( + None, + description="Contains a `null` value except for the last chunk which contains the token usage statistics for the entire request.", + ) + + @field_validator("choices", mode="after") + @classmethod + def validate_choices(cls, v: list[ChatCompletionChoice]) -> list[ChatCompletionChoice]: + # Override + return v + + +class TextContent(BaseModel): + type: Literal["text"] = Field( + "text", + description="The type of content.", + ) + text: EmptyIfNoneStr = Field( + description="The text content.", + ) + + +class ImageContentData(BaseModel): + url: str = Field( + description=( + "Either a URL of the image or the base64 encoded image data " + 'in the form of `"data:;base64,{base64_image}"`.' + ), + ) + + def __repr__(self): + _url = self.url + if len(_url) > 12: + _url = f"{_url[:6]}...{_url[-6:]}" + return f"{self.__class__.__name__}(url='{_url}')" + + +class ImageContent(BaseModel): + type: Literal["image_url"] = Field( + "image_url", + description="The type of content.", + ) + image_url: ImageContentData = Field( + description="The image content.", + ) + + +class AudioContentData(BaseModel): + data: str = Field( + description="Base-64 encoded audio data.", + ) + format: Literal["mp3", "wav"] = Field( + "wav", + description="The audio format.", + ) + + def __repr__(self): + _data = self.data + if len(_data) > 12: + _data = f"{_data[:6]}...{_data[-6:]}" + return f"{self.__class__.__name__}(data='{_data}', format='{self.format}')" + + +class AudioContent(BaseModel): + type: Literal["input_audio"] = Field( + "input_audio", + description="The type of content.", + ) + input_audio: AudioContentData = Field( + description="The audio content.", + ) + + +# class AudioURLData(BaseModel): +# url: str = Field( +# description=( +# "Either a URL of the audio or the base64 encoded audio data " +# 'in the form of `"data:;base64,{base64_audio}"`.' +# ), +# ) + +# def __repr__(self): +# _url = self.url +# if len(_url) > 12: +# _url = f"{_url[:6]}...{_url[-6:]}" +# return f"{self.__class__.__name__}(url='{_url}')" + + +# class AudioURL(BaseModel): +# type: Literal["audio_url"] = Field( +# "audio_url", +# description="The type of content.", +# ) +# audio_url: AudioURLData = Field( +# description="The audio content.", +# ) + + +class S3Content(BaseModel): + type: Literal["input_s3"] = Field( + "input_s3", + description="The type of content.", + ) + uri: str = Field( + description="The S3 URI.", + ) + column_name: str = Field( + description="The column holding this data.", + ) + + +ChatContent = Annotated[ + Union[TextContent, ImageContent, AudioContent], + Field(discriminator="type"), +] +ChatContentS3 = Annotated[ + Union[TextContent, S3Content], + Field(discriminator="type"), +] + + +class ChatRole(StrEnum): + """Represents who said a chat message.""" + + SYSTEM = "system" + """The message is from the system (usually a steering prompt).""" + USER = "user" + """The message is from the user.""" + ASSISTANT = "assistant" + """The message is from the language model.""" + # FUNCTION = "function" + # """The message is the result of a function call.""" + + +def _sanitise_name(v: str) -> str: + """Replace any non-alphanumeric and dash characters with space. + + Args: + v (str): Raw name string. + + Returns: + out (str): Sanitised name string that is safe for OpenAI. + """ + return re.sub(r"[^a-zA-Z0-9_-]", "_", v).strip() + + +class ChatEntry(BaseModel): + """Represents a message in the chat context.""" + + model_config = ConfigDict(use_enum_values=True) + + role: ChatRole = Field( + description="Who said the message?", + ) + content: EmptyIfNoneStr | list[ChatContent] = Field( + description="The content of the message.", + ) + name: Annotated[str, AfterValidator(_sanitise_name)] | None = Field( + None, + description="The name of the user who sent the message, if set (user messages only).", + ) + + @property + def text_content(self) -> str: + if isinstance(self.content, str): + return self.content + text_contents = [c for c in self.content if isinstance(c, TextContent)] + if len(text_contents) > 0: + return "\n".join(c.text for c in text_contents) + return "" + + @property + def has_text_only(self) -> bool: + # Explicitly use `isinstance(self.content, str)` to help the type checker + return isinstance(self.content, str) or all( + isinstance(c, TextContent) for c in self.content + ) + + @property + def has_image(self) -> bool: + # Explicitly use `isinstance(self.content, str)` to help the type checker + return (not isinstance(self.content, str)) and any( + isinstance(c, ImageContent) for c in self.content + ) + + @property + def has_audio(self) -> bool: + # Explicitly use `isinstance(self.content, str)` to help the type checker + return (not isinstance(self.content, str)) and any( + isinstance(c, AudioContent) for c in self.content + ) + + @classmethod + def system(cls, content: str | list[ChatContent | ChatContentS3], **kwargs): + """Create a new system message.""" + return cls(role="system", content=content, **kwargs) + + @classmethod + def user(cls, content: str | list[ChatContent | ChatContentS3], **kwargs): + """Create a new user message.""" + return cls(role="user", content=content, **kwargs) + + @classmethod + def assistant(cls, content: str | None, **kwargs): + """Create a new assistant message.""" + return cls(role="assistant", content=content, **kwargs) + + +class ChatThreadEntry(ChatEntry): + """Represents a message in the chat thread response.""" + + content: EmptyIfNoneStr | list[ChatContentS3] = Field( + description="The content of the message.", + ) + user_prompt: str | None = Field( + None, + description=( + "Original prompt sent by the user without content interpolation/injection. " + 'Only applicable for Chat Table column that references the "User" column. ' + "Defaults to None (not applicable)." + ), + ) + references: References | None = Field( + None, + description=( + "References of this Retrieval Augmented Generation (RAG) response. " + "Defaults to None (not applicable)." + ), + ) + row_id: str | None = Field( + None, + description="Table row ID of this chat message. Defaults to None (not applicable).", + ) + + +class ChatThreadResponse(BaseModel): + object: Literal["chat.thread"] = Field( + "chat.thread", + description="Type of API response object.", + examples=["chat.thread"], + ) + thread: list[ChatThreadEntry] = Field( + [], + description="List of chat messages.", + examples=[ + [ + ChatThreadEntry.system("You are an assistant."), + ChatThreadEntry.user("Hello."), + ChatThreadEntry.assistant( + "Hello.", + references=References( + chunks=[Chunk(title="Title", text="Text")], + search_query="hello", + ), + ), + ] + ], + ) + column_id: str = Field( + "", + description="Table column ID of this chat thread.", + ) + + +class _ChatThreadsBase(BaseModel): + object: Literal["chat.threads"] = Field( + "chat.threads", + description="Type of API response object.", + examples=["chat.threads"], + ) + threads: dict[str, ChatThreadResponse] = Field( + [], + description="List of chat threads.", + examples=[ + dict( + AI=ChatThreadResponse( + thread=[ + ChatThreadEntry.system("You are an assistant."), + ChatThreadEntry.user("Hello."), + ChatThreadEntry.assistant( + "Hello.", + references=References( + chunks=[Chunk(title="Title", text="Text")], + search_query="hello", + ), + ), + ] + ), + ) + ], + ) + + +class ChatThreadsResponse(_ChatThreadsBase): + table_id: str = Field( + "", + description="Table ID of the chat threads.", + ) + + +class ConversationThreadsResponse(_ChatThreadsBase): + conversation_id: str = Field( + "", + description="Conversation ID of the chat threads.", + ) + + +class FunctionParameter(BaseModel): + type: str = Field( + "", + description="The type of the parameter, e.g., 'string', 'number'.", + ) + description: str = Field( + "", + description="A description of the parameter.", + ) + enum: list[str] = Field( + [], + description="An optional list of allowed values for the parameter.", + ) + + +class FunctionParameters(BaseModel): + type: str = Field( + "object", + description="The type of the parameters object, usually 'object'.", + ) + properties: dict[str, FunctionParameter] = Field( + description="The properties of the parameters object.", + ) + required: list[str] = Field( + description="A list of required parameter names.", + ) + additionalProperties: bool = Field( + False, + description="Whether additional properties are allowed.", + ) + + +class Function(BaseModel): + name: str = Field( + max_length=64, + description=( + "The name of the function to be called. " + "Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64." + ), + ) + description: str | None = Field( + None, + description="A description of what the function does, used by the model to choose when and how to call the function.", + ) + parameters: FunctionParameters | None = Field( + None, + description="The parameters the functions accepts, described as a JSON Schema object.", + ) + strict: bool = Field( + False, + description=( + "Whether to enable strict schema adherence when generating the function call. " + "If set to `true`, the model will follow the exact schema defined in the `parameters` field. " + "Only a subset of JSON Schema is supported when `strict` is `true`." + ), + ) + + +class FunctionTool(BaseModel): + type: Literal["function"] = Field( + "function", + description="The type of the tool. Currently, only `function` is supported.", + ) + function: Function + + +class WebSearchTool(BaseModel): + type: Literal["web_search"] = Field( + "web_search", + description="The type of tool.", + ) + + +class CodeInterpreterTool(BaseModel): + type: Literal["code_interpreter"] = Field( + "code_interpreter", + description="The type of tool.", + ) + container: dict[str, str] = Field( + {"type": "auto"}, + description="The code interpreter container.", + ) + + +Tool = Annotated[ + WebSearchTool | CodeInterpreterTool | FunctionTool, + Field( + discriminator="type", + description=( + "The type of tool. " + "Currently, one of `web_search`, `code_interpreter`, or `function`. " + "Note that `web_search` and `code_interpreter` are only supported with OpenAI models. " + "They will be ignored with other models." + ), + ), +] + + +class ToolChoiceFunction(BaseModel): + name: str = Field( + description="The name of the function to call.", + ) + + +class ToolChoice(BaseModel): + type: str = Field( + "function", + description="The type of the tool. Currently, only `function` is supported.", + ) + function: ToolChoiceFunction = Field( + description="The function that should be called.", + ) + + +def _empty_list_to_none(v: list[str]) -> list[str] | None: + if len(v) == 0: + v = None + return v + + +class ChatRequestBase(BaseModel): + """ + Base for chat request and LLM gen config. + """ + + model: str = Field( + "", + description='ID of the model to use. Defaults to "".', + ) + rag_params: RAGParams | None = Field( + None, + description="Retrieval Augmented Generation params. Defaults to None (disabled).", + examples=[RAGParams(table_id="papers"), None], + ) + tools: list[Tool] | None = Field( + None, + description=( + "A list of tools available for the chat model to use. " + "Note that `web_search` and `code_interpreter` are only supported with OpenAI models. " + "They will be ignored with other models." + ), + min_length=1, + examples=[ + [ + WebSearchTool(), + CodeInterpreterTool(), + FunctionTool( + type="function", + function=Function( + name="get_weather", + description="Get current temperature for a given location.", + parameters=FunctionParameters( + type="object", + properties={ + "location": FunctionParameter( + type="string", + description="City and country e.g. Bogotá, Colombia", + ) + }, + required=["location"], + additionalProperties=False, + ), + ), + ), + ], + ], + ) + tool_choice: Literal["none", "auto", "required"] | ToolChoice | None = Field( + None, + description=( + "Controls which (if any) tool is called by the model. " + '`"none"` means the model will not call any tool and instead generates a message. ' + '`"auto"` means the model can pick between generating a message or calling one or more tools. ' + '`"required"` means the model must call one or more tools. ' + 'Specifying a particular tool via `{"type": "function", "function": {"name": "my_function"}` forces the model to call that tool. ' + '`"none"` is the default when no tools are present. ' + '`"auto"` is the default if tools are present.' + ), + examples=[ + "auto", + ToolChoice(type="function", function=ToolChoiceFunction(name="get_delivery_date")), + ], + ) + temperature: float = Field( + 0.2, + ge=0, + description=( + "What sampling temperature to use. " + "Higher values like 0.8 will make the output more random, " + "while lower values like 0.2 will make it more focused and deterministic. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models and Anthropic with extended thinking." + ), + examples=[0.2], + ) + top_p: float = Field( + 0.6, + ge=0.001, + 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. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models and Anthropic with extended thinking." + ), + examples=[0.6], + ) + stream: bool = Field( + True, + description=( + "If set, partial message deltas will be sent, like in ChatGPT. " + "Tokens will be sent as server-sent events (SSE) as they become available, " + "with the stream terminated by a `data: [DONE]` message." + ), + examples=[True, False], + ) + max_tokens: PositiveNonZeroInt = Field( + 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], + ) + stop: Annotated[list[str], AfterValidator(_empty_list_to_none)] | None = Field( + None, + min_length=1, + description=( + "A list of sequences where the API will stop generating further tokens. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models." + ), + examples=[None], + ) + presence_penalty: float = Field( + 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. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models." + ), + examples=[0.0], + ) + frequency_penalty: float = Field( + 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. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models." + ), + examples=[0.0], + ) + logit_bias: dict = Field( + {}, + 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. " + "Note that this parameter will be ignored when using that do not support it, " + "such as OpenAI's reasoning models." + ), + examples=[{}], + ) + reasoning_effort: Literal["disable", "minimal", "low", "medium", "high"] | None = Field( + "minimal", + description=( + "Constrains effort on reasoning for reasoning models. " + "Currently supported values are `disable`, `minimal`, `low`, `medium`, and `high`. " + "Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. " + "For non-OpenAI models, `low` ~ 1024 tokens, `medium` ~ 2048 tokens, `high` ~ 4096 tokens. " + "Note that this parameter will be ignored when using models that do not support it, " + "such as non-reasoning models." + ), + examples=["low"], + ) + reasoning_effort: Literal["disable", "minimal", "low", "medium", "high"] | None = Field( + None, + description=( + "Constrains effort on reasoning for reasoning models. " + "Currently supported values are `disable`, `minimal`, `low`, `medium`, and `high`. " + "Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. " + "For non-OpenAI models, `low` ~ 1024 tokens, `medium` ~ 4096 tokens, `high` ~ 8192 tokens. " + "Note that this parameter will be ignored when using models that do not support it, " + "such as non-reasoning models." + ), + examples=["low"], + ) + thinking_budget: int | None = Field( + None, + ge=0, + description=( + "Model reasoning budget in tokens. " + "Set to zero to disable reasoning if supported. " + "For OpenAI models, 1 <= budget <= 1024 is low, 1025 <= budget <= 4096 is medium, 4097 <= budget <= 8192 is high. " + "Note that this parameter will be ignored when using models that do not support it, " + "such as non-reasoning models." + ), + examples=[1024], + ) + reasoning_summary: Literal["auto", "concise", "detailed"] = Field( + "auto", + description=( + "To access the most detailed summarizer available for a model, set the value of this parameter to auto. " + "auto will be equivalent to detailed for most reasoning models today, " + "but there may be more granular settings in the future. " + "Will be ignored if the model does not support it." + ), # https://platform.openai.com/docs/guides/reasoning/advice-on-prompting#reasoning-summaries + ) + + @property + def hyperparams(self) -> dict[str, Any]: + # object key could cause issue to some LLM provider, ex: Anthropic + return self.model_dump(exclude_none=True, exclude={"object", "messages", "rag_params"}) + + +class ChatRequest(ChatRequestBase): + id: str = Field( + "", + description='Chat ID for logging. Defaults to "".', + ) + messages: list[ChatEntry] = Field( + min_length=1, + description="A list of messages comprising the conversation so far.", + ) + max_completion_tokens: PositiveNonZeroInt | None = Field( + None, + description=( + "An upper bound for the number of tokens that can be generated for a completion, " + "including visible output tokens and reasoning tokens. " + "Must be in [1, context_length - 1). Default is 2048. " + "If both `max_completion_tokens` and `max_tokens` are set, `max_completion_tokens` will be used. " + ), + examples=[2048], + ) + n: int = Field( + 1, + description=( + "How many chat completion choices to generate for each input message. " + "Note that this parameter will be ignored when using models and tools that do not support it." + ), + examples=[1], + ) + user: str = Field( + "", + description="A unique identifier representing your end-user. For monitoring and debugging purposes.", + examples=[""], + ) + stream: bool = Field( + False, + description=( + "If set, partial message deltas will be sent, like in ChatGPT. " + "Tokens will be sent as server-sent events (SSE) as they become available, " + "with the stream terminated by a 'data: [DONE]' message." + ), + examples=[True, False], + ) + + @model_validator(mode="after") + def validate_params(self): + self.max_tokens = self.max_completion_tokens or self.max_tokens + if self.thinking_budget and self.thinking_budget > self.max_tokens: + raise ValueError("`thinking_budget` cannot be higher than `max_tokens`.") + return self + + +class EmbeddingRequest(BaseModel): + input: str | list[str] = Field( + description=( + "Input text to embed, encoded as a string or array of strings " + "(to embed multiple inputs in a single request). " + "The input must not exceed the max input tokens for the model, and cannot contain empty string." + ), + examples=["What is a llama?", ["What is a llama?", "What is an alpaca?"]], + ) + model: str = Field( + description=( + "The ID of the model to use. " + "You can use the List models API to see all of your available models." + ), + examples=EXAMPLE_EMBEDDING_MODEL_IDS, + ) + type: Literal["query", "document"] = Field( + "document", + description=( + 'Whether the input text is a "query" (used to retrieve) or a "document" (to be retrieved).' + ), + examples=["query", "document"], + ) + encoding_format: Literal["float", "base64"] = Field( + "float", + description=( + '_Optional_. The format to return the embeddings in. Can be either "float" or "base64". ' + "`base64` string should be decoded as a `float32` array. " + "Example: `np.frombuffer(base64.b64decode(response), dtype=np.float32)`" + ), + examples=["float", "base64"], + ) + dimensions: PositiveNonZeroInt | None = Field( + None, + description=( + "The number of dimensions the resulting output embeddings should have. " + "Note that this parameter will only be used when using models that support Matryoshka embeddings." + ), + ) + + +class EmbeddingResponseData(BaseModel): + object: Literal["embedding"] = Field( + "embedding", + description="The object type, which is always `embedding`.", + examples=["embedding"], + ) + embedding: list[float] | str = Field( + description=( + "The embedding vector, which is a list of floats or a base64-encoded string. " + "The length of vector depends on the model." + ), + examples=[[0.0, 1.0, 2.0], []], + ) + index: int = Field( + 0, + description="The index of the embedding in the list of embeddings.", + examples=[0, 1], + ) + + +class EmbeddingUsage(BaseModel): + prompt_tokens: ZeroIfNoneInt = Field( + 0, + description="Number of tokens in the prompt.", + ) + total_tokens: ZeroIfNoneInt = Field( + 0, + description="Total number of tokens used in the request.", + ) + + +class EmbeddingResponse(BaseModel): + object: Literal["list"] = Field( + "list", + description="Type of API response object.", + examples=["list"], + ) + data: list[EmbeddingResponseData] = Field( + description="List of `EmbeddingResponseData`.", + examples=[[EmbeddingResponseData(embedding=[0.0, 1.0, 2.0])]], + ) + model: str = Field( + description="The ID of the model used.", + examples=["openai/text-embedding-3-small-512"], + ) + usage: EmbeddingUsage = Field( + EmbeddingUsage(), + description="The number of tokens consumed.", + examples=[EmbeddingUsage()], + ) + + +class RerankingRequest(BaseModel): + model: str = Field( + description=( + "The ID of the model to use. " + "You can use the List models API to see all of your available models." + ), + examples=EXAMPLE_RERANKING_MODEL_IDS, + ) + documents: list[str] + query: str + + +class RerankingData(BaseModel): + object: Literal["reranking"] = Field( + "reranking", + description="Type of API response object.", + examples=["reranking"], + ) + index: int + relevance_score: float + + +class RerankingApiVersion(BaseModel): + version: str = Field( + "", + description="API version.", + examples=["2"], + ) + is_deprecated: bool = Field( + False, + description="Whether it is deprecated.", + examples=[False], + ) + is_experimental: bool = Field( + False, + description="Whether it is experimental.", + examples=[False], + ) + + +class RerankingBilledUnits(BaseModel): + images: int | None = Field(None, description="The number of billed images.") + input_tokens: int | None = Field(None, description="The number of billed input tokens.") + output_tokens: int | None = Field(None, description="The number of billed output tokens.") + search_units: float | None = Field(None, description="The number of billed search units.") + classifications: float | None = Field( + None, description="The number of billed classifications units." + ) + + +class RerankingMetaUsage(BaseModel): + input_tokens: int | None = Field( + None, + description="The number of tokens used as input to the model.", + ) + output_tokens: int | None = Field( + None, + description="The number of tokens produced by the model.", + ) + + +class RerankingUsage(RerankingMetaUsage): + documents: ZeroIfNoneInt = Field( + description="The number of documents processed.", + ) + + +class RerankingMeta(BaseModel): + model: str = Field( + description="The ID of the model used.", + examples=["cohere/rerank-multilingual-v3.0"], + ) + api_version: RerankingApiVersion | None = Field( + None, + description="API version.", + examples=[RerankingApiVersion(), None], + ) + billed_units: RerankingBilledUnits | None = Field( + None, + description="Billed units.", + examples=[RerankingBilledUnits(), None], + ) + tokens: RerankingMetaUsage | None = Field( + None, + description="Token usage.", + examples=[RerankingMetaUsage(input_tokens=500), None], + ) + warnings: list[str] | None = Field( + None, + description="Warnings.", + examples=[["This is a warning."], None], + ) + + +class RerankingResponse(BaseModel): + object: Literal["list"] = Field( + "list", + description="Type of API response object.", + examples=["list"], + ) + results: list[RerankingData] = Field( + description="List of `RerankingResponseData`.", + examples=[[RerankingData(index=0, relevance_score=0.0032)]], + ) + usage: RerankingUsage = Field( + description="Usage.", + examples=[RerankingUsage(documents=10), None], + ) + meta: RerankingMeta = Field( + description="Reranking metadata from Cohere.", + ) diff --git a/clients/python/src/jamaibase/types/logs.py b/clients/python/src/jamaibase/types/logs.py new file mode 100644 index 0000000..748dd52 --- /dev/null +++ b/clients/python/src/jamaibase/types/logs.py @@ -0,0 +1,7 @@ +from typing import Any + +from pydantic import BaseModel + + +class LogQueryResponse(BaseModel): + logs: list[dict[str, Any]] diff --git a/clients/python/src/jamaibase/types/mcp.py b/clients/python/src/jamaibase/types/mcp.py new file mode 100644 index 0000000..827498d --- /dev/null +++ b/clients/python/src/jamaibase/types/mcp.py @@ -0,0 +1,461 @@ +from enum import IntEnum +from typing import Any, Generic, Literal, TypeVar + +from pydantic import AnyUrl, BaseModel, Field + + +# Standard JSON-RPC error codes +class JSONRPCErrorCode(IntEnum): + """Standard JSON-RPC error codes as defined by the JSON-RPC 2.0 specification.""" + + # Standard error codes + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + + # Custom error codes + UNAUTHORIZED = -32001 + FORBIDDEN = -32003 + + +ProgressToken = str | int +Cursor = str +Role = Literal["user", "assistant"] +# AnyFunction: TypeAlias = Callable[..., Any] + +MetaT = TypeVar("ParamsT", bound=dict[str, Any]) + + +class RequestParamsMeta(BaseModel, extra="allow"): + """Metadata for request parameters.""" + + progressToken: ProgressToken | None = Field( + None, + description=( + "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). " + "The value of this parameter is an opaque token that will be attached to any subsequent notifications. " + "The receiver is not obligated to provide these notifications." + ), + ) + + +class Params(BaseModel, Generic[MetaT], extra="allow"): + meta: MetaT | None = Field( + None, + alias="_meta", + description="This parameter name is reserved by MCP to allow clients and servers to attach additional metadata.", + ) + + +ParamsT = TypeVar("ParamsT", bound=Params) + + +class PaginatedRequestParams(Params): + cursor: str | None = Field( + None, + description=( + "An opaque token representing the current pagination position. " + "If provided, the server should return results starting after this cursor." + ), + ) + + +class JSONRPCBase(BaseModel, extra="allow"): + jsonrpc: Literal["2.0"] = "2.0" + + +class JSONRPCRequest(JSONRPCBase, Generic[ParamsT]): + """Base request interface.""" + + id: str | int = Field(description="Request ID.") + method: str + params: ParamsT | None = Field( + None, + description="Parameters for the request.", + ) + + +class PaginatedRequest(JSONRPCRequest[PaginatedRequestParams]): + pass + + +class JSONRPCNotification(JSONRPCBase, Generic[ParamsT]): + method: str + params: ParamsT | None = Field( + None, + description="This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + ) + + +class InitializedNotification(JSONRPCNotification[Params]): + method: Literal["notifications/initialized"] = "notifications/initialized" + + +class Result(BaseModel, extra="allow"): + """ + Base result class that allows for additional metadata and arbitrary fields. + """ + + meta: dict[str, Any] | None = Field( + None, + alias="_meta", + description="This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + ) + + +ResultT = TypeVar("ResultT", bound=Result) + + +class JSONRPCResponse(JSONRPCBase, Generic[ResultT]): + id: str | int = Field(description="Request ID that this response corresponds to.") + result: ResultT | None = None + + +class JSONRPCEmptyResponse(JSONRPCBase): + id: str | int = Field(description="Request ID that this response corresponds to.") + result: dict[str, Any] = {} + + +class ErrorData(BaseModel, extra="allow"): + """Error information for JSON-RPC error responses.""" + + code: int = Field( + description="The error code, which is a negative integer as defined by the JSON-RPC specification.", + ) + message: str = Field( + description="A short description of the error. This message should be concise and limited to a single sentence.", + ) + data: Any | None = Field( + None, + description=( + "Additional information about the error. " + "The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + ), + ) + + +class JSONRPCError(JSONRPCBase): + id: str | int = Field(description="Request ID that this response corresponds to.") + error: ErrorData + + +class Capability(BaseModel): + """Capabilities related to prompt templates.""" + + listChanged: bool = Field( + False, + description="Whether this server supports notifications for changes to the prompt list.", + ) + + +class ResourcesCapability(Capability): + """Capabilities related to resources.""" + + subscribe: bool | None = Field( + None, + description="Whether this server supports subscribing to resource updates.", + ) + + +class ServerCapabilities(BaseModel, extra="allow"): + """ + Capabilities that a server may support. Known capabilities are defined here, + in this schema, but this is not a closed set: any server can define its own, + additional capabilities. + """ + + experimental: dict[str, dict[str, Any]] | None = Field( + None, + description="Experimental, non-standard capabilities that the server supports.", + ) + logging: dict[str, Any] | None = Field( + None, + description="Present if the server supports sending log messages to the client.", + ) + completions: dict[str, Any] | None = Field( + None, + description="Present if the server supports argument autocompletion suggestions.", + ) + prompts: Capability | None = Field( + None, + description="Present if the server offers any prompt templates.", + ) + resources: ResourcesCapability | None = Field( + None, + description="Present if the server offers any resources to read.", + ) + tools: Capability | None = Field( + Capability(listChanged=False), + description="Present if the server offers any tools to call.", + ) + + +class Implementation(BaseModel): + name: str + version: str + + +class InitializeRequestParams(BaseModel): + protocolVersion: str + capabilities: dict[str, Any] + clientInfo: Implementation + + +class InitializeRequest(JSONRPCRequest[InitializeRequestParams]): + method: Literal["initialize"] = "initialize" + + +class InitializeResult(Result): + protocolVersion: Literal["2025-03-26"] = "2025-03-26" + capabilities: ServerCapabilities + serverInfo: Implementation + instructions: str | None = Field( + None, + description=( + "Instructions describing how to use the server and its features." + "This can be used by clients to improve the LLM's understanding of available tools, resources, etc. " + 'It can be thought of like a "hint" to the model. ' + "For example, this information MAY be added to the system prompt." + ), + ) + + +class ListToolsRequest(PaginatedRequest): + method: Literal["tools/list"] = "tools/list" + + +class ToolAnnotations(BaseModel): + """ + Additional properties describing a Tool to clients. + + NOTE: all properties in ToolAnnotations are *hints*. + They are not guaranteed to provide a faithful description of + tool behavior (including descriptive properties like `title`). + + Clients should never make tool use decisions based on ToolAnnotations + received from untrusted servers. + """ + + title: str | None = Field( + None, + description="A human-readable title for the tool.", + ) + readOnlyHint: bool | None = Field( + False, + description="If true, the tool does not modify its environment. Default: False", + ) + destructiveHint: bool | None = Field( + True, + description=( + "If true, the tool may perform destructive updates to its environment. " + "If false, the tool performs only additive updates. " + "(This property is meaningful only when `readOnlyHint == false`) Default: True" + ), + ) + idempotentHint: bool | None = Field( + False, + description=( + "If true, calling the tool repeatedly with the same arguments " + "will have no additional effect on the its environment. " + "(This property is meaningful only when `readOnlyHint == false`) Default: False" + ), + ) + openWorldHint: bool | None = Field( + True, + description=( + "If true, this tool may interact with an 'open world' of external " + "entities. If false, the tool's domain of interaction is closed. " + "For example, the world of a web search tool is open, whereas that " + "of a memory tool is not. Default: True" + ), + ) + + +class ToolInputSchema(BaseModel): + """JSON Schema object defining the expected parameters for the tool.""" + + type: Literal["object"] = "object" + properties: dict[str, dict[str, Any]] | None = Field( + None, + description="Schema properties defining the tool parameters.", + ) + required: list[str] | None = Field( + None, + description="List of required parameter names.", + ) + + +class ToolAPIInfo(BaseModel): + path: str + method: str + args_types: dict[str, Literal["header", "query", "path", "body"]] + method_info: dict[str, Any] + + +class Tool(BaseModel): + """Definition for a tool the client can call.""" + + name: str = Field( + description="The name of the tool.", + ) + description: str | None = Field( + None, + description=( + "A human-readable description of the tool. " + "This can be used by clients to improve the LLM's understanding of available tools. " + "It can be thought of like a 'hint' to the model." + ), + ) + input_schema: ToolInputSchema = Field( + alias="inputSchema", + description="A JSON Schema object defining the expected parameters for the tool.", + ) + annotations: ToolAnnotations | None = Field( + None, + description="Optional additional tool information.", + ) + + +class ToolAPI(Tool): + """Definition for a tool the client can call.""" + + api_info: ToolAPIInfo | None = Field( + None, + description="API information.", + ) + + +class ListToolsResult(Result): + tools: list[Tool] + + +class CallToolRequestParams(Params): + """Parameters specific to tool call requests.""" + + name: str = Field( + description="The name of the tool to call.", + ) + arguments: dict[str, Any] | None = Field( + None, + description="Arguments to pass to the tool.", + ) + + +class CallToolRequest(JSONRPCRequest[CallToolRequestParams]): + """Used by the client to invoke a tool provided by the server.""" + + method: Literal["tools/call"] = "tools/call" + + +class Annotations(BaseModel): + """ + Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + """ + + audience: list[Role] | None = Field( + None, + description="Describes who the intended customer of this object or data is. " + "It can include multiple entries to indicate content useful for multiple audiences (e.g., ['user', 'assistant']).", + ) + priority: float | None = Field( + None, + ge=0.0, + le=1.0, + description="Describes how important this data is for operating the server. " + "A value of 1 means 'most important,' and indicates that the data is " + "effectively required, while 0 means 'least important,' and indicates that " + "the data is entirely optional.", + ) + + +class Content(BaseModel): + annotations: Annotations | None = Field( + None, + description="Optional annotations for the client.", + ) + + +class TextContent(Content): + """Text provided to or from an LLM.""" + + type: Literal["text"] = "text" + text: str = Field( + description="The text content of the message.", + ) + + +class ImageContent(Content): + """An image provided to or from an LLM.""" + + type: Literal["image"] = "image" + data: str = Field( + description="The base64-encoded image data.", + ) + mimeType: str = Field( + description="The MIME type of the image. Different providers may support different image types.", + ) + + +class AudioContent(Content): + """Audio provided to or from an LLM.""" + + type: Literal["audio"] = "audio" + data: str = Field( + description="The base64-encoded audio data.", + ) + mimeType: str = Field( + description="The MIME type of the audio. Different providers may support different audio types.", + ) + + +class ResourceContents(BaseModel): + """The contents of a specific resource or sub-resource.""" + + uri: AnyUrl = Field(description="The URI of this resource.") + mimeType: str | None = Field( + None, + description="The MIME type of this resource, if known.", + ) + + +class TextResourceContents(ResourceContents): + """Resource contents with text data.""" + + text: str = Field( + description="The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + ) + + +class BlobResourceContents(ResourceContents): + """Resource contents with binary data.""" + + blob: str = Field( + description="A base64-encoded string representing the binary data of the item." + ) + + +class EmbeddedResource(Content): + """ + The contents of a resource, embedded into a prompt or tool call result. + + It is up to the client how best to render embedded resources for the benefit + of the LLM and/or the user. + """ + + type: Literal["resource"] = "resource" + resource: TextResourceContents | BlobResourceContents = Field( + description="The resource contents, either text or binary data." + ) + + +class CallToolResult(Result): + content: list[TextContent | ImageContent | AudioContent | EmbeddedResource] + isError: bool | None = Field( + False, + description=( + "Whether the tool call ended in an error. " + "If not set, this is assumed to be false (the call was successful)." + ), + ) diff --git a/clients/python/src/jamaibase/types/model.py b/clients/python/src/jamaibase/types/model.py new file mode 100644 index 0000000..cc48f2e --- /dev/null +++ b/clients/python/src/jamaibase/types/model.py @@ -0,0 +1,126 @@ +from functools import cached_property +from typing import Literal, Self, Union + +from natsort import natsorted +from pydantic import ( + BaseModel, + Field, + model_validator, +) + +from jamaibase.types.common import ( + EXAMPLE_CHAT_MODEL_IDS, + EXAMPLE_EMBEDDING_MODEL_IDS, + EXAMPLE_RERANKING_MODEL_IDS, +) +from jamaibase.types.db import ModelInfoRead + + +class ModelInfoListResponse(BaseModel): + object: Literal["models.info"] = Field( + "models.info", + description="Type of API response object.", + examples=["models.info"], + ) + data: list[ModelInfoRead] = Field( + description="List of model information.", + ) + + @model_validator(mode="after") + def sort_models(self) -> Self: + self.data = list(natsorted(self.data, key=self._sort_key)) + return self + + @staticmethod + def _sort_key(x: ModelInfoRead) -> str: + return (int(not x.id.startswith("ellm")), x.name) + + +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_IDS[0], + EXAMPLE_EMBEDDING_MODEL_IDS[0], + EXAMPLE_RERANKING_MODEL_IDS[0], + ], + ) + name: str = Field( + description="Name of the model.", + examples=["OpenAI GPT-4o Mini"], + ) + + +class LLMModelPrice(_ModelPrice): + llm_input_cost_per_mtoken: float = Field( + description="Cost in USD per million input / prompt token.", + ) + llm_output_cost_per_mtoken: float = Field( + description="Cost in USD per million output / completion token.", + ) + + +class EmbeddingModelPrice(_ModelPrice): + embedding_cost_per_mtoken: float = Field( + description="Cost in USD per million embedding tokens.", + ) + + +class RerankingModelPrice(_ModelPrice): + reranking_cost_per_ksearch: float = Field( + description="Cost in USD for a thousand (kilo) searches." + ) + + +class ModelPrice(BaseModel): + object: Literal["prices.models"] = Field( + "prices.models", + description="Type of API response object.", + examples=["prices.models"], + ) + llm_models: list[LLMModelPrice] = [] + embed_models: list[EmbeddingModelPrice] = [] + rerank_models: list[RerankingModelPrice] = [] + + @cached_property + def model_map( + self, + ) -> dict[str, Union[LLMModelPrice, EmbeddingModelPrice, RerankingModelPrice]]: + """ + Build and cache a dictionary of models for faster lookups. + + Returns: + Dict[str, Union[LLMModelPrice, EmbeddingModelPrice, RerankingModelPrice]]: A dictionary mapping model IDs to their price information. + """ + cache = {} + for model in self.llm_models: + cache[model.id] = model + for model in self.embed_models: + cache[model.id] = model + for model in self.rerank_models: + cache[model.id] = model + return cache + + def get(self, model_id: str) -> Union[LLMModelPrice, EmbeddingModelPrice, RerankingModelPrice]: + """ + Retrieve the price information for a specific model by its ID. + + Args: + model_id (str): The ID of the model to retrieve. + + Returns: + Union[LLMModelPrice, EmbeddingModelPrice, RerankingModelPrice]: + The pricing information for the requested model. + + Raises: + ValueError: If the model ID is not found in the `model_map`. + """ + try: + return self.model_map[model_id] + except KeyError as e: + raise ValueError( + f"Invalid model ID: {model_id}. Available models: {list(self.model_map.keys())}" + ) from e diff --git a/clients/python/src/jamaibase/types/telemetry.py b/clients/python/src/jamaibase/types/telemetry.py new file mode 100644 index 0000000..22e068e --- /dev/null +++ b/clients/python/src/jamaibase/types/telemetry.py @@ -0,0 +1,162 @@ +from datetime import datetime, timedelta +from typing import Any, ClassVar + +from pydantic import BaseModel + + +class Metric(BaseModel): + name: str + device_type: str + value: float + timestamp: int + hostname: str + device_model: str + device_id: str + + SYSTEM_METRIC_NAMES: ClassVar[dict[str, str]] = { + "cpu_util": "cpu_util", + "memory_util": "memory_util", + "disk_read_bytes": "disk_read_bytes", + "disk_write_bytes": "disk_write_bytes", + "network_receive_bytes": "network_receive_bytes", + "network_transmit_bytes": "network_transmit_bytes", + } + + AMD_METRIC_NAMES: ClassVar[dict[str, str]] = { + "gpu_clock": "device_clock", + "gpu_memory_clock": "device_memory_clock", + "gpu_edge_temperature": "device_temp", + "gpu_memory_temperature": "device_memory_temp", + "gpu_power_usage": "device_power_usage", + "gpu_gfx_activity": "device_util", + "gpu_umc_activity": "device_memory_utils", + "gpu_free_vram": "device_free_memory", + "gpu_used_vram": "device_used_memory", + } + + NVIDIA_METRIC_NAMES: ClassVar[dict[str, str]] = { + "DCGM_FI_DEV_SM_CLOCK": "device_clock", + "DCGM_FI_DEV_MEM_CLOCK": "device_memory_clock", + "DCGM_FI_DEV_GPU_TEMP": "device_temp", + "DCGM_FI_DEV_MEMORY_TEMP": "device_memory_temp", + "DCGM_FI_DEV_POWER_USAGE": "device_power_usage", + "DCGM_FI_DEV_GPU_UTIL": "device_util", + "DCGM_FI_DEV_MEM_COPY_UTIL": "device_memory_utils", + "DCGM_FI_DEV_FB_FREE": "device_free_memory", + "DCGM_FI_DEV_FB_USED": "device_used_memory", + } + + SYSTEM_LABELS: ClassVar[dict[str, str]] = { + "metric_name": "__name__", + "hostname": "instance", + "device_model": "N/A", + "device_id": "N/A", + } + + AMD_LABELS: ClassVar[dict[str, str]] = { + "metric_name": "__name__", + "hostname": "hostname", + "device_model": "card_series", + "device_id": "gpu_id", + } + + NVIDIA_LABELS: ClassVar[dict[str, str]] = { + "metric_name": "__name__", + "hostname": "Hostname", + "device_model": "modelName", + "device_id": "gpu", + } + + @classmethod + def from_response(cls, response: dict[str, Any]) -> "Metric": + """Create a Metric instance from a response dictionary. + + This method extracts relevant information from the response dictionary obtained from response + and uses it to create and return a Metric object. It determines the Device type and selects the appropriate + labels and metric names for processing. + + Args: + response (dict[str, Any]): A dictionary containing the metric data from response. + + Returns: + Metric: A Metric object populated with the data from the response. + + Raises: + ValueError: If the Device type is not recognized as either 'system', 'amd' or 'nvidia'. + """ + device_type = response["metric"]["job"].split("-")[0] + if device_type.lower() not in ["amd", "nvidia", "system"]: + raise ValueError( + f"Expected device_type to be within [nvidia, amd, system] but instead got {device_type}" + ) + + if device_type == "nvidia": + device_labels = cls.NVIDIA_LABELS + metric_names = cls.NVIDIA_METRIC_NAMES + elif device_type == "amd": + device_labels = cls.AMD_LABELS + metric_names = cls.AMD_METRIC_NAMES + elif device_type == "system": + device_labels = cls.SYSTEM_LABELS + metric_names = cls.SYSTEM_METRIC_NAMES + + return cls( + name=metric_names[response["metric"][device_labels["metric_name"]]], + device_type=device_type, + value=float(response["value"][1]), + timestamp=response["value"][0], + hostname=response["metric"][device_labels["hostname"]], + device_model=response["metric"].get(device_labels["device_model"], "N/A"), + device_id=response["metric"].get(device_labels["device_id"], "N/A"), + ) + + +class Host(BaseModel): + name: str + metrics: list[Metric] + + +class Usage(BaseModel): + value: float + window_start: str + window_end: str + subject: str + groupBy: dict[str, str] + + @classmethod + def from_result( + cls, + value: list[Any], + metrics: dict[str, Any], + data_interval: timedelta, + group_by: list[str], + ) -> "Usage": + """Create a Usage instance from a result entry. + + Args: + value (list[Any]): A list containing the timestamp and value. + metrics (dict[str, Any]): A dictionary containing metric labels. + data_interval (timedelta): The data interval to adjust the window range. + group_by (list[str]): The group-by fields for the query. + + Returns: + Usage: A Usage object populated with the data from the result entry. + """ + return cls( + value=float(value[1]), + window_start=(datetime.fromtimestamp(value[0]) - data_interval).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + window_end=datetime.fromtimestamp(value[0]).strftime("%Y-%m-%dT%H:%M:%SZ"), + subject=metrics["org_id"], + groupBy={ + key: metrics[key] for key in group_by if key != "org_id" and key in metrics.keys() + }, + ) + + +class UsageResponse(BaseModel): + windowSize: str + data: list[Usage] + start: str + end: str diff --git a/clients/python/src/jamaibase/utils/__init__.py b/clients/python/src/jamaibase/utils/__init__.py index 0b7677e..cce9e31 100644 --- a/clients/python/src/jamaibase/utils/__init__.py +++ b/clients/python/src/jamaibase/utils/__init__.py @@ -1,7 +1,11 @@ +import time from asyncio.coroutines import iscoroutine -from datetime import datetime, timezone from typing import Any, AsyncGenerator, Awaitable, Callable, Generator, TypeVar +import numpy as np +from uuid_extensions import uuid7str as _uuid7_draft2_str +from uuid_utils import uuid7 as _uuid7 + R = TypeVar("R") @@ -18,5 +22,54 @@ async def run(fn: Callable[..., R | Awaitable[R]], *args: Any, **kwargs: Any) -> return ret -def datetime_now_iso() -> str: - return datetime.now(timezone.utc).isoformat() +def get_non_empty(mapping: dict[str, Any], key: str, default: Any): + value = mapping.get(key, None) + return value if value else default + + +def uuid7_draft2_str(prefix: str = "") -> str: + return f"{prefix}{_uuid7_draft2_str()}" + + +def uuid7_str(prefix: str = "") -> str: + return f"{prefix}{_uuid7()}" + + +def get_ttl_hash(seconds: int = 3600) -> int: + """Return the same value within `seconds` time period""" + return round(time.time() / max(1, seconds)) + + +def mask_string(x: str | None, *, include_len: bool = True) -> str | None: + if x is None or x == "": + return x + str_len = len(x) + if str_len < 4: + return f"{'*' * str_len} ({str_len=})" + visible_len = min(100, str_len // 5) + x = f"{x[:visible_len]}***{x[-visible_len:]}" + return f"{x} ({str_len=})" if include_len else x + + +def mask_content(x: str | list | dict | np.ndarray | Any) -> str | list | dict | None: + if isinstance(x, str): + return mask_string(x) + 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, np.ndarray): + return f"array(shape={x.shape}, dtype={x.dtype})" + return None + + +def merge_dict(d: dict | Any, update: dict | Any): + if isinstance(d, dict) and isinstance(update, dict): + for k, v in update.items(): + d[k] = merge_dict(d.get(k, {}), v) + return d + return update + + +def mask_dict(value: dict[str, str | Any]) -> dict[str, str]: + return {k: "***" if v else v for k, v in value.items()} diff --git a/clients/python/src/jamaibase/utils/background_loop.py b/clients/python/src/jamaibase/utils/background_loop.py new file mode 100644 index 0000000..eca4582 --- /dev/null +++ b/clients/python/src/jamaibase/utils/background_loop.py @@ -0,0 +1,33 @@ +# Modified from LanceDB +# https://github.com/lancedb/lancedb/blob/main/python/python/lancedb/background_loop.py + +import asyncio +import threading + + +class BackgroundEventLoop: + """ + A background event loop that can run futures. + + Used to bridge sync and async code, without messing with users event loops. + """ + + def __init__(self): + self.loop = asyncio.new_event_loop() + self.thread = threading.Thread( + target=self.loop.run_forever, + name="JamAIBackgroundEventLoop", + daemon=True, + ) + self.thread.start() + + def run(self, future): + return asyncio.run_coroutine_threadsafe(future, self.loop).result() + + def cleanup(self): + self.loop.call_soon_threadsafe(self.loop.stop) + self.thread.join() + self.loop.close() + + +LOOP = BackgroundEventLoop() diff --git a/clients/python/src/jamaibase/utils/dates.py b/clients/python/src/jamaibase/utils/dates.py new file mode 100644 index 0000000..822f3ca --- /dev/null +++ b/clients/python/src/jamaibase/utils/dates.py @@ -0,0 +1,82 @@ +from datetime import date, datetime, time, timezone +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +def now(tz: str = "UTC") -> datetime: + return datetime.now(ZoneInfo(tz)) + + +def now_iso(tz: str = "UTC") -> str: + return now(tz).isoformat() + + +def now_tz_naive(tz: str = "UTC") -> datetime: + return datetime.now(ZoneInfo(tz)).replace(tzinfo=None) + + +def earliest(tz: str = "UTC") -> datetime: + return datetime.min.replace(tzinfo=ZoneInfo(tz)) + + +def utc_iso_from_string(dt: str) -> str: + parsed_dt: datetime = datetime.fromisoformat(dt) # Explicitly declare type + if parsed_dt.tzinfo is None: + raise ValueError("Input datetime string is not timezone aware.") + return parsed_dt.astimezone(timezone.utc).isoformat() + + +def utc_iso_from_datetime(dt: datetime) -> str: + if dt.tzinfo is None: + raise ValueError("Input datetime object is not timezone aware.") + return dt.astimezone(timezone.utc).isoformat() + + +def utc_datetime_from_iso(dt: str) -> datetime: + parsed_dt: datetime = datetime.fromisoformat(dt) # Explicitly declare type + if parsed_dt.tzinfo is None: + raise ValueError("Input datetime string is not timezone aware.") + return parsed_dt.astimezone(timezone.utc) + + +def utc_iso_from_uuid7(uuid7_str: str) -> str: + # from uuid_utils import uuid7 + # 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 utc_iso_from_uuid7_draft2(uuid7_str: str) -> str: + # from uuid_extensions import uuid7str + # 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 date_to_utc(d: date, tz: str = "UTC") -> datetime: + try: + return datetime.combine(d, time.min, ZoneInfo(tz)).astimezone(timezone.utc) + except ZoneInfoNotFoundError as e: + raise ValueError(f"Invalid timezone: {tz}") from e + + +def date_to_utc_iso(d: date, tz: str = "UTC") -> str: + return date_to_utc(d, tz).isoformat() + + +def ensure_utc_timezone(value: str) -> str: + dt = datetime.fromisoformat(value) + tz = str(dt.tzinfo) + if tz != "UTC": + raise ValueError(f'Time zone must be UTC, but received "{tz}".') + return value diff --git a/clients/python/src/jamaibase/exceptions.py b/clients/python/src/jamaibase/utils/exceptions.py similarity index 51% rename from clients/python/src/jamaibase/exceptions.py rename to clients/python/src/jamaibase/utils/exceptions.py index 755ebc3..dfef23b 100644 --- a/clients/python/src/jamaibase/exceptions.py +++ b/clients/python/src/jamaibase/utils/exceptions.py @@ -1,9 +1,6 @@ -import functools +from functools import wraps from typing import Any -from pydantic import ValidationError -from pydantic_core import InitErrorDetails - def docstring_message(cls): """ @@ -13,7 +10,7 @@ def docstring_message(cls): # Must use cls_init name, not cls.__init__ itself, in closure to avoid recursion cls_init = cls.__init__ - @functools.wraps(cls.__init__) + @wraps(cls.__init__) def wrapped_init(self, msg=cls.__doc__, *args, **kwargs): cls_init(self, msg, *args, **kwargs) @@ -21,31 +18,14 @@ def wrapped_init(self, msg=cls.__doc__, *args, **kwargs): 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(Exception): + """Base exception class for Jamai errors.""" @docstring_message -class JamaiException(RuntimeError): - """Base exception class for JamAIBase errors.""" - - pass +class UpStreamError(JamaiException): + """One or more upstream columns errored out.""" @docstring_message @@ -65,12 +45,22 @@ class ForbiddenError(JamaiException): @docstring_message class UpgradeTierError(JamaiException): - """You have exhausted the allocations of your subscribed tier. Please upgrade.""" + """Your organization has exhausted the allocations of your subscribed plan. Please upgrade or top up credits.""" + + +@docstring_message +class NoTierError(UpgradeTierError): + """Your organization has not subscribed to any plan. Please subscribe in Organization Billing Settings.""" + + +@docstring_message +class BaseTierCountError(UpgradeTierError): + """You can have only one organization with Free Plan. Please upgrade.""" @docstring_message class InsufficientCreditsError(JamaiException): - """Please ensure that you have sufficient credits.""" + """Your organization has exhausted your credits. Please top up.""" @docstring_message @@ -87,14 +77,17 @@ class ResourceExistsError(JamaiException): class UnsupportedMediaTypeError(JamaiException): """This file type is unsupported.""" - pass - @docstring_message class BadInputError(JamaiException): """Your input is invalid.""" +@docstring_message +class ModelCapabilityError(BadInputError): + """No model has the specified capabilities.""" + + @docstring_message class TableSchemaFixedError(JamaiException): """Table schema cannot be modified.""" @@ -109,11 +102,45 @@ class ContextOverflowError(JamaiException): class UnexpectedError(JamaiException): """We ran into an unexpected error.""" - pass + +@docstring_message +class RateLimitExceedError(JamaiException): + """The rate limit is exceeded.""" + + def __init__( + self, + *args, + limit: int, + remaining: int, + reset_at: int, + used: int | None = None, + retry_after: int | None = None, + meta: dict[str, Any] | None = None, + ): + super().__init__(*args) + self.limit = limit + self.remaining = remaining + self.reset_at = reset_at + self.used = used + self.retry_after = retry_after + self.meta = meta + + +@docstring_message +class UnavailableError(JamaiException): + """The requested functionality is unavailable.""" @docstring_message class ServerBusyError(JamaiException): """The server is busy.""" - pass + +@docstring_message +class ModelOverloadError(JamaiException): + """The model is overloaded.""" + + +@docstring_message +class MethodNotAllowedError(JamaiException): + """Method is not allowed.""" diff --git a/clients/python/src/jamaibase/utils/io.py b/clients/python/src/jamaibase/utils/io.py index d790803..4a89ef6 100644 --- a/clients/python/src/jamaibase/utils/io.py +++ b/clients/python/src/jamaibase/utils/io.py @@ -1,29 +1,65 @@ -from __future__ import annotations - import csv import logging import pickle -from io import BytesIO, StringIO +from collections import OrderedDict +from io import StringIO +from mimetypes import guess_type +from os.path import splitext from typing import Any +import filetype import numpy as np import orjson import pandas as pd -import srsly import toml +import yaml from PIL import ExifTags, Image -from jamaibase.utils.types import JSONInput, JSONOutput +from jamaibase.types.common import JSONInput, JSONOutput logger = logging.getLogger(__name__) +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/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], + "text/markdown": [".md"], + "text/plain": [".txt"], + "text/html": [".html"], + "text/tab-separated-values": [".tsv"], + "text/csv": [".csv"], + "text/xml": [".xml"], +} +DOC_WHITE_LIST = EMBED_WHITE_LIST +IMAGE_WHITE_LIST = { + "image/jpeg": [".jpg", ".jpeg"], + "image/png": [".png"], + "image/gif": [".gif"], + "image/webp": [".webp"], +} +AUDIO_WHITE_LIST = { + "audio/mpeg": [".mp3"], + "audio/wav": [".wav"], + "audio/x-wav": [".wav"], + "audio/x-pn-wav": [".wav"], + "audio/wave": [".wav"], + "audio/vnd.wav": [".wav"], + "audio/vnd.wave": [".wav"], +} + def load_pickle(file_path: str): with open(file_path, "rb") as f: return pickle.load(f) -def dump_pickle(out_path: str, obj: any): +def dump_pickle(out_path: str, obj: Any): with open(out_path, "wb") as f: pickle.dump(obj, f) @@ -61,8 +97,8 @@ def json_loads(data: str) -> JSONOutput: return orjson.loads(data) -def json_dumps(data: JSONInput) -> str: - return orjson.dumps(data).decode("utf-8") +def json_dumps(data: JSONInput, **kwargs) -> str: + return orjson.dumps(data, **kwargs).decode("utf-8") def read_yaml(path: str) -> JSONOutput: @@ -74,7 +110,8 @@ def read_yaml(path: str) -> JSONOutput: Returns: data (JSONOutput): The data. """ - return srsly.read_yaml(path) + with open(path, "r") as f: + return yaml.safe_load(f) def dump_yaml(data: JSONInput, path: str, **kwargs) -> str: @@ -83,12 +120,13 @@ def dump_yaml(data: JSONInput, path: str, **kwargs) -> str: Args: data (JSONInput): The data. path (str): Path to the file. - **kwargs: Other keyword arguments to pass into `srsly.write_yaml`. + **kwargs: Other keyword arguments to pass into `yaml.dump`. Returns: path (str): Path to the file. """ - srsly.write_yaml(path, data, **kwargs) + with open(path, "w") as f: + yaml.dump(data, f, **kwargs) return path @@ -116,6 +154,10 @@ def dump_toml(data: JSONInput, path: str, **kwargs) -> str: Returns: path (str): Path to the file. """ + # Convert non-dictionary data into a dictionary + if not isinstance(data, (dict, OrderedDict)): + data = {"value": data} # Wrap non-dictionary data in a dictionary + with open(path, "w") as f: toml.dump(data, f) return path @@ -126,14 +168,14 @@ def csv_to_df( column_names: list[str] | None = None, sep: str = ",", dtype: dict[str, Any] | None = None, + **kwargs, ) -> pd.DataFrame: - has_header = not (isinstance(column_names, list) and len(column_names) > 0) df = pd.read_csv( StringIO(data), - header=0 if has_header else None, names=column_names, sep=sep, dtype=dtype, + **kwargs, ) return df @@ -149,6 +191,7 @@ def df_to_csv( encoding="utf-8", lineterminator="\n", decimal=".", + header=True, index=False, quoting=csv.QUOTE_NONNUMERIC, quotechar='"', @@ -176,53 +219,32 @@ def read_image(img_path: str) -> tuple[np.ndarray, bool]: return np.asarray(image), is_rotated -def generate_image_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"" - - -def generate_audio_thumbnail(file_content: bytes, duration_ms: int = 30000) -> bytes: - """ - Generates a thumbnail audio by extracting a segment from the original audio. - - Args: - file_content (bytes): The audio file content. - duration_ms (int): Duration of the thumbnail in milliseconds. - - Returns: - bytes: The thumbnail audio segment as bytes. - """ - from pydub import AudioSegment - - # Use BytesIO to simulate a file object from the byte content - audio = AudioSegment.from_file(BytesIO(file_content)) +# Use the first MIME for each extension +MIME_WHITE_LIST = {**EMBED_WHITE_LIST, **IMAGE_WHITE_LIST, **AUDIO_WHITE_LIST} +EXT_TO_MIME = {} +for mime, exts in MIME_WHITE_LIST.items(): + for ext in exts: + EXT_TO_MIME[ext] = EXT_TO_MIME.get(ext, mime) - # Extract the first `duration_ms` milliseconds - thumbnail = audio[:duration_ms] - # Export the thumbnail to a bytes object - with BytesIO() as output: - thumbnail.export(output, format="mp3") - return output.getvalue() +def guess_mime(source: str | bytes) -> str: + if isinstance(source, str): + ext = splitext(source)[1].lower() + mime = EXT_TO_MIME.get(ext, None) + if mime is not None: + return mime + try: + # `filetype` can handle file path and content bytes + mime = filetype.guess(source) + if mime is not None: + return mime.mime + if isinstance(source, str): + # `mimetypes` can only handle file path + mime, _ = guess_type(source) + if mime is not None: + return mime + if source.endswith(".jsonl"): + return "application/jsonl" + except Exception: + logger.warning(f'Failed to sniff MIME type of file "{source}".') + return "application/octet-stream" diff --git a/clients/python/src/jamaibase/utils/types.py b/clients/python/src/jamaibase/utils/types.py index 5214177..74e8ba0 100644 --- a/clients/python/src/jamaibase/utils/types.py +++ b/clients/python/src/jamaibase/utils/types.py @@ -1,3 +1,54 @@ -from srsly.util import JSONInput, JSONOutput +import argparse +from enum import Enum +from typing import Callable, Type, TypeVar -__all__ = ["JSONInput", "JSONOutput"] +from pydantic import BaseModel + +try: + from enum import StrEnum +except ImportError: + + class StrEnum(str, Enum): + pass + + +### --- Enum Validator --- ### + +E = TypeVar("E", bound=Enum) + + +def get_enum_validator(enum_cls: Type[E]) -> Callable[[str], E]: + def _validator(v: str) -> E: + try: + return enum_cls[v] + except KeyError: + return enum_cls(v) + + return _validator + + +class CLI(BaseModel): + @classmethod + def parse_args(cls) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + for field_name, field_info in cls.model_fields.items(): + field_type = field_info.annotation + default = field_info.default + description = field_info.description or "" + if field_type is bool: + parser.add_argument( + f"--{field_name}", + action="store_true", + help=description, + ) + else: + parser.add_argument( + f"--{field_name}", + type=field_type, + default=default, + required=default is ..., + help=description, + ) + return cls(**vars(parser.parse_args())) diff --git a/clients/python/src/jamaibase/version.py b/clients/python/src/jamaibase/version.py index 3d26edf..382021f 100644 --- a/clients/python/src/jamaibase/version.py +++ b/clients/python/src/jamaibase/version.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "1.0.6" diff --git a/clients/python/tests/cloud/test_admin.py b/clients/python/tests/cloud/test_admin.py deleted file mode 100644 index a760683..0000000 --- a/clients/python/tests/cloud/test_admin.py +++ /dev/null @@ -1,1445 +0,0 @@ -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 ENV_CONFIG, 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) - ### --- Test service key auth --- ### - table = JamAI( - project_id=p0.id, - token=ENV_CONFIG.service_key_plain, - headers={"X-USER-ID": u0.id}, - ).table.get_table("action", "xx") - assert isinstance(table, TableMetaResponse) - # Try using invalid user ID - with pytest.raises(RuntimeError): - JamAI( - project_id=p0.id, - token=ENV_CONFIG.service_key_plain, - headers={"X-USER-ID": u1.id}, - ).table.get_table("action", "xx") - ### --- Test PAT --- ### - # Try using invalid PAT - with pytest.raises(RuntimeError): - JamAI(project_id=p0.id, token=pat1.id).table.get_table("action", "xx") - # 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, user_role="member") - 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()} ", user_role="admin" - ) - 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 deleted file mode 100644 index 16a8680..0000000 --- a/clients/python/tests/cloud/test_org_admin.py +++ /dev/null @@ -1,848 +0,0 @@ -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/oss/gen_table/test_export_ops.py b/clients/python/tests/oss/gen_table/test_export_ops.py index 4a04446..1907869 100644 --- a/clients/python/tests/oss/gen_table/test_export_ops.py +++ b/clients/python/tests/oss/gen_table/test_export_ops.py @@ -12,11 +12,11 @@ from flaky import flaky from jamaibase import JamAI -from jamaibase import protocol as p +from jamaibase import types as t 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_TYPES = [t.TableType.action, t.TableType.knowledge, t.TableType.chat] TABLE_ID_A = "table_a" TABLE_ID_B = "table_b" @@ -65,10 +65,10 @@ def _rerun_on_fs_error_with_delay(err, *args): @contextmanager def _create_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id: str = TABLE_ID_A, - cols: list[p.ColumnSchemaCreate] | None = None, - chat_cols: list[p.ColumnSchemaCreate] | None = None, + cols: list[t.ColumnSchemaCreate] | None = None, + chat_cols: list[t.ColumnSchemaCreate] | None = None, embedding_model: str | None = None, delete_first: bool = True, ): @@ -77,15 +77,15 @@ def _create_table( 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="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="good", dtype="bool"), + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate(id="stars", dtype="float"), + t.ColumnSchemaCreate(id="inputs", dtype="str"), + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", # Interpolate string and non-string input columns @@ -95,10 +95,10 @@ def _create_table( max_tokens=10, ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="captioning", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="You are a concise assistant.", # Interpolate file input column @@ -111,11 +111,11 @@ def _create_table( ] if chat_cols is None: chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a wacky assistant.", temperature=0.001, @@ -125,25 +125,25 @@ def _create_table( ), ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = jamai.table.create_action_table( - p.ActionTableSchemaCreate(id=table_id, cols=cols) + t.ActionTableSchemaCreate(id=table_id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: if embedding_model is None: embedding_model = "" table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id=table_id, cols=cols, embedding_model=embedding_model ) ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + t.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) ) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: jamai.table.delete_table(table_type, table_id) @@ -151,7 +151,7 @@ def _create_table( def _add_row( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, stream: bool, table_name: str = TABLE_ID_A, data: dict | None = None, @@ -175,21 +175,21 @@ def _add_row( ) if chat_data is None: chat_data = dict(User="Tell me a joke.") - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: data.update(knowledge_data) - elif table_type == p.TableType.chat: + elif table_type == t.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), + t.MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), ) if stream: return response - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert len(response.rows) == 1 return response.rows[0] @@ -201,13 +201,13 @@ def _add_row( @pytest.mark.parametrize("delimiter", [","], ids=["comma_delimiter"]) def test_import_data_complete( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, delimiter: str, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Complete CSV with TemporaryDirectory() as tmp_dir: @@ -264,7 +264,7 @@ def test_import_data_complete( df_to_csv(df, file_path, delimiter) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -275,14 +275,14 @@ def test_import_data_complete( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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: + if table_type == t.TableType.knowledge: assert isinstance(row["Text Embed"]["value"], list) assert len(row["Text Embed"]["value"]) > 0 assert isinstance(row["Title Embed"]["value"], list) @@ -291,13 +291,13 @@ def test_import_data_complete( 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}`" + 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}`" + 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) @@ -306,23 +306,23 @@ def test_import_data_complete( @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, + table_type: t.TableType, stream: bool, ): jamai = client_cls() - gen_cfg = p.LLMGenConfig() + gen_cfg = t.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"), + t.ColumnSchemaCreate(id="bool", dtype="str"), + t.ColumnSchemaCreate(id="int", dtype="str"), + t.ColumnSchemaCreate(id="float", dtype="str"), + t.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), + t.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) + assert isinstance(table, t.TableMetaResponse) # Complete CSV with TemporaryDirectory() as tmp_dir: @@ -356,7 +356,7 @@ def test_import_data_cast_to_string( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -366,14 +366,14 @@ def test_import_data_cast_to_string( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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: + if table_type == t.TableType.knowledge: assert isinstance(row["Text Embed"]["value"], list) assert len(row["Text Embed"]["value"]) > 0 assert isinstance(row["Title Embed"]["value"], list) @@ -381,9 +381,9 @@ def test_import_data_cast_to_string( 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}`" + 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) @@ -392,23 +392,23 @@ def test_import_data_cast_to_string( @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, + table_type: t.TableType, stream: bool, ): jamai = client_cls() - gen_cfg = p.LLMGenConfig() + gen_cfg = t.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"), + t.ColumnSchemaCreate(id="bool", dtype="bool"), + t.ColumnSchemaCreate(id="int", dtype="int"), + t.ColumnSchemaCreate(id="float", dtype="float"), + t.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), + t.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) + assert isinstance(table, t.TableMetaResponse) # Complete CSV with TemporaryDirectory() as tmp_dir: @@ -442,7 +442,7 @@ def test_import_data_cast_from_string( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -452,14 +452,14 @@ def test_import_data_cast_from_string( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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: + if table_type == t.TableType.knowledge: assert isinstance(row["Text Embed"]["value"], list) assert len(row["Text Embed"]["value"]) > 0 assert isinstance(row["Title Embed"]["value"], list) @@ -467,9 +467,9 @@ def test_import_data_cast_from_string( 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}`" + 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) @@ -478,12 +478,12 @@ def test_import_data_cast_from_string( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_import_data_cast_dtype( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Complete CSV with TemporaryDirectory() as tmp_dir: @@ -524,7 +524,7 @@ def test_import_data_cast_dtype( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -534,14 +534,14 @@ def test_import_data_cast_dtype( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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: + if table_type == t.TableType.knowledge: assert isinstance(row["Text Embed"]["value"], list) assert len(row["Text Embed"]["value"]) > 0 assert isinstance(row["Title Embed"]["value"], list) @@ -550,13 +550,13 @@ def test_import_data_cast_dtype( 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}`" + 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}`" + 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) @@ -565,12 +565,12 @@ def test_import_data_cast_dtype( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_import_data_incomplete( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # CSV without input column with TemporaryDirectory() as tmp_dir: @@ -623,7 +623,7 @@ def test_import_data_incomplete( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -633,7 +633,7 @@ def test_import_data_incomplete( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert response.object == "gen_table.completion.rows" rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) @@ -642,13 +642,13 @@ def test_import_data_incomplete( 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]}" + 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]}`" + 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) @@ -657,12 +657,12 @@ def test_import_data_incomplete( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_import_data_with_generation( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # CSV without output column with TemporaryDirectory() as tmp_dir: @@ -695,7 +695,7 @@ def test_import_data_with_generation( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -704,7 +704,7 @@ def test_import_data_with_generation( if stream: responses = [r for r in response] assert len(responses) > 0 - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) 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) @@ -715,9 +715,9 @@ def test_import_data_with_generation( 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, t.CellCompletionResponse) for r in responses) assert all( - isinstance(r.usage, p.CompletionUsage) + isinstance(r.usage, t.CompletionUsage) for r in responses if r.output_column_name in ("summary", "captioning") ) @@ -732,12 +732,12 @@ def test_import_data_with_generation( if r.output_column_name in ("summary", "captioning") ) else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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].usage, t.CompletionUsage) assert isinstance(row.columns[output_column_name].prompt_tokens, int) assert isinstance(row.columns[output_column_name].completion_tokens, int) @@ -749,13 +749,13 @@ def test_import_data_with_generation( 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}`" + 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}`" + 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) @@ -764,12 +764,12 @@ def test_import_data_with_generation( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_import_data_empty( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) with TemporaryDirectory() as tmp_dir: # Empty @@ -778,7 +778,7 @@ def test_import_data_empty( with pytest.raises(RuntimeError, match="No columns to parse"): response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream ), ) @@ -792,7 +792,7 @@ def test_import_data_empty( with pytest.raises(RuntimeError, match="empty"): response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream ), ) @@ -810,15 +810,15 @@ def test_import_data_with_vector( client_cls: Type[JamAI], stream: bool, ): - table_type = p.TableType.knowledge + table_type = t.TableType.knowledge jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Add a row first to figure out the vector length response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ { @@ -896,7 +896,7 @@ def test_import_data_with_vector( df_to_csv(df, file_path) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -906,7 +906,7 @@ def test_import_data_with_vector( responses = [r for r in response] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert response.object == "gen_table.completion.rows" rows = jamai.table.list_table_rows(table_type, table.id, vec_decimals=2) @@ -925,12 +925,12 @@ def test_import_data_with_vector( @pytest.mark.parametrize("delimiter", [","], ids=["comma_delimiter"]) def test_export_data( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, delimiter: str, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) data = [ {"good": True, "words": 5, "stars": 0.0, "inputs": TEXT, "summary": TEXT}, {"good": False, "words": 5, "stars": 1.0, "inputs": TEXT, "summary": TEXT}, @@ -959,9 +959,9 @@ def test_export_data( 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}`" + assert row[k] == v, ( + f"Exported data is wrong: col=`{k}` val={row[k]} ori=`{v}`" + ) else: assert k not in row @@ -972,12 +972,12 @@ def test_export_data( @pytest.mark.parametrize("delimiter", [","], ids=["comma_delimiter"]) def test_export_reordered_columns_data( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, delimiter: str, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row( jamai, table_type, @@ -994,7 +994,7 @@ def test_export_reordered_columns_data( "captioning", "good", ] - if table_type == p.TableType.knowledge: + if table_type == t.TableType.knowledge: new_cols_order = [ "Title", "Title Embed", @@ -1003,12 +1003,12 @@ def test_export_reordered_columns_data( "File ID", "Page", ] + new_cols_order - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: new_cols_order = ["User", "AI"] + new_cols_order jamai.table.reorder_columns( table_type=table_type, - request=p.ColumnReorderRequest( + request=t.ColumnReorderRequest( table_id=TABLE_ID_A, column_names=new_cols_order, ), @@ -1049,13 +1049,13 @@ def test_export_reordered_columns_data( @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, + table_type: t.TableType, stream: bool, delimiter: str, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) with TemporaryDirectory() as tmp_dir: data = [ { @@ -1105,7 +1105,7 @@ def test_import_export_data_round_trip( df_to_csv(df, file_path, delimiter) response = jamai.import_table_data( table_type, - p.TableDataImportRequest( + t.TableDataImportRequest( file_path=file_path, table_id=table.id, stream=stream, @@ -1114,12 +1114,12 @@ def test_import_export_data_round_trip( ) if stream: responses = [r for r in response] - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert len(responses) > 0 else: assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert response.object == "gen_table.completion.rows" csv_data = jamai.export_table_data(table_type, table.id, delimiter=delimiter) @@ -1133,11 +1133,11 @@ def test_import_export_data_round_trip( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_import_export_round_trip( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row(jamai, table_type, False) _add_row( jamai, @@ -1163,12 +1163,12 @@ def test_import_export_round_trip( try: imported_table = jamai.table.import_table( table_type, - p.TableImportRequest( + t.TableImportRequest( file_path=file_path, table_id_dst=table_id_dst, ), ) - assert isinstance(imported_table, p.TableMetaResponse) + assert isinstance(imported_table, t.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) @@ -1178,10 +1178,7 @@ def test_import_export_round_trip( 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 - ] + raw_files = [httpx.get(url).content for url in raw_urls.urls] assert ( raw_urls.urls[0] != raw_urls.urls[1] ) # URL is different but file should match @@ -1200,7 +1197,7 @@ def test_import_export_wrong_table_type( ): jamai = client_cls() with _create_table(jamai, "action") as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row(jamai, "action", False) _add_row( jamai, @@ -1220,7 +1217,7 @@ def test_import_export_wrong_table_type( with pytest.raises(RuntimeError): jamai.import_table( "knowledge", - p.TableImportRequest( + t.TableImportRequest( file_path=file_path, table_id_dst=table_id_dst, ), @@ -1229,7 +1226,7 @@ def test_import_export_wrong_table_type( with pytest.raises(RuntimeError): jamai.import_table( "chat", - p.TableImportRequest( + t.TableImportRequest( file_path=file_path, table_id_dst=table_id_dst, ), @@ -1237,4 +1234,4 @@ def test_import_export_wrong_table_type( if __name__ == "__main__": - test_import_export_round_trip(JamAI, p.TableType.action) + test_import_export_round_trip(JamAI, t.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 index 2a3dd62..4bdf375 100644 --- a/clients/python/tests/oss/gen_table/test_row_ops.py +++ b/clients/python/tests/oss/gen_table/test_row_ops.py @@ -13,13 +13,13 @@ 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 import types as t +from jamaibase.types import IMAGE_FILE_EXTENSIONS +from jamaibase.utils.exceptions import ResourceNotFoundError from jamaibase.utils.io import df_to_csv CLIENT_CLS = [JamAI] -TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] +TABLE_TYPES = [t.TableType.action, t.TableType.knowledge, t.TableType.chat] TABLE_ID_A = "table_a" TABLE_ID_B = "table_b" @@ -43,11 +43,8 @@ "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 ] @@ -107,10 +104,10 @@ def _rerun_on_fs_error_with_delay(err, *args): @contextmanager def _create_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id: str = TABLE_ID_A, - cols: list[p.ColumnSchemaCreate] | None = None, - chat_cols: list[p.ColumnSchemaCreate] | None = None, + cols: list[t.ColumnSchemaCreate] | None = None, + chat_cols: list[t.ColumnSchemaCreate] | None = None, embedding_model: str | None = None, delete_first: bool = True, ): @@ -119,16 +116,17 @@ def _create_table( 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="image"), - p.ColumnSchemaCreate(id="audio", dtype="audio"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="good", dtype="bool"), + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate(id="stars", dtype="float"), + t.ColumnSchemaCreate(id="inputs", dtype="str"), + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate(id="audio", dtype="audio"), + t.ColumnSchemaCreate(id="paper", dtype="document"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", # Interpolate string and non-string input columns @@ -138,10 +136,10 @@ def _create_table( max_tokens=10, ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="captioning", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="You are a concise assistant.", # Interpolate file input column @@ -151,10 +149,10 @@ def _create_table( max_tokens=20, ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="narration", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt="${audio} \n\nWhat happened?", temperature=0.001, @@ -162,14 +160,25 @@ def _create_table( max_tokens=10, ), ), + t.ColumnSchemaCreate( + id="concept", + dtype="str", + gen_config=t.LLMGenConfig( + model="", + prompt="${paper} \n\nTell the main concept of the paper in 5 words.", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), ] if chat_cols is None: chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a wacky assistant.", temperature=0.001, @@ -179,25 +188,25 @@ def _create_table( ), ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = jamai.table.create_action_table( - p.ActionTableSchemaCreate(id=table_id, cols=cols) + t.ActionTableSchemaCreate(id=table_id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: if embedding_model is None: embedding_model = "" table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id=table_id, cols=cols, embedding_model=embedding_model ) ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + t.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) ) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: jamai.table.delete_table(table_type, table_id) @@ -205,7 +214,7 @@ def _create_table( def _add_row( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, stream: bool, table_name: str = TABLE_ID_A, data: dict | None = None, @@ -219,6 +228,9 @@ def _add_row( audio_upload_response = jamai.file.upload_file( "clients/python/tests/files/mp3/turning-a4-size-magazine.mp3" ) + document_upload_response = jamai.file.upload_file( + "clients/python/tests/files/pdf/LLMs as Optimizers [DeepMind ; 2023].pdf" + ) data = dict( good=True, words=5, @@ -226,6 +238,7 @@ def _add_row( inputs=TEXT, photo=image_upload_response.uri, audio=audio_upload_response.uri, + paper=document_upload_response.uri, ) if knowledge_data is None: @@ -235,21 +248,21 @@ def _add_row( ) if chat_data is None: chat_data = dict(User="Tell me a joke.") - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: data.update(knowledge_data) - elif table_type == p.TableType.chat: + elif table_type == t.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), + t.MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), ) if stream: return response - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert len(response.rows) == 1 return response.rows[0] @@ -261,11 +274,10 @@ def _assert_is_vector(x: Any): def _collect_text( - responses: p.GenTableRowsChatCompletionChunks - | Generator[p.GenTableStreamChatCompletionChunk, None, None], + responses: t.MultiRowCompletionResponse | Generator[t.CellCompletionResponse, None, None], col: str, ): - if isinstance(responses, p.GenTableRowsChatCompletionChunks): + if isinstance(responses, t.MultiRowCompletionResponse): 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) @@ -283,7 +295,7 @@ def test_knowledge_table_embedding( ): jamai = client_cls() with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Don't include embeddings data = [ dict( @@ -307,13 +319,13 @@ def test_knowledge_table_embedding( ] response = jamai.table.add_table_rows( "knowledge", - p.RowAddRequest(table_id=table.id, data=data, stream=stream), + t.MultiRowAddRequest(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) + assert isinstance(response.rows[0], t.RowCompletionResponse) # Check embeddings rows = jamai.table.list_table_rows("knowledge", table.id) assert isinstance(rows.items, list) @@ -342,11 +354,11 @@ def test_knowledge_table_no_embed_input( ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), temperature=0.001, top_p=0.001, @@ -355,21 +367,21 @@ def test_knowledge_table_no_embed_input( ), ] with _create_table(jamai, "knowledge", cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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), + t.MultiRowAddRequest(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) + assert all(isinstance(r, t.CellCompletionResponse) 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) + assert isinstance(response.rows[0], t.RowCompletionResponse) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -380,9 +392,9 @@ def test_full_text_search( stream: bool, ): jamai = client_cls() - cols = [p.ColumnSchemaCreate(id="text", dtype="str")] + cols = [t.ColumnSchemaCreate(id="text", dtype="str")] with _create_table(jamai, "action", cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Add data texts = [ '"Dune: Part Two" 2024 is Denis\'s science-fiction film.', @@ -392,19 +404,21 @@ def test_full_text_search( ] response = jamai.table.add_table_rows( "action", - p.RowAddRequest(table_id=table.id, data=[{"text": t} for t in texts], stream=stream), + t.MultiRowAddRequest( + 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) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) # Search def _search(query: str): return jamai.table.hybrid_search( - "action", p.SearchRequest(table_id=table.id, query=query) + "action", t.SearchRequest(table_id=table.id, query=query) ) assert len(_search("AND")) == 0 # SQL-like statements should still work @@ -423,16 +437,16 @@ def _search(query: str): @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_rag( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(ktable, t.TableMetaResponse) response = jamai.table.add_table_rows( - p.TableType.knowledge, - p.RowAddRequest( + t.TableType.knowledge, + t.MultiRowAddRequest( table_id=ktable.id, data=[ dict( @@ -451,27 +465,27 @@ def test_rag( 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(response, t.MultiRowCompletionResponse) + assert isinstance(response.rows[0], t.RowCompletionResponse) + rows = jamai.table.list_table_rows(t.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( + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="rag", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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( + rag_params=t.RAGParams( table_id=ktable.id, reranking_model=_get_reranking_model(jamai), search_query="", # Generate using LM @@ -481,31 +495,31 @@ def test_rag( ), ] with _create_table(jamai, table_type, TABLE_ID_B, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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), + t.MultiRowAddRequest(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) + assert isinstance(responses[0], t.CellReferencesResponse) responses = responses[1:] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) 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 isinstance(row, t.RowCompletionResponse) assert len(row.columns) > 0 - if table_type == p.TableType.chat: + if table_type == t.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 isinstance(row.columns["rag"], t.ChatCompletionChunk) + assert isinstance(row.columns["rag"].references, t.References) assert len(row.columns["rag"].text) > 0 @@ -515,16 +529,16 @@ def test_rag( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_rag_with_image_input( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(ktable, t.TableMetaResponse) response = jamai.table.add_table_rows( - p.TableType.knowledge, - p.RowAddRequest( + t.TableType.knowledge, + t.MultiRowAddRequest( table_id=ktable.id, data=[ dict( @@ -539,28 +553,28 @@ def test_rag_with_image_input( 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(response, t.MultiRowCompletionResponse) + assert isinstance(response.rows[0], t.RowCompletionResponse) + rows = jamai.table.list_table_rows(t.TableType.knowledge, ktable.id) assert isinstance(rows.items, list) assert len(rows.items) == 2 # Create the other table cols = [ - p.ColumnSchemaCreate(id="photo", dtype="image"), - p.ColumnSchemaCreate(id="question", dtype="str"), - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate(id="question", dtype="str"), + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="rag", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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( + rag_params=t.RAGParams( table_id=ktable.id, reranking_model=_get_reranking_model(jamai), search_query="", # Generate using LM @@ -570,32 +584,32 @@ def test_rag_with_image_input( ), ] with _create_table(jamai, table_type, TABLE_ID_B, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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), + t.MultiRowAddRequest(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) + assert isinstance(responses[0], t.CellReferencesResponse) responses = responses[1:] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) 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 isinstance(row, t.RowCompletionResponse) assert len(row.columns) > 0 - if table_type == p.TableType.chat: + if table_type == t.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 isinstance(row.columns["rag"], t.ChatCompletionChunk) + assert isinstance(row.columns["rag"].references, t.References) assert len(row.columns["rag"].text) > 0 rows = jamai.table.list_table_rows(table_type, TABLE_ID_B) @@ -615,11 +629,11 @@ def test_conversation_starter( ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You help remember facts.", temperature=0.001, @@ -627,11 +641,11 @@ def test_conversation_starter( max_tokens=10, ), ), - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are an assistant", temperature=0.001, @@ -641,22 +655,24 @@ def test_conversation_starter( ), ] with _create_table(jamai, "chat", cols=[], chat_cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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), + t.MultiRowAddRequest( + 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) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response.rows[0], p.GenTableChatCompletionChunks) + assert isinstance(response.rows[0], t.RowCompletionResponse) # Chat with it response = jamai.table.add_table_rows( "chat", - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[dict(User="How many apples does Jim have?")], stream=stream, @@ -665,13 +681,13 @@ def test_conversation_starter( if stream: # Must wait until stream ends responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) 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) + assert isinstance(response.rows[0], t.RowCompletionResponse) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -680,38 +696,38 @@ def test_conversation_starter( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_add_row( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") + r.output_column_name in ("summary", "captioning", "narration", "concept") 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, t.CellCompletionResponse) for r in responses) + assert all(isinstance(r.usage, t.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 isinstance(response, t.RowCompletionResponse) assert response.object == "gen_table.completion.chunks" - for output_column_name in ("summary", "captioning", "narration"): + for output_column_name in ("summary", "captioning", "narration", "concept"): 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].usage, t.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) @@ -725,10 +741,96 @@ def test_add_row( assert row["audio"]["value"].endswith("/turning-a4-size-magazine.mp3"), row["audio"][ "value" ] + assert row["paper"]["value"].endswith("/LLMs as Optimizers [DeepMind ; 2023].pdf"), row[ + "paper" + ]["value"] for animal in ["deer", "rabbit"]: if animal in row["photo"]["value"].split("_")[0]: assert animal in row["captioning"]["value"] assert "paper" in row["narration"]["value"] or "turn" in row["narration"]["value"] + assert ( + "optimization" in row["concept"]["value"].lower() + or "optimize" in row["concept"]["value"].lower() + ) + + +@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("table_type", TABLE_TYPES[:1]) +@pytest.mark.parametrize("stream", [True, False]) +@pytest.mark.parametrize( + "docpath", + [ + "clients/python/tests/files/pdf/salary 总结.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/multilingual-code-examples.html", + "clients/python/tests/files/xml/weather-forecast-service.xml", + "clients/python/tests/files/jsonl/ChatMed_TCM-v0.2-5records.jsonl", + "clients/python/tests/files/docx/Recommendation Letter.docx", + "clients/python/tests/files/pptx/(2017.06.30) NMT in Linear Time (ByteNet).pptx", + "clients/python/tests/files/xlsx/Claims Form.xlsx", + "clients/python/tests/files/tsv/weather_observations.tsv", + "clients/python/tests/files/csv/weather_observations_long.csv", + ], + ids=lambda x: basename(x), +) +def test_add_row_document_dtype( + client_cls: Type[JamAI], table_type: t.TableType, stream: bool, docpath: str +): + jamai = client_cls() + cols = [ + t.ColumnSchemaCreate(id="doc", dtype="document"), + t.ColumnSchemaCreate( + id="content", + dtype="str", + gen_config=t.LLMGenConfig( + model="", + prompt="Document: \n${doc} \n\nReply 0 if document received, else -1. Omit any explanation, only answer 0 or -1.", + ), + ), + ] + with _create_table(jamai, table_type, cols=cols) as table: + assert isinstance(table, t.TableMetaResponse) + + upload_response = jamai.file.upload_file(docpath) + response = _add_row( + jamai, + table_type, + stream, + TABLE_ID_A, + data=dict(doc=upload_response.uri), + ) + if stream: + responses = [r for r in response] + assert all(isinstance(r, t.GenTableStreamChatCompletionChunk) for r in responses) + assert all(r.object == "gen_table.completion.chunk" for r in responses) + if table_type == t.TableType.chat: + assert all(r.output_column_name in ("content", "AI") for r in responses) + else: + assert all(r.output_column_name in ("content",) for r in responses) + assert len("".join(r.text for r in responses)) > 0 + assert all(isinstance(r, t.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r.usage, t.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, t.GenTableChatCompletionChunks) + assert response.object == "gen_table.completion.chunks" + output_column_name = "content" + assert len(response.columns[output_column_name].text) > 0 + assert isinstance(response.columns[output_column_name].usage, t.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["doc"]["value"] == upload_response.uri, row["doc"]["value"] + assert "0" in row["content"]["value"] @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -737,16 +839,16 @@ def test_add_row( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_regen_with_reordered_columns( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="number", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="number", dtype="int"), + t.ColumnSchemaCreate( id="col1-english", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt=( "Number: ${number} \n\nTell the 'Number' in English, " @@ -754,10 +856,10 @@ def test_regen_with_reordered_columns( ), ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="col2-malay", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt=( "Number: ${number} \n\nTell the 'Number' in Malay, " @@ -765,10 +867,10 @@ def test_regen_with_reordered_columns( ), ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="col3-mandarin", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt=( "Number: ${number} \n\nTell the 'Number' in Mandarin (Chinese Character), " @@ -776,10 +878,10 @@ def test_regen_with_reordered_columns( ), ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="col4-roman", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt=( "Number: ${number} \n\nTell the 'Number' in Roman Numerals, " @@ -790,14 +892,14 @@ def test_regen_with_reordered_columns( ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) row = _add_row( jamai, table_type, False, data=dict(number=1), ) - assert isinstance(row, p.GenTableChatCompletionChunks) + assert isinstance(row, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, table.id) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -812,7 +914,7 @@ def test_regen_with_reordered_columns( # Update Input + Regen jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=_id, data=dict(number=2), @@ -821,10 +923,10 @@ def test_regen_with_reordered_columns( response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=table.id, row_ids=[_id], - regen_strategy=p.RegenStrategy.RUN_ALL, + regen_strategy=t.RegenStrategy.RUN_ALL, stream=stream, ), ) @@ -850,13 +952,13 @@ def test_regen_with_reordered_columns( "col4-roman", "col2-malay", ] - if table_type == p.TableType.knowledge: + if table_type == t.TableType.knowledge: new_cols += ["Title", "Text", "Title Embed", "Text Embed", "File ID", "Page"] - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: new_cols += ["User", "AI"] jamai.table.reorder_columns( table_type=table_type, - request=p.ColumnReorderRequest( + request=t.ColumnReorderRequest( table_id=TABLE_ID_A, column_names=new_cols, ), @@ -864,7 +966,7 @@ def test_regen_with_reordered_columns( # RUN_SELECTED jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=_id, data=dict(number=5), @@ -872,10 +974,10 @@ def test_regen_with_reordered_columns( ) response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=TABLE_ID_A, row_ids=[_id], - regen_strategy=p.RegenStrategy.RUN_SELECTED, + regen_strategy=t.RegenStrategy.RUN_SELECTED, output_column_id="col1-english", stream=stream, ), @@ -895,7 +997,7 @@ def test_regen_with_reordered_columns( # RUN_BEFORE jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=_id, data=dict(number=6), @@ -903,10 +1005,10 @@ def test_regen_with_reordered_columns( ) response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=TABLE_ID_A, row_ids=[_id], - regen_strategy=p.RegenStrategy.RUN_BEFORE, + regen_strategy=t.RegenStrategy.RUN_BEFORE, output_column_id="col4-roman", stream=stream, ), @@ -926,7 +1028,7 @@ def test_regen_with_reordered_columns( # RUN_AFTER jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=_id, data=dict(number=7), @@ -934,10 +1036,10 @@ def test_regen_with_reordered_columns( ) response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=TABLE_ID_A, row_ids=[_id], - regen_strategy=p.RegenStrategy.RUN_AFTER, + regen_strategy=t.RegenStrategy.RUN_AFTER, output_column_id="col4-roman", stream=stream, ), @@ -961,29 +1063,29 @@ def test_regen_with_reordered_columns( @pytest.mark.parametrize("stream", [True, False]) def test_add_row_sequential_image_model_completion( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="photo", dtype="image"), - p.ColumnSchemaCreate(id="photo2", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate(id="photo2", dtype="image"), + t.ColumnSchemaCreate( id="caption", dtype="str", - gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), + gen_config=t.LLMGenConfig(model="", prompt="${photo} What's in the image?"), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="question", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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) + assert isinstance(table, t.TableMetaResponse) upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") response = _add_row( @@ -995,25 +1097,25 @@ def test_add_row_sequential_image_model_completion( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.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, t.CellCompletionResponse) for r in responses) + assert all(isinstance(r.usage, t.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 isinstance(response, t.RowCompletionResponse) 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].usage, t.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) @@ -1035,29 +1137,29 @@ def test_add_row_sequential_image_model_completion( @pytest.mark.parametrize("stream", [True, False]) def test_add_row_map_dtype_file_to_image( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="photo", dtype="file"), - p.ColumnSchemaCreate(id="photo2", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="photo", dtype="file"), + t.ColumnSchemaCreate(id="photo2", dtype="image"), + t.ColumnSchemaCreate( id="caption", dtype="str", - gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), + gen_config=t.LLMGenConfig(model="", prompt="${photo} What's in the image?"), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="question", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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) + assert isinstance(table, t.TableMetaResponse) upload_response = jamai.file.upload_file("clients/python/tests/files/jpeg/rabbit.jpeg") response = _add_row( @@ -1069,25 +1171,25 @@ def test_add_row_map_dtype_file_to_image( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.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, t.CellCompletionResponse) for r in responses) + assert all(isinstance(r.usage, t.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 isinstance(response, t.RowCompletionResponse) 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].usage, t.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) @@ -1149,42 +1251,42 @@ def test_add_row_map_dtype_file_to_image( @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, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="photo", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate( id="captioning", dtype="str", - gen_config=p.LLMGenConfig(model="", prompt="${photo} What's in the image?"), + gen_config=t.LLMGenConfig(model="", prompt="${photo} What's in the image?"), ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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( + t.ColumnSchemaCreate( id="captioning2", dtype="str", - gen_config=p.LLMGenConfig(model=chat_only_model), + gen_config=t.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: + if table_type == t.TableType.action: + jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) - elif table_type == p.TableType.chat: - jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.chat: + jamai.table.add_chat_columns(t.AddChatColumnSchema(id=table.id, cols=cols)) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @@ -1192,31 +1294,31 @@ def test_add_row_output_column_referred_image_input_with_chat_model( @pytest.mark.parametrize("stream", [True, False]) def test_add_row_sequential_completion_with_error( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt="Summarise ${input}.", ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="rephrase", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", prompt="Rephrase ${summary}", ), ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) response = _add_row( jamai, @@ -1227,18 +1329,18 @@ def test_add_row_sequential_completion_with_error( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.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) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) assert response.object == "gen_table.completion.chunks" for output_column_name in ("summary", "rephrase"): assert len(response.columns[output_column_name].text) > 0 @@ -1271,11 +1373,11 @@ def test_add_row_sequential_completion_with_error( 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 + client_cls: Type[JamAI], table_type: t.TableType, stream: bool, img_filename: str ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) upload_response = jamai.file.upload_file(img_filename) response = _add_row( @@ -1288,21 +1390,21 @@ def test_add_row_image_file_type_with_generation( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") + r.output_column_name in ("summary", "captioning", "narration", "concept") for r in responses ) assert len("".join(r.text for r in responses)) > 0 else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) 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) @@ -1336,11 +1438,11 @@ def test_add_row_image_file_type_with_generation( ], ) def test_add_row_image_file_column_invalid_extension( - client_cls: Type[JamAI], table_type: p.TableType, stream: bool, img_filename: str + client_cls: Type[JamAI], table_type: t.TableType, stream: bool, img_filename: str ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) with pytest.raises( ValidationError, match=( @@ -1363,18 +1465,18 @@ def test_add_row_image_file_column_invalid_extension( @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 + client_cls: Type[JamAI], table_type: t.TableType, stream: bool = True ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - captioning=p.LLMGenConfig( + captioning=t.LLMGenConfig( system_prompt="You are a concise assistant.", prompt="${photo} ${photo}\n\nWhat's in the image?", ), @@ -1392,16 +1494,17 @@ def test_add_row_validate_one_image_per_completion( ), ) responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") for r in responses + r.output_column_name in ("summary", "captioning", "narration", "concept") + for r in responses ) assert len("".join(r.text for r in responses)) > 0 @@ -1419,30 +1522,30 @@ def test_add_row_validate_one_image_per_completion( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_add_row_wrong_dtype( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") + r.output_column_name in ("summary", "captioning", "narration", "concept") for r in responses ) assert len("".join(r.text for r in responses)) > 0 else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) assert response.object == "gen_table.completion.chunks" assert len(response.columns["summary"].text) > 0 @@ -1456,9 +1559,9 @@ def test_add_row_wrong_dtype( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 2 @@ -1477,30 +1580,30 @@ def test_add_row_wrong_dtype( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_add_row_missing_columns( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") + r.output_column_name in ("summary", "captioning", "narration", "concept") for r in responses ) assert len("".join(r.text for r in responses)) > 0 else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) assert response.object == "gen_table.completion.chunks" assert len(response.columns["summary"].text) > 0 @@ -1514,9 +1617,9 @@ def test_add_row_missing_columns( ) if stream: responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 2 @@ -1535,21 +1638,21 @@ def test_add_row_missing_columns( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_add_rows_all_input( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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"), + t.ColumnSchemaCreate(id="0", dtype="int"), + t.ColumnSchemaCreate(id="1", dtype="float"), + t.ColumnSchemaCreate(id="2", dtype="bool"), + t.ColumnSchemaCreate(id="3", dtype="str"), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ {"0": 1, "1": 2.0, "2": False, "3": "days"}, @@ -1562,7 +1665,7 @@ def test_add_rows_all_input( responses = [r for r in response if r.output_column_name != "AI"] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert len(response.rows) == 2 rows = jamai.table.list_table_rows(table_type, table.id) assert isinstance(rows.items, list) @@ -1574,18 +1677,18 @@ def test_add_rows_all_input( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_update_row( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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) + assert isinstance(row, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -1597,13 +1700,13 @@ def test_update_row( # Regular update response = jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=row["ID"], data=dict(good=False, stars=1.0), ), ) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.OkResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -1616,13 +1719,13 @@ def test_update_row( # Test updating data with wrong dtype response = jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=row["ID"], data=dict(good="dummy", words="dummy", stars="dummy"), ), ) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.OkResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -1638,13 +1741,13 @@ def test_update_row( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_regen_rows( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) image_upload_response = jamai.file.upload_file( "clients/python/tests/files/jpeg/rabbit.jpeg" @@ -1665,7 +1768,7 @@ def test_regen_rows( audio=audio_upload_response.uri, ), ) - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -1676,7 +1779,7 @@ def test_regen_rows( # Regen jamai.table.update_table_row( table_type, - p.RowUpdateRequest( + t.RowUpdateRequest( table_id=TABLE_ID_A, row_id=_id, data=dict( @@ -1685,25 +1788,25 @@ def test_regen_rows( ), ) response = jamai.table.regen_table_rows( - table_type, p.RowRegenRequest(table_id=TABLE_ID_A, row_ids=[_id], stream=stream) + table_type, t.MultiRowRegenRequest(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(isinstance(r, t.CellCompletionResponse) for r in responses) assert all(r.object == "gen_table.completion.chunk" for r in responses) - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: assert all( - r.output_column_name in ("summary", "captioning", "narration", "AI") + r.output_column_name in ("summary", "captioning", "narration", "concept", "AI") for r in responses ) else: assert all( - r.output_column_name in ("summary", "captioning", "narration") + r.output_column_name in ("summary", "captioning", "narration", "concept") for r in responses ) assert len("".join(r.text for r in responses)) > 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) 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) @@ -1725,21 +1828,21 @@ def test_regen_rows( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_regen_rows_all_input( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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"), + t.ColumnSchemaCreate(id="0", dtype="int"), + t.ColumnSchemaCreate(id="1", dtype="float"), + t.ColumnSchemaCreate(id="2", dtype="bool"), + t.ColumnSchemaCreate(id="3", dtype="str"), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ {"0": 1, "1": 2.0, "2": False, "3": "days"}, @@ -1748,7 +1851,7 @@ def test_regen_rows_all_input( stream=False, ), ) - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert len(response.rows) == 2 rows = jamai.table.list_table_rows(table_type, table.id) assert isinstance(rows.items, list) @@ -1756,7 +1859,7 @@ def test_regen_rows_all_input( # Regen response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=table.id, row_ids=[r["ID"] for r in rows.items], stream=stream ), ) @@ -1764,7 +1867,7 @@ def test_regen_rows_all_input( responses = [r for r in response if r.output_column_name != "AI"] assert len(responses) == 0 else: - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -1772,12 +1875,12 @@ def test_regen_rows_all_input( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_delete_rows( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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) @@ -1802,7 +1905,7 @@ def test_delete_rows( # Delete one row response = jamai.table.delete_table_row(table_type, TABLE_ID_A, delete_id) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.OkResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 5 @@ -1812,12 +1915,12 @@ def test_delete_rows( delete_ids = [r["ID"] for r in ori_rows.items[1:4]] response = jamai.table.delete_table_rows( table_type, - p.RowDeleteRequest( + t.MultiRowDeleteRequest( table_id=TABLE_ID_A, row_ids=delete_ids, ), ) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.OkResponse) rows = jamai.table.list_table_rows(table_type, TABLE_ID_A) assert isinstance(rows.items, list) assert len(rows.items) == 2 @@ -1830,11 +1933,11 @@ def test_delete_rows( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_get_and_list_rows( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row(jamai, table_type, False) _add_row( jamai, @@ -1870,15 +1973,17 @@ def test_get_and_list_rows( "inputs", "photo", "audio", + "paper", "summary", "captioning", "narration", + "concept", } - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: expected_cols |= {"User", "AI"} else: raise ValueError(f"Invalid table type: {table_type}") @@ -2091,15 +2196,15 @@ def test_get_and_list_rows( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_column_interpolate( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", prompt='Say "Jan has 5 apples.".', @@ -2108,11 +2213,11 @@ def test_column_interpolate( max_tokens=10, ), ), - p.ColumnSchemaCreate(id="input0", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="int"), + t.ColumnSchemaCreate( id="output1", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", prompt=( @@ -2126,7 +2231,7 @@ def test_column_interpolate( ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) def _add_row_wrapped(stream, data): return _add_row( @@ -2165,16 +2270,16 @@ def _add_row_wrapped(stream, data): @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, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="output", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( system_prompt="You are a calculator.", prompt="${input}", multi_turn=True, @@ -2185,11 +2290,11 @@ def test_chat_history_and_sequential_add( ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Initialise chat thread and set output format response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ dict(input="x = 0", output="0"), @@ -2204,7 +2309,7 @@ def test_chat_history_and_sequential_add( # Test adding one row response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[dict(input="Add 1")], stream=stream, @@ -2215,7 +2320,7 @@ def test_chat_history_and_sequential_add( # Test adding multiple rows response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ dict(input="Add 1"), @@ -2237,16 +2342,16 @@ def test_chat_history_and_sequential_add( @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, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="output", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( system_prompt="You are a calculator.", prompt="${input}", multi_turn=True, @@ -2257,11 +2362,11 @@ def test_chat_history_and_sequential_regen( ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Initialise chat thread and set output format response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ dict(input="x = 0", output="0"), @@ -2278,7 +2383,7 @@ def test_chat_history_and_sequential_regen( # Test regen one row response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=table.id, row_ids=row_ids[3:4], stream=stream, @@ -2290,7 +2395,7 @@ def test_chat_history_and_sequential_regen( # Also test if regen proceeds in correct order from earliest row to latest response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=table.id, row_ids=row_ids[3:][::-1], stream=stream, @@ -2308,16 +2413,16 @@ def test_chat_history_and_sequential_regen( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_convert_into_multi_turn( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="output", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( system_prompt="You are a calculator.", prompt="${input}", multi_turn=False, @@ -2328,11 +2433,11 @@ def test_convert_into_multi_turn( ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Initialise chat thread and set output format response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[ dict(input="x = 0", output="0"), @@ -2346,7 +2451,7 @@ def test_convert_into_multi_turn( # Test adding one row as single-turn response = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=table.id, data=[dict(input="x += 1")], stream=stream, @@ -2357,10 +2462,10 @@ def test_convert_into_multi_turn( # Convert into multi-turn table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output=p.LLMGenConfig( + output=t.LLMGenConfig( system_prompt="You are a calculator.", prompt="${input}", multi_turn=True, @@ -2371,12 +2476,12 @@ def test_convert_into_multi_turn( ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Regen rows = jamai.table.list_table_rows(table_type, table.id) response = jamai.table.regen_table_rows( table_type, - p.RowRegenRequest( + t.MultiRowRegenRequest( table_id=table.id, row_ids=[rows.items[0]["ID"]], stream=stream, @@ -2391,15 +2496,15 @@ def test_convert_into_multi_turn( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_get_conversation_thread( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="output", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( system_prompt="You are a calculator.", prompt="${input}", multi_turn=True, @@ -2410,7 +2515,7 @@ def test_get_conversation_thread( ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # Initialise chat thread and set output format data = [ dict(input="x = 0", output="0"), @@ -2419,22 +2524,22 @@ def test_get_conversation_thread( dict(input="Add 3", output="6"), ] response = jamai.table.add_table_rows( - table_type, p.RowAddRequest(table_id=table.id, data=data, stream=False) + table_type, t.MultiRowAddRequest(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) + assert isinstance(_chat, t.ChatThreadResponse) 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 + assert message.role == t.ChatRole.SYSTEM elif i % 2 == 1: - assert message.role == p.ChatRole.USER + assert message.role == t.ChatRole.USER assert message.content == data[(i - 1) // 2]["input"] else: - assert message.role == p.ChatRole.ASSISTANT + assert message.role == t.ChatRole.ASSISTANT assert message.content == data[(i // 2) - 1]["output"] # --- Fetch complete thread --- # @@ -2471,32 +2576,32 @@ def test_hybrid_search( client_cls: Type[JamAI], ): jamai = client_cls() - table_type = p.TableType.knowledge + table_type = t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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( + t.MultiRowAddRequest( 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) + assert isinstance(rows, t.MultiRowCompletionResponse) rows = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( 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) + assert isinstance(rows, t.MultiRowCompletionResponse) rows = jamai.table.add_table_rows( table_type, - p.RowAddRequest( + t.MultiRowAddRequest( table_id=TABLE_ID_A, data=[ dict( @@ -2508,12 +2613,12 @@ def test_hybrid_search( stream=False, ), ) - assert isinstance(rows, p.GenTableRowsChatCompletionChunks) + assert isinstance(rows, t.MultiRowCompletionResponse) sleep(1) # Optional, give it some time to index # Rely on embedding rows = jamai.table.hybrid_search( table_type, - p.SearchRequest( + t.SearchRequest( table_id=TABLE_ID_A, query="language", reranking_model=_get_reranking_model(jamai), @@ -2525,7 +2630,7 @@ def test_hybrid_search( # Rely on FTS rows = jamai.table.hybrid_search( table_type, - p.SearchRequest( + t.SearchRequest( table_id=TABLE_ID_A, query="candidate 2013", reranking_model=_get_reranking_model(jamai), @@ -2537,7 +2642,7 @@ def test_hybrid_search( # hybrid_search without reranker (RRF only) rows = jamai.table.hybrid_search( table_type, - p.SearchRequest( + t.SearchRequest( table_id=TABLE_ID_A, query="language", reranking_model=None, @@ -2555,7 +2660,6 @@ def test_hybrid_search( "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", @@ -2568,11 +2672,8 @@ def test_hybrid_search( "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/pptx/(2017.06.30) NMT in Linear Time (ByteNet).pptx", "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", @@ -2584,12 +2685,12 @@ def test_upload_file( file_path: str, ): jamai = client_cls() - table_type = p.TableType.knowledge + table_type = t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) response = jamai.table.embed_file(file_path, table.id) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.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) @@ -2622,15 +2723,15 @@ def test_upload_empty_file( file_path: str, ): jamai = client_cls() - table_type = p.TableType.knowledge + table_type = t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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) + assert isinstance(response, t.OkResponse) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -2649,10 +2750,10 @@ def test_upload_file_invalid_file_type( file_path: str, ): jamai = client_cls() - table_type = p.TableType.knowledge + table_type = t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) with pytest.raises(RuntimeError, match=r"File type .+ is unsupported"): jamai.table.embed_file(file_path, table.id) @@ -2694,7 +2795,7 @@ def test_upload_long_file( ): jamai = client_cls() with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) with TemporaryDirectory() as tmp_dir: # Create a long CSV data = [ @@ -2705,10 +2806,10 @@ def test_upload_long_file( 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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) response = jamai.table.embed_file(file_path, table.id) - assert isinstance(response, p.OkResponse) + assert isinstance(response, t.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) @@ -2724,4 +2825,4 @@ def test_upload_long_file( if __name__ == "__main__": - test_get_conversation_thread(JamAI, p.TableType.action) + test_get_conversation_thread(JamAI, t.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 index a53d587..8e80531 100644 --- a/clients/python/tests/oss/gen_table/test_table_ops.py +++ b/clients/python/tests/oss/gen_table/test_table_ops.py @@ -8,11 +8,11 @@ from pydantic import ValidationError from jamaibase import JamAI -from jamaibase import protocol as p -from jamaibase.exceptions import ResourceNotFoundError +from jamaibase import types as t +from jamaibase.utils.exceptions import ResourceNotFoundError CLIENT_CLS = [JamAI] -TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] +TABLE_TYPES = [t.TableType.action, t.TableType.knowledge, t.TableType.chat] REGULAR_COLUMN_DTYPES: list[str] = ["int", "float", "bool", "str"] SAMPLE_DATA = { "int": -1, @@ -87,10 +87,10 @@ def _rerun_on_fs_error_with_delay(err, *args): @contextmanager def _create_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id: str = TABLE_ID_A, - cols: list[p.ColumnSchemaCreate] | None = None, - chat_cols: list[p.ColumnSchemaCreate] | None = None, + cols: list[t.ColumnSchemaCreate] | None = None, + chat_cols: list[t.ColumnSchemaCreate] | None = None, embedding_model: str | None = None, delete_first: bool = True, ): @@ -99,15 +99,15 @@ def _create_table( 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="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="good", dtype="bool"), + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate(id="stars", dtype="float"), + t.ColumnSchemaCreate(id="inputs", dtype="str"), + t.ColumnSchemaCreate(id="photo", dtype="image"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", # Interpolate string and non-string input columns @@ -117,10 +117,10 @@ def _create_table( max_tokens=10, ), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="captioning", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="You are a concise assistant.", # Interpolate file input column @@ -133,11 +133,11 @@ def _create_table( ] if chat_cols is None: chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a wacky assistant.", temperature=0.001, @@ -147,25 +147,25 @@ def _create_table( ), ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = jamai.table.create_action_table( - p.ActionTableSchemaCreate(id=table_id, cols=cols) + t.ActionTableSchemaCreate(id=table_id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: if embedding_model is None: embedding_model = "" table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id=table_id, cols=cols, embedding_model=embedding_model ) ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + t.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) ) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: jamai.table.delete_table(table_type, table_id) @@ -174,29 +174,29 @@ def _create_table( @contextmanager def _create_table_v2( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id: str = TABLE_ID_A, - cols: list[p.ColumnSchemaCreate] | None = None, - chat_cols: list[p.ColumnSchemaCreate] | None = None, + cols: list[t.ColumnSchemaCreate] | None = None, + chat_cols: list[t.ColumnSchemaCreate] | None = None, llm_model: str = "", embedding_model: str = "", system_prompt: str = "", prompt: str = "", delete_first: bool = True, -) -> Generator[p.TableMetaResponse, None, None]: +) -> Generator[t.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) + t.ColumnSchemaCreate(id=f"in_{dtype}", dtype=dtype) for dtype in REGULAR_COLUMN_DTYPES ] _output_cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id=f"out_{dtype}", dtype=dtype, - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=llm_model, system_prompt=system_prompt, prompt=" ".join(f"${{{col.id}}}" for col in _input_cols) + prompt, @@ -208,11 +208,11 @@ def _create_table_v2( cols = _input_cols + _output_cols if chat_cols is None: chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=llm_model, system_prompt=system_prompt, max_tokens=10, @@ -222,25 +222,25 @@ def _create_table_v2( expected_cols = {"ID", "Updated at"} expected_cols |= {c.id for c in cols} - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = jamai.table.create_action_table( - p.ActionTableSchemaCreate(id=table_id, cols=cols) + t.ActionTableSchemaCreate(id=table_id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: table = jamai.table.create_knowledge_table( - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id=table_id, cols=cols, embedding_model=embedding_model ) ) expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: table = jamai.table.create_chat_table( - p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols) + t.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) + assert isinstance(table, t.TableMetaResponse) col_ids = set(c.id for c in table.cols) assert col_ids == expected_cols yield table @@ -250,7 +250,7 @@ def _create_table_v2( def _add_row( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, stream: bool, table_name: str = TABLE_ID_A, data: dict | None = None, @@ -273,35 +273,35 @@ def _add_row( ) if chat_data is None: chat_data = dict(User="Tell me a joke.") - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: data.update(knowledge_data) - elif table_type == p.TableType.chat: + elif table_type == t.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), + t.MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), ) if stream: return response - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert len(response.rows) == 1 return response.rows[0] def _add_row_v2( jamai: JamAI, - table_type: p.TableType, + table_type: t.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: +) -> t.MultiRowCompletionResponse: if data is None: data = {f"in_{dtype}": SAMPLE_DATA[dtype] for dtype in REGULAR_COLUMN_DTYPES} if include_output_data: @@ -318,28 +318,28 @@ def _add_row_v2( chat_data = dict(User="Tell me a joke.") if include_output_data: chat_data.update({"AI": "Nah"}) - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: data.update(knowledge_data) - elif table_type == p.TableType.chat: + elif table_type == t.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), + t.MultiRowAddRequest(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(isinstance(c, t.CellCompletionResponse) 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)] + return t.MultiRowCompletionResponse( + rows=[t.RowCompletionResponse(columns=columns, row_id=chunks[0].row_id)] ) - assert isinstance(response, p.GenTableRowsChatCompletionChunks) + assert isinstance(response, t.MultiRowCompletionResponse) assert response.object == "gen_table.completion.rows" assert len(response.rows) == 1 return response @@ -348,7 +348,7 @@ def _add_row_v2( @contextmanager def _rename_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id_src: str, table_id_dst: str, delete_first: bool = True, @@ -357,7 +357,7 @@ def _rename_table( 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) + assert isinstance(table, t.TableMetaResponse) yield table finally: jamai.table.delete_table(table_type, table_id_dst) @@ -366,7 +366,7 @@ def _rename_table( @contextmanager def _duplicate_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id_src: str, table_id_dst: str, include_data: bool = True, @@ -383,7 +383,7 @@ def _duplicate_table( include_data=include_data, create_as_child=deploy, ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: jamai.table.delete_table(table_type, table_id_dst) @@ -392,7 +392,7 @@ def _duplicate_table( @contextmanager def _create_child_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id_src: str, table_id_dst: str | None, delete_first: bool = True, @@ -404,7 +404,7 @@ def _create_child_table( table_type, table_id_src, table_id_dst, create_as_child=True ) table_id_dst = table.id - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: if isinstance(table_id_dst, str): @@ -412,11 +412,10 @@ def _create_child_table( def _collect_text( - responses: p.GenTableRowsChatCompletionChunks - | Generator[p.GenTableStreamChatCompletionChunk, None, None], + responses: t.MultiRowCompletionResponse | Generator[t.CellCompletionResponse, None, None], col: str, ): - if isinstance(responses, p.GenTableRowsChatCompletionChunks): + if isinstance(responses, t.MultiRowCompletionResponse): 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) @@ -426,18 +425,18 @@ def _collect_text( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_delete_table( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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 isinstance(table_a, t.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) + assert all(isinstance(c, t.ColumnSchema) for c in table_a.cols) table = jamai.table.get_table(table_type, TABLE_ID_B) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # After deleting table B with pytest.raises(ResourceNotFoundError, match="is not found."): jamai.table.get_table(table_type, TABLE_ID_B) @@ -451,12 +450,12 @@ def test_create_delete_table( ) def test_create_table_valid_table_id( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, table_id: str, ): jamai = client_cls() with _create_table(jamai, table_type, table_id) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) assert table.id == table_id @@ -465,29 +464,29 @@ def test_create_table_valid_table_id( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_table_valid_column_id( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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] + cols = [t.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 isinstance(table, t.TableMetaResponse) assert len(set(col_ids) - {c.id for c in table.cols}) == 0 # --- Test output column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id=_id, dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ) for _id in col_ids ] with _create_table(jamai, table_type, table_id, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) assert len(set(col_ids) - {c.id for c in table.cols}) == 0 @@ -499,7 +498,7 @@ def test_create_table_valid_column_id( ) def test_create_table_invalid_table_id( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, column_id: str, ): table_id = TABLE_ID_A @@ -507,7 +506,7 @@ def test_create_table_invalid_table_id( # --- Test input column --- # cols = [ - p.ColumnSchemaCreate(id=column_id, dtype="str"), + t.ColumnSchemaCreate(id=column_id, dtype="str"), ] with pytest.raises(RuntimeError): with _create_table(jamai, table_type, table_id, cols=cols): @@ -515,10 +514,10 @@ def test_create_table_invalid_table_id( # --- Test output column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id=column_id, dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), ] with pytest.raises(RuntimeError): @@ -534,7 +533,7 @@ def test_create_table_invalid_table_id( ) def test_create_table_invalid_column_id( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, column_id: str, ): table_id = TABLE_ID_A @@ -542,7 +541,7 @@ def test_create_table_invalid_column_id( # --- Test input column --- # cols = [ - p.ColumnSchemaCreate(id=column_id, dtype="str"), + t.ColumnSchemaCreate(id=column_id, dtype="str"), ] with pytest.raises(RuntimeError): with _create_table(jamai, table_type, table_id, cols=cols): @@ -550,10 +549,10 @@ def test_create_table_invalid_column_id( # --- Test output column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id=column_id, dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), ] with pytest.raises(RuntimeError): @@ -566,16 +565,16 @@ def test_create_table_invalid_column_id( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_table_invalid_model( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): table_id = TABLE_ID_A jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(model="INVALID"), + gen_config=t.LLMGenConfig(model="INVALID"), ), ] with pytest.raises(ResourceNotFoundError): @@ -588,16 +587,16 @@ def test_create_table_invalid_model( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_table_invalid_column_ref( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): table_id = TABLE_ID_A jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(prompt="Summarise ${input2}"), + gen_config=t.LLMGenConfig(prompt="Summarise ${input2}"), ), ] with pytest.raises(RuntimeError): @@ -610,7 +609,7 @@ def test_create_table_invalid_column_ref( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_table_invalid_rag( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() @@ -618,25 +617,25 @@ def test_create_table_invalid_rag( with _create_table(jamai, "knowledge", TABLE_ID_B, cols=[]) as ktable: # --- Valid knowledge table ID --- # cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id), + gen_config=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id), ), ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # --- Invalid knowledge table ID --- # cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( - rag_params=p.RAGParams(table_id="INVALID"), + gen_config=t.LLMGenConfig( + rag_params=t.RAGParams(table_id="INVALID"), ), ), ] @@ -646,28 +645,28 @@ def test_create_table_invalid_rag( # --- Valid reranker --- # cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( - rag_params=p.RAGParams( + gen_config=t.LLMGenConfig( + rag_params=t.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) + assert isinstance(table, t.TableMetaResponse) # --- Invalid reranker --- # cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id, reranking_model="INVALID"), + gen_config=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id, reranking_model="INVALID"), ), ), ] @@ -681,93 +680,93 @@ def test_create_table_invalid_rag( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_default_llm_model( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.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( + t.GenConfigUpdateRequest( table_id=TABLE_ID_A, column_map=dict( output0=None, - output1=p.LLMGenConfig(), + output1=t.LLMGenConfig(), ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) assert isinstance(cols["AI"].gen_config.model, str) assert len(cols["AI"].gen_config.model) > 0 # --- Add column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output2", dtype="str", gen_config=None, ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output3", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.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)) + elif table_type == t.TableType.chat: + table = jamai.table.add_chat_columns(t.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, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) assert isinstance(cols["AI"].gen_config.model, str) assert len(cols["AI"].gen_config.model) > 0 @@ -777,107 +776,107 @@ def test_default_llm_model( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_default_image_model( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() available_image_models = _get_image_models(jamai) cols = [ - p.ColumnSchemaCreate(id="input0", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="image"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(prompt="${input0}"), + gen_config=t.LLMGenConfig(prompt="${input0}"), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.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( + t.GenConfigUpdateRequest( table_id=TABLE_ID_A, column_map=dict( output0=None, - output1=p.LLMGenConfig(prompt="${input0}"), + output1=t.LLMGenConfig(prompt="${input0}"), ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.GenConfig) assert isinstance(cols["output1"].gen_config.model, str) assert cols["output1"].gen_config.model in available_image_models # --- Add column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output2", dtype="str", - gen_config=p.LLMGenConfig(prompt="${input0}"), + gen_config=t.LLMGenConfig(prompt="${input0}"), ), - p.ColumnSchemaCreate(id="file_input1", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="file_input1", dtype="image"), + t.ColumnSchemaCreate( id="output3", dtype="str", - gen_config=p.LLMGenConfig(prompt="${file_input1}"), + gen_config=t.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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.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)) + elif table_type == t.TableType.chat: + table = jamai.table.add_chat_columns(t.AddChatColumnSchema(id=table.id, cols=cols)) else: raise ValueError(f"Invalid table type: {table_type}") # Add a column with default prompt cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output4", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.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)) + elif table_type == t.TableType.chat: + table = jamai.table.add_chat_columns(t.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) + assert isinstance(cols[output_column_name].gen_config, t.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}' + 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) @@ -885,16 +884,16 @@ def test_default_image_model( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_invalid_image_model( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() available_image_models = _get_image_models(jamai) cols = [ - p.ColumnSchemaCreate(id="input0", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="image"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), + gen_config=t.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), ), ] with pytest.raises(RuntimeError): @@ -902,22 +901,22 @@ def test_invalid_image_model( pass cols = [ - p.ColumnSchemaCreate(id="input0", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="image"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(prompt="${input0}"), + gen_config=t.LLMGenConfig(prompt="${input0}"), ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) assert isinstance(cols["AI"].gen_config.model, str) assert cols["AI"].gen_config.model in available_image_models @@ -925,10 +924,10 @@ def test_invalid_image_model( with pytest.raises(RuntimeError): table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=TABLE_ID_A, column_map=dict( - output0=p.LLMGenConfig( + output0=t.LLMGenConfig( model=_get_chat_only_model(jamai), prompt="${input0}", ), @@ -937,43 +936,43 @@ def test_invalid_image_model( ) table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=TABLE_ID_A, column_map=dict( - output0=p.LLMGenConfig(prompt="${input0}"), + output0=t.LLMGenConfig(prompt="${input0}"), ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) assert isinstance(cols["AI"].gen_config.model, str) assert cols["AI"].gen_config.model in available_image_models # --- Add column --- # cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", - gen_config=p.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), + gen_config=t.LLMGenConfig(model=_get_chat_only_model(jamai), prompt="${input0}"), ) ] with pytest.raises(RuntimeError): - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = jamai.table.add_action_columns( - p.AddActionColumnSchema(id=table.id, cols=cols) + t.AddActionColumnSchema(id=table.id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.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)) + elif table_type == t.TableType.chat: + table = jamai.table.add_chat_columns(t.AddChatColumnSchema(id=table.id, cols=cols)) else: raise ValueError(f"Invalid table type: {table_type}") @@ -985,7 +984,7 @@ def test_default_embedding_model( ): jamai = client_cls() with _create_table(jamai, "knowledge", cols=[], embedding_model="") as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) for col in table.cols: if col.vlen == 0: continue @@ -997,23 +996,23 @@ def test_default_embedding_model( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_default_reranker( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id, reranking_model=""), + gen_config=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id, reranking_model=""), ), ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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) @@ -1026,131 +1025,131 @@ def test_default_reranker( @pytest.mark.parametrize( "messages", [ - [p.ChatEntry.system(""), p.ChatEntry.user("")], - [p.ChatEntry.user("")], + [t.ChatEntry.system(""), t.ChatEntry.user("")], + [t.ChatEntry.user("")], ], ids=["system + user", "user only"], ) def test_default_prompts( client_cls: Type[JamAI], - table_type: p.TableType, - messages: list[p.ChatEntry], + table_type: t.TableType, + messages: list[t.ChatEntry], ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate(id="input1", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate(id="input1", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.ChatRequest(messages=messages), + gen_config=t.ChatRequest(messages=messages), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", - gen_config=p.ChatRequest(messages=messages), + gen_config=t.ChatRequest(messages=messages), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output2", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.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) + assert isinstance(table, t.TableMetaResponse) # ["output0", "output1"] should have default prompts input_cols = {"input0", "input1"} - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: input_cols |= {"Title", "Text", "File ID", "Page"} 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) + assert isinstance(cols[col_id].gen_config, t.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}" + referenced_cols = set(re.findall(t.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) + assert isinstance(cols[col_id].gen_config, t.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}" + referenced_cols = set(re.findall(t.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( + t.ColumnSchemaCreate( id="input2", dtype="int", ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output3", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.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)) + elif table_type == t.TableType.chat: + table = jamai.table.add_chat_columns(t.AddChatColumnSchema(id=table.id, cols=cols)) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) # ["output0", "output1"] should have default prompts input_cols = {"input0", "input1"} - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: input_cols |= {"Title", "Text", "File ID", "Page"} 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) + assert isinstance(cols[col_id].gen_config, t.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}" + referenced_cols = set(re.findall(t.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: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: input_cols |= {"Title", "Text", "File ID", "Page"} else: input_cols |= {"User"} for col_id in ["output3"]: - assert isinstance(cols[col_id].gen_config, p.LLMGenConfig) + assert isinstance(cols[col_id].gen_config, t.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}" + referenced_cols = set(re.findall(t.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) + assert isinstance(cols[col_id].gen_config, t.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}" + referenced_cols = set(re.findall(t.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) @@ -1158,12 +1157,12 @@ def test_default_prompts( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_add_drop_columns( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) _add_row_v2( jamai, table_type, @@ -1173,14 +1172,14 @@ def test_add_drop_columns( # --- COLUMN ADD --- # _input_cols = [ - p.ColumnSchemaCreate(id=f"add_in_{dtype}", dtype=dtype) + t.ColumnSchemaCreate(id=f"add_in_{dtype}", dtype=dtype) for dtype in REGULAR_COLUMN_DTYPES ] _output_cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id=f"add_out_{dtype}", dtype=dtype, - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="", prompt=" ".join(f"${{{col.id}}}" for col in _input_cols), @@ -1195,20 +1194,20 @@ def test_add_drop_columns( 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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: expected_cols |= {"User", "AI"} - table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + table = jamai.table.add_chat_columns(t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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 @@ -1242,7 +1241,7 @@ def test_add_drop_columns( # --- COLUMN DROP --- # table = jamai.table.drop_columns( table_type, - p.ColumnDropRequest( + t.ColumnDropRequest( table_id=table.id, column_names=[f"in_{dtype}" for dtype in REGULAR_COLUMN_DTYPES] + [f"out_{dtype}" for dtype in ["str"]], @@ -1251,16 +1250,16 @@ def test_add_drop_columns( 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: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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) @@ -1282,12 +1281,12 @@ def test_add_drop_columns( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_add_drop_file_column( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) _add_row_v2( jamai, table_type, @@ -1297,11 +1296,11 @@ def test_add_drop_file_column( # --- COLUMN ADD --- # cols = [ - p.ColumnSchemaCreate(id="add_in_file", dtype="image"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="add_in_file", dtype="image"), + t.ColumnSchemaCreate( id="add_out_str", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="", prompt="Describe image ${add_in_file}", @@ -1312,20 +1311,20 @@ def test_add_drop_file_column( 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: + if table_type == t.TableType.action: + table = jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: table = jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: expected_cols |= {"User", "AI"} - table = jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + table = jamai.table.add_chat_columns(t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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 @@ -1358,10 +1357,10 @@ def test_add_drop_file_column( # Block file output column with pytest.raises(RuntimeError): cols = [ - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="add_out_file", dtype="image", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model="", system_prompt="", prompt="Describe image ${add_in_file}", @@ -1369,37 +1368,37 @@ def test_add_drop_file_column( ), ), ] - if table_type == p.TableType.action: - jamai.table.add_action_columns(p.AddActionColumnSchema(id=table.id, cols=cols)) - elif table_type == p.TableType.knowledge: + if table_type == t.TableType.action: + jamai.table.add_action_columns(t.AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.knowledge: jamai.table.add_knowledge_columns( - p.AddKnowledgeColumnSchema(id=table.id, cols=cols) + t.AddKnowledgeColumnSchema(id=table.id, cols=cols) ) - elif table_type == p.TableType.chat: - jamai.table.add_chat_columns(p.AddChatColumnSchema(id=table.id, cols=cols)) + elif table_type == t.TableType.chat: + jamai.table.add_chat_columns(t.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( + t.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: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.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) @@ -1422,12 +1421,12 @@ 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) + assert isinstance(table, t.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]), + t.ColumnDropRequest(table_id=table.id, column_names=[col]), ) @@ -1437,12 +1436,12 @@ def test_ct_drop_invalid_columns(client_cls: Type[JamAI]): table_type = "chat" jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) for col in CT_FIXED_COLUMN_IDS: with pytest.raises(RuntimeError): jamai.table.drop_columns( table_type, - p.ColumnDropRequest(table_id=table.id, column_names=[col]), + t.ColumnDropRequest(table_id=table.id, column_names=[col]), ) @@ -1451,32 +1450,32 @@ def test_ct_drop_invalid_columns(client_cls: Type[JamAI]): @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_rename_columns( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="x", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="x", dtype="str"), + t.ColumnSchemaCreate( id="y", dtype="str", - gen_config=p.LLMGenConfig(prompt=r"Summarise ${x}, \${x}"), + gen_config=t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) # Test rename on empty table table = jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map=dict(y="z")), + t.ColumnRenameRequest(table_id=table.id, column_map=dict(y="z")), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) expected_cols = {"ID", "Updated at", "x", "z"} - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: expected_cols |= {"User", "AI"} else: raise ValueError(f"Invalid table type: {table_type}") @@ -1484,7 +1483,7 @@ def test_rename_columns( assert cols == expected_cols table = jamai.table.get_table(table_type, table.id) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) cols = set(c.id for c in table.cols) assert cols == expected_cols # Test adding data with new column names @@ -1493,22 +1492,22 @@ def test_rename_columns( # Test also auto gen config reference update table = jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map=dict(x="a")), + t.ColumnRenameRequest(table_id=table.id, column_map=dict(x="a")), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) expected_cols = {"ID", "Updated at", "a", "z"} - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} - elif table_type == p.TableType.chat: + elif table_type == t.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) + assert isinstance(table, t.TableMetaResponse) cols = set(c.id for c in table.cols) assert cols == expected_cols # Test auto gen config reference update @@ -1521,14 +1520,14 @@ def test_rename_columns( with pytest.raises(RuntimeError): jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="b")), + t.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="b")), ) # Overlapping new and old column names with pytest.raises(RuntimeError): jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="a")), + t.ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="a")), ) @@ -1538,12 +1537,12 @@ 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) + assert isinstance(table, t.TableMetaResponse) for col in KT_FIXED_COLUMN_IDS: with pytest.raises(RuntimeError): jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map={col: col}), + t.ColumnRenameRequest(table_id=table.id, column_map={col: col}), ) @@ -1553,12 +1552,12 @@ def test_ct_rename_invalid_columns(client_cls: Type[JamAI]): table_type = "chat" jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) for col in CT_FIXED_COLUMN_IDS: with pytest.raises(RuntimeError): jamai.table.rename_columns( table_type, - p.ColumnRenameRequest(table_id=table.id, column_map={col: col}), + t.ColumnRenameRequest(table_id=table.id, column_map={col: col}), ) @@ -1567,14 +1566,14 @@ def test_ct_rename_invalid_columns(client_cls: Type[JamAI]): @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_reorder_columns( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) table = jamai.table.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) column_names = [ "inputs", @@ -1596,16 +1595,16 @@ def test_reorder_columns( "summary", "captioning", ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] expected_order = ( expected_order[:2] + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order[2:] ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: column_names += ["User", "AI"] expected_order = expected_order[:2] + ["User", "AI"] + expected_order[2:] else: @@ -1615,7 +1614,7 @@ def test_reorder_columns( # Test reorder empty table table = jamai.table.reorder_columns( table_type, - p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), + t.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), ) expected_order = [ "ID", @@ -1628,18 +1627,18 @@ def test_reorder_columns( "summary", "captioning", ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] - elif table_type == p.TableType.chat: + elif table_type == t.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) + assert isinstance(table, t.TableMetaResponse) cols = [c.id for c in table.cols] assert cols == expected_order, cols # Test add row @@ -1658,14 +1657,14 @@ def test_reorder_columns( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_reorder_columns_invalid( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.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) + assert isinstance(table, t.TableMetaResponse) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) table = jamai.table.get_table(table_type, TABLE_ID_A) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) column_names = [ "inputs", @@ -1687,16 +1686,16 @@ def test_reorder_columns_invalid( "summary", "captioning", ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] expected_order = ( expected_order[:2] + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order[2:] ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: column_names += ["User", "AI"] expected_order = expected_order[:2] + ["User", "AI"] + expected_order[2:] else: @@ -1714,18 +1713,18 @@ def test_reorder_columns_invalid( "photo", "captioning", ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: pass - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] - elif table_type == p.TableType.chat: + elif table_type == t.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.table.reorder_columns( table_type, - p.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), + t.ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), ) @@ -1734,119 +1733,119 @@ def test_reorder_columns_invalid( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_update_gen_config( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Switch gen config --- # table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( output0=None, - output1=p.LLMGenConfig(), + output1=t.LLMGenConfig(), ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Update gen config --- # table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig(), + output0=t.LLMGenConfig(), ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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) + assert isinstance(cols["output0"].gen_config, t.GenConfig) + assert isinstance(cols["output1"].gen_config, t.GenConfig) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Update gen config --- # table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( output1=None, ), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.GenConfig) assert cols["output1"].gen_config is None - if table_type == p.TableType.chat: - assert isinstance(cols["AI"].gen_config, p.GenConfig) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Chat AI column must always have gen config --- # - if table_type == p.TableType.chat: + if table_type == t.TableType.chat: table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict(AI=None), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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: + if table_type == t.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( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict(AI=chat_cfg), ), ) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) cols = {c.id: c for c in table.cols} assert cols["AI"].gen_config.multi_turn is True @@ -1856,48 +1855,48 @@ def test_update_gen_config( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_update_gen_config_invalid_model( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.GenConfig) assert cols["output1"].gen_config is None - if table_type == p.TableType.chat: - assert isinstance(cols["AI"].gen_config, p.GenConfig) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Update gen config --- # with pytest.raises(ResourceNotFoundError): table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig(model="INVALID"), + output0=t.LLMGenConfig(model="INVALID"), ), ), ) table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig(model=_get_chat_model(jamai)), + output0=t.LLMGenConfig(model=_get_chat_model(jamai)), ), ), ) @@ -1908,57 +1907,57 @@ def test_update_gen_config_invalid_model( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_update_gen_config_invalid_column_ref( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.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) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Update gen config --- # with pytest.raises(RuntimeError): table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig(prompt="Summarise ${input2}"), + output0=t.LLMGenConfig(prompt="Summarise ${input2}"), ), ), ) table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig(prompt="Summarise ${input0}"), + output0=t.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, t.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 @@ -1970,42 +1969,42 @@ def test_update_gen_config_invalid_column_ref( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_update_gen_config_invalid_rag( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="input0", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input0", dtype="str"), + t.ColumnSchemaCreate( id="output0", dtype="str", - gen_config=p.LLMGenConfig(), + gen_config=t.LLMGenConfig(), ), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate( id="output1", dtype="str", gen_config=None, ), ] with _create_table(jamai, "knowledge", cols=[]) as ktable: - assert isinstance(ktable, p.TableMetaResponse) + assert isinstance(ktable, t.TableMetaResponse) with _create_table(jamai, table_type, cols=cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.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, t.GenConfig) assert cols["output1"].gen_config is None - if table_type == p.TableType.chat: - assert isinstance(cols["AI"].gen_config, p.GenConfig) + if table_type == t.TableType.chat: + assert isinstance(cols["AI"].gen_config, t.GenConfig) # --- Invalid knowledge table ID --- # with pytest.raises(ResourceNotFoundError): table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig( - rag_params=p.RAGParams(table_id="INVALID"), + output0=t.LLMGenConfig( + rag_params=t.RAGParams(table_id="INVALID"), ), ), ), @@ -2013,11 +2012,11 @@ def test_update_gen_config_invalid_rag( # --- Valid knowledge table ID --- # table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id), + output0=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id), ), ), ), @@ -2027,11 +2026,11 @@ def test_update_gen_config_invalid_rag( with pytest.raises(ResourceNotFoundError): table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig( - rag_params=p.RAGParams( + output0=t.LLMGenConfig( + rag_params=t.RAGParams( table_id=ktable.id, reranking_model="INVALID" ), ), @@ -2041,11 +2040,11 @@ def test_update_gen_config_invalid_rag( # --- Valid reranker --- # table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id, reranking_model=None), + output0=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id, reranking_model=None), ), ), ), @@ -2054,11 +2053,11 @@ def test_update_gen_config_invalid_rag( assert cols["output0"].gen_config.rag_params.reranking_model is None table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest( + t.GenConfigUpdateRequest( table_id=table.id, column_map=dict( - output0=p.LLMGenConfig( - rag_params=p.RAGParams(table_id=ktable.id, reranking_model=""), + output0=t.LLMGenConfig( + rag_params=t.RAGParams(table_id=ktable.id, reranking_model=""), ), ), ), @@ -2074,15 +2073,15 @@ def test_update_gen_config_invalid_rag( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_null_gen_config( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) table = jamai.table.update_gen_config( table_type, - p.GenConfigUpdateRequest(table_id=table.id, column_map=dict(summary=None)), + t.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) @@ -2090,9 +2089,9 @@ def test_null_gen_config( if stream: # Must wait until stream ends responses = [r for r in response] - assert all(isinstance(r, p.GenTableStreamChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.CellCompletionResponse) for r in responses) else: - assert isinstance(response, p.GenTableChatCompletionChunks) + assert isinstance(response, t.RowCompletionResponse) rows = jamai.table.list_table_rows(table_type, table.id) assert isinstance(rows.items, list) assert len(rows.items) == 1 @@ -2105,16 +2104,16 @@ def test_null_gen_config( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_invalid_referenced_column( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() # --- Non-existent column --- # cols = [ - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", prompt="Summarise ${inputs}", @@ -2130,11 +2129,11 @@ def test_invalid_referenced_column( # --- Vector column --- # cols = [ - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", prompt="Summarise ${Text Embed}", @@ -2155,16 +2154,16 @@ def test_invalid_referenced_column( @pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) def test_gen_config_empty_prompts( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, stream: bool, ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), temperature=0.001, top_p=0.001, @@ -2173,11 +2172,11 @@ def test_gen_config_empty_prompts( ), ] chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), temperature=0.001, top_p=0.001, @@ -2186,26 +2185,26 @@ def test_gen_config_empty_prompts( ), ] with _create_table(jamai, table_type, cols=cols, chat_cols=chat_cols) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) data = dict(words=5) - if table_type == p.TableType.knowledge: + if table_type == t.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), + t.MultiRowAddRequest(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) + assert all(isinstance(r, t.CellCompletionResponse) 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: + if table_type == t.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) + assert isinstance(response.rows[0], t.RowCompletionResponse) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @@ -2215,11 +2214,11 @@ def test_gen_config_no_message( jamai = client_cls() with pytest.raises(ValidationError, match="at least 1 item"): _ = [ - p.ColumnSchemaCreate(id="words", dtype="int"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="words", dtype="int"), + t.ColumnSchemaCreate( id="summary", dtype="str", - gen_config=p.ChatRequest( + gen_config=t.ChatRequest( model=_get_chat_model(jamai), messages=[], temperature=0.001, @@ -2235,7 +2234,7 @@ def test_gen_config_no_message( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_get_and_list_tables( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() _delete_tables(jamai) @@ -2245,7 +2244,7 @@ def test_get_and_list_tables( _create_table(jamai, table_type, TABLE_ID_C), _create_table(jamai, table_type, TABLE_ID_X), ): - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row( jamai, table_type, @@ -2255,7 +2254,7 @@ def test_get_and_list_tables( # Regular case table = jamai.table.get_table(table_type, TABLE_ID_B) - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) assert table.id == TABLE_ID_B tables = jamai.table.list_tables(table_type) @@ -2264,7 +2263,7 @@ def test_get_and_list_tables( assert tables.offset == 0 assert tables.limit == 100 assert len(tables.items) == 4 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.TableMetaResponse) for r in tables.items) # Test various offset and limit tables = jamai.table.list_tables(table_type, offset=3, limit=2) @@ -2273,7 +2272,7 @@ def test_get_and_list_tables( assert tables.offset == 3 assert tables.limit == 2 assert len(tables.items) == 1 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.TableMetaResponse) for r in tables.items) tables = jamai.table.list_tables(table_type, offset=4, limit=2) assert isinstance(tables.items, list) @@ -2295,7 +2294,7 @@ def test_get_and_list_tables( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_table_search_and_parent_id( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() _delete_tables(jamai) @@ -2305,7 +2304,7 @@ def test_table_search_and_parent_id( _create_table(jamai, table_type, "bear"), _create_table(jamai, table_type, "fear"), ): - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) with ( _create_child_table(jamai, table_type, "beast", "least"), _create_child_table(jamai, table_type, "beast", "lease"), @@ -2318,7 +2317,7 @@ def test_table_search_and_parent_id( assert tables.offset == 0 assert tables.limit == 3 assert len(tables.items) == 3 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.TableMetaResponse) for r in tables.items) # Search tables = jamai.table.list_tables(table_type, search_query="be", limit=3) assert isinstance(tables.items, list) @@ -2326,7 +2325,7 @@ def test_table_search_and_parent_id( assert tables.offset == 0 assert tables.limit == 3 assert len(tables.items) == 2 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.TableMetaResponse) for r in tables.items) # Search tables = jamai.table.list_tables(table_type, search_query="ast", limit=3) assert isinstance(tables.items, list) @@ -2334,7 +2333,7 @@ def test_table_search_and_parent_id( assert tables.offset == 0 assert tables.limit == 3 assert len(tables.items) == 3 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.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) @@ -2342,7 +2341,7 @@ def test_table_search_and_parent_id( assert tables.offset == 0 assert tables.limit == 100 assert len(tables.items) == 2 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.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) @@ -2350,7 +2349,7 @@ def test_table_search_and_parent_id( assert tables.offset == 0 assert tables.limit == 100 assert len(tables.items) == 3 - assert all(isinstance(r, p.TableMetaResponse) for r in tables.items) + assert all(isinstance(r, t.TableMetaResponse) for r in tables.items) @flaky(max_runs=5, min_passes=1, rerun_filter=_rerun_on_fs_error_with_delay) @@ -2358,11 +2357,11 @@ def test_table_search_and_parent_id( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_duplicate_table( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row( jamai, table_type, @@ -2418,12 +2417,12 @@ def test_duplicate_table( ) def test_duplicate_table_invalid_name( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, table_id_dst: str, ): jamai = client_cls() with _create_table(jamai, table_type) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row( jamai, table_type, @@ -2441,11 +2440,11 @@ def test_duplicate_table_invalid_name( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_create_child_table( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type) as table_a: - assert isinstance(table_a, p.TableMetaResponse) + assert isinstance(table_a, t.TableMetaResponse) _add_row( jamai, table_type, @@ -2454,7 +2453,7 @@ def test_create_child_table( ) # 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) + assert isinstance(table_b, t.TableMetaResponse) # Add another to table A _add_row( jamai, @@ -2484,11 +2483,11 @@ def test_create_child_table( @pytest.mark.parametrize("table_type", TABLE_TYPES) def test_rename_table( client_cls: Type[JamAI], - table_type: p.TableType, + table_type: t.TableType, ): jamai = client_cls() with _create_table(jamai, table_type, TABLE_ID_A) as table: - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) _add_row( jamai, table_type, @@ -2497,7 +2496,7 @@ def test_rename_table( ) # Create child table with _create_child_table(jamai, table_type, TABLE_ID_A, TABLE_ID_B) as child: - assert isinstance(child, p.TableMetaResponse) + assert isinstance(child, t.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) @@ -2531,11 +2530,11 @@ def test_chat_table_gen_config( ): jamai = client_cls() cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=_get_chat_model(jamai), system_prompt="You are a concise assistant.", multi_turn=False, @@ -2552,4 +2551,4 @@ def test_chat_table_gen_config( if __name__ == "__main__": - test_add_drop_columns(JamAI, p.TableType.action) + test_add_drop_columns(JamAI, t.TableType.action) diff --git a/clients/python/tests/oss/test_admin.py b/clients/python/tests/oss/test_admin.py index c7eb4bb..5261e0b 100644 --- a/clients/python/tests/oss/test_admin.py +++ b/clients/python/tests/oss/test_admin.py @@ -4,7 +4,7 @@ import pytest from jamaibase import JamAI, JamAIAsync -from jamaibase.protocol import LLMModelConfig, ModelDeploymentConfig, ModelListConfig, OkResponse +from jamaibase.types import LLMModelConfig, ModelDeploymentConfig, ModelListConfig, OkResponse from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] @@ -60,7 +60,7 @@ async def test_get_set_org_model_config( context_length=8000, languages=["mul"], capabilities=["chat"], - owned_by="ellm", + owned_by=ORG_ID, ) ) async with _set_org_model_config(jamai, ORG_ID, new_config) as response: diff --git a/clients/python/tests/oss/test_chat.py b/clients/python/tests/oss/test_chat.py index 7a0d18a..33345d1 100644 --- a/clients/python/tests/oss/test_chat.py +++ b/clients/python/tests/oss/test_chat.py @@ -5,7 +5,7 @@ from loguru import logger from jamaibase import JamAI, JamAIAsync -from jamaibase import protocol as p +from jamaibase import types as t from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] @@ -19,9 +19,9 @@ async def test_model_info( # Get all model info response = await run(jamai.model_info) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) assert len(response.data) > 0 - assert isinstance(response.data[0], p.ModelInfo) + assert isinstance(response.data[0], t.ModelInfo) model = response.data[0] assert isinstance(model.id, str) assert isinstance(model.context_length, int) @@ -31,20 +31,20 @@ async def test_model_info( # Get specific model info response = await run(jamai.model_info, name=model.id) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) assert len(response.data) == 1 assert response.data[0].id == model.id # Filter based on capability response = await run(jamai.model_info, capabilities=["chat"]) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) for m in response.data: assert "chat" in m.capabilities assert "embed" not in m.capabilities assert "rerank" not in m.capabilities response = await run(jamai.model_info, capabilities=["chat", "image"]) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) for m in response.data: assert "chat" in m.capabilities assert "image" in m.capabilities @@ -52,14 +52,14 @@ async def test_model_info( assert "rerank" not in m.capabilities response = await run(jamai.model_info, capabilities=["embed"]) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) for m in response.data: assert "chat" not in m.capabilities assert "embed" in m.capabilities assert "rerank" not in m.capabilities response = await run(jamai.model_info, capabilities=["rerank"]) - assert isinstance(response, p.ModelInfoResponse) + assert isinstance(response, t.ModelInfoListResponse) for m in response.data: assert "chat" not in m.capabilities assert "embed" not in m.capabilities @@ -124,12 +124,12 @@ async def test_model_names( def _get_chat_request(model: str, **kwargs): - request = p.ChatRequest( + request = t.ChatRequest( id="test", model=model, messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user("What is a llama?"), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.user("What is a llama?"), ], temperature=0.001, top_p=0.001, @@ -171,10 +171,10 @@ async def test_chat_completion( # Non-streaming request = _get_chat_request(model, stream=False) response = await run(jamai.generate_chat_completions, request) - assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response, t.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) assert response.prompt_tokens > 0 @@ -186,12 +186,12 @@ async def test_chat_completion( request.stream = True responses = await run(jamai.generate_chat_completions, request) assert len(responses) > 0 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.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) response = responses[-1] - assert all(isinstance(r.usage, p.CompletionUsage) for r in responses) + assert all(isinstance(r.usage, t.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 @@ -200,15 +200,15 @@ async def test_chat_completion( TOOLS = { - "get_weather": p.Tool( + "get_weather": t.Tool( type="function", - function=p.Function( + function=t.Function( name="get_weather", description="Get the current weather for a location", - parameters=p.FunctionParameters( + parameters=t.FunctionParameters( type="object", properties={ - "location": p.FunctionParameter( + "location": t.FunctionParameter( type="string", description="The city and state, e.g. San Francisco, CA" ) }, @@ -217,24 +217,24 @@ async def test_chat_completion( ), ), ), - "calculator": p.Tool( + "calculator": t.Tool( type="function", - function=p.Function( + function=t.Function( name="calculator", description="Perform a basic arithmetic operation", - parameters=p.FunctionParameters( + parameters=t.FunctionParameters( type="object", properties={ - "operation": p.FunctionParameter( + "operation": t.FunctionParameter( type="string", description="The arithmetic operation to perform", enum=["add", "subtract", "multiply", "divide"], ), - "first_number": p.FunctionParameter( + "first_number": t.FunctionParameter( type="number", description="The first number", ), - "second_number": p.FunctionParameter( + "second_number": t.FunctionParameter( type="number", description="The second number", ), @@ -270,20 +270,20 @@ async def test_chat_completion_with_tools( ): jamai = client_cls() - tool_choice = p.ToolChoice( + tool_choice = t.ToolChoice( type="function", - function=p.ToolChoiceFunction( + function=t.ToolChoiceFunction( name=tool_prompt["tool_choice"], ), ) # Create a chat request with a tool - request = p.ChatRequestWithTools( + request = t.ChatRequestWithTools( id="test", model=model, messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user(tool_prompt["prompt"]), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.user(tool_prompt["prompt"]), ], tools=[v for _, v in TOOLS.items()] if set_multi_tools @@ -297,7 +297,7 @@ async def test_chat_completion_with_tools( # Non-streaming response = await run(jamai.generate_chat_completions, request) - assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response, t.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) == 0 tool_calls = response.message.tool_calls @@ -306,7 +306,7 @@ async def test_chat_completion_with_tools( assert tool_calls[0].function.name == tool_prompt["tool_choice"] for argument in tool_prompt["response"]: assert argument in tool_calls[0].function.arguments.replace(" ", "") - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) assert response.references is None @@ -315,12 +315,12 @@ async def test_chat_completion_with_tools( request.stream = True responses = await run(jamai.generate_chat_completions, request) assert len(responses) > 0 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.ChatCompletionChunk) for r in responses) assert all(isinstance(r.text, str) for r in responses) assert len("".join(r.text for r in responses)) == 0 assert all(r.references is None for r in responses) response = responses[-1] - assert all(isinstance(r.usage, p.CompletionUsage) for r in responses) + assert all(isinstance(r.usage, t.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 @@ -351,13 +351,13 @@ async def test_chat_opener( jamai = client_cls() # Non-streaming - request = p.ChatRequest( + request = t.ChatRequest( id="test", model=model, messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.assistant("Sam has 7 apples."), - p.ChatEntry.user("How many apples does Sam have?"), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.assistant("Sam has 7 apples."), + t.ChatEntry.user("How many apples does Sam have?"), ], temperature=0.001, top_p=0.001, @@ -365,11 +365,11 @@ async def test_chat_opener( stream=False, ) response = await run(jamai.generate_chat_completions, request) - assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response, t.ChatCompletionChunk) assert isinstance(response.text, str) 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.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) assert response.references is None @@ -378,11 +378,11 @@ async def test_chat_opener( request.stream = True responses = await run(jamai.generate_chat_completions, request) assert len(responses) > 0 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.ChatCompletionChunk) for r in responses) assert all(isinstance(r.text, str) 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.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) @@ -397,20 +397,20 @@ async def test_chat_user_only( jamai = client_cls() # Non-streaming - request = p.ChatRequest( + request = t.ChatRequest( id="test", model=model, - messages=[p.ChatEntry.user("Hi there")], + messages=[t.ChatEntry.user("Hi there")], temperature=0.001, top_p=0.001, max_tokens=30, stream=False, ) response = await run(jamai.generate_chat_completions, request) - assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response, t.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) assert response.references is None @@ -419,11 +419,11 @@ async def test_chat_user_only( request.stream = True responses = await run(jamai.generate_chat_completions, request) assert len(responses) > 0 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.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.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) @@ -438,20 +438,20 @@ async def test_chat_system_only( jamai = client_cls() # Non-streaming - request = p.ChatRequest( + request = t.ChatRequest( id="test", model=model, - messages=[p.ChatEntry.system("You are a concise assistant.")], + messages=[t.ChatEntry.system("You are a concise assistant.")], temperature=0.001, top_p=0.001, max_tokens=30, stream=False, ) response = await run(jamai.generate_chat_completions, request) - assert isinstance(response, p.ChatCompletionChunk) + assert isinstance(response, t.ChatCompletionChunk) assert isinstance(response.text, str) assert len(response.text) > 1 - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) assert response.references is None @@ -460,11 +460,11 @@ async def test_chat_system_only( request.stream = True responses = await run(jamai.generate_chat_completions, request) assert len(responses) > 0 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.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.usage, t.CompletionUsage) assert isinstance(response.prompt_tokens, int) assert isinstance(response.completion_tokens, int) @@ -479,12 +479,12 @@ async def test_long_chat_completion( jamai = client_cls() # Streaming - request = p.ChatRequest( + request = t.ChatRequest( id="test", model=model, messages=[ - p.ChatEntry.system("You are a concise assistant."), - p.ChatEntry.user(" ".join(["What is a llama?"] * 50000)), + t.ChatEntry.system("You are a concise assistant."), + t.ChatEntry.user(" ".join(["What is a llama?"] * 50000)), ], temperature=0.001, top_p=0.001, @@ -493,7 +493,7 @@ async def test_long_chat_completion( ) responses = await run(jamai.generate_chat_completions, request) assert len(responses) == 1 - assert all(isinstance(r, p.ChatCompletionChunk) for r in responses) + assert all(isinstance(r, t.ChatCompletionChunk) for r in responses) completion = responses[0] assert completion.finish_reason == "error" assert "ContextWindowExceededError" in completion.text diff --git a/clients/python/tests/oss/test_embeddings.py b/clients/python/tests/oss/test_embeddings.py index 13a3a01..335e6a3 100644 --- a/clients/python/tests/oss/test_embeddings.py +++ b/clients/python/tests/oss/test_embeddings.py @@ -5,7 +5,7 @@ import pytest from jamaibase import JamAI, JamAIAsync -from jamaibase import protocol as p +from jamaibase import types as t from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] @@ -39,13 +39,13 @@ async def test_generate_embeddings( } # Get float embeddings - response = await run(jamai.generate_embeddings, p.EmbeddingRequest(**kwargs)) - assert isinstance(response, p.EmbeddingResponse) + response = await run(jamai.generate_embeddings, t.EmbeddingRequest(**kwargs)) + assert isinstance(response, t.EmbeddingResponse) assert isinstance(response.data, list) - assert all(isinstance(d, p.EmbeddingResponseData) for d in response.data) + assert all(isinstance(d, t.EmbeddingResponseData) for d in response.data) assert all(isinstance(d.embedding, list) for d in response.data) assert isinstance(response.model, str) - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) if isinstance(inputs, str): assert len(response.data) == 1 else: @@ -54,13 +54,13 @@ async def test_generate_embeddings( # Get base64 embeddings kwargs["encoding_format"] = "base64" - response = await run(jamai.generate_embeddings, p.EmbeddingRequest(**kwargs)) - assert isinstance(response, p.EmbeddingResponse) + response = await run(jamai.generate_embeddings, t.EmbeddingRequest(**kwargs)) + assert isinstance(response, t.EmbeddingResponse) assert isinstance(response.data, list) - assert all(isinstance(d, p.EmbeddingResponseData) for d in response.data) + assert all(isinstance(d, t.EmbeddingResponseData) for d in response.data) assert all(isinstance(d.embedding, str) for d in response.data) assert isinstance(response.model, str) - assert isinstance(response.usage, p.CompletionUsage) + assert isinstance(response.usage, t.CompletionUsage) if isinstance(inputs, str): assert len(response.data) == 1 else: diff --git a/clients/python/tests/oss/test_file.py b/clients/python/tests/oss/test_file.py index d31f90a..2e9be1c 100644 --- a/clients/python/tests/oss/test_file.py +++ b/clients/python/tests/oss/test_file.py @@ -11,12 +11,16 @@ from PIL import Image from jamaibase import JamAI, JamAIAsync -from jamaibase.protocol import ( +from jamaibase.types import ( FileUploadResponse, GetURLResponse, ) from jamaibase.utils import run -from jamaibase.utils.io import generate_audio_thumbnail, generate_image_thumbnail +from jamaibase.utils.io import ( + generate_extension_name_thumbnail, + generate_pdf_thumbnail, +) +from owl.utils.io import generate_audio_thumbnail, generate_image_thumbnail def read_file_content(file_path): @@ -37,6 +41,11 @@ def read_file_content(file_path): "clients/python/tests/files/mp3/turning-a4-size-magazine.mp3", ] +DOC_FILES = [ + "clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf", + "clients/python/tests/files/xlsx/Claims Form.xlsx", +] + CLIENT_CLS = [JamAI, JamAIAsync] @@ -51,9 +60,9 @@ async def test_upload_image(client_cls: Type[JamAI | JamAIAsync], image_file: st # 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://'" + 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( @@ -80,9 +89,9 @@ async def test_upload_audio(client_cls: Type[JamAI | JamAIAsync], audio_file: st # Upload the file upload_response = await run(jamai.file.upload_file, audio_file) assert isinstance(upload_response, FileUploadResponse) - assert upload_response.uri.startswith( - ("file://", "s3://") - ), f"Returned URI '{upload_response.uri}' does not start with 'file://' or 's3://'" + assert upload_response.uri.startswith(("file://", "s3://")), ( + f"Returned URI '{upload_response.uri}' does not start with 'file://' or 's3://'" + ) filename = os.path.basename(audio_file) expected_uri_pattern = re.compile( @@ -98,6 +107,35 @@ async def test_upload_audio(client_cls: Type[JamAI | JamAIAsync], audio_file: st print(f"Returned URI matches the expected format: {upload_response.uri}") +@pytest.mark.parametrize("client_cls", CLIENT_CLS) +@pytest.mark.parametrize("doc_file", DOC_FILES) +async def test_upload_doc(client_cls: Type[JamAI | JamAIAsync], doc_file: str): + # Initialize the client + jamai = client_cls() + + # Ensure the doc file exists + assert os.path.exists(doc_file), f"Test doc file does not exist: {doc_file}" + # Upload the file + upload_response = await run(jamai.file.upload_file, doc_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(doc_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() @@ -121,28 +159,23 @@ async def test_get_raw_urls(client_cls: Type[JamAI | JamAIAsync]): jamai = client_cls() # Upload files first uploaded_uris = [] - for file in IMAGE_FILES + AUDIO_FILES: + for file in IMAGE_FILES + AUDIO_FILES + DOC_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 + AUDIO_FILES) - for original_file, url in zip(IMAGE_FILES + AUDIO_FILES, response.urls, strict=True): - if url.startswith(("http://", "https://")): - # Handle HTTP/HTTPS URLs - HEADERS = {"X-PROJECT-ID": "default"} - with httpx.Client() as client: - downloaded_content = client.get(url, headers=HEADERS).content - + assert len(response.urls) == len(IMAGE_FILES + AUDIO_FILES + DOC_FILES) + for original_file, url in zip( + IMAGE_FILES + AUDIO_FILES + DOC_FILES, response.urls, strict=True + ): # 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}" + assert original_content == httpx.get(url).content, ( + f"Content mismatch for file: {original_file}" + ) # Check if the returned URIs are absolute paths for url in response.urls: @@ -163,14 +196,14 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): # Upload files first uploaded_uris = [] - for file in IMAGE_FILES + AUDIO_FILES: + for file in IMAGE_FILES + AUDIO_FILES + DOC_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 + AUDIO_FILES) + assert len(response.urls) == len(IMAGE_FILES + AUDIO_FILES + DOC_FILES) # Generate thumbnails and compare for original_file, url in zip(IMAGE_FILES, response.urls[: len(IMAGE_FILES)], strict=True): @@ -182,17 +215,21 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): 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 + downloaded_thumbnail = httpx.get(url).content else: downloaded_thumbnail = read_file_content(url) # Compare thumbnails - assert ( - expected_thumbnail == downloaded_thumbnail - ), f"Thumbnail mismatch for file: {original_file}" + assert expected_thumbnail == downloaded_thumbnail, ( + f"Thumbnail mismatch for file: {original_file}" + ) # Generate audio thumbnails and compare - for original_file, url in zip(AUDIO_FILES, response.urls[len(IMAGE_FILES) :], strict=True): + for original_file, url in zip( + AUDIO_FILES, + response.urls[len(IMAGE_FILES) : len(IMAGE_FILES) + len(AUDIO_FILES)], + strict=True, + ): # Read original file content original_content = read_file_content(original_file) @@ -201,7 +238,7 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): 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 + downloaded_thumbnail = httpx.get(url).content else: downloaded_thumbnail = read_file_content(url) @@ -212,6 +249,31 @@ async def test_get_thumbnail_urls(client_cls: Type[JamAI | JamAIAsync]): == downloaded_thumbnail[-round(len(expected_thumbnail) * 0.9) :] ), f"Thumbnail mismatch for file: {original_file}" + # Generate doc thumbnails and compare + for original_file, url in zip( + DOC_FILES, response.urls[len(IMAGE_FILES) + len(AUDIO_FILES) :], strict=True + ): + # Read original file content + original_content = read_file_content(original_file) + + # Generate document thumbnail + file_extension = os.path.splitext(original_file)[1].lower() + if file_extension == ".pdf": + expected_thumbnail = generate_pdf_thumbnail(original_content) + else: + expected_thumbnail = generate_extension_name_thumbnail(file_extension) + assert expected_thumbnail is not None, f"Failed to generate thumbnail for {original_file}" + + if url.startswith(("http://", "https://")): + downloaded_thumbnail = httpx.get(url).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) @@ -233,7 +295,7 @@ async def test_thumbnail_transparency(client_cls: Type[JamAI | JamAIAsync]): 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 + downloaded_thumbnail = httpx.get(thumb_url).content else: downloaded_thumbnail = read_file_content(thumb_url) diff --git a/clients/python/tests/oss/test_gen_executor.py b/clients/python/tests/oss/test_gen_executor.py index 1ca3d1d..df399b3 100644 --- a/clients/python/tests/oss/test_gen_executor.py +++ b/clients/python/tests/oss/test_gen_executor.py @@ -1,6 +1,5 @@ import asyncio import io -import time from contextlib import asynccontextmanager import httpx @@ -9,22 +8,22 @@ from PIL import Image from jamaibase import JamAI, JamAIAsync -from jamaibase.exceptions import ResourceNotFoundError -from jamaibase.protocol import ( +from jamaibase.types import ( + CellCompletionResponse, CodeGenConfig, ColumnSchemaCreate, GenConfigUpdateRequest, - GenTableRowsChatCompletionChunks, - GenTableStreamChatCompletionChunk, GetURLResponse, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowRegenRequest, RegenStrategy, - RowAddRequest, - RowRegenRequest, RowUpdateRequest, TableSchemaCreate, TableType, ) from jamaibase.utils import run +from jamaibase.utils.exceptions import ResourceNotFoundError CLIENT_CLS = [JamAI, JamAIAsync] REGEN_STRATEGY = [ @@ -183,12 +182,12 @@ async def test_exceed_context_length( chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) if stream: - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -199,140 +198,6 @@ async def test_exceed_context_length( 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) @@ -414,13 +279,13 @@ async def test_multicols_regen( chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) if not only_input_columns: if stream: - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -442,7 +307,7 @@ async def test_multicols_regen( chunks = await run( jamai.table.regen_table_rows, TableType.action, - RowRegenRequest( + MultiRowRegenRequest( table_id=table_id, row_ids=[row_id], regen_strategy=regen_strategy, @@ -453,9 +318,9 @@ async def test_multicols_regen( ) if not only_input_columns: if stream: - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -500,12 +365,12 @@ async def test_multicols_regen_invalid_column_id( chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) if stream: - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -526,14 +391,14 @@ async def test_multicols_regen_invalid_column_id( with pytest.raises( ResourceNotFoundError, match=( - f'`output_column_id` .*{invalid_output_column_id}.* is not found. ' + 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( + MultiRowRegenRequest( table_id=table_id, row_ids=[row_id], regen_strategy=regen_strategy, @@ -580,15 +445,15 @@ async def test_code_str(client_cls: JamAI | JamAIAsync, stream: bool): chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) if stream: print(chunks[0]) - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: print(chunks) - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -602,7 +467,7 @@ async def test_code_str(client_cls: JamAI | JamAIAsync, stream: bool): chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) rows = await run(jamai.table.list_table_rows, TableType.action, table_id) row_id = rows.items[0]["ID"] @@ -668,15 +533,15 @@ async def test_code_image(client_cls: JamAI | JamAIAsync, stream: bool): chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) if stream: print(chunks[0]) - assert isinstance(chunks[0], GenTableStreamChatCompletionChunk) + assert isinstance(chunks[0], CellCompletionResponse) else: print(chunks) - assert isinstance(chunks, GenTableRowsChatCompletionChunks) + assert isinstance(chunks, MultiRowCompletionResponse) # Get rows rows = await run(jamai.table.list_table_rows, TableType.action, table_id) @@ -693,12 +558,7 @@ async def test_code_image(client_cls: JamAI | JamAIAsync, stream: bool): assert isinstance(response, GetURLResponse) for url in response.urls: if url.startswith(("http://", "https://")): - # Handle HTTP/HTTPS URLs - HEADERS = {"X-PROJECT-ID": "default"} - with httpx.Client() as client: - downloaded_content = client.get(url, headers=HEADERS).content - - image = Image.open(io.BytesIO(downloaded_content)) + image = Image.open(io.BytesIO(httpx.get(url).content)) assert image.format == case["expected_format"] # Test error handling @@ -707,7 +567,7 @@ async def test_code_image(client_cls: JamAI | JamAIAsync, stream: bool): chunks = await run( jamai.table.add_table_rows, TableType.action, - RowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), + MultiRowAddRequest(table_id=table_id, data=[row_input_data], stream=stream), ) rows = await run(jamai.table.list_table_rows, TableType.action, table_id) diff --git a/clients/python/tests/oss/test_template.py b/clients/python/tests/oss/test_template.py index c9aeb95..cc0ab1e 100644 --- a/clients/python/tests/oss/test_template.py +++ b/clients/python/tests/oss/test_template.py @@ -5,21 +5,21 @@ import pytest from jamaibase import JamAI, JamAIAsync -from jamaibase import protocol as p +from jamaibase import types as t from jamaibase.utils import run CLIENT_CLS = [JamAI, JamAIAsync] -TABLE_TYPES = [p.TableType.action, p.TableType.knowledge, p.TableType.chat] +TABLE_TYPES = [t.TableType.action, t.TableType.knowledge, t.TableType.chat] @asynccontextmanager async def _create_gen_table( jamai: JamAI, - table_type: p.TableType, + table_type: t.TableType, table_id: str, model_id: str = "", - cols: list[p.ColumnSchemaCreate] | None = None, - chat_cols: list[p.ColumnSchemaCreate] | None = None, + cols: list[t.ColumnSchemaCreate] | None = None, + chat_cols: list[t.ColumnSchemaCreate] | None = None, embedding_model: str = "", delete_first: bool = True, delete: bool = True, @@ -29,11 +29,11 @@ async def _create_gen_table( await run(jamai.table.delete_table, table_type, table_id) if cols is None: cols = [ - p.ColumnSchemaCreate(id="input", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="input", dtype="str"), + t.ColumnSchemaCreate( id="output", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=model_id, prompt="${input}", max_tokens=3, @@ -42,36 +42,36 @@ async def _create_gen_table( ] if chat_cols is None: chat_cols = [ - p.ColumnSchemaCreate(id="User", dtype="str"), - p.ColumnSchemaCreate( + t.ColumnSchemaCreate(id="User", dtype="str"), + t.ColumnSchemaCreate( id="AI", dtype="str", - gen_config=p.LLMGenConfig( + gen_config=t.LLMGenConfig( model=model_id, system_prompt="You are an assistant.", max_tokens=3, ), ), ] - if table_type == p.TableType.action: + if table_type == t.TableType.action: table = await run( - jamai.table.create_action_table, p.ActionTableSchemaCreate(id=table_id, cols=cols) + jamai.table.create_action_table, t.ActionTableSchemaCreate(id=table_id, cols=cols) ) - elif table_type == p.TableType.knowledge: + elif table_type == t.TableType.knowledge: table = await run( jamai.table.create_knowledge_table, - p.KnowledgeTableSchemaCreate( + t.KnowledgeTableSchemaCreate( id=table_id, cols=cols, embedding_model=embedding_model ), ) - elif table_type == p.TableType.chat: + elif table_type == t.TableType.chat: table = await run( jamai.table.create_chat_table, - p.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols), + t.ChatTableSchemaCreate(id=table_id, cols=chat_cols + cols), ) else: raise ValueError(f"Invalid table type: {table_type}") - assert isinstance(table, p.TableMetaResponse) + assert isinstance(table, t.TableMetaResponse) yield table finally: if delete: @@ -82,7 +82,7 @@ async def _create_gen_table( 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) + assert isinstance(response, t.OkResponse) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @@ -92,7 +92,7 @@ async def test_list_templates(client_cls: Type[JamAI]): assert len(response.items) == response.total templates = response.items assert len(templates) > 0 - assert all(isinstance(t, p.Template) for t in templates) + assert all(isinstance(t, t.Template) for t in templates) for template in templates: assert len(template.id) > 0 assert len(template.name) > 0 @@ -107,12 +107,12 @@ async def test_get_template(client_cls: Type[JamAI]): template_id = templates[0].id # Fetch template template = await run(client.template.get_template, template_id) - assert isinstance(template, p.Template) + assert isinstance(template, t.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) + assert all(isinstance(t, t.TemplateTag) for t in template.tags) @pytest.mark.parametrize("client_cls", CLIENT_CLS) @@ -123,15 +123,15 @@ async def test_list_tables(client_cls: Type[JamAI]): assert len(templates) > 0 template_id = templates[0].id # List tables - tables: list[p.TableMetaResponse] = [] + tables: list[t.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) + assert all(isinstance(t, t.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 all(isinstance(c, t.ColumnSchema) for c in table.cols) assert len(table.updated_at) > 0 # Create a template by exporting default project @@ -147,7 +147,7 @@ async def test_list_tables(client_cls: Type[JamAI]): 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) + assert isinstance(response, t.OkResponse) # Search query tables = ( @@ -220,10 +220,10 @@ async def test_get_table(client_cls: Type[JamAI]): 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 isinstance(table, t.TableMetaResponse) assert len(table.id) > 0 assert len(table.cols) > 0 - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) assert len(table.updated_at) > 0 assert table_count > 0 @@ -244,10 +244,10 @@ async def test_list_table_rows(client_cls: Type[JamAI]): 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 isinstance(table, t.TableMetaResponse) assert len(table.id) > 0 assert len(table.cols) > 0 - assert all(isinstance(c, p.ColumnSchema) for c in table.cols) + assert all(isinstance(c, t.ColumnSchema) for c in table.cols) assert len(table.updated_at) > 0 # List rows rows = ( diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 910c304..5107148 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -130,7 +130,7 @@ Create an API client with api key and project id: ```javascript import JamAI from "jamaibase"; -const jamai = new JamAI({ apiKey: "jamai_apikey", projectId: "proj_id" }); +const jamai = new JamAI({ token: "jamai_pat", projectId: "proj_id" }); ``` Create an API client with custom HTTP client: @@ -186,18 +186,12 @@ jamai.setHttpagentConfig({ }); ``` -Can be imported from different modules depending on the need: - -```javascript -import JamAI from "jamaibase/index.umd.js"; -``` - ### Types Types can be imported from resources: ```javascript -import { ChatRequest } from "jamaibase/dist/resources/llm/chat"; +import { ChatRequest } from "jamaibase/resources/llm/chat"; let response: ChatRequest; ``` @@ -297,7 +291,7 @@ To integrate JamAI into a React application, follow these steps: import { useEffect, useState } from "react"; import JamAI from "jamaibase"; -import { PageListTableMetaResponse } from "jamaibase/dist/resources/gen_tables/tables"; +import { PageListTableMetaResponse } from "jamaibase/resources/gen_tables/tables"; export default function Home() { const [tableData, setTableData] = useState(); @@ -420,7 +414,7 @@ export async function GET(request: NextRequest) { "use client"; -import { PageListTableMetaResponse } from "jamaibase/dist/resources/gen_tables/tables"; +import { PageListTableMetaResponse } from "jamaibase/resources/gen_tables/tables"; import { ChangeEvent, useEffect, useState } from "react"; export default function Home() { diff --git a/clients/typescript/__tests__/file.test.ts b/clients/typescript/__tests__/file.test.ts index 65f2482..670625d 100644 --- a/clients/typescript/__tests__/file.test.ts +++ b/clients/typescript/__tests__/file.test.ts @@ -155,4 +155,13 @@ describe("APIClient File", () => { const parsedDataGetThumbUrl = GetUrlResponseSchema.parse(responseGetThumbUrls); expect(parsedDataGetThumbUrl).toEqual(responseGetThumbUrls); }); + + it("audio file upload by file path", async () => { + const responseUploadFile = await client.file.uploadFile({ + file_path: path.resolve(__dirname, "./zoom-in-audio.mp3") + }); + + const parsedDataUploadFile = UploadFileResponseSchema.parse(responseUploadFile); + expect(parsedDataUploadFile).toEqual(responseUploadFile); + }); }); diff --git a/clients/typescript/__tests__/gentable.test.ts b/clients/typescript/__tests__/gentable.test.ts index 56936bc..bcbffd2 100644 --- a/clients/typescript/__tests__/gentable.test.ts +++ b/clients/typescript/__tests__/gentable.test.ts @@ -1,7 +1,7 @@ import JamAI from "@/index"; import tmp from "tmp"; -import { GenTableRowsChatCompletionChunksSchema, GetConversationThreadResponseSchema } from "@/resources/gen_tables/chat"; +import { GetConversationThreadResponseSchema, MultiRowCompletionResponseSchema } from "@/resources/gen_tables/chat"; import { ColumnSchema, ColumnSchemaCreate, @@ -508,7 +508,7 @@ describe("APIClient Gentable", () => { concurrent: true }); - const parsedData = GenTableRowsChatCompletionChunksSchema.parse(response); + const parsedData = MultiRowCompletionResponseSchema.parse(response); expect(parsedData).toEqual(response); } }); @@ -530,7 +530,7 @@ describe("APIClient Gentable", () => { concurrent: true }); - const parsedData = GenTableRowsChatCompletionChunksSchema.parse(response); + const parsedData = MultiRowCompletionResponseSchema.parse(response); expect(parsedData).toEqual(response); } }); @@ -1244,7 +1244,7 @@ describe("APIClient Gentable", () => { // @TODO // verify that the suggestions output is different after regen - // const parsedregenRowResponseData = GenTableRowsChatCompletionChunksSchema.parse(regenRowResponse); + // const parsedregenRowResponseData = MultiRowCompletionResponseSchema.parse(regenRowResponse); // expect(parsedregenRowResponseData).toEqual(regenRowResponse); // const listRowResponse2 = await client.table.listRows({ @@ -1315,7 +1315,7 @@ describe("APIClient Gentable", () => { if (done) { break; } - // console.log(GenTableStreamChatCompletionChunkSchema.parse(value)); + // console.log(ColumnCompletionResponseSchema.parse(value)); } } }); diff --git a/clients/typescript/__tests__/llm.test.ts b/clients/typescript/__tests__/llm.test.ts index b22f9d2..2623064 100644 --- a/clients/typescript/__tests__/llm.test.ts +++ b/clients/typescript/__tests__/llm.test.ts @@ -231,14 +231,9 @@ describe("APIClient LLM", () => { }); it("generate chat completion", async () => { - try { - console.log("model: ", requestDataChat.model); - const response = await client.llm.generateChatCompletions(requestDataChat); + const response = await client.llm.generateChatCompletions(requestDataChat); - expect(ChatCompletionChunkSchema.parse(response)).toEqual(response); - } catch (err: any) { - console.log("error: ", err.response.data); - } + expect(ChatCompletionChunkSchema.parse(response)).toEqual(response); }); it("generate chat completion - stream", async () => { diff --git a/clients/typescript/__tests__/template.test.ts b/clients/typescript/__tests__/template.test.ts index ad6b837..48c982c 100644 --- a/clients/typescript/__tests__/template.test.ts +++ b/clients/typescript/__tests__/template.test.ts @@ -13,7 +13,7 @@ dotenv.config({ path: "__tests__/.env" }); -describe("APIClient Templates", () => { +describe.skip("APIClient Templates", () => { let client: JamAI; jest.setTimeout(30000); jest.retryTimes(1, { diff --git a/clients/typescript/__tests__/zoom-in-audio.mp3 b/clients/typescript/__tests__/zoom-in-audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..75587f7a56dd219905d8686ca12c446f9071f7ac GIT binary patch literal 19968 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 literal 0 HcmV?d00001 diff --git a/clients/typescript/build b/clients/typescript/build old mode 100644 new mode 100755 index 690e10c..713911d --- a/clients/typescript/build +++ b/clients/typescript/build @@ -3,14 +3,16 @@ set -e 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 +rimraf dist && tsc --project tsconfig.json && rollup -c --bundleConfigAsCjs && tsc-alias -p tsconfig.json -# cp -rp README.md dist -# for file in LICENSE CHANGELOG.md; do -# if [ -e "${file}" ]; then cp "${file}" dist; fi -# done +# Generate index.d.mts for ESM compatibility +cp dist/index.d.ts dist/index.d.mts +# Post-process files to fix import paths and compatibility +# node scripts/postprocess-files.cjs + +# Create package.json for dist folder node scripts/make-dist-package-json.cjs > dist/package.json # make sure that nothing crashes when we require the output CJS or @@ -18,7 +20,6 @@ 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 diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index b2a4fce..e34e6ac 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "jamaibase", - "version": "0.2.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jamaibase", - "version": "0.2.1", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "agentkeepalive": "^4.5.0", diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 04bb4b6..83d06a5 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "jamaibase", - "version": "0.3.0", + "version": "0.5.0", "description": "JamAIBase Client SDK (JS/TS). JamAI Base: Let Your Database Orchestrate LLMs and RAG", "main": "dist/index.cjs", "module": "dist/index.mjs", @@ -13,7 +13,7 @@ "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/" + "doc-ts": "typedoc --readme ./README.md --includeVersion --tsconfig tsconfig.build.json --entryPointStrategy expand --entryPoints ./dist/index.d.ts --entryPoints ./dist/resources --out docs-autogen-ts --categorizeByGroup --sort alphabetical && cp JamAI_Base_Cover.png docs-autogen-ts/" }, "repository": { "type": "git", @@ -27,29 +27,26 @@ ], "exports": { ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs.js", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./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" + "./resources/*": { + "import": "./dist/resources/*", + "require": "./dist/resources/*" } }, "files": [ "dist/**/*" ], - "author": "EmbeddedLLM, Tan Tun Jian", + "author": "EmbeddedLLM, Tan Tun Jian, Kamil Hassan", "license": "Apache-2.0", "bugs": { "url": "https://github.com/EmbeddedLLM/JAM.ai.dev/issues" diff --git a/clients/typescript/rollup.config.ts b/clients/typescript/rollup.config.ts new file mode 100644 index 0000000..d1381dc --- /dev/null +++ b/clients/typescript/rollup.config.ts @@ -0,0 +1,117 @@ +import commonjs from "@rollup/plugin-commonjs"; +import dynamicImportVars from "@rollup/plugin-dynamic-import-vars"; +import json from "@rollup/plugin-json"; +import resolve from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import copy from "rollup-plugin-copy"; +import builtins from "rollup-plugin-node-builtins"; +import globals from "rollup-plugin-node-globals"; +import polyfillNode from "rollup-plugin-polyfill-node"; + +export default [ + // Node.js Builds (CJS and ES Modules) + { + input: "src/index.ts", + output: [ + { + file: "dist/index.cjs.js", + format: "cjs", + sourcemap: true, + inlineDynamicImports: true + }, + { + file: "dist/index.mjs", + format: "es", + sourcemap: true, + inlineDynamicImports: true + } + ], + external: [ + "axios", + "zod", + "uuid", + "path", + "fs", + "os", + "agentkeepalive", + "axios-retry", + "csv-parser", + "mime-types", + "formdata-node", + "path-browserify" + ], + plugins: [ + + typescript({ + tsconfig: "./tsconfig.json", + sourceMap: true, + declaration: true, + emitDeclarationOnly: false + }), + json(), + + resolve({ + browser: false, + preferBuiltins: true, + extensions: ['.mjs', '.js', ".ts", '.jsx', '.json', '.sass', '.scss'] + }), + commonjs(), + dynamicImportVars(), + + copy({ + targets: [ + { src: "README.md", dest: "dist" }, + { src: "LICENSE", dest: "dist" }, + { src: "CHANGELOG.md", dest: "dist" } + ] + }) + ] + }, + + // Browser Build (UMD) + { + input: "src/index.ts", + output: { + file: "dist/index.umd.js", + format: "umd", + exports: "named", + name: "JamAI", + sourcemap: true, + inlineDynamicImports: true, + globals: { + axios: "axios", + zod: "zod", + uuid: "uuid" + } + }, + external: ["axios", "zod", "uuid"], + plugins: [ + + typescript({ + tsconfig: "./tsconfig.json", + sourceMap: true, + declaration: true, + emitDeclarationOnly: false + }), + json(), + polyfillNode(), + builtins(), + globals(), + + resolve({ + browser: true, + preferBuiltins: false, + extensions: ['.js', '.ts', '.tsx', '.json'] + }), + + commonjs(), + dynamicImportVars(), + + copy({ + targets: [ + // Only need to copy once, so you can remove this if already copied + ] + }) + ] + } +]; \ No newline at end of file diff --git a/clients/typescript/scripts/postprocess-files.cjs b/clients/typescript/scripts/postprocess-files.cjs index e7adf87..fa06da4 100644 --- a/clients/typescript/scripts/postprocess-files.cjs +++ b/clients/typescript/scripts/postprocess-files.cjs @@ -2,7 +2,7 @@ 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"] ?? "jamaibase/"; const distDir = process.env["DIST_PATH"] ? path.resolve(process.env["DIST_PATH"]) : path.resolve(__dirname, "..", "dist"); const distSrcDir = path.join(distDir, "src"); diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index 7f61ee2..46f4544 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -74,4 +74,11 @@ class JamAI extends Base { } } -export default JamAI; +// // Re-export types from internal modules for easier access +// export * from "@/resources/base"; +// export * from "@/resources/files"; +// export * from "@/resources/gen_tables/tables"; +// export * from "@/resources/llm/chat"; +// export * from "@/resources/templates"; + +export default JamAI; diff --git a/clients/typescript/src/resources/files/index.ts b/clients/typescript/src/resources/files/index.ts index 1cfe7d6..cad5d8f 100644 --- a/clients/typescript/src/resources/files/index.ts +++ b/clients/typescript/src/resources/files/index.ts @@ -12,14 +12,25 @@ import { UploadFileResponseSchema } from "./types"; +async function createFormData() { + if (!isRunningInBrowser()) { + // Node environment + // (import from `formdata-node`) + const { FormData } = await import("formdata-node"); + return new FormData(); + } else { + // Browser environment + return new FormData(); + } +} export class Files extends Base { public async uploadFile(params: IUploadFileRequest): Promise { - const apiURL = `/api/v1/files/upload`; + const apiURL = `/api/v2/files/upload`; const parsedParams = UploadFileRequestSchema.parse(params); // Create FormData to send as multipart/form-data - const formData = new FormData(); + const formData = await createFormData(); if (parsedParams.file) { formData.append("file", parsedParams.file, parsedParams.file.name); } else if (parsedParams.file_path) { @@ -27,7 +38,10 @@ export class Files extends Base { 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 }); + // const file = new Blob([data], { type: mimeType }); + const { File } = await import("formdata-node"); + const file = new File([data], fileName, { type: mimeType }); + // @ts-ignore formData.append("file", file, fileName); } else { throw new Error("Pass File instead of file path if you are using this function in client."); @@ -47,7 +61,7 @@ export class Files extends Base { public async getRawUrls(params: IGetUrlRequest): Promise { const parsedParams = GetUrlRequestSchema.parse(params); - const apiURL = `/api/v1/files/url/raw`; + const apiURL = `/api/v2/files/url/raw`; const response = await this.httpClient.post(apiURL, { uris: parsedParams.uris }); @@ -56,7 +70,7 @@ export class Files extends Base { public async getThumbUrls(params: IGetUrlRequest): Promise { const parsedParams = GetUrlRequestSchema.parse(params); - const apiURL = `/api/v1/files/url/thumb`; + const apiURL = `/api/v2/files/url/thumb`; const response = await this.httpClient.post(apiURL, { uris: parsedParams.uris }); diff --git a/clients/typescript/src/resources/files/types.ts b/clients/typescript/src/resources/files/types.ts index 14aba8a..a11f16a 100644 --- a/clients/typescript/src/resources/files/types.ts +++ b/clients/typescript/src/resources/files/types.ts @@ -3,9 +3,6 @@ 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() }); diff --git a/clients/typescript/src/resources/gen_tables/chat.ts b/clients/typescript/src/resources/gen_tables/chat.ts index c95baf4..eee07c5 100644 --- a/clients/typescript/src/resources/gen_tables/chat.ts +++ b/clients/typescript/src/resources/gen_tables/chat.ts @@ -3,11 +3,11 @@ import { ChatCompletionChunkSchema, ChatEntrySchema, ReferencesSchema } from "@/ import { z } from "zod"; export const GetConversationThreadRequestSchema = z.object({ + table_type: TableTypesSchema, table_id: IdSchema, column_id: IdSchema, - row_id: z.string().default(""), - table_type: TableTypesSchema, - include: z.boolean().default(true) + row_id: z.string().optional(), + include: z.boolean().optional() }); export const GetConversationThreadResponseSchema = z.object({ @@ -21,18 +21,18 @@ export const GenTableChatCompletionChunksSchema = z.object({ row_id: z.string() }); -export const GenTableRowsChatCompletionChunksSchema = z.object({ +export const MultiRowCompletionResponseSchema = z.object({ object: z.enum(["gen_table.completion.rows"]), rows: z.array(GenTableChatCompletionChunksSchema) }); -export const GenTableStreamChatCompletionChunkSchema = ChatCompletionChunkSchema.extend({ +export const ColumnCompletionResponseSchema = ChatCompletionChunkSchema.extend({ object: z.enum(["gen_table.completion.chunk"]), output_column_name: z.string(), row_id: z.string() }); -export const GenTableStreamReferencesSchema = ReferencesSchema.extend({ +export const RowReferencesResponseSchema = ReferencesSchema.extend({ object: z.enum(["gen_table.references"]), output_column_name: z.string() }); @@ -42,7 +42,7 @@ export type CreateChatTableRequest = z.input; export type GetConversationThreadResponse = z.infer; -export type GenTableChatCompletionChunks = z.infer; -export type GenTableRowsChatCompletionChunks = z.infer; -export type GenTableStreamChatCompletionChunk = z.infer; -export type GenTableStreamReferences = z.infer; +export type RowCompletionResponse = z.infer; +export type MultiRowCompletionResponse = z.infer; +export type CellCompletionResponse = z.infer; +export type CellReferencesResponse = z.infer; diff --git a/clients/typescript/src/resources/gen_tables/index.ts b/clients/typescript/src/resources/gen_tables/index.ts index 8ff882c..5936e92 100644 --- a/clients/typescript/src/resources/gen_tables/index.ts +++ b/clients/typescript/src/resources/gen_tables/index.ts @@ -9,28 +9,31 @@ import { CreateActionTableRequestSchema } from "@/resources/gen_tables/action"; import { + CellCompletionResponse, + CellReferencesResponse, + ColumnCompletionResponseSchema, CreateChatTableRequest, CreateChatTableRequestSchema, - GenTableRowsChatCompletionChunks, - GenTableRowsChatCompletionChunksSchema, - GenTableStreamChatCompletionChunk, - GenTableStreamChatCompletionChunkSchema, - GenTableStreamReferences, - GenTableStreamReferencesSchema, GetConversationThreadRequest, GetConversationThreadRequestSchema, GetConversationThreadResponse, - GetConversationThreadResponseSchema + GetConversationThreadResponseSchema, + MultiRowCompletionResponse, + MultiRowCompletionResponseSchema, + RowReferencesResponseSchema } from "@/resources/gen_tables/chat"; import { CreateKnowledgeTableRequest, CreateKnowledgeTableRequestSchema, UploadFileRequest } from "@/resources/gen_tables/knowledge"; import { AddColumnRequest, AddColumnRequestSchema, AddRowRequest, - DeleteRowRequest, + AddRowRequestSchema, DeleteRowsRequest, + DeleteRowsRequestSchema, DeleteTableRequest, + DeleteTableRequestSchema, DropColumnsRequest, + DropColumnsRequestSchema, DuplicateTableRequest, DuplicateTableRequestSchema, ExportTableRequest, @@ -54,33 +57,50 @@ import { PageListTableRowsResponse, PageListTableRowsResponseSchema, RegenRowRequest, + RegenRowRequestSchema, RenameColumnsRequest, + RenameColumnsRequestSchema, RenameTableRequest, + RenameTableRequestSchema, ReorderColumnsRequest, + ReorderColumnsRequestSchema, TableMetaRequest, + TableMetaRequestSchema, TableMetaResponse, TableMetaResponseSchema, UpdateGenConfigRequest, UpdateGenConfigRequestSchema, - UpdateRowRequest + UpdateRowRequest, + UpdateRowRequestSchema } from "@/resources/gen_tables/tables"; import { ChunkError } from "@/resources/shared/error"; import axios, { AxiosResponse } from "axios"; -import { Blob, FormData } from "formdata-node"; +// import { Blob, FormData } from "formdata-node"; + +async function createFormData() { + if (!isRunningInBrowser()) { + // Node environment + // (import from `formdata-node`) + const { FormData } = await import("formdata-node"); + + return new FormData(); + } else { + // Browser environment + return new FormData(); + } +} export class GenTable extends Base { // Helper method to handle stream responses - private handleGenTableStreamResponse( - response: AxiosResponse - ): ReadableStream { + private handleGenTableStreamResponse(response: AxiosResponse): ReadableStream { this.logWarning(response); if (response.status != 200) { throw new Error(`Received Error Status: ${response.status}`); } - return new ReadableStream({ - async start(controller: ReadableStreamDefaultController) { + return new ReadableStream({ + async start(controller: ReadableStreamDefaultController) { response.data.on("data", (data: any) => { data = data.toString(); if (data.endsWith("\n\n")) { @@ -100,9 +120,9 @@ export class GenTable extends Base { try { const parsedValue = JSON.parse(chunk); if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); + controller.enqueue(ColumnCompletionResponseSchema.parse(parsedValue)); } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); + controller.enqueue(RowReferencesResponseSchema.parse(parsedValue)); } else { throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); } @@ -125,9 +145,9 @@ export class GenTable extends Base { try { const parsedValue = JSON.parse(chunk); if (parsedValue["object"] === "gen_table.completion.chunk") { - controller.enqueue(GenTableStreamChatCompletionChunkSchema.parse(parsedValue)); + controller.enqueue(ColumnCompletionResponseSchema.parse(parsedValue)); } else if (parsedValue["object"] === "gen_table.references") { - controller.enqueue(GenTableStreamReferencesSchema.parse(parsedValue)); + controller.enqueue(RowReferencesResponseSchema.parse(parsedValue)); } else { throw new ChunkError(`Unexpected SSE Chunk: ${parsedValue}`); } @@ -154,39 +174,29 @@ export class GenTable extends Base { public async listTables(params: ListTableRequest): Promise { const parsedParams = ListTableRequestSchema.parse(params); - let getURL = `/api/v1/gen_tables/${params.table_type}`; - - delete (parsedParams as any).table_type; + let getURL = `/api/v2/gen_tables/${params.table_type}/list`; const response = await this.httpClient.get(getURL, { - params: { - ...parsedParams, - search_query: encodeURIComponent(parsedParams.search_query) - } + params: parsedParams }); return this.handleResponse(response, PageListTableMetaResponseSchema); } public async getTable(params: TableMetaRequest): Promise { - let getURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}`; + const parsedParams = TableMetaRequestSchema.parse(params); + let getURL = `/api/v2/gen_tables/${params.table_type}`; - const response = await this.httpClient.get(getURL); + const response = await this.httpClient.get(getURL, { + params: parsedParams + }); return this.handleResponse(response, TableMetaResponseSchema); } public async listRows(params: ListTableRowsRequest): Promise { const parsedParams = ListTableRowsRequestSchema.parse(params); - const response = await this.httpClient.get(`/api/v1/gen_tables/${parsedParams.table_type}/${parsedParams.table_id}/rows`, { - params: { - offset: parsedParams.offset, - limit: parsedParams.limit, - search_query: encodeURIComponent(parsedParams.search_query), - columns: parsedParams.columns ? parsedParams.columns?.map(encodeURIComponent) : [], - float_decimals: parsedParams.float_decimals, - vec_decimals: parsedParams.vec_decimals, - order_descending: parsedParams.order_descending - }, + const response = await this.httpClient.get(`/api/v2/gen_tables/${parsedParams.table_type}/rows/list`, { + params: parsedParams, paramsSerializer: (params) => { return Object.entries(params) .flatMap(([key, value]) => (Array.isArray(value) ? value.map((val) => `${key}=${val}`) : `${key}=${value}`)) @@ -199,12 +209,8 @@ export class GenTable extends Base { public async getRow(params: GetRowRequest): Promise { const parsedParams = GetRowRequestSchema.parse(params); - const response = await this.httpClient.get(`/api/v1/gen_tables/${params.table_type}/${params.table_id}/rows/${params.row_id}`, { - params: { - columns: parsedParams.columns ? parsedParams.columns?.map(encodeURIComponent) : [], - float_decimals: parsedParams.float_decimals, - vec_decimals: parsedParams.vec_decimals - }, + const response = await this.httpClient.get(`/api/v2/gen_tables/${params.table_type}/rows`, { + params: parsedParams, paramsSerializer: (params) => { return Object.entries(params) .flatMap(([key, value]) => (Array.isArray(value) ? value.map((val) => `${key}=${val}`) : `${key}=${value}`)) @@ -218,13 +224,9 @@ export class GenTable extends Base { public async getConversationThread(params: GetConversationThreadRequest): Promise { const parsedParams = GetConversationThreadRequestSchema.parse(params); - let getURL = `/api/v1/gen_tables/${parsedParams.table_type}/${parsedParams.table_id}/thread`; + let getURL = `/api/v2/gen_tables/${parsedParams.table_type}/thread`; const response = await this.httpClient.get(getURL, { - params: { - column_id: parsedParams.column_id, - row_id: parsedParams.row_id, - include: parsedParams.include - } + params: parsedParams }); return this.handleResponse(response, GetConversationThreadResponseSchema); @@ -235,21 +237,15 @@ export class GenTable extends Base { */ 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, - { - ...parsedParams, - stream: false - }, - {} - ); + const apiURL = "/api/v2/gen_tables/action"; + const response = await this.httpClient.post(apiURL, parsedParams); + return this.handleResponse(response, TableMetaResponseSchema); } public async createChatTable(params: CreateChatTableRequest): Promise { const parsedParams = CreateChatTableRequestSchema.parse(params); - const apiURL = "/api/v1/gen_tables/chat"; + const apiURL = "/api/v2/gen_tables/chat"; const response = await this.httpClient.post(apiURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); @@ -257,7 +253,7 @@ export class GenTable extends Base { public async createKnowledgeTable(params: CreateKnowledgeTableRequest): Promise { const parsedParams = CreateKnowledgeTableRequestSchema.parse(params); - const apiURL = "/api/v1/gen_tables/knowledge"; + const apiURL = "/api/v2/gen_tables/knowledge"; const response = await this.httpClient.post(apiURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); @@ -267,33 +263,35 @@ export class GenTable extends Base { * Gen Table Delete */ public async deleteTable(params: DeleteTableRequest): Promise { - let deleteURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}`; - const response = await this.httpClient.delete(deleteURL); + const parsedParams = DeleteTableRequestSchema.parse(params); + let deleteURL = `/api/v2/gen_tables/${params.table_type}`; + const response = await this.httpClient.delete(deleteURL, { + params: parsedParams + }); return this.handleResponse(response, OkResponseSchema); } - public async deleteRow(params: DeleteRowRequest): Promise { - let deleteURL = `/api/v1/gen_tables/${params.table_type}/${params.table_id}/rows/${params.row_id}`; + // public async deleteRow(params: DeleteRowRequest): Promise { + // let deleteURL = `/api/v2/gen_tables/${params.table_type}/${params.table_id}/rows/${params.row_id}`; - const response = await this.httpClient.delete(deleteURL, { - params: { - reindex: params?.reindex - } - }); + // const response = await this.httpClient.delete(deleteURL, { + // params: { + // reindex: params?.reindex + // } + // }); - return this.handleResponse(response, OkResponseSchema); - } + // return this.handleResponse(response, OkResponseSchema); + // } /** * @param {string} [params.where] - Optional. SQL where clause. If not provided, will match all rows and thus deleting all table content. */ public async deleteRows(params: DeleteRowsRequest): Promise { - const apiURL = `/api/v1/gen_tables/${params.table_type}/rows/delete`; - const response = await this.httpClient.post(apiURL, { - 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 parsedParams = DeleteRowsRequestSchema.parse(params); + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows/delete`; + const response = await this.httpClient.post(apiURL, parsedParams); + return this.handleResponse(response, OkResponseSchema); } @@ -301,8 +299,9 @@ export class GenTable extends Base { * 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 parsedParams = RenameTableRequestSchema.parse(params); + let postURL = `/api/v2/gen_tables/${params.table_type}/rename`; + const response = await this.httpClient.post(postURL, undefined, { params: parsedParams }); return this.handleResponse(response, TableMetaResponseSchema); } @@ -316,69 +315,41 @@ export class GenTable extends Base { } 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 - } - } - ); + let postURL = `/api/v2/gen_tables/${params.table_type}/duplicate`; + const response = await this.httpClient.post(postURL, undefined, { + params: parsedParams + }); return this.handleResponse(response, TableMetaResponseSchema); } public async renameColumns(params: RenameColumnsRequest): Promise { - let postURL = `/api/v1/gen_tables/${params.table_type}/columns/rename`; - const response = await this.httpClient.post( - postURL, - { - table_id: params.table_id, - column_map: params.column_map - }, - {} - ); + const parsedParams = RenameColumnsRequestSchema.parse(params); + let postURL = `/api/v2/gen_tables/${params.table_type}/columns/rename`; + const response = await this.httpClient.post(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); } public async reorderColumns(params: ReorderColumnsRequest): Promise { - let postURL = `/api/v1/gen_tables/${params.table_type}/columns/reorder`; - const response = await this.httpClient.post( - postURL, - { - table_id: params.table_id, - column_names: params.column_names - }, - {} - ); + const parsedParams = ReorderColumnsRequestSchema.parse(params); + let postURL = `/api/v2/gen_tables/${params.table_type}/columns/reorder`; + const response = await this.httpClient.post(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); } public async dropColumns(params: DropColumnsRequest): Promise { - let postURL = `/api/v1/gen_tables/${params.table_type}/columns/drop`; - const response = await this.httpClient.post( - postURL, - { - table_id: params.table_id, - column_names: params.column_names - }, - {} - ); + const parsedParams = DropColumnsRequestSchema.parse(params); + let postURL = `/api/v2/gen_tables/${params.table_type}/columns/drop`; + const response = await this.httpClient.post(postURL, parsedParams); 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`; - + let postURL = `/api/v2/gen_tables/action/columns/add`; const response = await this.httpClient.post(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); @@ -386,7 +357,7 @@ export class GenTable extends Base { public async addKnowledgeColumns(params: AddColumnRequest): Promise { const parsedParams = AddColumnRequestSchema.parse(params); - let postURL = `/api/v1/gen_tables/knowledge/columns/add`; + let postURL = `/api/v2/gen_tables/knowledge/columns/add`; const response = await this.httpClient.post(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); @@ -394,7 +365,7 @@ export class GenTable extends Base { public async addChatColumns(params: AddColumnRequest): Promise { const parsedParams = AddColumnRequestSchema.parse(params); - let postURL = `/api/v1/gen_tables/chat/columns/add`; + let postURL = `/api/v2/gen_tables/chat/columns/add`; const response = await this.httpClient.post(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); @@ -402,30 +373,20 @@ export class GenTable extends Base { 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: parsedParams.table_id, - column_map: parsedParams.column_map - }, - {} - ); + let postURL = `/api/v2/gen_tables/${params.table_type}/gen_config`; + const response = await this.httpClient.patch(postURL, parsedParams); return this.handleResponse(response, TableMetaResponseSchema); } - public async addRowStream(params: AddRowRequest): Promise> { - const apiURL = `/api/v1/gen_tables/${params.table_type}/rows/add`; - + public async addRowStream(params: AddRowRequest): Promise> { + const parsedParams = AddRowRequestSchema.parse(params); + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows/add`; const response = await this.httpClient.post( apiURL, { - table_id: params.table_id, - data: params.data, - stream: true, - reindex: params.reindex, - concurrent: params.concurrent + ...parsedParams, + stream: true }, { responseType: "stream" @@ -435,35 +396,29 @@ export class GenTable extends Base { return this.handleGenTableStreamResponse(response); } - public async addRow(params: AddRowRequest): Promise { - const url = `/api/v1/gen_tables/${params.table_type}/rows/add`; - + public async addRow(params: AddRowRequest): Promise { + const parsedParams = AddRowRequestSchema.parse(params); + const url = `/api/v2/gen_tables/${params.table_type}/rows/add`; const response = await this.httpClient.post( url, { - table_id: params.table_id, - stream: false, - data: params.data, - reindex: params.reindex, - concurrent: params.concurrent + ...parsedParams, + stream: false }, {} ); - return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); + return this.handleResponse(response, MultiRowCompletionResponseSchema); } public async regenRowStream(params: RegenRowRequest) { - const apiURL = `/api/v1/gen_tables/${params.table_type}/rows/regen`; - + const parsedParams = RegenRowRequestSchema.parse(params); + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows/regen`; const response = await this.httpClient.post( apiURL, { - table_id: params.table_id, - row_ids: params.row_ids, - stream: true, - reindex: params.reindex, - concurrent: params.concurrent + ...parsedParams, + stream: true }, { responseType: "stream" @@ -473,36 +428,46 @@ export class GenTable extends Base { return this.handleGenTableStreamResponse(response); } - public async regenRow(params: RegenRowRequest): Promise { - const apiURL = `/api/v1/gen_tables/${params.table_type}/rows/regen`; + public async regenRow(params: RegenRowRequest): Promise { + const parsedParams = RegenRowRequestSchema.parse(params); + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows/regen`; const response = await this.httpClient.post( apiURL, { - table_id: params.table_id, - row_ids: params.row_ids, - stream: false, - reindex: params.reindex, - concurrent: params.concurrent + ...parsedParams, + stream: false }, {} ); - return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); + + return this.handleResponse(response, MultiRowCompletionResponseSchema); } - public async updateRow(params: UpdateRowRequest): Promise { - const apiURL = `/api/v1/gen_tables/${params.table_type}/rows/update`; - const response = await this.httpClient.post(apiURL, { + /** + * @deprecated Deprecated since 0.4.0, use updateRows instead + */ + public async updateRow(params: UpdateRowRequest & { row_id: string }): Promise { + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows`; + const response = await this.httpClient.patch(apiURL, { table_id: params.table_id, - row_id: params.row_id, - data: params.data, - reindex: params.reindex + data: { + [params.row_id]: params.data + } }); return this.handleResponse(response, OkResponseSchema); } + public async updateRows(params: UpdateRowRequest): Promise { + const parsedParams = UpdateRowRequestSchema.parse(params); + const apiURL = `/api/v2/gen_tables/${params.table_type}/rows`; + const response = await this.httpClient.patch(apiURL, parsedParams); + + return this.handleResponse(response, OkResponseSchema); + } + public async hybridSearch(params: HybridSearchRequest): Promise { - const apiURL = `/api/v1/gen_tables/${params.table_type}/hybrid_search`; + const apiURL = `/api/v2/gen_tables/${params.table_type}/hybrid_search`; const { table_type, ...requestBody } = params; @@ -520,7 +485,7 @@ export class GenTable extends Base { const apiURL = `/api/v1/gen_tables/knowledge/upload_file`; // Create FormData to send as multipart/form-data - const formData = new FormData(); + const formData = await createFormData(); if (params.file) { formData.append("file", params.file, params.file.name); } else if (params.file_path) { @@ -528,7 +493,10 @@ export class GenTable extends Base { 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 }); + const { File } = await import("formdata-node"); + const file = new File([data], fileName, { type: mimeType }); + + // @ts-ignore formData.append("file", file, fileName); } else { throw new Error("Pass File instead of file path if you are using this function in client."); @@ -556,10 +524,10 @@ export class GenTable extends Base { } public async embedFile(params: UploadFileRequest): Promise { - const apiURL = `/api/v1/gen_tables/knowledge/embed_file`; + const apiURL = `/api/v2/gen_tables/knowledge/embed_file`; // Create FormData to send as multipart/form-data - const formData = new FormData(); + const formData = await createFormData(); if (params.file) { formData.append("file", params.file, params.file.name); } else if (params.file_path) { @@ -567,7 +535,10 @@ export class GenTable extends Base { 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 }); + const { File } = await import("formdata-node"); + const file = new File([data], fileName, { type: mimeType }); + + // @ts-ignore formData.append("file", file, fileName); } else { throw new Error("Pass File instead of file path if you are using this method in client."); @@ -594,12 +565,10 @@ export class GenTable extends Base { return this.handleResponse(response, OkResponseSchema); } - public async importTableData(params: ImportTableRequest): Promise { - const apiURL = `/api/v1/gen_tables/${params.table_type}/import_data`; + public async importTableData(params: ImportTableRequest): Promise { + const apiURL = `/api/v2/gen_tables/${params.table_type}/import_data`; - const delimiter = params.delimiter ? params.delimiter : ","; - - const formData = new FormData(); + const formData = await createFormData(); if (params.file) { formData.append("file", params.file, params.file.name); } else if (params.file_path) { @@ -607,7 +576,10 @@ export class GenTable extends Base { 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 }); + // const file = new Blob([data], { type: mimeType }); + const { File } = await import("formdata-node"); + const file = new File([data], fileName, { type: mimeType }); + // @ts-ignore formData.append("file", file, fileName); } else { throw new Error("Pass File instead of file path if you are using this function in client."); @@ -617,7 +589,7 @@ export class GenTable extends Base { } formData.append("table_id", params.table_id); - formData.append("delimiter", delimiter); + if (params.delimiter) formData.append("delimiter", params.delimiter); formData.append("stream", JSON.stringify(false)); const response = await this.httpClient.post(apiURL, formData, { @@ -626,17 +598,15 @@ export class GenTable extends Base { } }); - return this.handleResponse(response, GenTableRowsChatCompletionChunksSchema); + return this.handleResponse(response, MultiRowCompletionResponseSchema); } - public async importTableDataStream( - params: ImportTableRequest - ): Promise> { - const apiURL = `/api/v1/gen_tables/${params.table_type}/import_data`; + public async importTableDataStream(params: ImportTableRequest): Promise> { + const apiURL = `/api/v2/gen_tables/${params.table_type}/import_data`; // const fileName = params.file.name; const delimiter = params.delimiter ? params.delimiter : ","; - const formData = new FormData(); + const formData = await createFormData(); if (params.file) { formData.append("file", params.file, params.file.name); } else if (params.file_path) { @@ -644,7 +614,10 @@ export class GenTable extends Base { 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 }); + + const { File } = await import("formdata-node"); + const file = new File([data], fileName, { type: mimeType }); + // @ts-ignore formData.append("file", file, fileName); } else { throw new Error("Pass File instead of file path if you are using this function in client."); @@ -670,13 +643,10 @@ export class GenTable extends Base { public async exportTableData(params: ExportTableRequest): Promise { const parsedParams = ExportTableRequestSchema.parse(params); - const apiURL = `/api/v1/gen_tables/${parsedParams.table_type}/${encodeURIComponent(parsedParams.table_id)}/export_data`; + const apiURL = `/api/v2/gen_tables/${parsedParams.table_type}/export_data`; try { const response = await this.httpClient.get(apiURL, { - params: { - delimiter: encodeURIComponent(parsedParams.delimiter), - columns: parsedParams.columns ? parsedParams.columns?.map(encodeURIComponent) : [] - }, + params: parsedParams, paramsSerializer: (params) => { return Object.entries(params) .flatMap(([key, value]) => (Array.isArray(value) ? value.map((val) => `${key}=${val}`) : `${key}=${value}`)) diff --git a/clients/typescript/src/resources/gen_tables/knowledge.ts b/clients/typescript/src/resources/gen_tables/knowledge.ts index 8627263..8c9a28a 100644 --- a/clients/typescript/src/resources/gen_tables/knowledge.ts +++ b/clients/typescript/src/resources/gen_tables/knowledge.ts @@ -7,12 +7,7 @@ export const CreateKnowledgeTableRequestSchema = TableSchemaCreateSchema.extend( export type CreateKnowledgeTableRequest = z.input; export const UploadFileRequestSchema = z.object({ - file: z - .any() - .refine((value) => value instanceof File, { - message: "Value must be a File object" - }) - .optional(), + file: z.any().optional(), file_path: z.string().optional(), table_id: IdSchema, chunk_size: z.number().gt(0).optional(), diff --git a/clients/typescript/src/resources/gen_tables/tables.ts b/clients/typescript/src/resources/gen_tables/tables.ts index 5cb2de2..38e047d 100644 --- a/clients/typescript/src/resources/gen_tables/tables.ts +++ b/clients/typescript/src/resources/gen_tables/tables.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const GenTableOrderBy = Object.freeze({ ID: "id", // Sort by `id` column + TABLE_ID: "table_id", // Sort by `table_id` column UPDATED_AT: "updated_at" // Sort by `updated_at` column }); @@ -17,9 +18,9 @@ export const TableTypesSchema = z.enum(["action", "knowledge", "chat"]); export const IdSchema = z.string().regex(/^[A-Za-z0-9]([A-Za-z0-9 _-]{0,98}[A-Za-z0-9])?$/, "Invalid Id"); export const TableIdSchema = z.string().regex(/^[A-Za-z0-9]([A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/, "Invalid Table Id"); -const DtypeCreateEnumSchema = z.enum(["int", "float", "str", "bool", "image"]); +const DtypeCreateEnumSchema = z.enum(["int", "float", "bool", "str", "image", "audio", "document"]); -const DtypeEnumSchema = z.enum(["int", "int8", "float", "float64", "float32", "float16", "bool", "str", "date-time", "image", "bytes"]); +const DtypeEnumSchema = z.enum(["int", "int8", "float", "float32", "float16", "bool", "str", "image", "audio", "document", "date-time", "json"]); export const EmbedGenConfigSchema = z.object({ object: z.literal("gen_config.embed").default("gen_config.embed"), @@ -42,12 +43,17 @@ export const LLMGenConfigSchema = z.object({ logit_bias: z.record(z.string(), z.any()).default({}) }); +export const CodeGenConfigSchema = z.object({ + object: z.literal("gen_config.code").default("gen_config.code"), + source_column: z.string() +}); + export const ColumnSchemaSchema = z.object({ id: z.string(), 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() + gen_config: z.union([LLMGenConfigSchema, EmbedGenConfigSchema, CodeGenConfigSchema, z.null()]).optional() }); export const ColumnSchemaCreateSchema = ColumnSchemaSchema.extend({ @@ -61,12 +67,15 @@ export const TableSchemaCreateSchema = z.object({ }); export let ListTableRequestSchema = QueryRequestParams.extend({ - 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) + offset: z.number().min(0).optional(), + limit: z.number().min(1).max(100).optional(), + order_by: z.string().optional(), + order_ascending: z.boolean().optional().optional(), + parent_id: z.string().nullable().optional(), + search_query: z.string().optional(), + count_rows: z.boolean().optional(), + created_by: z.string().nullable().optional(), }); export const TableMetaResponseSchema = z.object({ @@ -74,12 +83,10 @@ export const TableMetaResponseSchema = z.object({ cols: z.array(ColumnSchemaSchema), parent_id: z.union([z.string(), z.null()]), title: z.string(), - lock_till: z.union([z.number(), z.null()]).optional(), + created_by: z.string(), updated_at: z.string(), - indexed_at_fts: z.union([z.string(), z.null()]), - indexed_at_vec: z.union([z.string(), z.null()]), - indexed_at_sca: z.union([z.string(), z.null()]), - num_rows: z.number().int() + num_rows: z.number().int(), + version: z.string() }); export const TableMetaRequestSchema = z.object({ @@ -104,8 +111,8 @@ export const GetRowRequestSchema = z.object({ table_id: TableIdSchema, row_id: z.string(), columns: z.array(IdSchema).nullable().optional(), - float_decimals: z.number().int().default(0), - vec_decimals: z.number().int().default(0) + float_decimals: z.number().int().optional(), + vec_decimals: z.number().int().optional() }); export const GetRowResponseSchema = z.record(z.string(), z.any()); @@ -131,9 +138,9 @@ export const RenameTableRequestSchema = z.object({ export const DuplicateTableRequestSchema = z.object({ table_type: TableTypesSchema, table_id_src: TableIdSchema, - table_id_dst: TableIdSchema.nullable().default(null), - include_data: z.boolean().optional().default(true), - create_as_child: z.boolean().optional().default(false) + table_id_dst: TableIdSchema.nullable().optional(), + include_data: z.boolean().optional(), + create_as_child: z.boolean().optional() }); export const CreateChildTableRequestSchema = z.object({ @@ -142,18 +149,20 @@ export const CreateChildTableRequestSchema = z.object({ table_id_dst: TableIdSchema }); -export const RenameColumnsRequestScheme = z.object({ +export const RenameColumnsRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, column_map: z.record(IdSchema, IdSchema) }); -export const ReorderColumnsRequestScheme = z.object({ +export const ReorderColumnsRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, column_names: z.array(IdSchema) }); +export const DropColumnsRequestSchema = ReorderColumnsRequestSchema; + export const AddColumnRequestSchema = z.object({ id: TableIdSchema, cols: z.array(ColumnSchemaCreateSchema) @@ -162,7 +171,7 @@ export const AddColumnRequestSchema = z.object({ export const UpdateGenConfigRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, - column_map: z.record(z.string(), z.union([LLMGenConfigSchema, EmbedGenConfigSchema, z.null()])) + column_map: z.record(z.string(), z.union([LLMGenConfigSchema, EmbedGenConfigSchema, CodeGenConfigSchema, z.null()])) }); export const DeleteRowRequestSchema = z.object({ @@ -174,7 +183,6 @@ export const DeleteRowRequestSchema = z.object({ export const AddRowRequestSchema = z.object({ table_type: TableTypesSchema, - reindex: z.boolean().nullable().default(true), table_id: TableIdSchema, data: z.array(z.record(IdSchema, z.any())), concurrent: z.boolean().default(true) @@ -185,35 +193,34 @@ export const RegenRowRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, row_ids: z.array(z.string()), - reindex: z.boolean().nullable().default(null), - concurrent: z.boolean().default(true) + regen_strategy: z.string().nullable().optional(), + output_column_id: z.string().nullable().optional(), + concurrent: z.boolean().optional() // stream: z.boolean() }); export const UpdateRowRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, - row_id: z.string(), - data: z.record(IdSchema, z.any()), - reindex: z.boolean().nullable().default(null) + data: z.record(z.string(), z.record(IdSchema, z.any())), }); export const DeleteRowsRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, + row_ids: z.array(z.string()).nullable().optional(), where: z.string().optional(), - reindex: z.boolean().default(true) }); export const HybridSearchRequestSchema = z.object({ table_type: TableTypesSchema, table_id: TableIdSchema, query: z.string(), - where: z.string().nullable().default(null).optional(), + // where: z.string().nullable().default(null).optional(), limit: z.number().gt(0).lte(1000).optional(), metric: z.string().optional(), - nprobes: z.number().gt(0).lte(1000).optional(), - refine_factor: z.number().gt(0).lte(1000).optional(), + // nprobes: z.number().gt(0).lte(1000).optional(), + // refine_factor: z.number().gt(0).lte(1000).optional(), reranking_model: z.string().nullable().default(null).optional(), float_decimals: z.number().int().default(0), vec_decimals: z.number().int().default(0) @@ -228,12 +235,7 @@ export const CreateTableRequestSchema = z.object({ 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(), + file: z.any().optional(), table_id: TableIdSchema, table_type: TableTypesSchema, delimiter: z.string().default(",").optional() @@ -264,8 +266,8 @@ 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 RenameColumnsRequest = z.infer; +export type ReorderColumnsRequest = z.infer; export type DropColumnsRequest = ReorderColumnsRequest; export type AddColumnRequest = z.input; export type UpdateGenConfigRequest = z.input; diff --git a/clients/typescript/src/resources/llm/model.ts b/clients/typescript/src/resources/llm/model.ts index 36c6f47..0f862c0 100644 --- a/clients/typescript/src/resources/llm/model.ts +++ b/clients/typescript/src/resources/llm/model.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const ModelInfoRequestSchema = z.object({ model: z.string().optional(), capabilities: z - .array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])) + .array(z.enum(["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"])) .nullable() .optional() }); @@ -14,7 +14,7 @@ export const ModelInfoSchema = z.object({ name: z.string(), context_length: z.number().default(16384), languages: z.array(z.string()), - capabilities: z.array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])).default(["chat"]), + capabilities: z.array(z.enum(["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"])).default(["chat"]), owned_by: z.string() }); @@ -26,7 +26,7 @@ export const ModelInfoResponseSchema = z.object({ export const ModelNamesRequestSchema = z.object({ prefer: z.string().optional(), capabilities: z - .array(z.enum(["completion", "chat", "image", "audio", "tool", "embed", "rerank"])) + .array(z.enum(["completion", "chat", "image", "audio", "document", "tool", "embed", "rerank"])) .nullable() .optional() }); diff --git a/clients/typescript/src/resources/templates/index.ts b/clients/typescript/src/resources/templates/index.ts index 8c56a36..69292d2 100644 --- a/clients/typescript/src/resources/templates/index.ts +++ b/clients/typescript/src/resources/templates/index.ts @@ -26,12 +26,10 @@ export class Templates extends Base { public async listTemplates(params: IListTemplatesRequest = {}): Promise { const parsedParams = ListTemplatesRequestSchema.parse(params); - let getURL = `/api/public/v1/templates`; + let getURL = `/api/v2/templates/list`; const response = await this.httpClient.get(getURL, { - params: { - search_query: encodeURIComponent(parsedParams.search_query) - } + params: parsedParams }); return this.handleResponse(response, ListTemplatesResponseSchema); @@ -39,38 +37,40 @@ export class Templates extends Base { public async getTemplate(params: IGetTemplateRequest): Promise { const parsedParams = GetTemplateRequestSchema.parse(params); - let getURL = `/api/public/v1/templates/${parsedParams.template_id}`; + let getURL = `/api/v2/templates`; - const response = await this.httpClient.get(getURL); + const response = await this.httpClient.get(getURL, { + params: parsedParams + }); 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}`; + let getURL = `/api/v2/templates/gen_tables/${parsedParams.table_type}/list`; - const response = await this.httpClient.get(getURL); + const response = await this.httpClient.get(getURL, { + params: parsedParams + }); 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}`; + let getURL = `/api/v2/templates/gen_tables/${parsedParams.table_type}`; - const response = await this.httpClient.get(getURL); + const response = await this.httpClient.get(getURL, { + params: parsedParams + }); 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; + let getURL = `/api/v2/templates/gen_tables/${parsedParams.table_type}/rows/list`; const response = await this.httpClient.get(getURL, { params: parsedParams diff --git a/clients/typescript/src/resources/templates/types.ts b/clients/typescript/src/resources/templates/types.ts index 3c64129..d185fcc 100644 --- a/clients/typescript/src/resources/templates/types.ts +++ b/clients/typescript/src/resources/templates/types.ts @@ -14,7 +14,11 @@ const TemplateSchema = z.object({ // List Templates export const ListTemplatesRequestSchema = z.object({ - search_query: z.string().default("") + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(1000).optional(), + order_by: z.string().optional(), + order_ascending: z.boolean().optional(), + search_query: z.string().optional() }); export const ListTemplatesResponseSchema = createPaginationSchema(TemplateSchema); @@ -27,7 +31,14 @@ export const GetTemplateResponseSchema = TemplateSchema; // List Table export const ListTablesRequestSchema = z.object({ table_type: TableTypesSchema, - template_id: z.string() + template_id: z.string(), + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).max(100).optional(), + order_by: z.string().optional(), + order_ascending: z.boolean().optional(), + parent_id: z.string().optional(), + search_query: z.string().optional(), + count_rows: z.boolean().optional() }); export const ListTablesResponseSchema = createPaginationSchema(TableMetaResponseSchema); @@ -47,8 +58,9 @@ export const ListTableRowsRequestSchema = z.object({ 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), + order_by: z.string().default("updated_at"), + order_ascending: z.boolean().default(true), + parent_id: z.string().nullable().optional(), float_decimals: z.number().int().min(0).default(0), vec_decimals: z.number().int().min(0).default(0) }); diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json index bd5c5e6..f583895 100644 --- a/clients/typescript/tsconfig.json +++ b/clients/typescript/tsconfig.json @@ -12,6 +12,7 @@ "compilerOptions": { "emitDeclarationOnly": true, "module": "ESNext", + "moduleResolution": "node", "removeComments": false, "declaration": true, "allowSyntheticDefaultImports": true, @@ -21,7 +22,6 @@ "baseUrl": "./", "incremental": false, "esModuleInterop": true, - "moduleResolution": "Node", "noUncheckedIndexedAccess": true, "paths": { "@/*": [ diff --git a/docker/Dockerfile.cnpg17 b/docker/Dockerfile.cnpg17 new file mode 100644 index 0000000..f5e9608 --- /dev/null +++ b/docker/Dockerfile.cnpg17 @@ -0,0 +1,76 @@ +# ======================== STAGE 1: BUILDER ======================== +ARG CNPG_IMAGE="ghcr.io/cloudnative-pg/postgresql:17.5-bookworm" +ARG PG_MAJOR=17 +FROM ${CNPG_IMAGE} AS builder + +# Switch to root to install build tools +USER root + +# Install build dependencies for compiling from source +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential pkg-config git \ + postgresql-server-dev-${PG_MAJOR} \ + curl libssl-dev libclang-dev clang \ + && rm -rf /var/lib/apt/lists/* + +# ---- Install Rust toolchain (as postgres user) ---- +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + export PATH="$HOME/.cargo/bin:$PATH" && \ + rustup update && \ + cargo install --locked cargo-pgrx --version 0.12.9 && \ + cargo pgrx init --pg${PG_MAJOR} $(which pg_config) + +# ---- Set build directory ---- +WORKDIR /tmp + +# ---- Build pgvector and pgvectorscale from source ---- +RUN git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \ + cd pgvector && make OPTFLAGS="" && make install && cd .. && rm -rf pgvector + +# commit: 6af0ee1953ca3dab6dc45011a985cffb2aa865c1 (v0.8.0) ad of 2025-07-17 +RUN export PATH="$HOME/.cargo/bin:$PATH" && \ + git clone https://github.com/timescale/pgvectorscale.git && \ + cd pgvectorscale && git checkout 6af0ee1953ca3dab6dc45011a985cffb2aa865c1 && \ + cd pgvectorscale && cargo pgrx install --release && cd ../.. && rm -rf pgvectorscale + +# ======================== STAGE 2: FINAL IMAGE ======================== +# Start from a fresh, clean CNPG image. +FROM ${CNPG_IMAGE} + +USER root + +# ---- PART 1: Copy extensions BUILT FROM SOURCE in the builder stage ---- +COPY --from=builder --chown=postgres:postgres \ + /usr/lib/postgresql/${PG_MAJOR}/lib/*vectorscale* \ + /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder --chown=postgres:postgres \ + /usr/share/postgresql/${PG_MAJOR}/extension/*vectorscale* \ + /usr/share/postgresql/${PG_MAJOR}/extension/ + +COPY --from=builder --chown=postgres:postgres \ + /usr/lib/postgresql/${PG_MAJOR}/lib/*vector* \ + /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder --chown=postgres:postgres \ + /usr/share/postgresql/${PG_MAJOR}/extension/*vector* \ + /usr/share/postgresql/${PG_MAJOR}/extension/ + +# ---- PART 2: Install PRE-PACKAGED extensions with ZERO recommended packages ---- +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + wget lsb-release ca-certificates && \ + wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb && \ + # Using 'apt-get install' here is slightly better as it can handle dependencies of the .deb itself + apt-get install -y --no-install-recommends ./apache-arrow-apt-source-latest-*.deb && \ + wget https://packages.groonga.org/debian/groonga-apt-source-latest-$(lsb_release --codename --short).deb && \ + apt-get install -y --no-install-recommends ./groonga-apt-source-latest-*.deb && \ + apt-get update && \ + # apt search groonga && \ + # Install the final package, again with no recommended extras + apt-get install -y --no-install-recommends postgresql-${PG_MAJOR}-pgdg-pgroonga && \ + # Clean up build-only tools and cache + apt-get purge -y --auto-remove wget && \ + rm -rf /var/lib/apt/lists/* *.deb + +# Switch back to the default non-root user for security +USER postgres \ No newline at end of file diff --git a/docker/Dockerfile.docio b/docker/Dockerfile.docio deleted file mode 100644 index bb3ecba..0000000 --- a/docker/Dockerfile.docio +++ /dev/null @@ -1,12 +0,0 @@ -FROM docker.io/embeddedllminfo/jamaibase:ci - -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -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 4a3b06f..6853504 100644 --- a/docker/Dockerfile.frontend +++ b/docker/Dockerfile.frontend @@ -1,16 +1,17 @@ FROM node:20-alpine -ARG JAMAI_URL=http://owl:6969 -ARG PUBLIC_JAMAI_URL= +ARG PUBLIC_JAMAI_URL="" ARG PUBLIC_IS_SPA=false ARG CHECK_ORIGIN=false +ENV PUBLIC_ADMIN_ORGANIZATION_ID="0" WORKDIR /app COPY ./services/app . -RUN mv .env.example .env -RUN npm ci --force +# RUN mv .env.example .env +RUN rm -rf .env +RUN npm ci -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 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 diff --git a/docker/Dockerfile.owl b/docker/Dockerfile.owl index a6ed08f..ed2dc2b 100644 --- a/docker/Dockerfile.owl +++ b/docker/Dockerfile.owl @@ -1,17 +1,36 @@ -FROM python:3.12 +FROM ghcr.io/embeddedllm/jamaibase/owl.base:latest -RUN pip install --no-cache-dir --upgrade setuptools -RUN apt-get update -qq && apt-get install ffmpeg libavcodec-extra -y +# Set initial working directory +WORKDIR /usr/src/app -WORKDIR /app +# Install owl requirements +COPY ./services/api/pyproject.toml ./api/ +COPY ./services/api/src/owl/version.py ./api/src/owl/version.py +RUN uv venv --python 3.12 && cd api && uv pip install --no-cache-dir --upgrade -e . -COPY ./clients/python /app/client -WORKDIR /app/client -RUN pip install --no-cache-dir --upgrade . +# Install Python client requirements +COPY ./clients/python/pyproject.toml ./client/ +COPY ./clients/python/src/jamaibase/version.py ./client/src/jamaibase/version.py +RUN cd client && uv pip install --no-cache-dir -e . -COPY ./services/api /app/api -WORKDIR /app/api +# Copy Python client source code +COPY ./clients/python/ ./client/ +RUN cd client && uv pip install --no-cache-dir -e . -RUN pip install --no-cache-dir --upgrade . +# Copy owl source code +COPY ./services/api/ ./api/ +RUN cd api && uv pip install --no-cache-dir -e . -CMD ["python", "-m", "owl.entrypoints.api"] +# Setup environment variables +ENV OWL_OPENTELEMETRY_HOST=otel-collector +ENV OWL_OPENTELEMETRY_PORT=4317 +ENV OTEL_PYTHON_FASTAPI_EXCLUDED_URLS=api/health +ENV OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST='X-.*' +ENV OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION=base2_exponential_bucket_histogram +ENV SKYPILOT_DISABLE_USAGE_COLLECTION=1 +# If wanted to skip instrumentation on some components, ex: redis +# ENV OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=redis + +# Run the service +# OTEL_RESOURCE_ATTRIBUTES needs to be set at runtime +CMD uv run python -m owl.entrypoints.api diff --git a/docker/Dockerfile.owl.base b/docker/Dockerfile.owl.base new file mode 100644 index 0000000..bb84454 --- /dev/null +++ b/docker/Dockerfile.owl.base @@ -0,0 +1,12 @@ +FROM python:3.12 + +RUN apt-get update \ + && apt-get install -y \ + ffmpeg \ + git \ + libavcodec-extra \ + poppler-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.5.8 /uv /uvx /bin/ diff --git a/docker/Dockerfile.pg17 b/docker/Dockerfile.pg17 new file mode 100644 index 0000000..e31f98e --- /dev/null +++ b/docker/Dockerfile.pg17 @@ -0,0 +1,63 @@ +# Use the official PostgreSQL 17 base image +FROM postgres:17.4 + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive + +# Install prerequisites for pgvectorscale and PGroonga +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + git \ + build-essential \ + pkg-config \ + libssl-dev \ + libclang-dev \ + clang \ + lsb-release \ + wget \ + ca-certificates \ + jq \ + postgresql-server-dev-17 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install Rust and cargo-pgrx 0.12.5 +# compatible with the version of pgvectorscale +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + export PATH="$HOME/.cargo/bin:$PATH" && \ + rustup update && \ + cargo install --locked cargo-pgrx --version "0.12.5" && \ + cargo pgrx init --pg17 $(which pg_config) + +# Install pgvectorscale +# 6c01899405c19ab545c4e43881cc07f2cd5dd0d9 is the commit of pgvectorscale main branch as of 2025-03-04 (v0.6) +RUN export PATH="$HOME/.cargo/bin:$PATH" && \ + cd /tmp && \ + git clone https://github.com/timescale/pgvectorscale && \ + cd pgvectorscale && \ + git checkout 6c01899405c19ab545c4e43881cc07f2cd5dd0d9 && \ + cd pgvectorscale && \ + cargo pgrx install --release + +# Install PGroonga +RUN wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb && \ + apt-get install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb && \ + wget https://packages.groonga.org/debian/groonga-apt-source-latest-$(lsb_release --codename --short).deb && \ + apt-get install -y -V ./groonga-apt-source-latest-$(lsb_release --codename --short).deb && \ + apt-get update && \ + apt-get install -y -V postgresql-17-pgdg-pgroonga && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install pgvector +# OPTFLAGS = "" to avoid optimization (default flag = -march=native) +RUN cd /tmp && \ + git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \ + cd pgvector && \ + make clean && \ + make OPTFLAGS="" && \ + make install + +# Set the default command to run PostgreSQL +CMD ["postgres"] \ No newline at end of file diff --git a/docker/amd.yml b/docker/amd.yml index 81209af..2f8deaa 100644 --- a/docker/amd.yml +++ b/docker/amd.yml @@ -45,16 +45,3 @@ services: - 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/build_owl_base_image.sh b/docker/build_owl_base_image.sh new file mode 100644 index 0000000..1bab7ac --- /dev/null +++ b/docker/build_owl_base_image.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +# Get the current date in YYYYMMDD format +current_date=$(date +"%Y%m%d") + +docker build -t ghcr.io/embeddedllm/jamaibase/owl.base:latest -f docker/Dockerfile.owl.base . +docker image tag ghcr.io/embeddedllm/jamaibase/owl.base:latest ghcr.io/embeddedllm/jamaibase/owl.base:${current_date} +docker push ghcr.io/embeddedllm/jamaibase/owl.base:latest +docker push ghcr.io/embeddedllm/jamaibase/owl.base:${current_date} diff --git a/docker/ch_configs/clickhouse_config.xml b/docker/ch_configs/clickhouse_config.xml new file mode 100644 index 0000000..1f424bd --- /dev/null +++ b/docker/ch_configs/clickhouse_config.xml @@ -0,0 +1,35 @@ + + 0.0.0.0 + + + 9363 + + + /metrics + + expose_metrics + true + true + true + true + + + + /write + + remote_write + jamaibase_owl + jamaibase_owl_metrics
+
+
+ + /read + + remote_read + jamaibase_owl + jamaibase_owl_metrics
+
+
+
+
+
diff --git a/docker/ch_configs/clickhouse_user_config.xml b/docker/ch_configs/clickhouse_user_config.xml new file mode 100644 index 0000000..7620fbd --- /dev/null +++ b/docker/ch_configs/clickhouse_user_config.xml @@ -0,0 +1,8 @@ + + + + 1 + 1 + + + \ No newline at end of file diff --git a/docker/ch_configs/create_ch_prom_db.sh b/docker/ch_configs/create_ch_prom_db.sh new file mode 100644 index 0000000..cb80d20 --- /dev/null +++ b/docker/ch_configs/create_ch_prom_db.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +# Table to record llm, embed, rerank usage and costs +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.llm_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`model\` String, + \`input_token\` UInt32, + \`output_token\` UInt32, + \`cost\` Decimal128(12), + \`input_cost\` Decimal128(12), + \`output_cost\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp, model)" + +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.embed_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`model\` String, + \`num_token\` UInt32, + \`cost\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp, model)" + +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.rerank_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`model\` String, + \`num_search\` UInt32, + \`cost\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp, model)" + +# Table to record egress usage +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.egress_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`amount_gib\` Decimal128(12), + \`cost\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp)" + +# Table to record file storage usage +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.file_storage_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`amount_gib\` Decimal128(12), + \`cost\` Decimal128(12), + \`snapshot_gib\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp)" + +# Table to record db storage usage +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.db_storage_usage +( + \`id\` UUID, + \`org_id\` String, + \`proj_id\` String, + \`user_id\` String, + \`timestamp\` DateTime64(6, 'UTC'), + \`amount_gib\` Decimal128(12), + \`cost\` Decimal128(12), + \`snapshot_gib\` Decimal128(12) +) +ENGINE=MergeTree +PARTITION BY toYYYYMM(timestamp) +ORDER BY (org_id, timestamp)" + +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.owl_traces +( + \`Timestamp\` DateTime64(9) CODEC(Delta(8), ZSTD(1)), + \`TraceId\` String CODEC(ZSTD(1)), + \`SpanId\` String CODEC(ZSTD(1)), + \`ParentSpanId\` String CODEC(ZSTD(1)), + \`TraceState\` String CODEC(ZSTD(1)), + \`SpanName\` LowCardinality(String) CODEC(ZSTD(1)), + \`SpanKind\` LowCardinality(String) CODEC(ZSTD(1)), + \`ServiceName\` LowCardinality(String) CODEC(ZSTD(1)), + \`ResourceAttributes\` Map(LowCardinality(String), String) CODEC(ZSTD(1)), + \`ScopeName\` String CODEC(ZSTD(1)), + \`ScopeVersion\` String CODEC(ZSTD(1)), + \`SpanAttributes\` Map(LowCardinality(String), String) CODEC(ZSTD(1)), + \`Duration\` Int64 CODEC(ZSTD(1)), + \`StatusCode\` LowCardinality(String) CODEC(ZSTD(1)), + \`StatusMessage\` String CODEC(ZSTD(1)), + \`Events.Timestamp\` Array(DateTime64(9)) CODEC(ZSTD(1)), + \`Events.Name\` Array(LowCardinality(String)) CODEC(ZSTD(1)), + \`Events.Attributes\` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)), + \`Links.TraceId\` Array(String) CODEC(ZSTD(1)), + \`Links.SpanId\` Array(String) CODEC(ZSTD(1)), + \`Links.TraceState\` Array(String) CODEC(ZSTD(1)), + \`Links.Attributes\` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_duration Duration TYPE minmax GRANULARITY 1 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId) +TTL toDateTime(Timestamp) + toIntervalDay(3) +SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1" + + +clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.owl_traces_trace_id_ts +( + \`TraceId\` String CODEC(ZSTD(1)), + \`Start\` DateTime64(9) CODEC(Delta(8), ZSTD(1)), + \`End\` DateTime64(9) CODEC(Delta(8), ZSTD(1)), + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1 +) +ENGINE = MergeTree +ORDER BY (TraceId, toUnixTimestamp(Start)) +TTL toDateTime(Start) + toIntervalDay(3)" + + +clickhouse-client --query="CREATE MATERIALIZED VIEW IF NOT EXISTS jamaibase_owl.owl_traces_trace_id_ts_mv TO jamaibase_owl.owl_traces_trace_id_ts +( + \`TraceId\` String, + \`Start\` DateTime64(9), + \`End\` DateTime64(9) +) AS +SELECT + TraceId, + min(Timestamp) AS Start, + max(Timestamp) AS End +FROM jamaibase_owl.owl_traces +WHERE TraceId != '' +GROUP BY TraceId" + +# Table using Json data type +# clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.owl_usage +# ( +# \`id\` UUID, +# \`org_id\` String, +# \`timestamp\` DateTime64(6, 'UTC'), +# \`data\` JSON() +# ) +# ENGINE=MergeTree ORDER BY (org_id, timestamp)" + +### --- Migrations --- ### + +clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage RENAME COLUMN IF EXISTS amount_gb to amount_gib" +clickhouse-client --query="ALTER TABLE jamaibase_owl.llm_usage MODIFY COLUMN cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.llm_usage MODIFY COLUMN input_cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.llm_usage MODIFY COLUMN output_cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.embed_usage MODIFY COLUMN cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.rerank_usage MODIFY COLUMN cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage MODIFY COLUMN cost Decimal128(12)" +clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage MODIFY COLUMN amount_gib Decimal128(12)" \ No newline at end of file diff --git a/docker/compose.amd.yml b/docker/compose.amd.yml deleted file mode 100644 index a77af99..0000000 --- a/docker/compose.amd.yml +++ /dev/null @@ -1,4 +0,0 @@ -include: - - path: - - compose.cpu.yml - - amd.yml diff --git a/docker/compose.bake.hcl b/docker/compose.bake.hcl new file mode 100644 index 0000000..ab77323 --- /dev/null +++ b/docker/compose.bake.hcl @@ -0,0 +1,15 @@ +group "default" { + targets = ["owl", "jambu"] +} + +target "owl" { + dockerfile = "docker/Dockerfile.owl" + cache-from = ["type=azblob,name=owl-cache,account_url=AZURE_STORAGE_ACCOUNT_URL,secret_access_key=AZURE_STORAGE_ACCESS_KEY"] + cache-to = ["type=azblob,name=owl-cache,mode=max,account_url=AZURE_STORAGE_ACCOUNT_URL,secret_access_key=AZURE_STORAGE_ACCESS_KEY"] +} + +target "jambu" { + dockerfile = "docker/Dockerfile.frontend" + cache-from = ["type=azblob,name=jambu-cache,account_url=AZURE_STORAGE_ACCOUNT_URL,secret_access_key=AZURE_STORAGE_ACCESS_KEY"] + cache-to = ["type=azblob,name=jambu-cache,mode=max,account_url=AZURE_STORAGE_ACCOUNT_URL,secret_access_key=AZURE_STORAGE_ACCESS_KEY"] +} \ No newline at end of file diff --git a/docker/compose.base.yml b/docker/compose.base.yml new file mode 100644 index 0000000..1e303ef --- /dev/null +++ b/docker/compose.base.yml @@ -0,0 +1,327 @@ +services: + dragonfly: + image: ghcr.io/embeddedllm/dragonflydb/dragonfly:v1.27.0-ubuntu + ulimits: + memlock: -1 + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 6379 || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + # For better performance, consider `host` mode instead `port` to avoid docker NAT. + # `host` mode is NOT currently supported in Swarm Mode. + # https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode + # network_mode: "host" + # volumes: + # - ${PWD}/docker_data/dragonfly:/data + networks: + - jamai + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.113.0 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ${PWD}/docker/otel_configs/otel-collector-config.yaml:/etc/otelcol/config.yaml + networks: + - jamai + + victoriametrics: + image: victoriametrics/victoria-metrics:v1.124.0 + command: + - "--selfScrapeInterval=15s" + - "--retentionPeriod=100y" + volumes: + - ${PWD}/docker_data/vm_data:/victoria-metrics-data + networks: + - jamai + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://0.0.0.0:8428/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + vmauth: + image: victoriametrics/vmauth:v1.124.0 + command: + - "--auth.config=/etc/config.yml" + volumes: + - ${PWD}/docker/vmauth/config.yml:/etc/config.yml + networks: + - jamai + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://0.0.0.0:8427/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + victorialogs: + image: victoriametrics/victoria-logs:v1.28.0 + command: + - "--retentionPeriod=100y" + volumes: + - ${PWD}/docker_data/vl_data:/victoria-logs-data + networks: + - jamai + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://0.0.0.0:9428/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + vmagent: + image: victoriametrics/vmagent:v1.124.0 + depends_on: + victoriametrics: + condition: service_healthy + vmauth: + condition: service_healthy + victorialogs: + condition: service_healthy + volumes: + - ${PWD}/docker/vmagent/streamingAggregation.yaml:/etc/config/streamingAggregation.yaml + - ${PWD}/docker_data/vmagent:/tmp/vmagent + command: + # - "--promscrape.config=/etc/prometheus/prometheus.yml" + - "--remoteWrite.url=http://vmauth:8427/vm/api/v1/write" + - "--remoteWrite.basicAuth.username=${VMAUTH_USER:-owl}" + - "--remoteWrite.basicAuth.password=${VMAUTH_PASSWORD:-owl-vm}" + - "--remoteWrite.streamAggr.config=/etc/config/streamingAggregation.yaml" + - "--remoteWrite.streamAggr.enableWindows=true" + - "--remoteWrite.tmpDataPath=/tmp/vmagent" + # - "--promscrape.config.strictParse=false" + networks: + - jamai + + clickhouse: + image: clickhouse:24.10.2.80 + volumes: + - ${PWD}/docker_data/ch_data:/var/lib/clickhouse/ + - ${PWD}/docker_data/ch_logs:/var/log/clickhouse-server/ + - ${PWD}/docker/ch_configs/clickhouse_config.xml:/etc/clickhouse-server/config.d/custom_config.xml + - ${PWD}/docker/ch_configs/clickhouse_user_config.xml:/etc/clickhouse-server/users.d/custom_config.xml + - ${PWD}/docker/ch_configs/create_ch_prom_db.sh:/docker-entrypoint-initdb.d/create_ch_prom_db.sh + environment: + - CLICKHOUSE_ALWAYS_RUN_INITDB_SCRIPTS=1 + - CLICKHOUSE_USER=${CLICKHOUSE_USER:-owluser} + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 + - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-owlpassword} + - CLICKHOUSE_DB=${CLICKHOUSE_DB:-jamaibase_owl} + ulimits: + nofile: + soft: 262144 + hard: 262144 + cap_add: + - SYS_NICE + - NET_ADMIN + - IPC_LOCK + networks: + - jamai + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8123/ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + postgresql: + image: ghcr.io/embeddedllm/jamaibase/postgres:20250305 + command: + - "-c" + - "max_connections=${PG_MAX_CONNECTIONS:-100}" + - "-c" + - "max_locks_per_transaction=512" + - "-c" + - "pgroonga.enable_wal_resource_manager=on" + - "-c" + - "shared_preload_libraries=pgroonga_wal_resource_manager,pg_stat_statements" + environment: + POSTGRES_USER: owlpguser + POSTGRES_PASSWORD: owlpgpassword + POSTGRES_DB: jamaibase_owl + PGUSER: owlpguser + PGPASSWORD: owlpgpassword + PGDATABASE: jamaibase_owl + volumes: + - ${PWD}/docker_data/postgres_db:/var/lib/postgresql/data + networks: + - jamai + healthcheck: + test: ["CMD", "pg_isready", "-U", "owlpguser", "-d", "jamaibase_owl"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + pgbouncer: + image: edoburu/pgbouncer:v1.24.0-p0 + depends_on: + postgresql: + condition: service_healthy + ports: + - 5432:5432 + environment: + DB_USER: owlpguser + DB_PASSWORD: owlpgpassword + DB_HOST: postgresql + DB_PORT: 5432 + DB_NAME: jamaibase_owl + AUTH_TYPE: scram-sha-256 + POOL_MODE: transaction + ADMIN_USERS: owlpguser + MAX_CLIENT_CONN: ${PB_MAX_CLIENT_CONN:-100} + DEFAULT_POOL_SIZE: ${PB_MAX_CLIENT_CONN:-80} + SERVER_IDLE_TIMEOUT: 600 + QUERY_WAIT_TIMEOUT: 120 + SERVER_RESET_QUERY: DISCARD ALL + healthcheck: + test: ["CMD", "pg_isready", "-h", "localhost", "-U", "owlpguser", "-d", "jamaibase_owl"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - jamai + + minio: + image: minio/minio:RELEASE.2025-05-24T17-08-30Z + 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 + networks: + - jamai + volumes: + - ${PWD}/docker_data/minio:/data + + docling: + image: ghcr.io/embeddedllm/docling-serve:20250528 + healthcheck: + test: ["CMD-SHELL", "curl --fail http://localhost:5001/health || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + restart: unless-stopped + networks: + - jamai + + owl: + build: + context: .. + dockerfile: docker/Dockerfile.owl + env_file: + - ../.env + entrypoint: + - /bin/bash + - -c + - uv run python -m owl.entrypoints.api + ports: + - "${API_PORT:-6969}:${OWL_PORT:-6969}" + networks: + - jamai + volumes: + - ${PWD}/docker_data/owl/db:/usr/src/app/db + - ${PWD}/docker_data/owl/logs:/usr/src/app/logs + - ${PWD}/docker_data/owl/file:/usr/src/app/file + depends_on: + dragonfly: + condition: service_healthy + otel-collector: # Ensure otel-collector is running before owl starts + condition: service_started + clickhouse: + condition: service_healthy + victorialogs: + condition: service_healthy + victoriametrics: + condition: service_healthy + vmagent: + condition: service_started + vmauth: + condition: service_healthy + pgbouncer: + condition: service_healthy + minio: + condition: service_healthy + docling: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --fail localhost:6969/api/health || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + restart: unless-stopped + + starling: + extends: + service: owl + entrypoint: + - /bin/bash + - -c + - | + uv run --no-sync celery -A owl.entrypoints.starling worker --loglevel=info --max-memory-per-child 65536 --autoscale=4,2 --beat + command: !reset [] + depends_on: !override + owl: + condition: service_healthy + ports: !reset [] + healthcheck: !reset [] + + frontend: + build: + context: .. + dockerfile: docker/Dockerfile.frontend + args: + PUBLIC_JAMAI_URL: ${PUBLIC_JAMAI_URL} + PUBLIC_IS_SPA: ${PUBLIC_IS_SPA} + CHECK_ORIGIN: ${CHECK_ORIGIN} + command: ["node", "server"] + depends_on: + owl: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --fail localhost:4000 || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + restart: unless-stopped + environment: + - NODE_ENV=production + - BODY_SIZE_LIMIT=Infinity + env_file: + - ../.env + ports: + - "${FRONTEND_PORT:-4000}:4000" + networks: + - jamai + + kopi: + image: hoipangg/v8-kopi:0.4 + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/health || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + restart: unless-stopped + environment: + - PORT=3000 + - MAX_SIZE_BYTES=20971520 + networks: + - jamai + +networks: + jamai: + driver_opts: + com.docker.network.driver.mtu: 1442 diff --git a/docker/compose.ci.yml b/docker/compose.ci.yml new file mode 100644 index 0000000..be969b1 --- /dev/null +++ b/docker/compose.ci.yml @@ -0,0 +1,6 @@ +include: + - path: + - compose.base.yml + - override.ci.yml + - path: + - compose.test-llm.yml diff --git a/docker/compose.cpu.ollama.yml b/docker/compose.cpu.ollama.yml deleted file mode 100644 index 1b9749e..0000000 --- a/docker/compose.cpu.ollama.yml +++ /dev/null @@ -1,43 +0,0 @@ -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 qwen2.5:3b && ollama cp qwen2.5:3b Qwen/Qwen2.5-3B-Instruct; \ - tail -f /dev/null", - ] - restart: unless-stopped - healthcheck: - test: ["CMD", "sh", "-c", "ollama show Qwen/Qwen2.5-3B-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 deleted file mode 100644 index 6ff3f25..0000000 --- a/docker/compose.cpu.yml +++ /dev/null @@ -1,196 +0,0 @@ -services: - infinity: - image: michaelf34/infinity:0.0.70-cpu - container_name: jamai_infinity - command: ["v2", "--engine", "torch", "--port", "6909", "--model-warmup", "--model-id", "${EMBEDDING_MODEL}", "--model-id", "${RERANKER_MODEL}"] - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:6909/health"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - volumes: - - ${PWD}/infinity_cache:/app/.cache - networks: - - jamai - - unstructuredio: - image: downloads.unstructured.io/unstructured-io/unstructured-api:latest - entrypoint: ["/usr/bin/env", "bash", "-c", "uvicorn prepline_general.api.app:app --log-config logger_config.yaml --port 6989 --host 0.0.0.0"] - healthcheck: - test: ["CMD-SHELL", "wget http://localhost:6989/healthcheck -O /dev/null || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - networks: - - jamai - - docio: - build: - context: .. - dockerfile: docker/Dockerfile.docio - image: jamai/docio - pull_policy: build - command: ["python", "-m", "docio.entrypoints.api"] - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:6979/health || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - networks: - - jamai - - dragonfly: - image: "ghcr.io/embeddedllm/dragonfly" - ulimits: - memlock: -1 - healthcheck: - test: ["CMD-SHELL", "nc -z localhost 6379 || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - # For better performance, consider `host` mode instead `port` to avoid docker NAT. - # `host` mode is NOT currently supported in Swarm Mode. - # https://docs.docker.com/compose/compose-file/compose-file-v3/#network_mode - # network_mode: "host" - # volumes: - # - ${PWD}/dragonflydata:/data - networks: - - jamai - - owl: - build: - context: .. - dockerfile: docker/Dockerfile.owl - image: jamai/owl - pull_policy: build - command: ["python", "-m", "owl.entrypoints.api"] - depends_on: - infinity: - condition: service_healthy - unstructuredio: - condition: service_healthy - docio: - condition: service_healthy - dragonfly: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail localhost:6969/api/health || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - env_file: - - ../.env - volumes: - - ${PWD}/db:/app/api/db - - ${PWD}/logs:/app/api/logs - - ${PWD}/file:/app/api/file - ports: - - "${API_PORT:-6969}:6969" - networks: - - jamai - - starling: - extends: - service: owl - entrypoint: - - /bin/bash - - -c - - | - celery -A owl.entrypoints.starling worker --loglevel=info --max-memory-per-child 65536 --autoscale=2,4 & \ - celery -A owl.entrypoints.starling beat --loglevel=info & \ - FLOWER_UNAUTHENTICATED_API=1 celery -A owl.entrypoints.starling flower --loglevel=info - command: !reset [] - depends_on: - owl: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:5555/api/workers || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - ports: !override - - "${STARLING_PORT:-5555}:5555" - - frontend: - build: - context: .. - dockerfile: docker/Dockerfile.frontend - args: - JAMAI_URL: ${JAMAI_URL} - PUBLIC_JAMAI_URL: ${PUBLIC_JAMAI_URL} - PUBLIC_IS_SPA: ${PUBLIC_IS_SPA} - CHECK_ORIGIN: ${CHECK_ORIGIN} - image: jamai/frontend - pull_policy: build - command: ["node", "server"] - depends_on: - owl: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl --fail localhost:4000 || exit 1"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - restart: unless-stopped - environment: - - NODE_ENV=production - - BODY_SIZE_LIMIT=Infinity - env_file: - - ../.env - ports: - - "${FRONTEND_PORT:-4000}:4000" - networks: - - jamai - - # By default, minio service is not enabled, and only used for testing. use --profile minio along docker compose up if minio is needed. - minio: - profiles: ["minio"] - image: minio/minio - entrypoint: /bin/sh -c " minio server /data --console-address ':9001' & until (mc config host add myminio http://localhost:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}) do echo '...waiting...' && sleep 1; done; mc mb myminio/file; wait " - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - ports: - - "9000:9000" - - "9001:9001" - networks: - - jamai - - # By default, kopi service is not enabled, and only used for testing. use --profile kopi along docker compose up if kopi is needed. - kopi: - profiles: ["kopi"] - image: hoipangg/kopi - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5569/health')"] - interval: 10s - timeout: 2s - retries: 20 - start_period: 10s - ports: - - "5569:5569" - networks: - - jamai - -networks: - jamai: diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml new file mode 100644 index 0000000..8677308 --- /dev/null +++ b/docker/compose.dev.yml @@ -0,0 +1,6 @@ +include: + - path: + - compose.base.yml + - override.dev.yml + - path: + - compose.test-llm.yml diff --git a/docker/compose.nvidia.yml b/docker/compose.nvidia.yml deleted file mode 100644 index 5424af5..0000000 --- a/docker/compose.nvidia.yml +++ /dev/null @@ -1,4 +0,0 @@ -include: - - path: - - compose.cpu.yml - - nvidia.yml diff --git a/docker/compose.test-llm.yml b/docker/compose.test-llm.yml new file mode 100644 index 0000000..04c865c --- /dev/null +++ b/docker/compose.test-llm.yml @@ -0,0 +1,26 @@ +services: + test-llm: + build: + context: .. + dockerfile: docker/Dockerfile.owl + env_file: + - ../.env + entrypoint: + - /bin/bash + - -c + - uv run coverage run --data-file=db/.coverage --rcfile=api/pyproject.toml -m owl.entrypoints.llm + ports: + - 6970:6970 + networks: + - jamai + volumes: + - ${PWD}/docker_data/owl/db:/usr/src/app/db + # depends_on: + # rqlite: + # condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --fail localhost:6970/health || exit 1"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s diff --git a/docker/infinity.yml b/docker/infinity.yml new file mode 100644 index 0000000..2dd42d8 --- /dev/null +++ b/docker/infinity.yml @@ -0,0 +1,18 @@ +services: + infinity: + image: michaelf34/infinity:0.0.70-cpu + container_name: jamai_infinity + command: ["v2", "--engine", "torch", "--port", "6909", "--model-warmup", "--model-id", "${EMBEDDING_MODEL}", "--model-id", "${RERANKER_MODEL}"] + healthcheck: + test: ["CMD-SHELL", "curl --fail http://localhost:6909/health"] + interval: 10s + timeout: 2s + retries: 20 + start_period: 10s + restart: unless-stopped + env_file: + - ../.env + volumes: + - ${PWD}/docker_data/infinity_cache:/app/.cache + networks: + - jamai diff --git a/docker/kong.yml b/docker/kong.yml new file mode 100644 index 0000000..f7988d0 --- /dev/null +++ b/docker/kong.yml @@ -0,0 +1,42 @@ +services: + kong-external: + image: kong/kong-gateway:3.6.1.4 + volumes: + - ../services/gateway/kong_external.yml:/kong/declarative/kong.yml + environment: + - KONG_DATABASE=off + - KONG_DECLARATIVE_CONFIG=/kong/declarative/kong.yml + - KONG_PROXY_ACCESS_LOG=/dev/stdout + - KONG_ADMIN_ACCESS_LOG=/dev/stdout + - KONG_PROXY_ERROR_LOG=/dev/stderr + - KONG_ADMIN_ERROR_LOG=/dev/stderr + - KONG_ADMIN_LISTEN=0.0.0.0:8001 + - KONG_ADMIN_GUI_PATH=/ + ports: + - "8000:8000" # HTTP requests + - "8443:8443" # HTTPS requests + - "127.0.0.1:8001:8001" # HTTP Admin listen + - "127.0.0.1:8444:8444" # HTTPS Admin listen + networks: + - jamai + + kong-internal: + image: kong/kong-gateway:3.6.1.4 + volumes: + - ../services/gateway/kong_internal.yml:/kong/declarative/kong.yml + environment: + - KONG_DATABASE=off + - KONG_DECLARATIVE_CONFIG=/kong/declarative/kong.yml + - KONG_PROXY_ACCESS_LOG=/dev/stdout + - KONG_ADMIN_ACCESS_LOG=/dev/stdout + - KONG_PROXY_ERROR_LOG=/dev/stderr + - KONG_ADMIN_ERROR_LOG=/dev/stderr + - KONG_ADMIN_LISTEN=0.0.0.0:8001 + - KONG_ADMIN_GUI_PATH=/ + ports: + - "8010:8000" # HTTP requests + - "8453:8443" # HTTPS requests + - "127.0.0.1:8011:8001" # HTTP Admin listen + - "127.0.0.1:8454:8444" # HTTPS Admin listen + networks: + - jamai diff --git a/docker/ollama.yml b/docker/ollama.yml deleted file mode 100644 index 8c0cc58..0000000 --- a/docker/ollama.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - owl: - environment: - - OWL_MODELS_CONFIG=models_ollama.json diff --git a/docker/otel_configs/otel-collector-config.yaml b/docker/otel_configs/otel-collector-config.yaml new file mode 100644 index 0000000..8359b15 --- /dev/null +++ b/docker/otel_configs/otel-collector-config.yaml @@ -0,0 +1,69 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + +exporters: + # prometheus: + # endpoint: "0.0.0.0:8889" + # namespace: "owl" + + otlphttp/victoriametrics: + endpoint: http://vmagent:8429/opentelemetry + compression: gzip + encoding: proto + + clickhouse: + endpoint: http://clickhouse:8123?dial_timeout=10s&compress=lz4&async_insert=1 + ttl: 24h + database: jamaibase_owl + traces_table_name: owl_traces + username: owluser + password: owlpassword + create_schema: false + timeout: 5s + sending_queue: + queue_size: 1000 + retry_on_failure: + enabled: true + initial_interval: 5s + max_interval: 30s + max_elapsed_time: 300s + + debug: + verbosity: detailed + + otlphttp: + logs_endpoint: http://victorialogs:9428/insert/opentelemetry/v1/logs + +processors: + batch: + timeout: 5s + + filter/ottl: + traces: + span: + - 'IsMatch(attributes["db.statement"], "^PRAGMA")' + +extensions: + health_check: + endpoint: "0.0.0.0:13133" + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [filter/ottl, batch] + exporters: [clickhouse] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [otlphttp/victoriametrics] + logs: + receivers: [otlp] + processors: [batch] + exporters: [otlphttp] diff --git a/docker/override.ci.yml b/docker/override.ci.yml new file mode 100644 index 0000000..93969d9 --- /dev/null +++ b/docker/override.ci.yml @@ -0,0 +1,25 @@ +services: + dragonfly: + ports: + - 6379:6379 + + clickhouse: + ports: + - 8123:8123 + + docling: + ports: + - 5001:5001 + + owl: + volumes: + - ${PWD}/docker_data/owl/db:/usr/src/app/db + - ${HOME}/.kube/config:/root/.kube/config + environment: + - KUBECONFIG=/root/.kube/config + ports: + - "${API_PORT:-6969}:${OWL_PORT:-6969}" + entrypoint: + - /bin/bash + - -c + - uv run coverage run --data-file=db/.coverage --rcfile=api/pyproject.toml -m owl.entrypoints.api diff --git a/docker/override.dev.yml b/docker/override.dev.yml new file mode 100644 index 0000000..e679b28 --- /dev/null +++ b/docker/override.dev.yml @@ -0,0 +1,65 @@ +services: + dragonfly: + ports: + - 6379:6379 + + otel-collector: + ports: + - 8889:8889 # Prometheus metrics endpoint + - 4317:4317 # OTLP gRPC receiver + - 4318:4318 # OTLP HTTP receiver + - 13133:13133 # health_check extension + + vmauth: + ports: + - 8427:8427 + + vmagent: + ports: + - 8429:8429 + + victoriametrics: + ports: + - 8428:8428 + + victorialogs: + ports: + - 9428:9428 + + clickhouse: + ports: + - 8123:8123 + - 19000:9000 + - 9363:9363 + + postgresql: + ports: + - 5431:5432 + + pgbouncer: + ports: + - 5432:5432 + + minio: + ports: + - 9000:9000 + - 9001:9001 + + docling: + ports: + - 5001:5001 + + owl: + ports: + - "${API_PORT:-6969}:${OWL_PORT:-6969}" + volumes: + - ${PWD}/docker_data/owl/db:/usr/src/app/db + - ${PWD}/services/api/src:/usr/src/app/api/src + + kopi: + ports: + - 5569:3000 + + frontend: + ports: + - "${FRONTEND_PORT:-4000}:4000" diff --git a/docker/nvidia.yml b/docker/override.nvidia.yml similarity index 97% rename from docker/nvidia.yml rename to docker/override.nvidia.yml index 0c788b2..757d348 100644 --- a/docker/nvidia.yml +++ b/docker/override.nvidia.yml @@ -9,7 +9,7 @@ services: device_ids: ["0"] capabilities: [gpu] - docio: + docling: deploy: resources: reservations: diff --git a/docker/vmagent/streamingAggregation.yaml b/docker/vmagent/streamingAggregation.yaml new file mode 100644 index 0000000..9b2e32f --- /dev/null +++ b/docker/vmagent/streamingAggregation.yaml @@ -0,0 +1,73 @@ +- match: "flower.task.runtime.seconds_bucket" + interval: 1m + without: [service.instance.id, worker] + outputs: [total] + keep_metric_names: true + +- match: '{__name__=~"flower.task.runtime.seconds_(count|sum)"}' + interval: 1m + without: [service.instance.id, worker] + outputs: [total] + keep_metric_names: true + +- match: '{__name__=~"http.+_bucket"}' + interval: 1m + without: [service.instance.id, http.server_name, http.host] + outputs: [total] + keep_metric_names: true + +- match: '{__name__=~"http.+_(count|sum)"}' + interval: 1m + without: [service.instance.id, http.server_name, http.host] + outputs: [total] + keep_metric_names: true + +- match: "db.client.connections.usage" + interval: 1m + without: [service.instance.id] + outputs: [total] + keep_metric_names: true + +- match: "http.server.active_requests" + interval: 1m + without: [service.instance.id, http.server_name, http.host] + outputs: [total] + keep_metric_names: true + +- match: + - "request_count" + interval: 1m + without: [service.instance.id] + outputs: [total] + keep_metric_names: true +# - match: "storage_usage" +# interval: 5m +# without: [service.instance.id] +# outputs: [max] +# flush_on_shutdown: true +# keep_metric_names: true + +# - match: +# - "bandwidth_usage" +# interval: 5m +# without: [service.instance.id] +# outputs: [total] +# flush_on_shutdown: true +# keep_metric_names: true + +# - match: +# - "llm_token_usage" +# - "embedding_token_usage" +# - "reranker_search_usage" +# - "spent" +# interval: 1m +# without: [service.instance.id] +# outputs: [total] +# flush_on_shutdown: true +# keep_metric_names: true +# output_relabel_configs: +# - action: replace +# source_labels: [__name__] +# regex: "^([^:]+):.*$" +# target_label: __name__ +# replacement: "${1}_agg" diff --git a/docker/vmauth/config.yml b/docker/vmauth/config.yml new file mode 100644 index 0000000..f10d91e --- /dev/null +++ b/docker/vmauth/config.yml @@ -0,0 +1,12 @@ +users: + - username: owl + password: owl-vm + url_map: + - src_paths: + - "/vm/.*" + drop_src_path_prefix_parts: 1 + url_prefix: "http://victoriametrics:8428/" + - src_paths: + - "/vl/.*" + drop_src_path_prefix_parts: 1 + url_prefix: "http://victorialogs:9428/" diff --git a/docs/alert_guide.md b/docs/alert_guide.md new file mode 100644 index 0000000..be8d4bf --- /dev/null +++ b/docs/alert_guide.md @@ -0,0 +1,255 @@ +# JamAIBase Alerting Guide + +> A quick reference for what’s already in `vm-alert-config.yaml`. Update thresholds only if needed, and **remember to set the Discord `webhook_url` correctly**. As of 2025-10-22 + +--- + +## Recording Rules + +**Group:** `storage.rules` (interval: **30s**) + +- **ClickHouse free disk (%)** + + - **Record:** `chi_clickhouse_disk_free_percentage` + - **Expr:** `(DiskFreeBytes / DiskTotalBytes) * 100` + - **Labels:** `unit=percent`, `component=clickhouse` + +- **VictoriaMetrics cluster disk usage (%)** + - **Record:** `vmcluster_disk_usage_percentage` + - **Expr:** (derived from `vm_data_size_bytes` and free disk) + - **Labels:** `unit=percent`, `component=vmcluster` + +--- + +## Alert Rules + +> Update the threshold according to your need, especially for PostgreSQL + +- **ClickHouseDiskSpaceLow** + + - **Expr:** `chi_clickhouse_disk_free_percentage < 10` + - **For:** `1h` + - **Labels:** `severity=critical`, `component=clickhouse` + - **Meaning:** Free space < **10%** for 1h. + +- **PostgreSQLDatabaseSizeTooLarge** + + - **Expr:** `cnpg_pg_database_size_bytes / 1024 ^ 3 > 10` + - **For:** `1h` + - **Labels:** `severity=warning`, `component=postgresql` + - **Meaning:** DB size > **10 GiB** for 1h. + +- **VMClusterDiskSpaceHigh** + - **Expr:** `vmcluster_disk_usage_percentage > 80` + - **For:** `1h` + - **Labels:** `severity=critical`, `component=vmcluster` + - **Meaning:** VM storage usage > **80%** for 1h. + +**Routing & Inhibition (as configured):** + +- All alerts route to **`jamaibase-discord`**. +- Sub-routes match `component` but currently target the same receiver. +- Inhibition: a `critical` alert suppresses a `warning` of the **same `alertname`**. + +--- + +## Log Alerts (VictoriaLogs → vmalert) + +> Structured alerts from app logs (`owl`, `starling`) evaluated every **30s** by `vmalert-log` against **VictoriaLogs**. + +- **JamAIBase Exception** + + - **Match:** `severity:i(critical)` and exception fields present + - **Agg by:** `service.name`, `code.filepath`, `code.function`, `code.lineno`, `_msg`, `exception.message`, `exception.stacktrace` + - **When:** `count() > 0` + - **Labels:** `severity=exception`, `component=jamaibase-log` + +- **JamAIBase Error** + + - **Match:** `severity:i(error)` + - **Agg by:** `service.name`, `code.filepath`, `code.function`, `code.lineno`, `_msg` + - **When:** `count() > 0` + - **Labels:** `severity=critical`, `component=jamaibase-log` + +- **JamAIBase Warning** _(optional; enable/disable per need)_ + - **Match:** `severity:i(warning)` + - **Agg by:** `service.name`, `code.filepath`, `code.function`, `code.lineno`, `_msg` + - **When:** `count() > 0` + - **Labels:** `severity=warning`, `component=jamaibase-log` + +**vmalert (logs):** `vmalert-log` selects `vmalert/rule-type=logs`, datasource `VictoriaLogs :9428`, notifier `Alertmanager :9093`, interval **30s**. + +--- + +## Discord Integration + +- Get the webhook url from discord (server settings > integration > webhook) +- Update the webhook_url with the url generated + +```yaml +- webhook_url: "https://discord.com/api/webhooks/XXXX/YYYY" +``` + +## Manual Trigger (Alertmanager API) + +> Prefer rule-based alerts in Prometheus/vmalert for real monitoring. + +**Prereq:** Reach Alertmanager on `:9093`. If inside the cluster, port-forward: + +```bash +kubectl -n vm-operator port-forward svc/vmalertmanager-vmalertmanager 9093:9093 +``` + +**Fire a test alert (firing):** + +```bash +curl -XPOST http://127.0.0.1:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[ + { + "labels": { + "alertname": "ManualSmokeTest", + "severity": "critical", + "component": "clickhouse" + }, + "annotations": { + "summary": "Manual test alert", + "description": "End-to-end Discord delivery check." + } + } + ]' +``` + +**Resolve the same alert (identical labels + endsAt):** + +```bash +curl -XPOST http://127.0.0.1:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[ + { + "labels": { + "alertname": "ManualSmokeTest", + "severity": "critical", + "component": "clickhouse" + }, + "annotations": { + "summary": "Manual test resolved", + "description": "Marking alert resolved." + }, + "startsAt": "'"$(date -u -d "-10m" +"%Y-%m-%dT%H:%M:%SZ")"'", + "endsAt": "'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'" + } + ]' +``` + +**Force a fresh notification immediately (change fingerprint):** + +```bash +curl -XPOST http://127.0.0.1:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[ + { + "labels": { + "alertname": "ManualSmokeTest", + "severity": "critical", + "component": "clickhouse", + "run_id": "'"$(date +%s)"'" + }, + "annotations": { + "summary": "Another ping", + "description": "Forcing a new notification." + } + } + ]' +``` + +**Notes (matches current config):** + +- `group_by: ["alertname"]` → alerts with the same `alertname` are grouped. +- `group_wait: 10s` / `group_interval: 10s` → small, intentional delays before/between sends. +- `repeat_interval: 3h` → duplicate alerts with the **same label set** won’t resend within 3 hours. +- All routes currently go to **`jamaibase-discord`**; `component` is mainly for title templating/inhibition now, but keep it for future per-component routing. + +--- + +### Field reference (JSON payload) + +> Minimal payload = `labels` + `annotations`. Timestamps are optional but recommended for clarity. + +- **`labels`** _(object, required)_ — define the alert’s identity (**fingerprint**) and routing. + + - **`alertname`**: Logical name (e.g., `ManualSmokeTest`). Used by `group_by: ["alertname"]`. + - **`severity`**: Freeform (`warning`, `critical`, …). Your template adds `@here` for `critical`. + - **`component`**: Freeform (`clickhouse`, `postgresql`, `vmcluster`, …). Useful for titles and future routing. + - **(optional)** e.g., `instance`, `hostname`, or **`run_id`** to force a new fingerprint/notification. + +- **`annotations`** _(object, required)_ — human-readable content for messages. + + - **`summary`**: One-liner. + - **`description`**: A few sentences of detail. + +- **`startsAt`** _(RFC3339 UTC, optional)_ — when the alert **started firing**. If omitted, Alertmanager treats it as “nowâ€. Example: `2025-10-11T05:00:00Z`. + +- **`endsAt`** _(RFC3339 UTC, optional)_ — when the alert **stops**. + + - **Firing**: omit `endsAt`, or set it **in the future**; AM considers it active. + - **Resolved**: set `endsAt` **in the past** (and keep the _same labels_) to immediately resolve it. + +#### Firing vs. Resolved — quick examples + +- **Fire (no timestamps):** + +```json +[ + { + "labels": { "alertname": "ManualSmokeTest", "severity": "critical", "component": "clickhouse" }, + "annotations": { "summary": "Manual test", "description": "End-to-end Discord check." } + } +] +``` + +- **Resolve (same labels + past `endsAt`):** + +```json +[ + { + "labels": { "alertname": "ManualSmokeTest", "severity": "critical", "component": "clickhouse" }, + "annotations": { "summary": "Manual test resolved", "description": "Marking resolved." }, + "startsAt": "2025-10-11T04:50:00Z", + "endsAt": "2025-10-11T05:00:00Z" + } +] +``` + +- **Force a fresh notification (new fingerprint via `run_id`):** + +```json +[ + { + "labels": { + "alertname": "ManualSmokeTest", + "severity": "critical", + "component": "clickhouse", + "run_id": "1697000000" + }, + "annotations": { "summary": "Another ping", "description": "New fingerprint via run_id." } + } +] +``` + +#### Practical tips + +- **Fingerprint ≈ labels only.** Same labels → same alert; change any label (e.g., `run_id`) → new alert. +- **Grouping.** With `group_by: ["alertname"]`, different severities/components sharing the same `alertname` are batched after `group_wait`. +- **Time helpers (UTC/Zulu):** + +```bash +date -u +"%Y-%m-%dT%H:%M:%SZ" # now +date -u -d "-10 minutes" +"%Y-%m-%dT%H:%M:%SZ" +date -u -d "+30 minutes" +"%Y-%m-%dT%H:%M:%SZ" +``` + +- **Auto-resolution.** If a client doesn’t refresh a firing alert, AM eventually considers it resolved. For manual tests, either send a resolved payload or let it expire. +- Resolution timeout is defined in the manifest, ref. https://prometheus.io/docs/alerting/latest/configuration/ + +``` +global: + # ResolveTimeout is the default value used by alertmanager if the alert does not + # include EndsAt, after this time passes it can declare the alert as resolved if it has not been updated. + # This has no impact on alerts from Prometheus, as they always include EndsAt. + [ resolve_timeout: | default = 5m ] +``` diff --git a/docs/pgaudit_guide.md b/docs/pgaudit_guide.md new file mode 100644 index 0000000..275acca --- /dev/null +++ b/docs/pgaudit_guide.md @@ -0,0 +1,71 @@ +# JamAIBase — pgaudit Setup + +1. PostgreSQL cluster wide auditing is set by pgaudit params in cnpg-cluster-deploy.yaml +2. Object auditing is set in owl db/\_\_init\_\_.py + +--- + +## 1) CNPG Cluster Config (role + pgaudit parameters) + +- Can customize the logging option in the parameters, ref. https://github.com/pgaudit/pgaudit/blob/main/README.md +- jamaibase_auditor is the role required for object auditing + +```yaml +spec: + managed: + roles: + - name: jamaibase_auditor + ensure: present + comment: pgaudit role for jamaibase + login: false + + postgresql: + parameters: + # pgaudit for logging DDL and role changes; include useful context + # NOTE: Ensure pgaudit is actually loaded via shared_preload_libraries in your cluster. + # If not already set elsewhere, add: + # shared_preload_libraries: "pgaudit" + pgaudit.log: "ddl, role" + pgaudit.log_catalog: "off" + pgaudit.log_parameter: "on" + pgaudit.log_client: "on" + pgaudit.role: "jamaibase_auditor" # object-based auditing role +``` + +--- + +## 2) Object Auditing Grants (in db/\_\_init\_\_.py) + +- customize audit_statement based on the level of DML statement you would want to monitor + +```python +async def _grant_auditor_priviledge(engine: AsyncEngine) -> bool: + """ + Apply the necessary grants to allow the auditor role to audit the database. + """ + auditor_role = "jamaibase_auditor" + audit_statement = "UPDATE, DELETE" + + async with engine.connect() as conn: + role_exists = await conn.scalar( + text(f"SELECT 1 FROM pg_roles WHERE rolname = '{auditor_role}'") + ) + if role_exists is None: + return False + + # FUTURE tables in this schema + await conn.execute( + text( + f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{SCHEMA}" ' + f"GRANT {audit_statement} ON TABLES TO {auditor_role};" + ) + ) + + # EXISTING tables now + await conn.exec_driver_sql( + f'GRANT {audit_statement} ON ALL TABLES IN SCHEMA "{SCHEMA}" TO {auditor_role};' + ) + await conn.commit() + + return True +``` diff --git a/scripts/compile_docio_exe.ps1 b/scripts/compile_docio_exe.ps1 deleted file mode 100644 index 6799fd9..0000000 --- a/scripts/compile_docio_exe.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -.\scripts\remove_cloud_modules.ps1 -cd .\clients\python -pip install . -cd .\..\..\services\docio -pip install -e . -pip install pyinstaller==6.9.0 -pip install cryptography==42.0.8 -pip install python-magic-bin -pyinstaller docio.spec \ No newline at end of file diff --git a/scripts/migrate_model_json.py b/scripts/migrate_model_json.py index ec82f8c..2bf99ee 100644 --- a/scripts/migrate_model_json.py +++ b/scripts/migrate_model_json.py @@ -1,7 +1,7 @@ import json import sys -from owl.protocol import ModelListConfig +from owl.types import ModelListConfig def transform_json(original_json): @@ -16,7 +16,7 @@ def transform_json(original_json): # Create the ModelDeploymentConfig instance deployment_config = { - "litellm_id": config.get("litellm_id", ""), + "routing_id": config.get("litellm_id", ""), "api_base": config.get("api_base", ""), "provider": provider, } diff --git a/scripts/migrate_v1_to_v2.py b/scripts/migrate_v1_to_v2.py new file mode 100644 index 0000000..f358e9e --- /dev/null +++ b/scripts/migrate_v1_to_v2.py @@ -0,0 +1,394 @@ +import argparse +import json +import logging +import sqlite3 +from pathlib import Path +from typing import Any, Dict, List + +import lancedb +from filelock import FileLock + +from owl.db.gen_table import ( + ColumnDtype, + ColumnMetadata, + GenerativeTableCore, + TableMetadata, +) +from owl.types import ColName, TableName, TableType + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +class V1DatabaseReader: + """Class to read data from v1 database format.""" + + def __init__(self, base_path: str): + self.base_path = Path(base_path) + self.locks: Dict[str, FileLock] = {} + + def get_org_projects(self) -> List[Dict[str, str]]: + """Get list of all org/project directories.""" + org_projects = [] + for org_dir in self.base_path.iterdir(): + if org_dir.is_dir(): + for project_dir in org_dir.iterdir(): + if project_dir.is_dir(): + org_projects.append( + { + "org_id": org_dir.name, + "project_id": project_dir.name, + "path": str(project_dir), + } + ) + return org_projects + + def get_tables_for_project(self, project_path: str) -> List[Dict[str, str]]: + """Get list of tables for a project.""" + tables = [] + for table_type in ["action", "chat", "knowledge"]: + db_path = Path(project_path) / f"{table_type}.db" + if db_path.exists(): + # Get all .lance directories in the table_type folder + table_dir = db_path.parent / table_type + if table_dir.exists(): + for lance_dir in table_dir.iterdir(): + if lance_dir.is_dir() and lance_dir.suffix == ".lance": + tables.append( + { + "type": table_type, + "sqlite_path": str(db_path), + "lance_path": str(lance_dir), + "table_name": lance_dir.stem, + } + ) + return tables + + def read_table_metadata(self, sqlite_path: str) -> List[Dict[str, Any]]: + """Read table metadata from SQLite database.""" + with sqlite3.connect(sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Get all table metadata + cursor.execute("SELECT * FROM TableMeta") + meta_rows = cursor.fetchall() + if not meta_rows: + return [] + + # Process all metadata rows + metadata_list = [] + for row in meta_rows: + metadata = dict(row) + + # Parse columns if present + if "cols" in metadata and metadata["cols"]: + import json + + # Handle both string and dict formats + if isinstance(metadata["cols"], str): + try: + metadata["cols"] = json.loads(metadata["cols"]) + except json.JSONDecodeError: + logger.warning(f"Failed to parse columns metadata for {sqlite_path}") + metadata["cols"] = [] + # Convert columns to structured format + if isinstance(metadata["cols"], (list, dict)): + if isinstance(metadata["cols"], dict): + metadata["cols"] = [metadata["cols"]] + for col in metadata["cols"]: + col["dtype"] = col.get("dtype", "str") + col["vlen"] = col.get("vlen", 0) + if "gen_config" in col and col["gen_config"]: + if isinstance(col["gen_config"], str): + try: + col["gen_config"] = json.loads(col["gen_config"]) + except json.JSONDecodeError: + col["gen_config"] = {} + + metadata_list.append(metadata) + + return metadata_list + + def _process_state_column(self, value: Any) -> Any: + """Process state column values.""" + if isinstance(value, str): + if value == "": + return {} + try: + return json.loads(value) + except json.JSONDecodeError: + logger.warning(f"Failed to parse state column value: {value}") + return {} + return value + + def read_table_data(self, lance_path: str) -> List[Dict[str, Any]]: + """Read table data from LanceDB.""" + # Connect to parent directory of the .lance folder + db = lancedb.connect(str(Path(lance_path).parent)) + # Open table using the directory name + table_name = Path(lance_path).stem + data = db.open_table(table_name).to_pandas().to_dict("records") + + # Process state columns + for row in data: + for col_name in list(row.keys()): + if col_name.endswith("_"): + row[col_name] = self._process_state_column(row[col_name]) + return data + + def lock_table(self, table_path: str) -> FileLock: + """Acquire a file lock for the table.""" + lock_path = f"{table_path}.lock" + self.locks[lock_path] = FileLock(lock_path) + self.locks[lock_path].acquire() + return self.locks[lock_path] + + def release_table_lock(self, table_path: str) -> None: + """Release the file lock for the table.""" + lock_path = f"{table_path}.lock" + if lock_path in self.locks: + self.locks[lock_path].release() + del self.locks[lock_path] + + +class V2Migrator: + """Class to handle v2 migration using GenerativeTableCore.""" + + def __init__(self, migrate: bool = False): + self.v1_conn = None + self.migrate = migrate + + # Mapping between v1 and v2 ColumnDtype values + _DTYPE_MAPPING = { + "int": "INTEGER", + "int8": "INTEGER", + "float": "FLOAT", + "float32": "FLOAT", + "float16": "FLOAT", + "bool": "BOOL", + "str": "TEXT", + "date-time": "TIMESTAMPTZ", + "image": "TEXT", + "audio": "TEXT", + "document": "TEXT", + } + + def _map_dtype(self, dtype: str) -> str: + """Map v1 dtype to v2 ColumnDtype.""" + dtype = dtype.lower() + return self._DTYPE_MAPPING.get(dtype, "TEXT") + + async def connect(self): + """Connect to SQLite database""" + self.v1_conn = sqlite3.connect(":memory:") # Will attach v1 databases + + async def close(self): + """Close database connections""" + if self.v1_conn: + self.v1_conn.close() + + async def migrate_table( + self, + proj_id: str, + table_type: TableType, + table_name: TableName, + metadata_list: List[Dict[str, Any]], + data: List[Dict[str, Any]], + ): + """Migrate a single table""" + logger.info(f"Validating table {table_name} for migration") + + # Validate metadata + if not metadata_list: + logger.warning(f"No metadata found for table {table_name}") + return + + # Find metadata for this specific table + metadata = next((m for m in metadata_list if m.get("id") == table_name), None) + if not metadata: + logger.warning(f"No matching metadata found for table {table_name}") + return + + # Log migration details + logger.info(f"Table {table_name} would be migrated with:") + if data: + logger.info(f"- {len(data)} rows") + logger.info(f"- Columns: {list(data[0].keys())}") + else: + logger.info("- Empty table (0 rows)") + + # Skip actual migration unless --migrate is specified + if not self.migrate: + logger.info(f"Dry-run mode: Table {table_name} would be migrated") + return + + # Create PostgreSQL schema and metadata tables + schema_id = f"{proj_id}_{table_type}" + # clean up before migration + await GenerativeTableCore.drop_schema(proj_id, table_type) + await GenerativeTableCore.create_schema(proj_id, table_type) + await GenerativeTableCore.create_metadata_tables(schema_id) + + # System columns that are handled automatically + SYSTEM_COLUMNS = ["ID", "Updated at"] + # TODO: Are these columns really migrated? There seems to be a mismatch between the data model and this + + # Create PostgreSQL table + columns = [] + if metadata.get("cols"): + # Use column metadata from v1 if available + col_order_counter = 1 # Initialize the counter + for col in metadata["cols"]: + if col["id"] not in SYSTEM_COLUMNS and not col["id"].endswith("_"): + columns.append( + ColumnMetadata( + column_id=ColName(col["id"]), + table_id=table_name, + dtype=ColumnDtype.FLOAT + if col.get("vlen") + else ColumnDtype(self._map_dtype(col.get("dtype", "str"))), + vlen=col.get("vlen"), + gen_config=col.get("gen_config"), + column_order=col_order_counter, # Use the counter here + ) + ) + col_order_counter += 1 # Increment the counter only when the condition is True + else: + raise ValueError("No column metadata found for table") + # elif data: + # # Fallback to creating metadata from data if no v1 metadata + # columns = [ + # ColumnMetadataCreate( + # column_id=ColName(col), + # table_id=table_name, + # dtype=ColumnDtype.STR, # Default to STR if no type info + # vlen=None, + # gen_config=None, + # column_order=idx + 1 + # ) + # for idx, col in enumerate(data[0].keys()) + # if col not in SYSTEM_COLUMNS + # ] + logger.info(f"Creating table {table_name} with {[c.column_id for c in columns]} columns") + table = await GenerativeTableCore.create_data_table( + project_id=proj_id, + table_id=table_name, + table_type=table_type, + table_metadata=TableMetadata( + table_id=table_name, + title=metadata.get("title", ""), + parent_id=metadata.get("parent_id", ""), + ), + column_metadata_list=columns, + ) + + # Migrate data if present + if data: + await table.add_rows(data_list=data) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Migrate data from v1 to v2 database format") + parser.add_argument("-i", "--input", required=True, help="Path to v1 database directory") + parser.add_argument( + "--org-project", help="Specific org_id/project_id to migrate (format: org_id/project_id)" + ) + parser.add_argument("--org-id", help="Specific org_id to migrate") + parser.add_argument("--project-id", help="Specific project_id to migrate") + parser.add_argument( + "--migrate", + action="store_true", + help="Actually perform the migration (default is dry-run)", + ) + return parser.parse_args() + + +async def main(): + args = parse_args() + + # Initialize reader and migrator + reader = V1DatabaseReader(args.input) + migrator = V2Migrator(args.migrate) + + try: + await migrator.connect() + + # Get org/project directories to process + org_projects = reader.get_org_projects() + + # Filter for specific org/project if specified + if args.org_project: + org_id, project_id = args.org_project.split("/") + org_projects = [ + p for p in org_projects if p["org_id"] == org_id and p["project_id"] == project_id + ] + if not org_projects: + logger.error(f"Could not find org/project: {args.org_project}") + return + else: + # Filter by org_id if specified + if args.org_id: + org_projects = [p for p in org_projects if p["org_id"] == args.org_id] + if not org_projects: + logger.error(f"Could not find org: {args.org_id}") + return + + # Filter by project_id if specified + if args.project_id: + org_projects = [p for p in org_projects if p["project_id"] == args.project_id] + if not org_projects: + logger.error(f"Could not find project: {args.project_id}") + return + + # Process each project + for project in org_projects: + logger.info(f"Processing project: {project['project_id']}") + + # Get tables for project + tables = reader.get_tables_for_project(project["path"]) + + # Process each table + for table in tables: + logger.info( + f"Processing table: {table['type']}, sqlite: {table['sqlite_path']}, lance: {table['lance_path']}" + ) + try: + # Acquire lock + reader.lock_table(table["lance_path"]) + + # Read metadata and data + metadata = reader.read_table_metadata(table["sqlite_path"]) + data = reader.read_table_data(table["lance_path"]) + + # Migrate table + await migrator.migrate_table( + project["project_id"], + TableType(table["type"]), + TableName(table["table_name"]), + metadata, + data, + ) + if args.migrate: + logger.info(f"Migrated table: {table['type']} with {len(data)} rows") + + except Exception as e: + logger.error(f"Error processing table {table['type']}: {str(e)}") + raise e + finally: + # Release lock and log + reader.release_table_lock(table["lance_path"]) + logger.debug(f"Released lock for table: {table['type']}") + + finally: + await migrator.close() + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/scripts/migration_s3_v1_to_v2.py b/scripts/migration_s3_v1_to_v2.py new file mode 100644 index 0000000..240d6b9 --- /dev/null +++ b/scripts/migration_s3_v1_to_v2.py @@ -0,0 +1,486 @@ +import concurrent.futures +import math +import os +import sys +import time + +import boto3 +from botocore.exceptions import ClientError, NoCredentialsError +from dotenv import load_dotenv +from loguru import logger + + +def logger_config(max_workers: int = 10): + logger.remove() + logger.add( + sys.stderr, + level="INFO", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + ) + logger.add( + f"s3_migration_{max_workers}.log", + level="INFO", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + rotation="5 MB", + enqueue=True, + backtrace=True, + diagnose=True, + ) + logger.info("Logger configured. Starting S3 migration script...") + + +def get_s3_client(endpoint, access_key, secret_key): + try: + client = boto3.client( + "s3", + endpoint_url=f"http://{endpoint}", + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + ) + client.list_buckets() + logger.info(f"Successfully connected to MinIO at {endpoint}") + return client + except (NoCredentialsError, ClientError) as e: + logger.error(f"Failed to connect to MinIO at {endpoint}. Error: {e}") + return None + + +def get_all_organization_ids(s3_client, bucket_name: str) -> list[str]: + org_ids = set() + prefix_to_scan = "raw/" + logger.info(f"Discovering all organization IDs in s3://{bucket_name}/{prefix_to_scan}...") + try: + paginator = s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=bucket_name, Prefix=prefix_to_scan, Delimiter="/") + for page in pages: + if "CommonPrefixes" in page: + for common_prefix in page["CommonPrefixes"]: + parts = common_prefix.get("Prefix", "").strip("/").split("/") + if len(parts) > 1: + org_ids.add(parts[1]) + except ClientError as e: + logger.error(f"Failed to scan for organization IDs in s3://{bucket_name}/. Error: {e}") + return [] + found_ids = list(org_ids) + if found_ids: + logger.info(f"Found {len(found_ids)} organization IDs: {found_ids}") + else: + logger.warning(f"No organization IDs found under the '{prefix_to_scan}' prefix.") + return found_ids + + +def format_bytes(size_bytes: int) -> str: + """Converts a size in bytes to a human-readable format (KB, MB, GB, etc.).""" + if size_bytes == 0: + return "0B" + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_name[i]}" + + +def get_organization_storage_size( + s3_client, bucket_name: str, organization_id: str, log_summary: bool = True +) -> tuple[int, int]: + """ + Calculates the total number of files and storage size for a specific organization. + The `log_summary` parameter controls if the function prints its own summary. + """ + if log_summary: + logger.info( + f"Calculating storage size for organization '{organization_id}' in bucket '{bucket_name}'..." + ) + + total_bytes, total_files = 0, 0 + prefixes_to_scan = [f"raw/{organization_id}/", f"thumb/{organization_id}/"] + + try: + for prefix in prefixes_to_scan: + paginator = s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket_name, Prefix=prefix): + for obj in page.get("Contents", []): + total_bytes += obj["Size"] + total_files += 1 + + if log_summary: + readable_size = format_bytes(total_bytes) + logger.info("=" * 40) + logger.info(f"Storage Summary for Organization: '{organization_id}'") + logger.info(f" Total Files: {total_files:,}") + logger.info(f" Total Size: {readable_size} ({total_bytes:,} bytes)") + logger.info("=" * 40) + + return total_bytes, total_files + except ClientError as e: + logger.error(f"Could not calculate storage for org '{organization_id}'. Error: {e}") + return 0, 0 + + +def analyze_all_organizations_storage(s3_client, bucket_name: str): + """ + Analyzes storage for all organizations, then logs a sorted report. + """ + logger.info(f"Starting storage analysis for all organizations in bucket '{bucket_name}'...") + + organization_ids = get_all_organization_ids(s3_client, bucket_name) + if not organization_ids: + logger.warning("No organizations found to analyze.") + return + + storage_data = [] + grand_total_bytes = 0 + grand_total_files = 0 + + analysis_start_time = time.time() + for org_id in organization_ids: + # Call with log_summary=False to prevent noisy individual logs + total_bytes, total_files = get_organization_storage_size( + s3_client, bucket_name, org_id, log_summary=False + ) + if total_files > 0: + storage_data.append( + { + "org_id": org_id, + "total_bytes": total_bytes, + "total_files": total_files, + } + ) + grand_total_bytes += total_bytes + grand_total_files += total_files + + # Sort the collected data by size, from lowest to highest + sorted_storage_data = sorted(storage_data, key=lambda x: x["total_bytes"]) + + analysis_end_time = time.time() + logger.info( + f"Completed storage analysis in {analysis_end_time - analysis_start_time:.2f} seconds." + ) + + # --- Log the formatted report --- + logger.info("=" * 70) + logger.info("Storage Size Report by Organization (Sorted Lowest to Highest)") + logger.info("-" * 70) + logger.info(f"{'Organization ID':<40} | {'Total Files':>12} | {'Total Size':>12}") + logger.info("-" * 70) + + for data in sorted_storage_data: + readable_size = format_bytes(data["total_bytes"]) + # Use f-string alignment and formatting for a clean table + logger.info(f"{data['org_id']:<40} | {data['total_files']:>12,} | {readable_size:>12}") + + logger.info("-" * 70) + readable_grand_total = format_bytes(grand_total_bytes) + logger.info(f"{'GRAND TOTAL':<40} | {grand_total_files:>12,} | {readable_grand_total:>12}") + logger.info("=" * 70) + + +def _copy_single_object( + source_s3_client, dest_s3_client, source_bucket, source_key, dest_bucket, dest_key +): + """ + Worker function executed by each thread. Handles one object. + Returns a status string: "COPIED", "SKIPPED", "FAILED". + """ + source_loc = f"s3://{source_bucket}/{source_key}" + dest_loc = f"s3://{dest_bucket}/{dest_key}" + try: + # 1. Check if the object already exists at the destination + dest_s3_client.head_object(Bucket=dest_bucket, Key=dest_key) + logger.info(f"[SKIP-EXISTING] Destination object already exists: {dest_loc}") + return "SKIPPED" + except ClientError as e: + if e.response["Error"]["Code"] != "404": + logger.error(f"[FAIL] Failed to check destination {dest_loc}: {e}") + return "FAILED" + + # 2. If it doesn't exist, copy it + try: + response = source_s3_client.get_object(Bucket=source_bucket, Key=source_key) + dest_s3_client.put_object( + Bucket=dest_bucket, + Key=dest_key, + Body=response["Body"].read(), + ContentType=response.get("ContentType", "application/octet-stream"), + ) + logger.info(f"[COPIED] {source_loc} -> {dest_loc}") + return "COPIED" + except ClientError as e: + logger.error(f"[FAIL] Failed during copy for {source_loc}: {e}") + return "FAILED" + + +def migrate_s3_structure_across_endpoints( + source_s3_client, + dest_s3_client, + old_organization_id: str, + source_bucket: str, + dest_bucket: str, + new_organization_id: str = None, + max_workers: int = 10, + dry_run: bool = True, +): + """ + Migrates a SINGLE organization's files in parallel, skipping existing files. + """ + if new_organization_id is None: + new_organization_id = old_organization_id + + if dry_run: + logger.info(f"DRY RUN for org '{old_organization_id}'. No changes will be made.") + # Perform a simple listing for the dry run plan + total_planned = 0 + for prefix in [f"raw/{old_organization_id}/", f"thumb/{old_organization_id}/"]: + paginator = source_s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=source_bucket, Prefix=prefix): + for obj in page.get("Contents", []): + logger.info(f"[PLAN-COPY] s3://{source_bucket}/{obj['Key']}") + total_planned += 1 + logger.info( + f"Dry run summary for org '{old_organization_id}': Planned to copy {total_planned} objects." + ) + return total_planned, 0, 0 + + total_copied, total_skipped, total_failed = 0, 0, 0 + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {} + for prefix in [f"raw/{old_organization_id}/", f"thumb/{old_organization_id}/"]: + try: + paginator = source_s3_client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=source_bucket, Prefix=prefix): + for obj in page.get("Contents", []): + source_key = obj["Key"] + dest_key = source_key.replace( + f"/{old_organization_id}/", f"/{new_organization_id}/", 1 + ) + + future = executor.submit( + _copy_single_object, + source_s3_client, + dest_s3_client, + source_bucket, + source_key, + dest_bucket, + dest_key, + ) + futures[future] = source_key + except ClientError as e: + logger.error( + f"Could not list objects in s3://{source_bucket}/{prefix}. Error: {e}" + ) + total_failed += 1 # Count listing itself as a failure + + for future in concurrent.futures.as_completed(futures): + status = future.result() + if status == "COPIED": + total_copied += 1 + elif status == "SKIPPED": + total_skipped += 1 + else: + total_failed += 1 + + logger.info( + f"Summary for org '{old_organization_id}' (Workers: {max_workers}): " + f"Copied={total_copied}, Skipped={total_skipped}, Failed={total_failed}" + ) + return total_copied, total_skipped, total_failed + + +def migrate_all_organizations( + source_s3_client, + dest_s3_client, + source_bucket: str, + dest_bucket: str = None, + max_workers: int = 10, + dry_run: bool = True, +): + """ + Discovers all organization IDs and migrates their files in parallel, logging time per org. + """ + if dest_bucket is None: + dest_bucket = source_bucket + if not dry_run: + try: + dest_s3_client.create_bucket(Bucket=dest_bucket) + logger.info(f"Ensured destination bucket '{dest_bucket}' exists.") + except ClientError as e: + if e.response["Error"]["Code"] not in [ + "BucketAlreadyOwnedByYou", + "BucketAlreadyExists", + ]: + logger.error( + f"Could not create/verify destination bucket '{dest_bucket}'. Aborting. Error: {e}" + ) + return + + organization_ids = get_all_organization_ids(source_s3_client, source_bucket) + if not organization_ids: + logger.warning("No organizations found to migrate. Exiting.") + return + + grand_total_copied, grand_total_skipped, grand_total_failed = 0, 0, 0 + for i, org_id in enumerate(organization_ids): + logger.info("-" * 50) + logger.info( + f"Starting migration for Organization {i + 1}/{len(organization_ids)}: '{org_id}'" + ) + + org_start_time = time.time() + + copied, skipped, failed = migrate_s3_structure_across_endpoints( + source_s3_client=source_s3_client, + dest_s3_client=dest_s3_client, + source_bucket=source_bucket, + dest_bucket=dest_bucket, + old_organization_id=org_id, + new_organization_id="0" if org_id == "org_82d01c923f25d5939b9d4188" else org_id, + max_workers=max_workers, + dry_run=dry_run, + ) + + org_end_time = time.time() + org_time_taken_sec = org_end_time - org_start_time + org_time_taken_min = org_time_taken_sec / 60 + logger.warning( + f"\n'{org_id}' migration completed in {org_time_taken_sec:.3f} seconds ({org_time_taken_min:.3f} minutes)." + ) + + grand_total_copied += copied + grand_total_skipped += skipped + grand_total_failed += failed + + logger.info("=" * 50) + logger.info("BULK MIGRATION COMPLETE") + logger.info(f"Total organizations processed: {len(organization_ids)}") + logger.info(f"Grand total objects copied: {grand_total_copied}") + logger.info(f"Grand total objects skipped (already exist): {grand_total_skipped}") + logger.info(f"Grand total failures: {grand_total_failed}") + if dry_run: + logger.warning("This was a DRY RUN. No actual data was moved.") + logger.info("=" * 50) + + +def setup_dummy_v1_data(s3_client, bucket_name, org_id, project_id, uuid): + try: + s3_client.create_bucket(Bucket=bucket_name) + except ClientError as e: + if e.response["Error"]["Code"] not in ["BucketAlreadyOwnedByYou", "BucketAlreadyExists"]: + raise + raw_key = f"raw/{org_id}/{project_id}/{uuid}/report.pdf" + s3_client.put_object( + Bucket=bucket_name, Key=raw_key, Body=b"pdf content", ContentType="application/pdf" + ) + thumb_key = f"thumb/{org_id}/{project_id}/{uuid}/report.webp" + s3_client.put_object( + Bucket=bucket_name, Key=thumb_key, Body=b"thumbnail", ContentType="image/webp" + ) + logger.info(f" Created dummy data for org '{org_id}' in bucket '{bucket_name}'") + + +if __name__ == "__main__": + script_start_time = time.time() + load_dotenv() + + MAX_WORKERS = int(os.getenv("MIGRATION_MAX_WORKERS", 12)) + logger_config(MAX_WORKERS) + + SOURCE_MINIO_ENDPOINT = os.getenv("SOURCE_MINIO_ENDPOINT", "localhost:9000") + SOURCE_MINIO_ACCESS_KEY = os.getenv("OWL_S3_ACCESS_KEY_ID") + SOURCE_MINIO_SECRET_KEY = os.getenv("OWL_S3_SECRET_ACCESS_KEY") + SOURCE_BUCKET_NAME = os.getenv("SOURCE_BUCKET_NAME", "v1-company-bucket") + + DEST_MINIO_ENDPOINT = os.getenv("DEST_MINIO_ENDPOINT", "localhost:9000") + DEST_MINIO_ACCESS_KEY = os.getenv("OWL_S3_ACCESS_KEY_ID") + DEST_MINIO_SECRET_KEY = os.getenv("OWL_S3_SECRET_ACCESS_KEY") + DEST_BUCKET_NAME = os.getenv("DEST_BUCKET_NAME", "v2-migrated-data") + + logger.info(f"Source Endpoint: {SOURCE_MINIO_ENDPOINT}, Source Bucket: {SOURCE_BUCKET_NAME}") + logger.info( + f"Destination Endpoint: {DEST_MINIO_ENDPOINT}, Destination Bucket: {DEST_BUCKET_NAME}" + ) + logger.info(f"Using a maximum of {MAX_WORKERS} parallel workers.") + + s3_source = get_s3_client( + SOURCE_MINIO_ENDPOINT, SOURCE_MINIO_ACCESS_KEY, SOURCE_MINIO_SECRET_KEY + ) + s3_dest = get_s3_client(DEST_MINIO_ENDPOINT, DEST_MINIO_ACCESS_KEY, DEST_MINIO_SECRET_KEY) + + if not s3_source or not s3_dest: + logger.error("Could not establish connection to MinIO. Exiting.") + sys.exit(1) + + # # --- Setup Dummy Data for Testing --- + # logger.info("\n--- Setting up test data ---") + # setup_dummy_v1_data( + # s3_source, SOURCE_BUCKET_NAME, "org-acme-corp", "proj-q1-reports", "uuid-acme-1" + # ) + # setup_dummy_v1_data( + # s3_source, SOURCE_BUCKET_NAME, "org-acme-corp", "proj-q1-reports", "uuid-acme-2" + # ) + # setup_dummy_v1_data( + # s3_source, SOURCE_BUCKET_NAME, "org-globex-inc", "proj-doomsday", "uuid-globex-1" + # ) + # setup_dummy_v1_data( + # s3_source, SOURCE_BUCKET_NAME, "org-stark-industries", "proj-arc-reactor", "uuid-stark-1" + # ) + + logger.info("\n--- Starting ALL-ORG Migration (Dry Run) ---") + migrate_all_organizations( + source_s3_client=s3_source, + dest_s3_client=s3_dest, + source_bucket=SOURCE_BUCKET_NAME, + dest_bucket=DEST_BUCKET_NAME, + max_workers=MAX_WORKERS, + dry_run=True, + ) + + logger.info("\n--- Starting ALL-ORG Migration (Actual Run) ---") + # This run will copy some files and skip the one that was pre-seeded. + migrate_all_organizations( + source_s3_client=s3_source, + dest_s3_client=s3_dest, + source_bucket=SOURCE_BUCKET_NAME, + dest_bucket=DEST_BUCKET_NAME, + max_workers=MAX_WORKERS, + dry_run=False, + ) + + logger.info("\n--- Re-running Migration to demonstrate idempotency ---") + # This second run should skip all files, as they were all copied in the previous step. + migrate_all_organizations( + source_s3_client=s3_source, + dest_s3_client=s3_dest, + source_bucket=SOURCE_BUCKET_NAME, + dest_bucket=DEST_BUCKET_NAME, + max_workers=MAX_WORKERS, + dry_run=False, + ) + + script_end_time = time.time() + time_taken_min = (script_end_time - script_start_time) / 60 + time_taken_hrs = time_taken_min / 60 + logger.warning( + f"\nScript completed in {time_taken_min:.3f} minutes ({time_taken_hrs:.3f} hours)." + ) + + # source_org_size = get_organization_storage_size( + # s3_client=s3_source, + # bucket_name=SOURCE_BUCKET_NAME, + # organization_id="org_82d01c923f25d5939b9d4188", + # ) + # dest_org_size = get_organization_storage_size( + # s3_client=s3_dest, bucket_name=DEST_BUCKET_NAME, organization_id="0" + # ) + # assert ( + # source_org_size[0] == dest_org_size[0] + # ), f"Source size {source_org_size[0]} does not match destination size {dest_org_size[0]}" + # assert ( + # source_org_size[1] == dest_org_size[1] + # ), f"Source files {source_org_size[1]} do not match destination files {dest_org_size[1]}" + + # logger.info("\n--- Generating Storage Analysis Report for All Organizations ---") + # analyze_all_organizations_storage(s3_client=s3_source, bucket_name=SOURCE_BUCKET_NAME) + + logger.info("\n--- Generating Storage Analysis Report for All Organizations ---") + analyze_all_organizations_storage(s3_client=s3_dest, bucket_name=DEST_BUCKET_NAME) diff --git a/scripts/migration_v030.py b/scripts/migration_v030.py index 4d31a07..d279ada 100644 --- a/scripts/migration_v030.py +++ b/scripts/migration_v030.py @@ -10,7 +10,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict import owl -from jamaibase.protocol import GEN_CONFIG_VAR_PATTERN, ColumnSchema, LLMGenConfig +from jamaibase.types import GEN_CONFIG_VAR_PATTERN, ColumnSchema, LLMGenConfig class EnvConfig(BaseSettings): @@ -53,7 +53,7 @@ def restore(db_dir: str): ) ) src_path = join(proj_dir, bak_files[0]) - dst_path = join(proj_dir, f'{bak_files[0].split("_")[0]}.db') + dst_path = join(proj_dir, f"{bak_files[0].split('_')[0]}.db") os.remove(dst_path) copy2(src_path, dst_path) @@ -121,7 +121,7 @@ def update_gen_table(db_path: str): cols = orjson.loads(record[1]) updated_cols = [] - print(f"└─ (Table {i+1:,d}/{len(records):,d}) Checking table: {table_id}") + 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": @@ -166,7 +166,7 @@ def update_gen_table(db_path: str): 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}") + 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/migration_v040.py b/scripts/migration_v040.py index 2d09e11..776418a 100644 --- a/scripts/migration_v040.py +++ b/scripts/migration_v040.py @@ -10,7 +10,7 @@ from loguru import logger from pydantic_settings import BaseSettings, SettingsConfigDict -from jamaibase.protocol import ColumnSchema +from jamaibase.types import ColumnSchema class EnvConfig(BaseSettings): diff --git a/scripts/remove_cloud_modules.ps1 b/scripts/remove_cloud_modules.ps1 deleted file mode 100644 index 2a86c15..0000000 --- a/scripts/remove_cloud_modules.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -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" -function quiet_rm($item) -{ - if (Test-Path $item) { - echo "Removing $item" - Remove-Item -Force $item - } -} -quiet_rm "services/app/ecosystem.config.cjs" -quiet_rm "services/appecosystem.json" -quiet_rm ".github/workflows/trigger-push-gh-image.yml" -quiet_rm ".github/workflows/ci.cloud.yml" \ No newline at end of file diff --git a/scripts/remove_cloud_modules.sh b/scripts/remove_cloud_modules.sh index 0ef410f..748c54b 100644 --- a/scripts/remove_cloud_modules.sh +++ b/scripts/remove_cloud_modules.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash +rm -rf k8s/ 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 {} + +find . -type f -iname "*cloud*.md" -delete +find . -type f -iname "*cloud*.py" -delete +find . -type f -iname "cloud_*.json" -delete +find . -type f -iname "*_cloud.json" -delete +find . -type f -iname "compose.*.cloud.yml" -delete +find . -type d -iname "*cloud" -exec rm -rf {} + +find . -type d -iname "(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/scripts/update_model_id.py b/scripts/update_model_id.py new file mode 100644 index 0000000..0e6cf66 --- /dev/null +++ b/scripts/update_model_id.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +""" +A script to update model IDs with a clear, subcommand-based interface. + +Usage: + python update.py one-to-one + python update.py many-to-one ... --to + python update.py file +""" + +import asyncio +import json +import pathlib +import sys +from typing import Dict, List, Optional, Tuple + +import typer +from asyncpg import Connection, Record +from loguru import logger + +from owl.db import async_session +from owl.db.gen_table import ( + GENTABLE_ENGINE, + ActionTable, + ChatTable, + KnowledgeTable, + TableType, +) +from owl.db.models import ModelConfig, Organization, Project +from owl.types import ( + CodeGenConfig, + DiscriminatedGenConfig, + EmbedGenConfig, + LLMGenConfig, + OrganizationRead, + ProjectRead, +) + +# Typer app with subcommands +app = typer.Typer( + help="A tool to update model IDs using different modes.", + context_settings={"help_option_names": ["-h", "--help"]}, + no_args_is_help=True, +) + +# Common options that can be used with any subcommand +_DRY_RUN_OPTION = typer.Option( + False, "--dry-run", "-n", help="Show what would be updated without making changes." +) +_ORGANIZATIONS_OPTION = typer.Option( + None, + "--organizations", + "-o", + help="Comma-separated list of specific organization IDs to target.", +) + + +# ------------------------------------------------------------------ +# 1. HELPER FUNCTIONS +# ------------------------------------------------------------------ + + +async def validate_models(mapping: Dict[str, str]) -> bool: + """ + Check that all new models exist + Check that no embedding models. + """ + async with async_session() as session: + # Use a set to check each new ID only once, improving efficiency + for new_id in set(mapping.values()): + new_model = await ModelConfig.get(session, new_id) + if not new_model: + logger.error( + f"Validation failed: New model ID '{new_id}' does not exist in the system." + ) + return False + if "embed" in new_model.capabilities: + logger.error(f"Validation failed: New model ID '{new_id}' is an embedding model.") + return False + for old_id in set(mapping.keys()): + old_model = await ModelConfig.get(session, old_id) + if not old_model: + logger.warning( + f"Old Model ID '{old_id}' does not exist in the system, assumed to be deleted. Skipping validation." + ) + continue + if "embed" in old_model.capabilities: + logger.error(f"Validation failed: Old model ID '{old_id}' is an embedding model.") + return False + logger.success("All new models validated successfully.") + return True + + +async def validate_model_mapping(mapping: Dict[str, str]) -> bool: + """Check that all new models has at least the same capability as old models, and not embedding models.""" + async with async_session() as session: + # Use a set to check each new ID only once, improving efficiency + for old_id, new_id in mapping.items(): + new_model = await ModelConfig.get(session, new_id) + old_model = await ModelConfig.get(session, old_id) + if not new_model: + logger.error( + f"Validation failed: New model ID '{new_id}' does not exist in the system." + ) + return False + if not old_model: + logger.warning( + f"Old Model ID '{old_id}' does not exist in the system, assumed to be deleted. Skipping validation." + ) + continue + if "embed" in new_model.capabilities: + logger.error(f"Validation failed: New model ID '{new_id}' is an embedding model.") + return False + if not [c for c in old_model.capabilities if c in new_model.capabilities]: + logger.error( + f"Validation failed: New model ID '{new_id}' does not have the same capabilities as old model ID '{old_id}'." + ) + return False + logger.success("All new model IDs validated successfully.") + return True + + +# ------------------------------------------------------------------ +# 2. CORE PROCESSING LOGIC +# ------------------------------------------------------------------ + + +class ModelIDUpdater: + """The engine that performs the single-pass update. This is generic and works with any mapping.""" + + def __init__( + self, + model_mapping: Dict[str, str], + dry_run: bool = False, + organization_ids: Optional[List[str]] = None, + ): + self.model_mapping = model_mapping + self.dry_run = dry_run + self.organization_ids = organization_ids + self.updated_count = 0 + + @staticmethod + def _gen_config_model_validate(gen_config_json: dict) -> Optional[DiscriminatedGenConfig]: + obj_type = gen_config_json.get("object") + try: + if obj_type in ("gen_config.llm", "gen_config.chat"): + return LLMGenConfig.model_validate(gen_config_json) + elif obj_type == "gen_config.embed": + return EmbedGenConfig.model_validate(gen_config_json) + elif obj_type == "gen_config.code": + return CodeGenConfig.model_validate(gen_config_json) + except Exception as e: + logger.warning(f"Skipping column due to validation error in its gen_config: {e}") + return None + + async def get_all_organizations( + self, organization_ids: Optional[List[str]] = None + ) -> List[OrganizationRead]: + """Retrieve all organizations from the database, optionally filtered.""" + async with async_session() as session: + if not organization_ids: + orgs = await Organization.list_(session=session, return_type=OrganizationRead) + return orgs.items + + all_organizations = [] + for org_id in organization_ids: + org = await Organization.get(session, org_id) + org = OrganizationRead.model_validate(org) + if org: + all_organizations.append(org) + else: + logger.warning(f"Organization with ID '{org_id}' not found. Skipping.") + return all_organizations + + async def get_all_projects_from_org(self, organization_id: str) -> List[ProjectRead]: + """Retrieve all projects from a specific organization.""" + async with async_session() as session: + projects = await Project.list_( + session=session, + return_type=ProjectRead, + filters=dict(organization_id=organization_id), + ) + return projects.items + + async def get_tables_for_project( + self, conn: Connection, project_id: str, table_type: TableType + ) -> List[Record]: + """Get all tables for a project and table type.""" + schema_id = f"{project_id}_{table_type.value}" + try: + return await conn.fetch(f'SELECT table_id FROM "{schema_id}"."TableMetadata"') + except Exception as e: + logger.warning(f"Could not access schema '{schema_id}': {e}") + return [] + + async def get_columns_with_gen_config( + self, conn: Connection, project_id: str, table_type: TableType, table_id: str + ) -> List[Record]: + """Get all columns with gen_config for a specific table.""" + schema_id = f"{project_id}_{table_type.value}" + return await conn.fetch( + f'SELECT column_id, gen_config FROM "{schema_id}"."ColumnMetadata" ' + "WHERE table_id = $1 AND gen_config IS NOT NULL", + table_id, + ) + + def update_model_id_in_config( + self, gen_config: DiscriminatedGenConfig + ) -> Tuple[bool, DiscriminatedGenConfig]: + """Update model IDs in a gen_config if they match the mapping.""" + updated = False + config = gen_config.model_copy(deep=True) + + if isinstance(config, LLMGenConfig): + if config.model in self.model_mapping: + config.model = self.model_mapping[config.model] + updated = True + if config.rag_params and config.rag_params.reranking_model in self.model_mapping: + config.rag_params.reranking_model = self.model_mapping[ + config.rag_params.reranking_model + ] + updated = True + elif isinstance(config, EmbedGenConfig): + if config.embedding_model in self.model_mapping: + config.embedding_model = self.model_mapping[config.embedding_model] + updated = True + + return updated, config + + @staticmethod + async def get_table_instance( + project_id: str, table_type: TableType, table_id: str + ) -> ActionTable | KnowledgeTable | ChatTable: + table_classes = { + TableType.ACTION: ActionTable, + TableType.KNOWLEDGE: KnowledgeTable, + TableType.CHAT: ChatTable, + } + table_class = table_classes[table_type] + return await table_class.open_table(project_id=project_id, table_id=table_id) + + async def update_column_gen_config( + self, + project_id: str, + table_type: TableType, + table_id: str, + update_mapping: dict[str, DiscriminatedGenConfig], + ): + """Update the gen_config for a specific column in the database.""" + if self.dry_run: + return + + table = await self.get_table_instance(project_id, table_type, table_id) + await table.update_gen_config(update_mapping=update_mapping, allow_nonexistent_refs=True) + + async def process_organization(self, organization: OrganizationRead): + """Iterate through all projects and tables in an organization and update them.""" + logger.info(f"Processing organization: {organization.id}") + projects = await self.get_all_projects_from_org(organization.id) + + for project in projects: + logger.info(f" Processing project: {project.id}") + for table_type in [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT]: + col_updated_count = 0 + async with GENTABLE_ENGINE.transaction() as conn: + tables = await self.get_tables_for_project(conn, project.id, table_type) + if not tables: + continue + + for table in tables: + table_id = table["table_id"] + columns = await self.get_columns_with_gen_config( + conn, project.id, table_type, table_id + ) + to_be_update = {} + for column in columns: + gen_config = self._gen_config_model_validate(column["gen_config"]) + if not gen_config: + continue + was_updated, updated_config = self.update_model_id_in_config( + gen_config + ) + if was_updated: + found_old_model = None + if isinstance(gen_config, LLMGenConfig): + if gen_config.model in self.model_mapping: + found_old_model = gen_config.model + elif ( + gen_config.rag_params + and gen_config.rag_params.reranking_model + in self.model_mapping + ): + found_old_model = gen_config.rag_params.reranking_model + elif isinstance(gen_config, EmbedGenConfig): + if gen_config.embedding_model in self.model_mapping: + found_old_model = gen_config.embedding_model + + log_msg = ( + f" - Found '{found_old_model}' in column '{column['column_id']}' (table: {table_id})" + if found_old_model + else f" - Updating column '{column['column_id']}' in table '{table_id}'" + ) + logger.info(log_msg) + to_be_update[column["column_id"]] = updated_config + if len(to_be_update) > 0: + await self.update_column_gen_config( + project.id, + table_type, + table_id, + to_be_update, + ) + col_updated_count += len(to_be_update) + + if col_updated_count > 0: + self.updated_count += col_updated_count + logger.info( + f" Updated {col_updated_count} columns in {table_type.value} tables for this project." + ) + + async def run(self): + """Run the entire model ID update process.""" + mode = "DRY RUN" if self.dry_run else "UPDATE MODE" + logger.info(f"Starting model ID update in {mode}.") + logger.info( + f"Applying {len(self.model_mapping)} model mapping(s): {json.dumps(self.model_mapping, indent=2)}" + ) + + organizations = await self.get_all_organizations(self.organization_ids) + logger.info(f"Found {len(organizations)} organization(s) to process.") + + for organization in organizations: + await self.process_organization(organization) + + summary = "Dry run complete" if self.dry_run else "Update complete" + logger.success( + f"{summary}. Total columns affected across all organizations: {self.updated_count}" + ) + + +# ------------------------------------------------------------------ +# 3. CLI SUBCOMMANDS +# ------------------------------------------------------------------ + + +@app.command("one-to-one") +def cmd_one( + old_model_id: str = typer.Argument(..., help="The single old model ID to be replaced."), + new_model_id: str = typer.Argument(..., help="The single new model ID to use."), + dry_run: bool = _DRY_RUN_OPTION, + organizations: Optional[str] = _ORGANIZATIONS_OPTION, +): + """Replaces one model ID with another.""" + mapping = {old_model_id: new_model_id} + run_update(mapping, dry_run, organizations) + + +@app.command("many-to-one") +def cmd_many_to_one( + old_model_ids: List[str] = typer.Argument(..., help="A list of old model IDs to be replaced."), # noqa: B008 + new_model_id: str = typer.Option( + ..., "--to", "-t", help="The target model ID that will replace all old models. (Required)" + ), + dry_run: bool = _DRY_RUN_OPTION, + organizations: Optional[str] = _ORGANIZATIONS_OPTION, +): + """Maps many old model IDs to a single new model ID.""" + mapping = {old_id: new_model_id for old_id in old_model_ids} + run_update(mapping, dry_run, organizations) + + +@app.command("file") +def cmd_file( + mapping_file: pathlib.Path = typer.Argument( # noqa: B008 + ..., + help="Path to a JSON file with {old_id: new_id} mappings.", + exists=True, + readable=True, + dir_okay=False, + ), + dry_run: bool = _DRY_RUN_OPTION, + organizations: Optional[str] = _ORGANIZATIONS_OPTION, +): + """Replaces model IDs based on a JSON mapping file.""" + try: + mapping = json.loads(mapping_file.read_text()) + if not isinstance(mapping, dict): + raise TypeError("Mapping file must contain a JSON object (a dictionary).") + except Exception as e: + logger.error(f"Cannot read or parse mapping file '{mapping_file}': {type(e)}") + raise typer.Exit(code=1) from None + run_update(mapping, dry_run, organizations) + + +# ------------------------------------------------------------------ +# 4. SHARED RUNNER +# ------------------------------------------------------------------ + + +def run_update(mapping: Dict[str, str], dry_run: bool, org_string: Optional[str]): + """A central runner that validates and executes the update process.""" + + if not mapping: + logger.warning("The model mapping is empty. Nothing to do.") + return + + organization_ids = org_string.split(",") if org_string else None + + async def _inner(): + # 1. Validate models before doing anything else + if not await validate_models(mapping): + raise typer.Exit(code=1) + + # 2. Create and run the updater + updater = ModelIDUpdater(mapping, dry_run, organization_ids) + await updater.run() + + try: + asyncio.run(_inner()) + except Exception as e: + logger.error(f"An unexpected error occurred during the update process: {type(e)} {str(e)}") + sys.exit(1) + + +# ------------------------------------------------------------------ +# 5. SCRIPT ENTRYPOINT +# ------------------------------------------------------------------ + +if __name__ == "__main__": + app() diff --git a/services/api/Chat.md b/services/api/Chat.md new file mode 100644 index 0000000..b123097 --- /dev/null +++ b/services/api/Chat.md @@ -0,0 +1,71 @@ +# JamAI Chat + +# Chat Message Format + +A user message can include: + +- Text +- Images (zero or more) +- Audio (zero or more) +- Document (zero or more) + +User can also request for RAG to be used so that the model can get context from a Knowledge Table. In this case, the assistant's reply will have references attached. + +## RAG References + +RAG references contain the following data: + +- Search query (text sentence) used to retrieve the chunks/data +- A list of chunks/data, each containing: + - Title: Text sentence + - Text: Text sentences/paragraphs + - Page: Integer or null + - Chunk ID: UUID text-string, can be empty + - Context: + - Any arbitrary number of : + - Metadata: + - Any arbitrary number of : + - Project ID + - Knowledge Table ID (can be used together with Project ID to display a hyperlink to the user) + +For example: + +```json +{ + "search_query": "pet rabbit name", + "chunks": [ + { + "title": "Pet names", + "text": "My rabbit's name is Latte.", + "page": 1, + "chunk_id": "066a8a49-6dcc-764f-8000-a7bfc34f863c", + "context": { + "Colour": "White", + "Weight": "1 kg" + }, + "metadata": { + "bm25-score": 1.5, + "rrf-score": 0.8, + "project_id": "proj_f37ff1cf46aaa453143ca50b", + "table_id": "pet-names" + } + }, + { + "title": "Pet names", + "text": "My deer's name is Daisy.", + "page": null, + "chunk_id": "066a8a49-6dcc-764f-8000-a7bfc34f864c", + "context": { + "Colour": "Brown", + "Weight": "8 kg" + }, + "metadata": { + "bm25-score": 1.95, + "rrf-score": 0.6, + "project_id": "proj_f37ff1cf46aaa453143ca50b", + "table_id": "pet-names" + } + } + ] +} +``` diff --git a/services/api/README.md b/services/api/README.md index 4d73f61..e283eee 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -1,13 +1,76 @@ # JamAI Base API service -## Compiling Executable +## Note for VSCode Users -### Windows +In order for Ruff settings to apply correctly, you must open the repo folder directly via `Open Folder...` instead of as a `Workspace`. Workspace does not work correctly for some unknown reason. -1. Create fresh python environment: `conda create -n jamaiapi python=3.10`. -2. Activate the python environment: `conda activate jamaiapi`. -3. Remove any of the cloud modules in PowerShell: `.\scripts\remove_cloud_modules.ps1`. -4. Install JamAI Base Python SDK: `pip install .\clients\python` -5. Install api service: `cd services\api ; pip install -e .` -6. Install Pyinstaller: `pip install pyinstaller` -7. Create Pyinstaller executable: `pyinstaller api.spec` +## Getting Started + +1. Create an environment `.env` file. You can modify it from the provided `.env.example` file. +2. Start the services using Docker Compose. Depending on your needs, you can choose to either start everything or excluding the API server `owl` (for easier dev work, for example). + + - Launch all services + ```bash + docker compose -p jm --env-file .env -f docker/compose.dev.yml up --quiet-pull + ``` + - Launch all except `owl`, `frontend` + ```bash + docker compose -p jm --env-file .env -f docker/compose.dev.yml up --quiet-pull -d --scale owl=0 --scale frontend=0 + ``` + +3. If you choose to launch `owl` manually, then run these steps to setup your environment + + 1. Create a Python 3.12 environment and install `owl` (here we use [micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html) but you can use other tools such as [conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html), virtualenv, etc): + + ```bash + micromamba create -n jamai312 python=3.12 -y + micromamba activate jamai312 + cd services/api + python -m pip install -e . + ``` + + 2. Uncomment the "Service connection" section of `.env.example` and copy them into your `.env` file. + 3. Start `owl`. + + ```bash + OWL_WORKERS=2 OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="api/health" OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST='X-.*' python -m owl.entrypoints.api + + # Delete existing DB data, start owl + OWL_DB_RESET=1 OWL_WORKERS=2 OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="api/health" OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST='X-.*' python -m owl.entrypoints.api + ``` + +4. To run Stripe tests: + 1. Add Stripe API keys into `.env`: + - `OWL_STRIPE_API_KEY` + - `OWL_STRIPE_PUBLISHABLE_KEY_TEST` + - `OWL_STRIPE_WEBHOOK_SECRET_TEST` + 2. Run Stripe event forwarding `stripe listen --forward-to localhost:6969/api/v2/organizations/webhooks/stripe --api-key ` + + + +> [!TIP] +> - You can launch the Docker services in background mode by appending `-d --wait` +> - You can rebuild the `owl` image by appending `--build --force-recreate owl` + + + +## Backend Dev Tips + +- How to run tests (can refer to `.github/workflows/ci.yml` for more info) + + 1. Launch services via `compose.dev.yml` by following the steps above + 2. `pytest services/api/tests` + + + +> [!TIP] +> - Run all tests except those that require on-prem setup: `pytest services/api/tests -m "not onprem"` +> - Run a specific test or a subset: `pytest services/api/tests -k ` + + + +- How to have your code reflected in the Docker environment + + 1. Launch services via `compose.dev.yml` by following the steps above + 2. Modify backend code + 3. Restart `owl` by issuing: `docker compose -p jm --env-file .env -f docker/compose.ci.yml restart owl` diff --git a/services/api/api.spec b/services/api/api.spec deleted file mode 100644 index c94c036..0000000 --- a/services/api/api.spec +++ /dev/null @@ -1,73 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import glob -from pathlib import Path -from PyInstaller.utils.hooks import collect_all - -binaries_list = [] - -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_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): - datas, binaries, hiddenimports = collect_all(package_name) - datas_list.extend(datas) - binaries_list.extend(binaries) - hiddenimports_list.extend(hiddenimports) - -add_package('litellm') -# add_package('fastapi') - -a = Analysis( - ['src\\owl\\entrypoints\\api.py'], - pathex=[], - binaries=binaries_list, - datas=datas_list, - hiddenimports=hiddenimports_list, - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='api', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - 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 1f45a29..3360db2 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -9,7 +9,7 @@ timeout = 90 log_cli = true asyncio_mode = "auto" # log_cli_level = "DEBUG" -addopts = "--cov=owl --doctest-modules" +addopts = "--doctest-modules -vv -ra --strict-markers --no-flaky-report --durations=200 --durations-min=0.05" testpaths = ["tests"] filterwarnings = [ "ignore::DeprecationWarning:tensorflow.*", @@ -17,6 +17,24 @@ filterwarnings = [ "ignore::DeprecationWarning:matplotlib.*", "ignore::DeprecationWarning:flatbuffers.*", ] +markers = [ + "oss: Cloud-only tests", + "cloud: Cloud-only tests", + "stripe: Stripe tests", +] + +# ----------------------------------------------------------------------------- +# Coverage configuration +# https://coverage.readthedocs.io/en + +[tool.coverage.run] +source = ["owl"] +relative_files = true +concurrency = ["multiprocessing", "thread", "greenlet"] +parallel = true + +[tool.coverage.paths] +source = ["src", "api/src"] # ----------------------------------------------------------------------------- # Ruff configuration @@ -57,7 +75,7 @@ unfixable = ["B"] "**/{tests,docs,tools}/*" = ["E402"] [tool.ruff.lint.isort] -known-first-party = ["jamaibase", "owl", "docio"] +known-first-party = ["jamaibase", "owl"] [tool.ruff.lint.flake8-bugbear] # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. @@ -74,91 +92,140 @@ extend-immutable-calls = [ # https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html [build-system] -# setuptools-scm considers all files tracked by git to be data files -requires = ["setuptools>=62.0", "setuptools-scm"] +requires = ["setuptools>=62.0"] build-backend = "setuptools.build_meta" [project] name = "owl" description = "Owl: API server for JamAI Base." readme = "README.md" -requires-python = "~=3.10" +requires-python = "~=3.12.0" # keywords = ["one", "two"] -license = { text = "Apache 2.0" } +license = "Apache-2.0" classifiers = [ # https://pypi.org/classifiers/ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Information Technology", "Operating System :: Unix", ] +# Sort your dependencies https://sortmylist.com/ +# In general, for v1 and above, we pin to minor version using ~= dependencies = [ "aioboto3~=7.0.0", - "aiobotocore~=2.15.0", + "aiobotocore~=2.21.0", # Took long time to resolve "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", + "aiosqlite~=0.21.0", + "async-lru~=2.0.0", + "asyncpg~=0.30.0", + "authlib~=1.6.0", + "bm25s~=0.2.0", + "boto3==1.37.1", # Took long time to resolve + "celery~=5.5.0", + "clickhouse-connect~=0.8.0", + "cloudevents~=1.12.0", + "coredis~=4.24.0", + "coverage~=7.10.0", + "cryptography", + "duckdb~=1.3.0", + "email-validator~=2.2.0", + "fastapi[standard]~=0.115.0", + "flower~=2.0.0", "gunicorn~=22.0.0", "httpx~=0.27.0", "itsdangerous~=2.2.0", - "jamaibase>=0.2.1", - # lancedb 0.9.0 has issues with row deletion + "jamaibase>=0.4.1", "lancedb==0.12.0", - "langchain-community~=0.2.12", - "langchain~=0.2.14", - "litellm~=1.50.0", - "loguru~=0.7.2", - "natsort[fast]>=8.4.0", - "numpy>=1.26.4", - "openai>=1.51.0", - "openmeter~=1.0.0b89", - "orjson~=3.10.7", - "pandas~=2.2", - "Pillow~=10.4.0", + "langchain~=0.2.0", + "limits~=3.14.0", + "litellm~=1.75.0", + "loguru~=0.7.0", + "natsort[fast]~=8.4.0", + "nltk~=3.9.0", + "numpy>=1.26.0", + "openai~=1.99.0", + "opentelemetry-api~=1.36.0", + "opentelemetry-distro~=0.57b0", + "opentelemetry-exporter-otlp~=1.36.0", + "opentelemetry-instrumentation-aiohttp-client~=0.57b0", + "opentelemetry-instrumentation-aiohttp-server~=0.57b0", + "opentelemetry-instrumentation-asgi~=0.57b0", + "opentelemetry-instrumentation-asyncio~=0.57b0", + "opentelemetry-instrumentation-boto3sqs~=0.57b0", + "opentelemetry-instrumentation-botocore~=0.57b0", + "opentelemetry-instrumentation-celery~=0.57b0", + "opentelemetry-instrumentation-dbapi~=0.57b0", + "opentelemetry-instrumentation-fastapi~=0.57b0", + "opentelemetry-instrumentation-grpc~=0.57b0", + "opentelemetry-instrumentation-httpx~=0.57b0", + "opentelemetry-instrumentation-jinja2~=0.57b0", + "opentelemetry-instrumentation-logging~=0.57b0", + "opentelemetry-instrumentation-redis~=0.57b0", + "opentelemetry-instrumentation-requests~=0.57b0", + "opentelemetry-instrumentation-sqlalchemy~=0.57b0", + "opentelemetry-instrumentation-sqlite3~=0.57b0", + "opentelemetry-instrumentation-starlette~=0.57b0", + "opentelemetry-instrumentation-system-metrics~=0.57b0", + "opentelemetry-instrumentation-threading~=0.57b0", + "opentelemetry-instrumentation-tornado~=0.57b0", + "opentelemetry-instrumentation-tortoiseorm~=0.57b0", + "opentelemetry-instrumentation-urllib3~=0.57b0", + "opentelemetry-instrumentation-urllib~=0.57b0", + "opentelemetry-instrumentation-wsgi~=0.57b0", + "opentelemetry-sdk~=1.36.0", + "orjson~=3.11.0", + "pandas~=2.3.0", + "pdf2image~=1.17.0", + "pgvector~=0.3.0", + "Pillow~=11.3.0", + "pottery~=3.0.0", + "prometheus-api-client~=0.5.0", + "psutil~=7.0.0", + "psycopg[binary]~=3.2.0", + "pwdlib[argon2]>=0.2.0", "pyarrow~=17.0.0", - "pycryptodomex~=3.20.0", - "pydantic-settings~=2.4.0", - "pydantic[email,timezone]~=2.8.2", - "pydub~=0.25.1", - "pyjwt~=2.9.0", - # pylance 0.13.0 has issues with row deletion + "pycountry~=24.6.0", + "pycryptodomex~=3.23.0", + "pydantic-extra-types~=2.10.0", + "pydantic-settings~=2.10.0", + "pydantic[email,timezone]~=2.11.0", + "pydub~=0.25.0", + "pyjwt~=2.10.0", "pylance==0.16.0", - "python-multipart~=0.0.9", - "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.41.3", + "python-multipart~=0.0.20", + "redis[hiredis]~=5.3.0", + "SQLAlchemy~=2.0.0", + "sqlmodel~=0.0.20", + "sqlparse~=0.5.0", + "starlette~=0.41.0", "stripe~=9.12.0", + "tabulate~=0.9.0", "tantivy~=0.22.0", "tenacity~=8.5.0", "tiktoken~=0.7.0", - "toml~=0.10.2", - "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", + "toml~=0.10.0", + "tqdm~=4.67.0", + "typer~=0.17.0", + "typing_extensions~=4.14.0", "uuid-utils~=0.9.0", "uuid7~=0.1.0", # 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/ + "uvicorn[standard]~=0.28.0", + "xmltodict~=0.14.0", +] dynamic = ["version"] [project.optional-dependencies] -lint = ["ruff~=0.6.1"] +lint = ["ruff~=0.12.9"] test = [ - "flaky~=3.8.1", - "mypy~=1.11.1", + "flaky~=3.8", + "freezegun~=1.5", + "junitparser", + "locust~=2.36", + "mcp[cli]~=1.12", + "mypy~=1.11", "pytest-asyncio>=0.23.8", - "pytest-cov~=5.0.0", - "pytest-timeout>=2.3.1", - "pytest~=8.3.2", + "pytest-timeout~=2.3", + "pytest~=8.3", ] docs = [ "furo~=2024.8.6", # Sphinx theme (nice looking, with dark mode) @@ -186,4 +253,4 @@ version = { attr = "owl.version.__version__" } where = ["src"] [tool.setuptools.package-data] -owl = ["**/*.json", "**/*.parquet"] +owl = ["**/*.json", "**/*.parquet", "**/*.ttf"] diff --git a/services/api/scripts/recreate_template.py b/services/api/scripts/recreate_template.py new file mode 100644 index 0000000..df990e0 --- /dev/null +++ b/services/api/scripts/recreate_template.py @@ -0,0 +1,83 @@ +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import NullPool +from sqlmodel import Session, create_engine, delete, select, text + +from owl.db.models import ( + Organization, + OrgMember, + Project, + ProjectMember, +) + + +@contextmanager +def sync_session() -> Generator[Session, None, None]: + engine = create_engine( + "postgresql+psycopg://:@/jamaibase_owl", + poolclass=NullPool, + ) + with Session(engine) as session: + yield session + + +def main(): + template_id = "template" + template_owner = "github|16820751" + with sync_session() as sess: + # Re-assign owner + org = sess.get(Organization, template_id) + org.owner = template_owner + org.created_by = template_owner + sess.add(org) + sess.commit() + # Re-build template membership + sess.exec(delete(OrgMember).where(OrgMember.organization_id == template_id)) + sys_members = sess.exec(select(OrgMember).where(OrgMember.organization_id == "0")).all() + for m in sys_members: + sess.add(OrgMember(user_id=m.user_id, organization_id=template_id, role=m.role)) + sess.commit() + + # Get list of orphaned Gen Table schemas + orphaned = sess.exec( + text(""" + SELECT + s.schema_name + FROM + information_schema.schemata s + WHERE + ( + s.schema_name LIKE 'proj_%_action' OR + s.schema_name LIKE 'proj_%_knowledge' OR + s.schema_name LIKE 'proj_%_chat' + ) + AND NOT EXISTS ( + -- Check if a project exists with an ID matching the extracted identifier + SELECT 1 + FROM jamai."Project" p + WHERE p.id = substring(s.schema_name from '(proj_[^_]+)_') + ) + ORDER BY + s.schema_name; + """) + ).all() + project_ids = list({"_".join(o[0].split("_")[:2]) for o in orphaned}) + # Re-create projects + for project_id in project_ids: + sess.add( + Project( + id=project_id, + name=project_id, + organization_id=template_id, + created_by=template_owner, + owner=template_owner, + ) + ) + sess.commit() + for m in sys_members: + sess.add(ProjectMember(user_id=m.user_id, project_id=project_id, role=m.role)) + sess.commit() + + +main() diff --git a/services/api/src/owl/__init__.py b/services/api/src/owl/__init__.py index 8c77e2c..ff2110e 100644 --- a/services/api/src/owl/__init__.py +++ b/services/api/src/owl/__init__.py @@ -1,6 +1,3 @@ -from loguru import logger - from owl.version import __version__ -logger.disable("owl") __all__ = ["__version__"] diff --git a/services/api/src/owl/assets/Roboto-Regular.ttf b/services/api/src/owl/assets/Roboto-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7e3bb2f8ce7ae5b69e9f32c1481a06f16ebcfe71 GIT binary patch literal 146004 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�egE!tu*v~K}gWeK5h%eT<6pQ9}X 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�PJ}HG?Q&#wR5zLv9qnRi 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 Gfd2w|Hu^&V literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/html.webp b/services/api/src/owl/assets/icons/html.webp new file mode 100644 index 0000000000000000000000000000000000000000..c7a9e8df42c15a99b30779447c7eca00e14770e9 GIT binary patch literal 7194 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%0DHVadg zaIF*0kA8+caFL#J1SZ6I5+blQOmglHdsleHb~uQ>hCwjfXm-D2jP)a0m|~zFwK+@y zqh-ib)t0Xz4Evg<#NFt1A9Wu~@Dt{V$N^gGa3`XPC(gVcuomy2vNAf6?n9YND7Oab zAQEe8Fo2%h$^1Ueb>(w%z)8Bm!tLct+#P$55MlmFi@5}6uPEJC1o-AIx9P(t8*Qs^ zv3)|GDEWizvb6I(#_kmot>+xS3k_{6hxQp@D)|j^V7<_TY<3f~7fyAjQfGahz<@RP zxzX{YK_SZXJc66f&skn7peEy|_QvGW_>RtT=35&;h?4l%5h>?joeKp4h6}og6xONT zau)H4B+cdvlrTp5h{osIV>n-prsVlNXEHlUhBGDT<~~dwWdMhXtWgUaKRHbhHD0I{ zr7Rx7yi?>#Ui7$4UfRx07~JD+=@n@JUpC#s{F2!11ux7`L|WA)moF9UVNXWnj*Lf# zwm&>AT9c`W!WPtLurPEp-wQA@PH14T{X`e5P&t3@>xJvlPe8x0Ay{ftp3)Rl_D-X2 z;Bo&0slAyhSDI^L*BR=3x&1R1vfc>MB(gU#McPigac!MFwb>2eaWmD&`*|_v83zsl=0HQH0dfxh-zt&%Oj`kezvf?uMimBQ z7l|ZGyYNWZG&a(5y&Tq_ntO)gi9UzMrcX@1Fm`T`#v1kwv7|wo^GHP!u#xYK($tl2 zOasDo0(gAn)Sqe(IL@k-a)+E?4Kp5H>?a#5Xz-XaeZN%&nb>wN3m+*N-w1pQAgKeO zncAoU{BZ%~^`36v4~`^kp;ql8vDZVMkN1c~0B&*GY@{$qVPP@}AZ)T(ewXSxII6fQ zgEsogW8+;%o4wqTir)8tbAV^G?IRxZ+Y^x@l)=<5iVyUOsq@RoAq}Gy2%+9(KU|l+ul<`po&czbX=hzG3 z6Rnr_vG!UmF|)=oukv$Gz>E*?z-q2Z_s2YxF#i#W2MP?-H^V+JTpPFgV2&}*R(zB`d+<4Or|WqRqnL(~Yw=TG zdWo>eJC1&GNv*0$Gz4OOM>eNGK~<{f%J`>Dh$z55rmiW`_U1G ziz~~S%e>bLsQtCbV5=%8UCsT>^aV5k@fs(Wpbzd(R;GU18&_LaNIXKB)(3T-wZo;O f-uB%~Y5(2BAN7GgsI~l4?Vo&H_tRZZP1V$BDJv+%(*gkc@*qt;O|kdb004mSc_EPgnQv4z zl?2fM0Hj>B)keEjgS{-@9P`yi$NxKr%Pn6&i!|bU(UUIK;)9D{BCs&++QyFeeqKye zXN8St-f@sfq9ZC-j|Qs4^wN%}KZ`VuubV?rE%N3tbX>!)D5-74`hSE41~<#(q?0lD;w_64*>P^b1#tF8K6)>RtdKtS& z(J8(e(wJ>Wny$z_4qdZ&1=H7gEIrmQq=Kf++ombZYMirzUM>!hIh)omBwi{ml?UX@ z5*AB@@=?9G3eh0+G<3?-CbX6accQAiECLgXaBFzs_Q?583m1eQpX2q&bV*>CWzXQA znAT6QUusb9Sz<6bT;zrm7T@*AQ@)s8R>bU8^(5}$jj=cBu}${)Q}S};Iu^9FBcov^ zH2kg#^nM2w%!KUJOdLD6aam|ydyei`CnlW7lCCTc-&<_$GO-MRSk&W zP&;4M49IJF@T@53_v7qhH=d_hpegCB(X+V<9pL990%KH5X)bnzLQ9P>T-5F>L z2B==7OMA>>9WZ*rZcjANeDo9%-kKQUDGpL^yH?b7{@T+zR6eeHN!A+qyrZ(f$RZGr z1_jJFZwW{ND?{^2LA)@@+e}G22>^!WBNiSfxZ4vuoY!kZ^+l)e*0K5#(2Ju40FACu zwT)7Dwb4lh>(czizyik`+uz=cP@$idzdbFe*+94BM|^l?%~v_<3LW;f5#`wwGmQ^HUb=j^ zWgmQ&n^H1~XPPb}Y{n(TSkFS=uGv4}GbP;QZ0d~b5e&E6UzU!9?8Z+O(WJD_CiTji zp8(g>Y7~@?$xO;6W}<1Rp`<{UTnb$f(d}@O{%TpUuy^1%A44)tr_-yx)_Vb6r(U#dtjkR>1RZ z$|yns0GxgWWFc__kO9cjT%=*LOnF!iI=Dz6gAbjU5a4iBGD=qbWw&YP8p>YSvqFy|6Zb7s&{JPKhK3PZ*xP@PecE`j%)tojqe+q z(lWsB2u(QRE(MYCSM3kdL(>BTqRSd`27v?Ho)C{@Pos#x2qO4%pud85IadfcI0teQ z5GVxzLJ*-3m$e7}e&+D&~{HZFj} zekcgKr-G*qc!@Q56=)7wIC(;vE|)8E+;jLM1ZRjr4Ds|qtL>uuo5}46xcbWO&bV9u zIM^;IVwOxsYF}*bWYu!c{{14(9~S8I*s&l;zG={qCyVawDDbTbd3=O4L`EuL{g5ik zg?B~!R3$+n$5Puo(+1e@wmBD-$1P7R2$5#fn2dA+kKu%=b7_ruK6^>IL(53*t`$O= z*wZof{iG`Hi3?SR5-)z~JluX~eYjMu`%nIe!#QR^Z82$>awsYxx)ROyr1zr9oR;J_^P4~rR;&y7JvV}On zhjik$Y*q6*w?Z)ab37#dV+;DWDnwM6985-|M+oT4l>EA*4fI>U_~?1NVa&t@{|F0{ zFzX(jaG^Y?q`QrKk(b>QTG^-!=ls2xRMrhOJ~xOQ;U>wuX={Bn%l>pBVFl_Ok*<-%!xbg^n+E?=+to*mJg z-r>EQP~N?@y?`W^qFo^2yEy}}VvjFmW2)er9%Bu4N`s+?q>dj3-tl_!bif958jYt4 z1e3(rWUS`+mZ)nr{^`j00K5YSw}M5+6t6xhEL&3DbrnfFih1I(s+{JQB)q4})2<`V zGR&@@)&h27H=(buwWjqFG^&4<{Y#knS4XiBiA(JL8+-Y-Bu=C>=S{^D$8T{B6~9Z~>u+Npf~*&wS=i?1-%Ls;EySi&pz#k!qY2yI zPwMQUS%7q&navynoG{-sKw|z!lpcCM!euGJIqUmoXY2mQGcl?CpSfyE5R{yJ#E|<* zp}|+x`UR}+qYLjGRxux1yv68}i@(-C%zyTe-a>GMaf8My@%f0VV^Llw8Tg4Q|7UXt z&q5ytctWh8GCYCHL=o5KV8qhx4J<^N#V?lxBOeF&4XkMkTfhn>`*b5<_nc!|+sdT8EfNr#ySPKPKHI$9K4t7@_$AtDa3y0T7kfl_%&F0-{K z*h}ra#2-sLq9RKPFle`UZZ4QTQvZGz?YduWX4qM#IPIgkVW`X6cIx}hF*Hk5h+nw& zmQKxT^)zTs{{uq}$`NV#SXUxC)e0z z&P3YJrMESpk~{AwTw!T z!o{T)!bQ6E%B5B6GJ&Fg8Kp8>Sq0W;1}?W}Gqg+P$4zYeb=40QUk!vWJ8nA9Pax|B z;s2*Dc6F+le|x;#0ABy6(u}gLnnV5E1neYHjS@7_2lD5It8lgnnYA7?Thv)12T~4C0C?=2yJVvp4=S9}fxWkk20-w=h zWY)DC)qGV)&%|fqn#C)fUM+gykK1a-YGjguP>yb88BwKtNg=aLz6A2XQ^*@Gj#Lfi zC&YQ$W}?5Wbu+Kh447S*y4Pq3IEJETs4~145>kd%Dcu6Es4O9+h~em6VoRsQv-Wsw zUzzOb*V*{A!JWuVK|+?ity6j7+^=1j;%0uZ<4&rif071v`^=ii1xwtyV$=C9B+cg@ ziQu0EWD`ZSQ(6)23e+}w`>31WX3fHnfy14zs=H$X!^p#<8$~^BQ(@4S?0MvQv^C=` z;Us6rDrAJ{lv1rK`!9ViTmsgY3_s0Ydns%t#gia~65_KWnF~dw-%}&szFQ;iBnpJ3 z?TWiU4wee( zmC|k3c;7LjuIx^QpCl&*`jvpA@d#6lpZ;y+qHgRX^WDGIFamDq1<<4)Q>8y3hqRvX zxw+F+o;~#*nFLF0!!0wHjYL2H;M`A6d}2Nj0o1X{V@Pr~WL;vTCoXLb7itu8zc2{M z-8=|A5R$?ywEW|P(Jh;+!sFnm4*peR9`E#r|3UCr`%A5phTWw!^lJ>#5zDOD_p1Gs zh8M`)=LaT+CUxV+jz!CuuQ?hk(J-;~0#pV8>d0|~f4$!*P{8E_UBi9u_nvII3rybu z8BBeu{lgBJvxjxT@?Uns*w1ds)q-DF&rf!~gX{G&{)qY6b3>&02CZl zBavr8JH1l(-x65L#`x88VrLWPM+!53;}XQow={m7aZFl1ak_$p7~5v^ZVq*^e+}Nb z>(W-5)|2|s5Nt2BVvoR6*^&-wS+6OK&2j;IrVfVPQ4>)!fo3&R0LQ1mHGhTP+lqb= zI3d}saD){NL=T$q-N7kqQ4M_$?&@YWJPi$B%l{qeKcw=l$(%u_C^c0+JuI_h8!`l& zW1|yIdM9izWsHg39dKzy{b&dZl9 zhtzO*8mT$I;ui*p^9j#+*wl92)UYJYVGk&qIr> zU`?_);}c1)LHzdVHv{Kg!N(A{TeYo9(%g4$BLW|3e@fGZ%Ohv7#+OZN+7$oCu71_>7}jJl}O~ zY5$SJ;B|!@dRno;xnAqqS5aal;!Z{p#w~YIw!guw;+wCy(LL>ozbAg% z4LH&ClM*ULtF^t9eslNE1Z|sy7}7SYoTmiA4I1XzQ=Dvj1f>)+rhzjl_ak!(d<5Tu zBdJH_25(L=mRa*d*yyYr|18)$om;z!L4KAZt%$?2%~wRyv+8u6z8oS|Ge8^3Ef(A%iCN0as(%=wN2=93;*e$6@|lGO>OtfUJpw+SEtujso8eHN z=Q+OBi@Fp&dzrF4-OoVF}8Sp)bq)`k{~x-o}%r(5;iEDQqN^n=c*_g0GJCQJ}>}EW<|- z_6@B{gnPyTpG^29&D`{(M+FO>iEGw8SA(iJ4F4YWFd$FkeG@am?68V1>CgpxJGr+- z5tlxIRsp%WkJd>_HngsNPUKF1k*MB^NOlcxCxPW~Q1W79^-qg$xVq1>g4FwNdd7n; zt^150)H9b25wvDt$cTVxIBzat2Pk8s(dU?EQkjTXswyILOe!M5xT?Y^nDXKE>8`GG z!CZeYps&(OThF#!qFzU$K7P%%pi0b{31;dT+(fJRC^6)JPaIW@cMya~( zvBnQrq=9R7qO#TFnAbZOL3^YG7_McKT+%KcLcz6fM?C2;3 zyI(<~hzVE&DnQxknVA0acVy0|BrpGbX}5gxs!K=|0HzIW4+4K7m&&F|xxVx6@+5eZ zbv5fVj(M?A)qx=0s+E^%F$k$4XXciQIr*i@KEQIj43wEZR1U=liF6a+I%j#yiDO>+ zZW1?KBz_LUo@3V)`FekOZNR3>SHIDMu!9l>Z0C)T8-0mb3*0Fi@Gwiu10w6T9CIk7 zl;M1QlUuZ#tD=^-^|NNt8SM2Z*6eUlrn5XLe_bf-!T3XUR9DRyl?_eLy~}yw$m9L% zD3gciJs)F3bR>86D1yx+>W(xc6-7s%{BLudEfYWh^k^CpNcEll8pq0j-kDnsgzevE znpaK7*c61*p@cVo#|h-V#$~&n59f3#3r5dDT2ilefFvpL>a+yp)!8d z^1sh1%G>tSbnJI=)<}?8q-Bur@&3GEU%Jgwg-v4Hl2-D{^ou^IGU+RtoNBAQva=nq zC=6D%ki$>HSx|F++GIn!FIh|L`>NQG)O$4){gjYgqP@3Z`#y!>(p zz38Dc482BFhgfm(#D!L`B+pr=H}y$W#rLm>j6{cL6~K4}I+I+HmyBkW{= z)Agss5b%~-<$H+kOXN~fJ#Uf@ctU7{af!CeIpam7_4cRMv|rqwKm9W^9^@D7ZhK0n ziys?p9=Y)uvGA&~3iD3&)W;Pyim$`nX9os)qW8)tvNHraZy1y_W_}SE< z<1AnIEZZB;`EI3>?;lUy&hX>D^$#J5!X1Med@5g#7GXj_o_TYfVQAvBNv)jKgxWM- zm6{3}CjPJDhx6cN>n2>i1_Xz`BB;NA6kWB;$%C`;d}>pnc^wOhh;`4p9<0XMVFpds)BMewAB)MjH5RzHq+inY_~jPh9g;+D5K8pPNJ4vWnPV#Ugj zhp%5`EN(!l6*5+n$4X1XilDVO&?{dDS*JcWJ*>uMQRE*w$Q2GkDIzoyj`OB$^5VP{ z3(lBO|2}ES;3PTXa-P%iDR0xhDpLQ-2@tl5NXCXp0iA@dNG!YXVps=;I^0QuBNOTu z-$h;MF|!CBY<_gwPy9JvInqauR_!gbgi5G}^qS|egeBj!)u(wEX(nYvYi?T_?}2Qd zWGC%uYP#l+jh^JwXrO723vBZgW9l6uFWSuGvvyqDQIJ5_b*OO>@($jQ@VIbRw356c z(M(-(gb5_Y=)Q_pK*NAXB>C0Eoit1Tjg$7gLZLiwN!LFgK>)y3Taw!;HBya{JQ`9i z-FgLy%q|)`c^wQ^hpVDRvyi=^F*WSsW6$$=#ad?TZZM(1pt68{0c;RXNMAqjIR^`036>{yudOA zio!O~gM$)ALf*2|M^-af(2OOW6n%7cwaZ1Jv5@iKG#w8Rv$KvxS)*lnEA3Bk`8ncD zar%|eUO~HU#$^xq?V%tQ(J{*5pT8kYb02aQ4I3u}0Y~A-GfSmaDooiXs_tYOyrbRdbl51(?P}1;|6YpG1Bb>ouA0`_ zTRZGD+-e7_ei*AkLuQTLH5_blaP|@u^6>!i>gH79t6(I1C^fimkf^jqnwD`cm^aAb zGClpB$n~2X6lAd7jg6mish?#?!?HQhF_ni*XWgh>n7cBdLYjZ#6ZY5@`B^Key?mb& zI6NkdPy3Rjjoro_6R-b{=*|cJt1tc0Mp>D4`fZ*TGnIG&PF)+GIU+Av?1YFo?)R7; zQdHR}!x5)oNt0KaSPR~0x-_U6>?bX*pSZZ|&fflcW$DTzs|@2Lsf2PXr=Q(+ z*Wk|J?O#xBI`rx$qG|TZB@4;hMqt0Yh^@qXhfuWpK*ck|AtpxU|5Cl$GfGQ+*ErDe zq@D%*Ygb8uZntNYHVs)b@nGr|Z#0Qo(+(0zNE&rgt9-Np`z~7b^~N5e8=Vs8CzIr? z20Gv8KG57^NkPY<3ESfenzx#iND7L9eU0J@mq{v~9eRud$*$Fdc+tBXhrnmz{U7h! BddUC) literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/json.webp b/services/api/src/owl/assets/icons/json.webp new file mode 100644 index 0000000000000000000000000000000000000000..db206915db3b8693d46d6aad3554ad74585bd9aa GIT binary patch literal 6530 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 literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/jsonl.webp b/services/api/src/owl/assets/icons/jsonl.webp new file mode 100644 index 0000000000000000000000000000000000000000..249257fdcdaad9679a5042bef3c8efa1aa401c50 GIT binary patch literal 6512 zcmai%bx<5kkmwf)gbnVli$e$y+$A_c5?mGt7J@7xxGe6$g9dkZ3+^F6Ah_G&?u#vZ z`M$fWt9$icy^-pFx_hR(e_b`Bt*oG6O924ry^+<_)fCnT0ssK~zZ;42&yrEoR6<1s z0C3_yEH&A^!E9C}>XdH@J~@eHX$kR>#UKcpUx`>*F_CpY25g_`Y_!3b>u3mYmz%zV zeq}CrBV~TkJz{b^BW2N-*g8abUIU0)rb82xTyCm>xSke06wIExjl`bg09Vju10ve! z4$2f=koDk~e5I!Zx}&iKtC}(nOEycGT;Pq#M5_BZPPw_rTi{?NHQ2sg9t#Dp0sS#)~B4u99%bZ8(3-CIn0 zy(&TezpnKMjnaq%+`Zf)(B{@05Pre0mUJBxLe`-77mm5H!GE;iy@(O8k2#N+_S63v z)ZY;0I7~hG=3LG_U-Qld-Ex6==h|<-Y>U*)7E7LsEOJ>@S`(TzMWYWIekaL=i|kwB z8v8ciJrHbi%(^62P>OZ5XPh#byDfJ>;0Z~8Q&z^$x(yrnw<|3SsH6llShJc)2LWs z_XvIvxU)~ z5ZQOgm9>=Wi?t=GZtu{?vZsJ2v@NtdyesG}^hCf<>A_>peTO|aC$xnJtC?HFwcjP? z2Y0Sqm}wnxl~tcM>-+z%#UBriJj5E}$@^=ta^C%~!D%Bz=1op1B%V5tdoZwp!#w@| zfFsV68<=Wh^hilx3nuUylX|esE25~b$}7c8m?#aI=^A@`=V;|40wJLj-^bzxGEQp10h{rZf5yf8?I&Jm>_1M1e6L>gYW9ixR zeKqE{{z*wZIYjNByY3}E%YFBv9Qyrz(D1)X0hRO@hM2b!e^X$f1735yQVyIb_4?-g z>cd;yFp4&*!-L?om18nw}yDtF<5 zk8xI;pP9sMLi1rsnGDRer_EaMXlsxtd*gLlGmLK!Ke#|Q)4a+Y@=%h11EY4JQ!Ho*ys14hyLHCzObhao5G$!knx<*8SgXwPXYVmd&5xl6NEL_Iq``W zjD~Hs*v7#B)d(9dF|)V()ARD2(VX_TK{h2@6RW?I%E}>decR!qT6rM(Ze?kPKPo=mxzMZt?Z6Hx4SX#;*ECL zJP^N|_)fAac=df{PWdPTV@uGmhI8UZ6QDWaV>71sQH8-$>sy(A)i37<3Z2VrkdEt& zjx6dyJV8dd_Qt}G^ei8DMwoe%AZg#i77c5+EQHwnllY%uocPsP=5&Wykx0^A(r)}X zRE>AGb~z_+*h1nJbW1JT*oTg+1WR(2B1i8nkkEYVGMfyO%L6vfmNbx#|wU{Iz! zXoh37aN=Ay6JQ$llOQphN6`T^I(ohVO|s;yggXXvA+~%-%zfef`)rdmlH~T@yUU$+ z+eF3m3oX}xEINUMA%4)|C;yr>Iema)&EL1t-$4S-F>ArGOkL4dznK=OKoxMs+>YUo zbmN?@pM4fGE{@&H)f-O?u3lo}W;lvTIZI;2$(;Y?;HGhC@=@$WF!XVU!H>?bxhjGYo@@4AFCvIdn%8~ zld7UhUAc$bZ4sG zQvW7)1ne<`685T~$<&YX5q%;q{3d@gBzzz_n|=jLlxQ@>-D(_The0df=}4yH65R_x zozrp;m+;{);MUt7(BsL2k$&7VKx)R`%O7_Ig_>JcJNWWD2HNMD(1|u&k$K-0Zg8a8 zHwEsAC=HU1))gBsQu|O12ADhtmowq8;L|EXS--L;Um9*o6L$|jSU-L?y&dU zbCBU$CHXb$Q4>UPs)O=kHY6i5A-_tX3%4{?>_a%*l~yVz<>_4qH;Nt#-N^6Mw@ErT zu!}?Y^r_1N+rrX^0aY>+qqiSphcIr=qk5R91l>=(oA<}JjP~>HKNgCqyw|$)$pyby z{g5^0tJQWe|8N7``C3@<62qC}B<^f;2+Mql|^4PM6j?ECiy~f+b;~9ZF5)_rg zyJ1RZTBzKh3$|X)8NRIZ!#&anPVQJ$ddI;~xREH;O8ha~cSMHJ0!8njb|q>~M7vPD z$%9oxmU!Yyv*oeFdl%O5iX-+d1xEGjw`-S&=dPfa2P+%hopSKiiEC{~;3;KCCK&iG z0Mo`fMp6u$45MX*^w|=lWKO}aU#d45PKmULjAAJT%21-^z54d`qFt@SJ=gfI1`vJB z{ye~s)x41;Z&fSsV!KgBW%V;8jqqEmVAA0w3%7CmDx=&Sro zE)xsH70?|j6lf5;%aIq_k)N|_wGk`pL-hPM&Q-OiPlPT-3OIhVz&ge~T1$NKd^SlH z9G4#zE|GWGub+}FL^w_^Yk!*bfCs}(YZsC?R#|#@Xt4D6_y#xIyNlaZ%?fpHsS)>1Ol$MNg|KQbxvhF>mtj51PI^FM zN!GRC?+N~J7|pEHy-2>|URsgN6`0fI#)A#Va8G~{oE*>FJBh&Rf%ikt z=L;o5uY_z8IGnpciU<2erhUJ&8o#1xL3$E+GimKciQ%Z z0uqAo!40*hX60Fgze!2^$Bl&f`}^SMkKyx7AXa=SwV@kVe#thb(dxrq1T>e_z2AhE zr7A&!Soy>4+uo{$)%*@Ltp?cj=!8U8P?=JABHmNr%aQ!317irw1yESv)vfrUw3x92U&Zrmaca+ysAg=vskm%^{ghZ6jLHmBQZ5EW`U6vG&a4)0h!fe4p|@V-gzHG7V>%qJ-+}%@)=|=sY(#OAx<~*0K!g*6@cpmDW0&&op1+Sjv-G zK3_Du)x_RS=Z=b#C@FsGov__qt=~vesUjp^OU<7-e2>?i_{+~0x{@~-$4-nfu2$dT z7{jVdQ{Txp?HRgnm^Ur*j+1I$K%1dhW2>m*($MK-=L;0d zZZ^@+3t{?NmziqmU2LvyAvn*nl_NpDWq%YBB4>{gTC*f)TcMlL$}s)wuoI3MV5rID zV(ArXzvWqA?%4Bo3|YX@p-u!^tT})D05UKAk{$c?M+|?9_P58;StXZCUn?; zHjLMHd5$PL9J+U`tL9H)Vb+AC7L}BNMq%8rGt4LjOlIN2Fq(v&xH(Z(n%ELXWoF?J ztEk2#8mjQ+mla|D!LTW(1@2yV`SG67b=FzESCb!_Qfe5MG#X%qI8UsB!+8ZKN z+f=1xc39iDJa#lU&v9Y92SL_dm63N=C&m=cr&7sfZ7D4fPYvODg8fg$!llaBCHKQs ziKHM9JAR5Z=ad#5m%;#2l=qTi#LbB`mSfSE)8+0|zTNqzy+h(z61C1-5%aY-5xQka zu&oYrH}{~g+3B&T<~e)e@}-3u8G%^NT=sVD*-uKA!bo3BW{wk4&~`U^3eF4cH}#mE zFZLtdT<{^;-SJ9xN?u3>0kNU(lr}+|x?H|(_L9I+Qe+sjOT<-bP}|vr+wfM?#1fsi zS6`*YfYij|e$bZ&`f zyo&pN+s1E2!M2$wT~gNZl#WA?Cuj}2rxS|uk_e;6T6uIN=X^FTha;WGlL_g5AVzQ&hP3@Z3eycX>vV`PiW6Xm~cy>eh^84 z%6z0&=e*e$A8&axw=q~%j^0A`h@Zeqq_p*yxkBb>FS zwX``O4%N*P_mh2e<1mV9jW&XAu?2frf(!LFoGkca#_uZ2`r}cT%R;RV$Om*6;d?M; z)YAeXTGnU6Uoo>IU$+uEz~xm_Qas&ZW$)OAdq2`LalnjVcY8A?{9|b~Mp=f!9zgz!ZU2}2py#s zU+9&+-S<*-l&Mf4h+^ceJxX;^p}_VgaS=yPQ{eKo>+1zVJH}*~Z3cLc$ES`tJU(W` zZ*xC|)wZbJkhc&C$Kx4?bd4rm8E!9ntt9Fl=h>r&#Y~_ z%&q=V6GrQC1ee*Uijz^3Jh

cI$-n&JHlhMIhHIN8A zX)HfsPIa#ho!_hr83&&(T6sA_FVw!OXH5L-2!O(edBAOcD_}Zo1Rmn+oN+b8(4<|y UHRmgQpFvFyKr?vn_pjoA079?>G5`Po literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/md.webp b/services/api/src/owl/assets/icons/md.webp new file mode 100644 index 0000000000000000000000000000000000000000..3065acbf0e6c3e68e86a76cf4af0320dbcadf534 GIT binary patch literal 5766 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 V8(4`Uk8kjT95i;^YU_3u{15YHeyIQe literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/pdf.webp b/services/api/src/owl/assets/icons/pdf.webp new file mode 100644 index 0000000000000000000000000000000000000000..1e3f837500edbc13ca7c4d432846a386785d6b05 GIT binary patch literal 6640 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 V`KYPDR&aABu4@;FOmPDM{u}w=93B7w literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/pptx.webp b/services/api/src/owl/assets/icons/pptx.webp new file mode 100644 index 0000000000000000000000000000000000000000..efefc92c1117d2ee047d6a1bbf80c0a1ba922da9 GIT binary patch literal 7816 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 uQKwhZAoQF{l3>Auy9I}2kl+@AySoe!EWn^M z%p>>hR_*SOeScr|X{|nezV7q&>8jRJRZ^;?008tA2kjl@HfMidq^=2jy*F!vz}57(h}_!zc)K3bC< zF_Lw|MlOzxs#-eguZht6d_3_^sOhZqMh&?q@@s7}g*8T9Tk7H$eid%K;ZBZfqm5VE zSMwaOwUW8vQ8T2`&MWzr#&)X6EvYABvSLD6FW`$(Yqxw{y2_N&l|8~Lwm){X&V_1` zs@naBfRTt;fAcE`>!v=qt`SH|(=Q<+LOFRYzkGv)NUF51P+S7+*#kPx@7lnJ&2cJ; zbmLJjO0;x6^0fCB>GHs^_r31aZw$ByA#m`Ycin34w{A2m2aS+ynJVFg@9Smmpv9#6 zXG-C=9%;CvcRP1X5fx5c=0EMW<&q4Yuxg#4vaEH@4t+7#Pw8sWIGc2#JYNx1AP+1N zgK&em;9+2(kC98hHqcfq(gjp?QEUzrdJgs_>{jrf5G;fopAmJ-bco?t=1dZvSTv5Z zUVy1#HaLvV=XsI9lACTtYObjTW!xS$AF^)FSVyyN`xNiJ(icB3b!B4B8e;lV z%)^*O+B)fgT|QVo1=y)VoVBrn<7^af9a=C~dFoFaFu9*KiZjJ3(fT=qD z^XH|hI?J(qQ&C)I`Rl2lPlBW)3Q)7i(dXZj+FV!bA^O4-H>-GksPOZnPXHQSlNvjf zt{Rh*O6K{Q^Zr>jS^M3$bKwHZRl7dck6GldM~=9O%A4T1>T^(TP`8tm{t8FY)^mQ7 zQ{I!wpja5JdGvJX zK>oJIYYr2-AK)lDUmLJD7c4fM1@$3LHG!X93hEnP4BZT2dsh4J`+8>CZ2ubIB9Adz z-M{6*)-|yXN}C%sls144a@#H6%<|Ais^vEK677yZV4oDaI zg#@G4<<7=#I!xLrg0#n0Gjf_dAn(i@RvnPG2q&0b2-T+fK+x88UnhjtXOH)aSkd^RQwfVb`kYY$7g9;|)^KAg^<%_;=uFALpQ{k;j2GDbM2@&eK=7hF0CZv&CZ zV|1QMkocM5=>I7VA~8qyB)#WcT_WCI^wzaglK3Z~C+5m6wV`)4anr9*wck#!M{CpzC}*g8 z1_tc(VMso0JvE(hL`Rac*E!9wkrzE_q-wyrD13gcx|F%=9P2th+Qg?0N!~n;nuCj# zgjj>WDlq!K-&~1fLDb$4dlSxg&N3;E#D~)!p!;EkxtU0}h*6-q<7}GSyu@Mpro&cm zelGeR)5TjYE$L&}TyIM^QCNLLwk#gIv@VMZ+eOGg|DwR%o82P1H_b0TXeYcW!6n)2 zbyAn`NUZ6y0M`=%n{c&63F!G7M^uF&V%Cq^^p?Xw&gr+taF@2K76tj9I$diZ8|fE! zy;pd*oWJTC!4W!|?Hzq5ld}k$#%p_h4NjW(8_0_UnHX6?!&i)>@}71kv7Q+B)M8{0x-+<2V9AS0jkvYfPy>y#M3b20i%y zJ>hj*sFv;jy6}$@=eGxH;2mevARY+%hx-q=BC86t`jQaKC_j>0r9(gaAZWN=A51l``(4 z{5fdj4zNn?Hab1XwvDFkN_$&(8-lMfzX*rtqO76aVGJMBk_9+JvIY4 zT-U+g70!TGW9B_EbaWb!1Qq1~QJAOKh9iiY3?!WhkLM?9SBi=Qk!%JM;hFs9&23L2 zm$VmQ_4|kY5<$!eqAdWSl9OYZ4WPYi&to}3hXKut-rXQ4SkPWy%zUkndoahSNBouRZ0||g>f>V>B4LB>66mCHm*hw{B!JN zuS31cK|(g?-S|$lm&(IzmcKQObCs3FKlGm8e35IOVIU@vvtb7badgV~B2z@N>R)ax z>#^mI7*UR<)!=g&*!lFwMjSEG;%ozwiRr~`XScx_98qy>h(|&kdNvW{-#isq#PI5k zluXb8Ebhk~{j5t=X4qAi)54&naiOhSieZ-`AS$@D$Qq0I3DgihQ@gk?+?Z)ngf=GH$8_OJ_84 zWMcv-C_?}Mj4RksFzU`#QuH9X-OIdMT}1SUOihHTuHyiCox0`h;fqun-DCcR7mGB!Z1E{Fa4ZT;aF8fzfpyOx7;B^io z8dIzGH`Eys74X;9vq*IbqMB)lg6!-BN6m7Z?GF+5D_~Eb@~k);GsADKxV>VWo|6gU zLJ-fUey^KqOSo|Rg5xucyi$ujpoVF*WG3s`p)av^U;CX4r5pb?nZ)E9m2PIW$-xk= z2I3?&CWUG;dP~8=byeXJdw!&IhRPoFQojf^47);0T@7KnAfLThviHxRHdEH!GE$7h zrFAH%WWF@DX&O1(v-bTI#IFP@nd!W;t+RiJH7+()dittn;G8K)^jRH?h~O+Wf*LHA zEk%aEQ0*9wiZcwt7AehG!lQhfaqa)OP`77tHUh(A8c*4S!KfM*O_2oq)ZgXF*6ET& zeyj7IMQ6O7NZ%LO74DCu&>4&sz+xL-(}*EVa0FTm^rX*qfx&|AlDIp&3B% zlOWI8u(psbHS1I4%^NTb1jPMidJXPQ9YY%yxm@%ozw9q4X_35cO0hbF?nmFmvm2{D z1K6|33q;X2S<^jnAmlhUCZe92UkIc+D`8vOFZh_Q5Tmqm(u*GF_G&>Vx_3%r#?$-_rvbY=nar+{>{{;1AHEb--u!Sl ze%%B(K5JIOcnhSiSq?`HF>EbPU#r6}{*lqa=`gi7h z@hA5+E$322Nw~x(eRIMyAy@n6+ja2O4ev+-=0NSTFWB?%9qCa;tn7-Kf{y6-V;YC9M z8TfuSW)9Lczkne3;D?G=)2sIojfGvaF%$``K~cvf`FZKqLs%FuYW?1y1Q&Yo_a6PS zlH{DiY#VV8tl)UNg!VnS168$o{oy6{WU(q^b9tf`g8LY8Tbo7Q>F!1YfH0VzE`crIk}nE8#w;fF zsj64WNF*D^re6zMy^FOGZS$ml!E`@9{UpdsP=9o^80UBE$^l)VK-N z2v!>Tvj3ZAc$M%`P)!o5BRWDW~?Rry`EB z2GC~Z;%r?Kn;a_YyIy#>Ya@B9XAi6}Z=*I(Cbup0lvl|zP+=zIEqiQ*%{xqdyT&LU z4#BZWEn7n{#YTZB9;x&fZ1;8;u8vvNT{i=B=$o}5gGC@A4+D@KJ634QCl8!GrI(Hz zxm^x9vEnrrb$JgGoF4ooJw>?{P~&mEZV(>kf`|%W7m;}}7Uwnl)_7BM5oj^*bMlR8to&v$(VzRQ0-dX7Ymo5^28stet(DaHo;2gvO?J%%u^V-A8AZ-tW*;Y8 z@vatwVCim^)sBNp5gOYG2e;^M&Ba>5wR@L$!q1V<18vI+3O)*W2Oep?2 znH{;YY{p1D^R-;XPCAe|JNe;Y>pjbJ#i7sVTr6$e-`}OETyw4X@wCskcMtt3`Em%R zF2PYcy=}bBreLX&VO<{F0j+HvHfE{YZ7?}pm?qXGOku%DWDeG^8>Vh54B9_pDYzqY zvAbAMVJ7kiR!pNHH+|#G&3&j!soE7W0L$CT2(sKo5~Qvl z)oP8?5`(Wc*YjHtGq*0E+>Z9YRE#}}b=)O4r8#bR;^2AlF_>uc&a~ z`jAf7hlVE1+R2iqjKslk9Q13LH)DC18HNI0t3dXJ%Us6n7uY>C@y?tzs~b03OlLfiiNO0RZ?P7z%1& literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/xlsx.webp b/services/api/src/owl/assets/icons/xlsx.webp new file mode 100644 index 0000000000000000000000000000000000000000..d7babe260a163470534b04c34b745b9ddde7ad27 GIT binary patch literal 8648 zcmb_=Wl&sAwC&*T?(QCf2DjiA+$BJ8mtcbi*WeC;;2JCp8r(Iw2A9DZY+zo#@7{Oo zzFYO`{dslzoa$a@*V?PQ_wK6R+DZxvy>tM8zPya4o~Ez?IsgE`{rke;{%O*xnu^Lu z003N(ymKzyTIJ+&tMl4_G%>wYXPDNjUyIDMv2cr7E}=09V8>W%dtng|-TFlds= zj65#@SnIXFxp4k!F^#Szs!oCRbP>MAMZ9TQKbW_TrQe3Pz5JIu z?@yLr1z5So%HjLNzfKAee*|KI0gRO2ojTH#>p_6-@8fC7{W_iQNrdL!^(F&+s+GuVWg*oZgH ziB>2Mi-yEd%%PQBTGZB`sdT8{j}`D1IB%%m<6`p1GospN!@aFFw!tf)CAIM`pBeyWKS6}ovUHOF*N@y|aq@q7mH9`(zn1^V&>Z<6{wLUbR+?b{{dp6g zAqu)4bQ^gzZ+={xx$UtnfYZF|vDq&y>e3yS3h;!XUj3%+1{ZfH{caOo@cjwhOy0UU zam=skHZ1P`A7uZX_&@ru*PPd$1pD`2q3DnK3_WH0hh`aDqH1?P%lCzD%1=DwaC8cd zFU9YEl3nC8OB`lgh{y*{P5SgQ-0^%#fc8+bI=FDWvodSr!l)Y||c7ud!benqH#1kVk@g}ikoq(Gu$#y*pDYayf;9M>2#1X zw>?E=d5v^&d85qf^akoceKOYyw)9bZH&*5{zQ3N+@J(fVz9U+|iXn3VMWgI@VRgIQ zrWI|anOQH>jhL>2z{Qap+J=W&xVmhcZ%o&q)Egp<_Pv6aQ0A1O!leW4z-re|3yzkV zM^wYu-)5H9KoNz{-_dR#A4hyLAsFit1)`EvTgP+2sW{}>gO``pJgl|WbBP3AYwG1? zLyzM5Ef;MYtNI{YuLG04WeIDg>+u)R*M_?cxb>DfHyNy>k8tZfJwam98AyxCglaSt+s=|ZHBTmd}&J&7>TYvnWCW7#l& z>E}>i*o$3=UKJStDDav9Ls=+14@Q#ox&ypszxuww)~Dz6uM1Cs6R?1n8i?;>8${y) zxYNTK+y_jD>A<{SPVejQVKCTcQZ3I)Ji|{f$RfWlY;?|V%?9(Y z{MJ6E(I9in>VabKstx4^wT8Wwkoc#U;PPAG&rgKR5oW>uRpU1!0dlX>e67^GOx8_x z4*^Ea>-O#Q#^U^|cFofc0@XdYPcL1I=WqX_$dT>y?%#LS|CB3$`%E4LL>M*p@V*lg zP*{Tb*X;oIqpuu=$mNVNP~g#QEA_ni_u#x&D7)jJ=k~&0iex-(eX^=^RPzGFd@EAT zuP%^0FO}uw^za%jp#c&|^eI&*=Bc0PKY1C@|3R~lb^ETDK7q*I5A$P)jr#xMApriX z`mZ$mf|T@vx$UCAZUR&xe8AZcHLf;$lSme17 zA$wK#5u`TcJuvy*B@wLww#M)OEJ~C<;^7DqUQLexS0#I1s>U2nOjUcG4Ptt6*DA5^ zoO&=@l}QLc$VLPb&mQ2Lxo$E^uD=dnNS-c#ojBha`}+^?STuNP1*lF~v5zHeWp_$o z>-J*jI@lf_R88<;w>Z`2@pI+I9vLTG@uH+m6HnzmYa#pmkl{Q3Uw9pni=g9Bf;g3tNt^q^*|{u2zEkP5PM z6mzi}UH&_3n-xnojfCZ8%$)I?S`#1FgE_1fA^Y0(x**6_vAC253Dp;pZ>vpaowY;; zc%lxZUKms~@TjTB^juGR-nn!dH=Vka*bL{-4R%DTvWo$iU*fBVfVQ-3iUO)maP;If zX*qr|dqi%OR%|NeDQ2KlKmxCDb`yC=;|Kw{e?nV2tL$ct;5jSb+bRMT!zc+~`>U=EFZ=S)cTG ze+9k&47$JJruf&`P4mC(|23X9F;r9i@Hbih$u}W#$DkEpFpA;-rUY}p9~q(=;$#Z? z8o9ZqMDU0>wEUOP=WKVB-mmWSI8B}362&#Ak}=M|tp3JC2C4wSD-2KA|I}TGnhGS3 z(@R|?LCL#B=MHf8v$45h>W7Q-`x0xRKx6=N=VPp@z7#2g#qRA3Jw&mBwu5Zy>hMJE z3z)@zjH%iw_a@z6!-(crlRLAdY#NfjscO4}py>B?E8tNL*1DZ>hUg=m(^CnT*nNE(526YaiR@5DfN*U1nu3QL3ftnR_@@ zPm7I)d$6vk^<{s3;Xk30fmgP563L@$M(W}Xt?bo<$L`(|!#qZ|V9!0`Nw!3aTNvH3 zsfvbss^XX72n`O!M7D2E-S9Nw%!yTY*H1@24@;f`ZcoJKo zaAjzYd73@6+(vF&k#jTZ-3~?bSg8?p4K>2$TQ|5=+(`2k*S598(0tmdPP+@;@}Uk? zp^q1Q{2_!y@uT`7yuRmHC4(n6=4bRcJmjpN!3p#OWw*yOC>@Q?n`Unz&w-cxrDRL- zFG~m1ag;J4TSQ~%SiM5&aW2I*AZX`EG zHtkt({3Hk3Q7Go@Ye~T`^!oyYTK9?&nXPwI)Talk<`;P}q&IL77<$l5?D%vGRvshlZk-L$SWS?aCz+9Qh#~$ z{JlaU_w&_3_GaZvWzp0M-f#wezkS1-0ZgR4b~g}EJbzV`dLsUorzl}-57892 z?KeWV4rdq0`PSaYeA%(485GASmyAE|n16jt-ut`?L`TXKQr$#wTKny{0S8vpV|STY z>azih`e7=yaNA#4(aB>Zf|Dj@ zwv?L0TtBpgzw6d#*BQP5eiSD)gGNTh?i9bH2+?Xf05R|T22EWfavN(~D(d7R5%>L3mO zG}E$FtFT(>LrP^7e&CHtQr$#QRP-`r6%zFr0hderKhMEm>*JFO@_Y+3Y zi%Y}E_Z~7(wCYoo)o;CtNibQoh@D$8F_J=o)@y^6d zx8jU(+i=VB?hyV4pX2qTF@-uI7JTH6@8)~dYaVAXfOsonB)l)S z{xn!TNkJl%d$PzjMQ&dr!mvHHQmtx3c*62~e8Ej`?8i)+8pnggKu--;(w25KrYiz` zYpTy$=(!X+r5qa&zo+N#0eB~KSZudq3Z?3Je=pz zexc{hsb?5$i0~=C%1m&%RJ+ZtsGBJ#ZU*Y8Sk3}Uk-crJeWcv+q2tjWzNiuitJ=gYIWRL;~J39F^u(r3cjJP$q zy*bS(ai*j!RTS46#6J$-20qC{mHUy)>*`EB|5^8QowQ zO!EaEJ&G%K2Zez{$dOAoOOobbK7`wow1gwT&%R7ktiGL5r$m-usRFe#Ty4hoGE&G$ zsN(n87?zhF`hcy6z6WYGSs33+6efOAFC%;&PG_{rZq8V&=8Pa3-OF&j1%-p!_iI12 zgc0pSJX1VN8czH~@yYl7HD$MFCYs-~mD#VXp!;6b1f?^FcaI2&_yvvi*Qpfm8#+%i zm!Gsd;M~sEurx(+HpG@`@e}5Dw{71ttDz;f;h*B8xCUiW_sUg~KJuzaJSk-xp_Ee`V+v0alS;7@uCKP6-heph`*e_^%T?dkh8mPS zM$~Eamvcd{yDIy@2u+TQ!ce&vm0+dbN<^d()DNH}l8y+q_eH}=UpA&hTPHNh*GD#xZ zpl&r#_ya^-6!t;@9a>hgXSnG1p(J^Wp~4Rh^W4tnZpaSsR#pAJ(qb*4N*4491QrGU zUk7-j59K!MvKB91FbPh_vF+XCaN^33|LSiMe&169D7q|{(i-?J;_>()${3v_-*f(%c6R8Cn3we56HAu5 zB!7-?Ey1d=z{mU zm}`_R6;w^`TBk|sCm#D63&)n})`!XFLF5|!J%btJ`8T0!l$i)6u$p_shOJo}q}j

WvAwO@4o=D!dXcBxDR76o4Ld9w=YBeIxW_@O9H-tnHSy{5< z!_B%i3u<%PbPP~``zdU%aPsx-Y@TEswfMXPbJ0e2%%T2XZ-xB}*|@l}NvLQS zO(tB!FDeMO47@i+kgjuytKXd}gIbI)ykc~^Q6HsyR=vjCUjG=T6AzI#g#(qg_IMkG zsWke@zBdgSoz>%=Uq+RxWdMQR&2m`puy{A8ZbI-RWA!tKJ7Ukp*>Y*g%UM-K3)@=0 z{=rSR%t#csbBd9)%Xu6c3YQ6xftJD#RUMPi;^(Cy5!H6LJnkhY$Vu%(_Vc~QzjW6x z=o#DGP+ggmruWv*=dJM+?!)(UYccO5*9q;+CzwEwXtZtx;kNnm>3_~{x$8v?8ijKl zF3v{2V|TimFp2t9L!v(&Jd^yeVk}Hk@l~$$(dsFi z*e9poo}gJuvQ~NH^)WhPq7s*wP_ab~gz6mAHWR*o)7&=$^l7x;)1*qu!Zjax8d|o$n zjHtToch{BNP9w&LV(I;O5-4LjeYw*Dl}B;Y6a+zV;^lVd{RvBvJB>_5YwzJGt8``$ zZ-Y8&SlPsA@Kamx;b;DuZ<}E@3hEei$_5M0moK-Tzitj8W8H(n-lN7m_H!efPQ}eI zDJqB}qD}hdgIknlc)2(DRF(x>r~%BZfJG*`8pR-d#B9RZ_C%Rc;6m5q5Lg~fagX*H zO%vI_B;MeHfn+-wg`-CzeAh9mqAS5R>Q}L-L~&pcw#k3n9jV|I0D_~PR^|uT9{jwj zz!9v5g0fzT7qdc0^b0ct@-~(o8}!Gy>{EvHH_8)MbUzPBt2nDu zBXX5N!HD5J6mot;QJ3k|j1_>Jx^m!H9(EOq+dD>5LhP8K;d(cYKhtWKCw%yl_(#r5 zp({3yd3lz!rjwD09)lE75T#b_jLhR8#nO(XXe@gq)R?s%r%mm8$dGs&M2p@j0yCA+ zYIP|dh-D*YCsBRdO#W#-P?GOTwkpe>W0WTYkEF7bSijhGym65&h`q68FivuWGS0BU zh*}xiVCgUTS@rpSPkDn`OrPXuBI`*e4*9Y?`baqr19i7%s+Sc|woW06Ce%DF;zEF>IJwYiJ`*?oO@$jwm$J^_?*FL83q4d?wA13#&y>DDY&54QFslw-4L%q46 z)rZBTDq;^ZF0hnx;@asC)904T0n9?~h{?0$^(;$Vn(DPH8MS_BX)gVPecCFR_9kcy zjIy1T42aAjd1p}UkBJkpFF@Dc$hf&)g8n0LCoLYs*k`LUTb$yp7iSNojMas-~Pr?Q79=G;N6W43ByVo=sR9g;#(Sl`1^9VE)PCx?)^?a~3uj zvls&7EWJh@`)TzcO>5-K3V41g_b$qSjd;RduT8g`!w< z_W!8z8fzbVQ?Gf$Aem)=l)T63**YOEUXPr-MpUX;+VbH4B~+pF|N0%^>_ajcFe2qCjNdGGm3C%PonL2W|7gjDA(WXUc)q|Vi%?3 z?}Gs&gr^8D6qlvJSy37ROSYcTdj8pdoOxME)Z<ZWG%S9XY}@W2;EV^}tIp;( zE!L|%3s>7R5yTc{x6lqfVm6HVPql^=_6()&NA|({V(iVafg{JfpTIxrXPtqZjZ%Wn z_v9$EVKn<5uq(7Xv%|jHOH-itw6Roq;ne5%vbkikahIc=%BP&fiG?pHyt=Tpa>WDI zz~8QD?VmFp&=l4xf?b8<0A>Rrm`LYCNj(Aukw#noYa4L*y6tYgXxmn!ZSqM{u zE8Q#7H~b!VZ#vjMa8Bi~W-hUIIQH@n(HQrp1qq!iaD5@#{YZ&mNaz{iHy-+fJAdQq zo=|k&;W}mS%&kT#G&~~wbQDuQ6w>DAA%s!06O>?&E;<)3IO==(!1rL5Y$?j9L8Q)@ibyj$=hvq7v4uxi0dRD(HFp% zct9&~Gx^Xl`#Ni#L>x*h**1a$zD9Ts40+x9JLoOgbw$>DmkJFFhnJ;A4}tS(o}Q`& z0Az;%hzHvM%WNX@891d;GE6pVsFF=R*^nyD+jqY_;ti_sD*zYzEO2AIB5k%lI&v3O zoDPSVR{MSBuhvVddim3kBYU)^XZn%`r_!n)0iAcAHh&o0;&k2*5nuGedrKuG1zezc z@tD)zqQy|5#5)RgCOyV@@LM{~F1t&TBxEx94qm-erJ}wB%b55~v=rMCpd-+)b!D@I z7+QT(=LYdYa~DIhgO~cf5I|B#(L;+`#{kKb-b-88hM{S%(T^Ou+ao=K3 z8b@aha#Fpy!qAV*C#Wj73)N1X%(;{)ndfZ+)M1;+lbO1=oCQBJx?ZxsX%@sGBhijO zkdR{w!g4hKvocUAs(WRwgR1g1Lz+X(>gO2lL+{M=f%MlYGrqX}lh1B?Iv7C+nU~Hf zTMo%(+uQ~6Zt;c0ItR%K*RgKA8FAcUqH%MNk3N6mzS&1XV{>4+r~$UscCgkjkWThd kO;bis;qv1Y&|#_R@36J75IdEMN90ff=BU@qzxBxf1*gp(#sB~S literal 0 HcmV?d00001 diff --git a/services/api/src/owl/assets/icons/xml.webp b/services/api/src/owl/assets/icons/xml.webp new file mode 100644 index 0000000000000000000000000000000000000000..95b567c62130f8bd0ad2a7b10fa64a9d706cd50e GIT binary patch literal 7512 zcmd6KWl$VI)8^t5Towz#2~KdA;O-LKo#5^+L4!+x#Vt6&bqNsM-JRgNID6#1@9Mt0 zANTvJXQq0(x_X}Jer9S$O;$=ug%kkLmK0ObQsMoA3IG7G-Z~WKU;C_}BEx|I0Kk9{ zR+?;9bPjTS^GsKozW!GmsxTLPQ%U#^Vkewx_y!lgMWGfHjNvI8?SR9SSF};{==!u z%RkU6Z)4Wdf2*lWB&_O_7!e_zvXNiDg^4a)T309_sO;KDbe7+{g?g~SB*hCx!Cw|^ z>jk=6yYu$Cz*&3V_h>iz-2}a}@m+M>X&o?YG%P0?!`v~DLJIqjNE>z z;tUJ6@18*~oPy+2fi0PZf~QQ|rwB}H9dm*|Eezm08aK`--$*Z31Qdv4mGFnN5Ha6| zDr0%*I^?TkS@K6Z5LMk28)0!XD0`xTC48s23d7H?(7~eJ{75Fb(`e_$jpMX8%7h2z zNEG(hph&Ee2e2d|^URVoa-V_+4wxy<)(~u+>V8=IX&4eOwzw;*Y{EIDS}pcv7v6{h z%_Xbh)AEjjBgL-tpM*p*3K6k@uxI24>?^AOFAQwMZ=edF3HRR6ss0Dqo8_NExHm{A zBK7c}S?raW`U7+FOZtBolg8d#k-J=t!YrtyZ*zVLl#4M?UK6UvO(JpnRVaB`&zrFS zj-%fbFt;G-zoO!Q_opWHS!&oZ|1nF=f5aSW@&A;UJ=&{x*SkCl0f`%mS1T$3`K`|$ zKPy=M=(=eQ<_P9VO1o;ctZu^xSXr=*khA@|Wc(}q!2b8>> zJs+@oJrFz!5YxDsYU8*jKjL}Ww85{l)?YNhv(PsRP}fT4pOyzj7mG0}6ZH7zFNzTK zlp}biL75HnH`6lD14JSUpz|-|3?0dxj_dW|+B{Pa>nQ!uu-VowA^px7_9_Ei|(bf$fB~*BFa-7&!FLauSmW zd{$#MlPRCKoYKP@SDl*LxmanWco$Ymev&3#s~c6me?RTI^jZxumYEu}6ws}7NeVN2 zb!(Wi3zHCP-Zyt`!W{C6Okn1trap1~pv5OtWdBzkT9HdI-SiynsmX#?uIIDVoc04} zy7?x`YK)(k`Z8R8&Gs3C;`?3RrpA~S=1_<2P1$hhUgBgiNm|=XN}sstxyX8Et(43e zzF`IbbSw#R7@mj|h(OcGh@RSKZEr@u>>!t7OWWHv_$jsp^nN-Z?%<$x{9@!t{ISMu z0lxXKpDo{Ft>587Aphu(W)F-s{o5-Dm$uH$$ioPtYqjrRPuCyjyT^mfxUstHe;-*9 zHTBH`(ig^cM7}f+u~@A>%(Ie4D&)2PM&FG7h(De@_BTJ=>3VZ-X11B4a+Ch~0i7-hWjK$W1-euE+?!|V6Qx|h<| zy9d^+toAF%*1Xy7BWTX?OR?XoAL}LbZ{7^l3R((>kA>At|M zdGob=v4Xza`UoMrg-!?Tbn#s16dR@MrO(TsemsLR21t2e_Po2?*lTYihwEfF_Xapg zJ|fj3MFqZe9|~^1C_c$w;6G11L5sC(&R5T&uX|)=fzW}B1quv)T7*wVufoV>f~b)l zW?tNR9-=Ii7Sb_(=q`9VdYpNUfi59P^-VUPm4(yj;JrN{iJc_`Q91}ha050X8Fs8u zL)g_~F^oC`NmN^4F|*4aT4-qkZ2mbhubHrChmHEAphbCHuA1tKq29A@{8i%z1o3sY z(}3aJ;2q9%(XL)pCR;V=K^b)!IEK+Osj1sSS@beX9%K}=tfT+u?W=T)srf)CM_J zuRC*gH$l|UO%|i(oiDy!3~vY+7rjxLs5zXAK_&aClG9YGzn&O;kp8EQYpG^QU3uVw;lpL|Kd>q$uP{@BsZxfB;+)^* zi&uHj`<(vI!KQVsBb})lM%BI7c+_>ls$U@Ql=JGOU-0 znZGH^XqX*+)X`Ab&%oT!;y8Bb^&X?9Rl&BE7I?27)f-cG(yPJ=%xhjcMroP(u1@4LjQb)^MYA){J( z_GD*Gk>!6MES2UIN?CE9Z~c6a!iTnq zJp6Q<%b_QO=sZsR5e#=6oBIiFyLKAj6v!W*Kfyd|xx|Tru zElDoY5%qY{iSb2OE4>JDfkljWjJcf^-tK)S;FOX}XHYEanmzM;?PwHwe-VA^Dy!&|0BL%Ny$HjfPq~Hf6 z;N2ikvOVgGAM&ZaEa^in5Xp0e(I-aVA5(nnq=a1er7-XT)+>1wc5un9VIG2WPNFnw zd>eyW-BsxcdP>xLVB8I7h#%qX(kTd@Cg1sv6AAy~0_EcNk;#^4M1&*VOUNuNJ#I(t zZ{06KA-&f4hI;ho{KI@cb^K`)vqoREEFJF4Us5X~i+hH?+9^(g0-V@We_wK>-HO`U0AC6lu_Nlg&2OYSyw67(gDs$lDbw7rfsoK>P4iAe;oH+G zm~+1<(~u%s2sVhnt;pXaV8Tt&j^KHh!8CexYS)a^xT|B95SjT zR~zGMq;G0dmtnGYev|hB1g(IoeU>4!&Cc-_Xj0kWY7I1@V~2zktTJ^NUt_=zZU53m z`&2!d9ryEwW8}w4_5fQbZH?G{Dr|B>2(5_I9bn7bgRj z8;T{QQ%4~j@AlZh#ep>mf+9x~KZ)o+t~0PXX_0%uAhHd7>XQIA0L0DDo-1rj>NUSo zeTNNqs^>#-xJQ+*0IVLNo;Ns}dcMpa9^HQ~1qECjzFF>Y7*(ZvGalPcpXL zYM>xF<_M>`kmp=;eN;W}SmF^|^H`t5MAZ|5AN-i9D_xvn$zSZ;n`&>YJ&ImV7L2e- zuMvRI7j&q4k7Wp(D!r-CixK|6%{!kq%}JBz6oTQmfH86=X-#@Y=iLlhU*3`~OJnzz z(H*D3&O`tJK{Ms;0|uR#FVPr@?M(x~}uVarHJ{rL3l2M`A616T>SO=GinNrmr&O{Jp=KC?#g z7m##`c$Az)c^RFZI?7Il`}keiIb|*27V^n7%XsqEUmiTpfgZ#xaFle8YRY->VCSOMV`5|2@>I z)|`1qDPJ@naj?O65*$E>$Vgua#6|0@;6O4c8O73d`tt&LI*=9^slXAI7t^(7E|gM~VaAUMr2qYqPodJVIvsT&mG+@5RXd+VxeW~~md?YSX|1g3gFMDHE5MCav+x!#3$C{MRdd7RduiC`lSV`kR20s3E5FYqJuEZhF364m zes=X45lX@E@(HK>MxbPEB4+yY{st+b4U070SBM(%gpS~7n_*Gu%er9fwVnDc*C5eF z@=c`5O6=L;)mEYA9!{j49mOaciUnPim5U;F+We2U(jVVFx#w{jZBlYCgZNM?ERJ&E zG*lmYPV6&>!a9;D(@Qq2a=n+evNNCT2RC>FQSAnDPU4SrQ-`nHoH0}(!@aK0M5J9- zL~z>WQ>nktMB)WHC4Vk67(6s_1D`=X4AftDYf9}^@kc$SeE)c0ajsnOZ=MHLRL6Lq zULlM>+(h#olk#66@H}xw&g9%wjhg87o_y>NeILJHKq;k)(SmFs-PHw?E#E|efNM!o z74&W5VRvQ5b@x|_1XRfaH&%yfd>jIDMzaFqo;r@Mkc1idI#C7|-I}5+{wRGJry_?T zz3E)O32(DCkevN4@eaKwl6$bhjzJbN`Fs&`D(+msV0{)``ZJSXp_9byd?HrqKtPo3 z%+dCH!#BKDB>ApDQH~&*i!=*^@`2HWD@#tpXU^FVdhdHLV$p{ve%L;p{ZL4^krK$} zzok;YJKPR*7Y90>VP}VQS_HHyPnO6}|Ky8v(O4;t7ooRG>aeVY>tYar*{)z^{ZJri z?bEbs149$PE`SNXd%U)7X`g++#7cEL`I@bOxOqWXBUrv*5aDQ&AHRnyX}o7ba0MJ$ z(lqW-wkXy<8wNFXVUF+>7FHeN0{wMSx3>5n@HyGQD34&96s9SU%v}*QQ;LIm#;QE@ zK|FfcR`CkDqwHDskng*r`c-#m!BLNc2J`dyg`)yjjLr3;i1K^E&~VT?p*Xd{Y?M}P}paW zu%rM~Om~lIJkU((DxoDLPU~>osZz8}-?A!$gxM8=)y^(3DQy!)51Kf?;yI@lU0f?* zG5WyHJ-w=er>Rq;IEkj*6$gU%oUNxt(9lQk7N*;qN35pE)V5FT(DcwXJ<}i_IWKKr z4veT3U3hkBH|!8;Bguno|EW)IiFOv2@w`!~euUFgaG`4xVEfD9(OY@^IN6cE-y^ru2 zKOH)XbjZGN4Roh>e}=6keF+@t0H^eOnY@VkRX1hYp^c*QI8UhGPlA5RhD zBm8N$I(I1-or*}pY+d@telmKkUXbj2Y0rBZtZC_w6p7+`?B4+6yUP0e=d4-bbA(d^ zKwWgV`e*-z;OnEmaeJY>o=5q2Ci|fM@J#Iwu3EnQ&&M$mm1q}u6f1i6&{27MP4eS2 zypDz=%Cls9GH+^%&RTj9+RAk6mb4u}8HKN?|c$rv@yjf>(tYx#QdQrcjL7bH4-sI2xQ4$NN0$M1@Aj2#UXG-V?g z(5+ICVlA5eO=VISy6A1dK1+EvhJ%XlIl_Um9fsOy{ElZqotY5=sx%$Ycl)?%n1!1A zdXO*(LCDWBwbEC4Gc&!bs=DJP7?HD9E@ws)Vu=Zok%OkDBB(_~I8AZKJo>JQz*lY4NuJ%hkIJuGO zUR^WDJPJhCu}Nf{7Qx(qo68L>2olaz7gkc4OZYZ4Z+Oyhhe96_b&$1i^=IVwSUqc5 z%;!nkc~XkO^UJ>G?IUvcs*Y+xoZwMXJUqOYQqxin>xtpoQzSU-EO81NE_U1GVK{NT zeCbe`=i62KMs*a&qg3lJ2HMo0U(@}TvE6T!$m90OK!?Gn{$v&Ea``WO zpr9ah6~9^C%(&V(jyO7Ea+c?XdcvIs(3Mvup`u!s$Wbd@p==nV1fko-U4H6puO9d_ z=7ag5R52$|9<8na-m3s2i&Z#VBQ1|W@PR(sFBDhQhZkN1Nwh;4dUt%H6AO)nR<|&Ok~ROw~DlBXMJ{4Yb}*% zpz3HcvcW)W$;;|wH7&(*l#Uj8CiaOoZ6;YdAXapNZ_zuZd@b8DU`Mp=Z&$kbp*5Pi zA}G0%?2tIg9I>cY!?UH(6KM|d!j_kf*;*R1cxQv`S-5sk;g3;iY}|a`=;T=gy>tZ9 z5fs36KR%5qc$O=Pj!2lJ-|oLRu?VRY*_QL)4<23^>&IL3LY@jiJ^vE|quJOoW_*!1 zaRB#q4ewmcn2{-ym!NIE{_z>>5`l|v==E3e=cU`wMIhywz``JiP8GhaIcc@aM$Y&P zjvQv1Z^k|+?cZ7O~eo7 ztvhcwXjWngA!SvVEZE-JUL#+D{$RhCc~iRdgbQ~DJ#)YvMs0j%sQ84)sJJV_iwmtP$b z9JeEvM@gfnwWfi1aiuGxfp=gES|+*10TFP~?7-#JYdeRrl_wX#$n()@y!wN&I{BDLF|#2>zP-(c(uyA%Vk#2X z^rcE39kNcXgbX5F1!3c(Lo{%wSETLd+xC@h;ui=lyZ^kbwgz{e=L=k+C4H5>Xj^|F zp)}hwW|EEpT(M2~(F3akgg~z`s*Yp&^#cWr6DMWrVf+zvgcopsxK%$%rQ1iGL>uk< zD6I^~R+B%6N{Guaf2&LSKAAhLy*T0DrQ_tZ-3^tUyih@-H9TozqhU|h7<{>A&rn$W z_kTfwPlr3Sr^TF)E#J-I7NEOif43g|own?VCg`UL%`3YCSB>j&^i;Hc_YTFamB81n z9qbueYp{~gU#-y<@HeP69J(_yOts+lvfGPWFi|}c<;6pm53j&X_U-WK%bim6tx!lP z`EQ4JFe(wl7v_ZTPK1V!007uOn}EwcI)GLN;5Q{`t@S!a+D!rM=rfG^uuviuw5u{6 z+?&jP+eWzfGmK&rj+~tZ%Q-(o1>^5P>W5_9GTUe3xp1aLqYX!AOSWkTfvB1HP9;tg zsx(9%^WTmT_}5aKB4#IC@U>j2&*aH_Q`{P^s8(CDKFjxg!DhDx5WygyN;FEW{6vs@ z>hun9P^(zGSaBZ|vAnJ7?{B+*IKlJD&^^{y0C$qOu*)aHR1&qF7z|^T)8Y z4e!;_u0+P_CZM5#mdhWhpD7-Gzq?tY??x8IydCbM&8qHWFnas4q>cLPRES&kFzRSa zob@V`nW&#vFI`mp+1PKOs*lZiDoPi5!Os1@+e*xshsaTUBDAt{jCRiamM>T~l93pR zjZ^?l(Qz3ZAEWO0xITIyaKet&?bla4;ZKeCVo=wP!Xu=IAaSiT%w7~6nHMzW;Tk%~S(yw)zkKD8^6o;toZCK781GW8eP(;j7pO literal 0 HcmV?d00001 diff --git a/services/api/src/owl/billing.py b/services/api/src/owl/billing.py deleted file mode 100644 index 788dd7f..0000000 --- a/services/api/src/owl/billing.py +++ /dev/null @@ -1,663 +0,0 @@ -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, - *, - 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: - 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, - }, - ), - ] - - # 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, 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, - 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, - 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 - - 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 - - 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 - - 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 - - # 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, - }, - ) - ] - - # --- 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: - 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: - 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.") - - 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/client.py b/services/api/src/owl/client.py new file mode 100644 index 0000000..de9c27f --- /dev/null +++ b/services/api/src/owl/client.py @@ -0,0 +1,125 @@ +import base64 +from typing import Any, Type + +import httpx +from fastapi import FastAPI +from pydantic import BaseModel + +from jamaibase.client import _ClientAsync +from owl.configs import ENV_CONFIG +from owl.version import __version__ + + +class VictoriaMetricsAsync(_ClientAsync): + def __init__( + self, + api_base: str = f"http://{ENV_CONFIG.victoria_metrics_host}:{ENV_CONFIG.victoria_metrics_port}", + user: str = ENV_CONFIG.victoria_metrics_user, + password: str = ENV_CONFIG.victoria_metrics_password_plain, + timeout: float | None = 10.0, + ) -> None: + """ + Creates an async Emu client. + + Args: + api_base (str, optional): The base URL for the API. + Defaults to "http://{ENV_CONFIG.victoria_metrics_host}:{ENV_CONFIG.victoria_metrics_port}". + user (str, optional): Victoria Metrics Basic authentication Username. + Defaults to ENV_CONFIG.victoria_metrics_user. + password (str, optional): Victoria Metrics Basic authentication Password. + Defaults to ENV_CONFIG.victoria_metrics_password_plain. + timeout (float | None, optional): The timeout to use when sending requests. + Defaults to 10 seconds. + """ + http_client = httpx.AsyncClient( + timeout=timeout, + transport=httpx.AsyncHTTPTransport(retries=3), + ) + + def basic_auth(username, password): + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + return f"Basic {token}" + + headers = {"Authorization": basic_auth(user, password)} + kwargs = dict( + user_id="", + project_id="", + token="", + api_base=api_base, + headers=headers, + http_client=http_client, + timeout=timeout, + ) + super().__init__(**kwargs) + + async def _fetch_victoria_metrics( + self, endpoint: str, params: dict | None = None + ) -> httpx.Response: + """ + Send a GET request to the specified VictoriaMetrics API endpoint. + + Args: + endpoint (str): The API endpoint to send the request to. + params (dict | None, optional): Query parameters to include in the request. + + Returns: + httpx.Response | None: The HTTP response object if the request is successful, or None if the request fails. + """ + return await self._get(endpoint, params=params) + + +class JamaiASGIAsync(_ClientAsync): + def __init__( + self, + app: FastAPI, + timeout: float | None = None, + ) -> None: + """ + Creates an async Owl ASGI client. + + Args: + timeout (float | None, optional): The timeout to use when sending requests. + Defaults to None. + """ + super().__init__( + user_id="", + project_id="", + token="", + api_base="", + headers=None, + http_client=httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://apiserver", + timeout=timeout, + ), + timeout=timeout, + ) + + async def request( + self, + method: str, + endpoint: str, + *, + headers: dict[str, Any] | None = None, + params: dict[str, Any] | BaseModel | None = None, + body: BaseModel | None = None, + response_model: Type[BaseModel] | None = None, + timeout: float | None = None, + **kwargs, + ) -> httpx.Response | BaseModel: + if headers is None: + headers = {} + headers["User-Agent"] = headers.get("User-Agent", f"MCP-Server/{__version__}") + return await self._request( + method=method, + address="", + endpoint=endpoint, + headers=headers, + params=params, + body=body, + response_model=response_model, + timeout=timeout, + ignore_code=None, + process_body_kwargs=None, + **kwargs, + ) diff --git a/services/api/src/owl/configs/__init__.py b/services/api/src/owl/configs/__init__.py index e69de29..6ac6281 100644 --- a/services/api/src/owl/configs/__init__.py +++ b/services/api/src/owl/configs/__init__.py @@ -0,0 +1,35 @@ +import os +from os.path import join + +from celery import Celery + +from owl.utils.cache import Cache + +try: + from owl.configs.cloud import EnvConfig +except ImportError: + from owl.configs.oss import EnvConfig + +ENV_CONFIG = EnvConfig() +CACHE = Cache( + redis_url=f"redis://{ENV_CONFIG.redis_host}:{ENV_CONFIG.redis_port}/1", + clickhouse_buffer_key=ENV_CONFIG.clickhouse_buffer_key, +) + + +celery_app = Celery("tasks", broker=f"redis://{ENV_CONFIG.redis_host}:{ENV_CONFIG.redis_port}/0") + +# Configure Celery +CELERY_SCHEDULER_DB = "_scheduler" +os.makedirs(CELERY_SCHEDULER_DB, exist_ok=True) +celery_app.conf.update( + result_backend=f"redis://{ENV_CONFIG.redis_host}:{ENV_CONFIG.redis_port}/0", + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + result_expires=36000, + # TODO: Update to use DB via sqlalchemy-celery-beat + beat_schedule_filename=join(CELERY_SCHEDULER_DB, "celerybeat-schedule"), +) diff --git a/services/api/src/owl/configs/manager.py b/services/api/src/owl/configs/manager.py deleted file mode 100644 index a434edc..0000000 --- a/services/api/src/owl/configs/manager.py +++ /dev/null @@ -1,553 +0,0 @@ -import os -from decimal import Decimal -from enum import Enum -from functools import cached_property, lru_cache -from os.path import abspath -from pathlib import Path -from typing import Annotated, Any - -import redis -from loguru import logger -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 ( - EXAMPLE_CHAT_MODEL_IDS, - EXAMPLE_EMBEDDING_MODEL_IDS, - EXAMPLE_RERANKING_MODEL_IDS, - ModelListConfig, -) - -CURR_DIR = Path(__file__).resolve().parent - - -class EnvConfig(BaseSettings): - model_config = SettingsConfigDict( - # env_prefix="owl_", # TODO: Enable this - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - cli_parse_args=False, - ) - # API configs - owl_is_prod: bool = False - owl_cache_purge: bool = False - owl_db_dir: str = "db" - owl_file_dir: str = "file://file" - owl_log_dir: str = "logs" - owl_file_proxy_url: str = "localhost:6969" - owl_host: str = "0.0.0.0" - 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_embed_file_upload_max_bytes: int = 200 * 1024 * 1024 # 200MB in bytes - owl_image_file_upload_max_bytes: int = 20 * 1024 * 1024 # 20MB in bytes - owl_audio_file_upload_max_bytes: int = 120 * 1024 * 1024 # 120MB in bytes - owl_compute_storage_period_min: float = 1 - owl_models_config: str = "models.json" - owl_pricing_config: str = "cloud_pricing.json" - # 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 - # Code Executor configs - code_executor_endpoint: str = "http://kopi:5569" - # Loader configs - docio_url: str = "http://docio:6979/api/docio" - unstructuredio_url: str = "http://unstructuredio:6989" - # 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" - # 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 = "" - cohere_api_key: SecretStr = "" - groq_api_key: SecretStr = "" - together_api_key: SecretStr = "" - jina_api_key: SecretStr = "" - voyage_api_key: SecretStr = "" - hyperbolic_api_key: SecretStr = "" - cerebras_api_key: SecretStr = "" - sambanova_api_key: SecretStr = "" - deepseek_api_key: SecretStr = "" - - @model_validator(mode="after") - def make_paths_absolute(self): - 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() - - @property - def stripe_api_key_plain(self): - return self.stripe_api_key.get_secret_value() - - @property - 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() - - @property - def anthropic_api_key_plain(self): - return self.anthropic_api_key.get_secret_value() - - @property - def gemini_api_key_plain(self): - return self.gemini_api_key.get_secret_value() - - @property - def cohere_api_key_plain(self): - return self.cohere_api_key.get_secret_value() - - @property - def groq_api_key_plain(self): - return self.groq_api_key.get_secret_value() - - @property - def together_api_key_plain(self): - return self.together_api_key.get_secret_value() - - @property - def jina_api_key_plain(self): - return self.jina_api_key.get_secret_value() - - @property - 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() - - @property - def deepseek_api_key_plain(self): - return self.deepseek_api_key.get_secret_value() - - -MODEL_CONFIG_KEY = " models" -PRICES_KEY = " prices" -INTERNAL_ORG_ID_KEY = " internal_org_id" -ENV_CONFIG = EnvConfig() -# Create db dir -try: - os.makedirs(ENV_CONFIG.owl_db_dir, exist_ok=False) -except OSError: - pass - - -class PlanName(str, Enum): - 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" - 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): - """ - 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[ProductType, Product] = Field( - description="Mapping of price name to tier list where each element represents a pricing tier.", - ) - - @computed_field - @property - def stripe_price_id(self) -> str: - return ( - self.stripe_price_id_live - if ENV_CONFIG.stripe_api_key_plain.startswith("sk_live") - else self.stripe_price_id_test - ) - - -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 _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_IDS[0], - EXAMPLE_EMBEDDING_MODEL_IDS[0], - EXAMPLE_RERANKING_MODEL_IDS[0], - ], - ) - 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.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.debug("Using in-memory dict as cache.") - self._data = {} - - def get(self, key: str) -> Any: - return self[key] - - def set(self, key: str, value: str) -> None: - self[key] = value - - def purge(self): - if self.use_redis: - for key in self._redis.scan_iter("*"): - self._redis.delete(key) - else: - self._data = {} - - def __setitem__(self, key: str, value: str) -> None: - if not isinstance(value, str): - raise TypeError(f"`value` must be a str, received: {type(value)}") - if not (isinstance(key, str) and key.startswith("")): - raise ValueError(f'`key` must be a str that starts with "", received: {key}') - if self.use_redis: - self._redis.set(key, value) - else: - self._data[key] = value - - def __getitem__(self, key: str) -> str | None: - if self.use_redis: - item = self._redis.get(key) - return None if item is None else item.decode("utf-8") - else: - try: - return self._data[key] - except KeyError: - return None - - def __delitem__(self, key) -> None: - if self.use_redis: - self._redis.delete(key) - else: - if key in self._data: - del self._data[key] - - def __contains__(self, key) -> bool: - if self.use_redis: - self._redis.exists(key) - else: - return key in self._data - - def __repr__(self) -> str: - if self.use_redis: - _data = {key.decode("utf-8"): self[key] for key in self._redis.scan_iter("*")} - else: - _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_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 = self._load_model_config_from_json(f.read()) - return models - - def get_model_json(self) -> str: - 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 - 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 self._load_model_config_from_json(model_json) - - def set_model_config(self, body: ModelListConfig) -> None: - 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(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_pricing(self) -> ModelPrice: - return ModelPrice.model_validate(self.get_model_config().model_dump(exclude={"object"})) - - @staticmethod - @lru_cache(maxsize=1) - 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 = self._load_pricing_from_json(f.read()) - return pricing - - def get_pricing(self) -> Price: - pricing_json = self[PRICES_KEY] - if pricing_json is None: - pricing = self._load_pricing_from_file() - self[PRICES_KEY] = pricing.model_dump_json() - logger.warning(f"Pricing set to: {pricing}") - return pricing - return self._load_pricing_from_json(pricing_json) - - def set_pricing(self, body: Price) -> None: - 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(body.model_dump_json(exclude_defaults=True)) - except Exception as e: - logger.warning(f"Failed to update `{ENV_CONFIG.owl_pricing_config}`: {e}") - - -CONFIG = Config() diff --git a/services/api/src/owl/configs/models.json b/services/api/src/owl/configs/models.json deleted file mode 100644 index 7ec0332..0000000 --- a/services/api/src/owl/configs/models.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "llm_models": [ - { - "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", - "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-8B-Instruct-Turbo", - "name": "Together AI Meta Llama 3.1 (8B)", - "context_length": 130000, - "languages": ["mul"], - "capabilities": ["chat"], - "deployments": [ - { - "litellm_id": "", - "api_base": "", - "provider": "together_ai" - } - ] - } - ], - "embed_models": [ - { - "id": "ellm/BAAI/bge-small-en-v1.5", - "name": "ELLM BAAI BGE Small EN v1.5", - "context_length": 512, - "embedding_size": 384, - "languages": ["mul"], - "capabilities": ["embed"], - "deployments": [ - { - "litellm_id": "openai/BAAI/bge-small-en-v1.5", - "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"], - "deployments": [ - { - "litellm_id": "embed-multilingual-v3.0", - "api_base": "", - "provider": "cohere" - } - ] - } - ], - "rerank_models": [ - { - "id": "ellm/mixedbread-ai/mxbai-rerank-xsmall-v1", - "name": "ELLM MxBAI Rerank XSmall v1", - "context_length": 512, - "languages": ["en"], - "capabilities": ["rerank"], - "deployments": [ - { - "litellm_id": "", - "api_base": "http://infinity:6909", - "provider": "ellm" - } - ] - }, - { - "id": "cohere/rerank-multilingual-v3.0", - "name": "Cohere Rerank Multilingual v3.0", - "context_length": 512, - "languages": ["mul"], - "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 deleted file mode 100644 index 3ba623c..0000000 --- a/services/api/src/owl/configs/models_aipc.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "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/BAAI/bge-small-en-v1.5", - "name": "ELLM BAAI BGE Small EN v1.5", - "context_length": 512, - "embedding_size": 384, - "languages": ["mul"], - "capabilities": ["embed"], - "deployments": [ - { - "litellm_id": "openai/BAAI/bge-small-en-v1.5", - "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/mixedbread-ai/mxbai-rerank-xsmall-v1", - "name": "ELLM MxBAI Rerank XSmall v1", - "context_length": 512, - "languages": ["en"], - "capabilities": ["rerank"], - "deployments": [ - { - "litellm_id": "", - "api_base": "http://infinity:6909", - "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_ci.json b/services/api/src/owl/configs/models_ci.json deleted file mode 100644 index fcc62a0..0000000 --- a/services/api/src/owl/configs/models_ci.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "llm_models": [ - { - "id": "openai/gpt-4o-mini", - "name": "OpenAI GPT-4o Mini", - "context_length": 128000, - "languages": ["mul"], - "capabilities": ["chat", "image", "tool"], - "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", "tool"], - "deployments": [ - { - "litellm_id": "", - "api_base": "", - "provider": "anthropic" - } - ] - }, - { - "id": "meta/Llama3.2-3b-instruct", - "name": "Meta Llama 3.2 (3B)", - "context_length": 128000, - "languages": ["mul"], - "capabilities": ["chat"], - "deployments": [ - { - "litellm_id": "openai/meta/Llama3.2-3b-instruct", - "api_base": "https://llmci.embeddedllm.com/chat/v1", - "provider": "custom" - } - ] - }, - { - "id": "ellm/Qwen/Qwen-2-Audio-7B", - "object": "model", - "name": "Qwen 2 Audio 7B (Audio, internal)", - "context_length": 128000, - "languages": ["mul"], - "capabilities": ["chat", "audio"], - "deployments": [ - { - "litellm_id": "openai/Qwen/Qwen-2-Audio-7B", - "api_base": "https://llmci.embeddedllm.com/audio/v1", - "provider": "custom" - } - ] - } - ], - "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-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"], - "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:6909", - "provider": "ellm" - } - ] - }, - { - "id": "cohere/rerank-multilingual-v3.0", - "name": "Cohere Rerank Multilingual v3.0", - "context_length": 512, - "languages": ["mul"], - "capabilities": ["rerank"], - "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 deleted file mode 100644 index 00f1ed0..0000000 --- a/services/api/src/owl/configs/models_ollama.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "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/Qwen/Qwen2.5-3B-Instruct", - "name": "ELLM Qwen2.5 (3B)", - "context_length": 32000, - "languages": ["en"], - "capabilities": ["chat"], - "deployments": [ - { - "litellm_id": "openai/Qwen/Qwen2.5-3B-Instruct", - "api_base": "http://ollama:11434/v1", - "provider": "ellm" - } - ] - } - ], - "embed_models": [ - { - "id": "ellm/BAAI/bge-small-en-v1.5", - "name": "ELLM BAAI BGE Small EN v1.5", - "context_length": 512, - "embedding_size": 384, - "languages": ["mul"], - "capabilities": ["embed"], - "deployments": [ - { - "litellm_id": "openai/BAAI/bge-small-en-v1.5", - "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/mixedbread-ai/mxbai-rerank-xsmall-v1", - "name": "ELLM MxBAI Rerank XSmall v1", - "context_length": 512, - "languages": ["en"], - "capabilities": ["rerank"], - "deployments": [ - { - "litellm_id": "", - "api_base": "http://infinity:6909", - "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/oss.py b/services/api/src/owl/configs/oss.py new file mode 100644 index 0000000..87d9338 --- /dev/null +++ b/services/api/src/owl/configs/oss.py @@ -0,0 +1,306 @@ +from functools import cached_property +from os.path import abspath +from pathlib import Path +from typing import Annotated, Literal, Self + +from loguru import logger +from pydantic import Field, SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +CURR_DIR = Path(__file__).resolve().parent + + +class EnvConfig(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="owl_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + cli_parse_args=False, + ) + # API configs + db_path: str = "postgresql+psycopg://owlpguser:owlpgpassword@pgbouncer:5432/jamaibase_owl" # Default to Postgres + log_dir: str = "logs" + host: str = "0.0.0.0" + port: int = 6969 + workers: int = 1 # The suggested number of workers is (2*CPU)+1 + max_concurrency: int = 300 + db_init: bool | None = None # None means unset + db_reset: bool = False + db_init_max_users: int = 5 + cache_reset: bool = False + enable_byok: bool = True + disable_billing: bool = False + log_timings: bool = False + # Services + redis_host: str = "dragonfly" + redis_port: int = 6379 + file_proxy_url: str = "localhost:6969" + file_dir: str = "s3://file" + s3_endpoint: str = "http://minio:9000" + s3_access_key_id: str = "minioadmin" + s3_secret_access_key: SecretStr = "minioadmin" + code_executor_endpoint: str = "http://kopi:3000" + docling_url: str = "http://docling:5001" + docling_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 20 * 60 + test_llm_api_base: str = "http://test-llm:6970/v1" + # Configs + embed_file_upload_max_bytes: int = 200 * 1024 * 1024 # 200MiB in bytes + image_file_upload_max_bytes: int = 20 * 1024 * 1024 # 20MiB in bytes + audio_file_upload_max_bytes: int = 120 * 1024 * 1024 # 120MiB in bytes + compute_storage_period_sec: Annotated[float, Field(ge=0, le=60 * 60)] = 60 * 5 + document_loader_cache_ttl_sec: int = 60 * 15 # 15 minutes + # Starling configs + s3_backup_bucket_name: str = "" + # Starling database configs + flush_clickhouse_buffer_sec: int = 60 + # Generative Table configs + concurrent_rows_batch_size: int = 3 + concurrent_cols_batch_size: int = 5 + max_write_batch_size: int = 100 + max_file_cache_size: int = 20 + # PDF Loader configs + fast_pdf_parsing: bool = True + # LLM configs + llm_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 60 + embed_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 60 + code_timeout_sec: Annotated[int, Field(gt=0, le=60 * 60)] = 120 + cohere_api_base: str = "https://api.cohere.ai/v1" + jina_ai_api_base: str = "https://api.jina.ai/v1" + voyage_api_base: str = "https://api.voyageai.com/v1" + # Keys + encryption_key: SecretStr = "" + service_key: SecretStr = "" + service_key_alt: SecretStr = "" + # OpenTelemetry configs + opentelemetry_host: str = "otel-collector" + opentelemetry_port: int = 4317 + # VictoriaMetrics configs + victoria_metrics_host: str = "vmauth" + victoria_metrics_port: int = 8427 + victoria_metrics_user: str = "owl" + victoria_metrics_password: SecretStr = "owl-vm" + # Clickhouse configs + clickhouse_host: str = "clickhouse" + clickhouse_port: int = 8123 + clickhouse_user: str = "owluser" + clickhouse_password: SecretStr = "owlpassword" + clickhouse_db: str = "jamaibase_owl" + clickhouse_max_buffer_queue_size: int = 10000 + # Clickhouse Redis queue buffer + clickhouse_buffer_key: str = "clickhouse_insert_buffer" + # Stripe & Billing + stripe_api_key: SecretStr = "" + stripe_publishable_key_live: SecretStr = "" + stripe_publishable_key_test: SecretStr = "" + stripe_webhook_secret_live: SecretStr = "" + stripe_webhook_secret_test: SecretStr = "" + payment_lapse_max_days: int = 7 + # Auth0 + auth0_api_key: SecretStr = "" + # Keys + unstructuredio_api_key: SecretStr = "ellm" + anthropic_api_key: SecretStr = "" + azure_api_key: SecretStr = "" + azure_ai_api_key: SecretStr = "" + bedrock_api_key: SecretStr = "" + cerebras_api_key: SecretStr = "" + cohere_api_key: SecretStr = "" + deepseek_api_key: SecretStr = "" + ellm_api_key: SecretStr = "" + gemini_api_key: SecretStr = "" + groq_api_key: SecretStr = "" + hyperbolic_api_key: SecretStr = "" + jina_ai_api_key: SecretStr = "" + openai_api_key: SecretStr = "" + openrouter_api_key: SecretStr = "" + sagemaker_api_key: SecretStr = "" + sambanova_api_key: SecretStr = "" + together_ai_api_key: SecretStr = "" + vertex_ai_api_key: SecretStr = "" + voyage_api_key: SecretStr = "" + + @model_validator(mode="after") + def check_db_init(self) -> Self: + if self.db_init is None: + self.db_init = True if self.is_oss else False + return self + + @model_validator(mode="after") + def make_paths_absolute(self) -> Self: + self.log_dir = abspath(self.log_dir) + return self + + @model_validator(mode="after") + def check_alternate_service_key(self) -> Self: + if self.service_key_alt.get_secret_value().strip() == "": + self.service_key_alt = self.service_key + return self + + @model_validator(mode="after") + def validate_db_path(self) -> Self: + """ + Validates that `db_path` starts with either `rqlite+pyrqlite://` or `sqlite://` or `sqlite+libsql://` or `postgresql`. + """ + if not ( + self.db_path.startswith("rqlite+pyrqlite://") + or self.db_path.startswith("sqlite://") + or self.db_path.startswith("sqlite+libsql://") + or self.db_path.startswith("postgresql") + ): + raise ValueError(f'`db_path` "{self.db_path}" has an invalid dialect.') + return self + + @property + def db_dialect(self) -> Literal["rqlite", "libsql", "postgresql", "sqlite"]: + """ + Show the sqlite dialect that's in use based on the `db_path`. + """ + if self.db_path.startswith("rqlite+pyrqlite://"): + return "rqlite" + elif self.db_path.startswith("sqlite+libsql://"): + return "libsql" + elif self.db_path.startswith("postgresql"): + return "postgresql" + elif self.db_path.startswith("sqlite://"): + return "sqlite" + + @cached_property + def is_oss(self) -> bool: + logger.opt(colors=True).info("Launching in OSS mode.") + return True + + @cached_property + def is_cloud(self) -> bool: + return not self.is_oss + + @property + def s3_secret_access_key_plain(self) -> str: + return self.s3_secret_access_key.get_secret_value() + + @property + def victoria_metrics_password_plain(self) -> str: + return self.victoria_metrics_password.get_secret_value().strip() + + @property + def is_stripe_live(self) -> bool: + return self.stripe_api_key_plain.startswith("sk_live") + + @property + def stripe_api_key_plain(self) -> str: + return self.stripe_api_key.get_secret_value() + + @property + def stripe_webhook_secret_plain(self) -> str: + return ( + self.stripe_webhook_secret_live.get_secret_value() + if self.is_stripe_live + else self.stripe_webhook_secret_test.get_secret_value() + ) + + @property + def stripe_publishable_key_plain(self) -> str: + return ( + self.stripe_publishable_key_live.get_secret_value() + if self.is_stripe_live + else self.stripe_publishable_key_test.get_secret_value() + ) + + @property + def auth0_api_key_plain(self) -> str: + return self.auth0_api_key.get_secret_value() + + @property + def encryption_key_plain(self) -> str: + return self.encryption_key.get_secret_value() + + @property + def service_key_plain(self) -> str: + return self.service_key.get_secret_value() + + @property + def service_key_alt_plain(self) -> str: + return self.service_key_alt.get_secret_value() + + @property + def unstructuredio_api_key_plain(self) -> str: + return self.unstructuredio_api_key.get_secret_value() + + @property + def anthropic_api_key_plain(self) -> str: + return self.anthropic_api_key.get_secret_value() + + @property + def azure_api_key_plain(self) -> str: + return self.azure_api_key.get_secret_value() + + @property + def azure_ai_api_key_plain(self) -> str: + return self.azure_ai_api_key.get_secret_value() + + @property + def bedrock_api_key_plain(self) -> str: + return self.azure_ai_api_key.get_secret_value() + + @property + def cerebras_api_key_plain(self) -> str: + return self.cerebras_api_key.get_secret_value() + + @property + def cohere_api_key_plain(self) -> str: + return self.cohere_api_key.get_secret_value() + + @property + def deepseek_api_key_plain(self) -> str: + return self.deepseek_api_key.get_secret_value() + + @property + def ellm_api_key_plain(self) -> str: + return self.ellm_api_key.get_secret_value() + + @property + def gemini_api_key_plain(self) -> str: + return self.gemini_api_key.get_secret_value() + + @property + def groq_api_key_plain(self) -> str: + return self.groq_api_key.get_secret_value() + + @property + def hyperbolic_api_key_plain(self) -> str: + return self.hyperbolic_api_key.get_secret_value() + + @property + def jina_ai_api_key_plain(self) -> str: + return self.jina_ai_api_key.get_secret_value() + + @property + def openai_api_key_plain(self) -> str: + return self.openai_api_key.get_secret_value() + + @property + def openrouter_api_key_plain(self) -> str: + return self.openrouter_api_key.get_secret_value() + + @property + def sagemaker_api_key_plain(self) -> str: + return self.sagemaker_api_key.get_secret_value() + + @property + def sambanova_api_key_plain(self) -> str: + return self.sambanova_api_key.get_secret_value() + + @property + def together_ai_api_key_plain(self) -> str: + return self.together_ai_api_key.get_secret_value() + + @property + def vertex_ai_api_key_plain(self) -> str: + return self.vertex_ai_api_key.get_secret_value() + + @property + def voyage_api_key_plain(self) -> str: + return self.voyage_api_key.get_secret_value() + + def get_api_key(self, provider: str, default: str = "") -> str: + return getattr(self, f"{provider}_api_key_plain", default) diff --git a/services/api/src/owl/configs/preset_models.json b/services/api/src/owl/configs/preset_models.json index 34ef5ce..fb60cc0 100644 --- a/services/api/src/owl/configs/preset_models.json +++ b/services/api/src/owl/configs/preset_models.json @@ -7,13 +7,14 @@ "name": "OpenAI GPT-4.1", "type": "llm", "context_length": 1047576, + "max_output_tokens": 32768, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 2.0, "llm_output_cost_per_mtoken": 8.0, "deployments": [ { - "name": "OpenAI GPT-4.1", + "name": "OpenAI GPT-4.1 Deployment", "provider": "openai", "routing_id": "openai/gpt-4.1", "api_base": "" @@ -28,13 +29,14 @@ "name": "OpenAI GPT-4.1 Mini", "type": "llm", "context_length": 1047576, + "max_output_tokens": 32768, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 0.4, "llm_output_cost_per_mtoken": 1.6, "deployments": [ { - "name": "OpenAI GPT-4.1 Mini", + "name": "OpenAI GPT-4.1 Mini Deployment", "provider": "openai", "routing_id": "openai/gpt-4.1-mini", "api_base": "" @@ -49,13 +51,14 @@ "name": "OpenAI GPT-4.1 Nano", "type": "llm", "context_length": 1047576, + "max_output_tokens": 32768, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 0.1, "llm_output_cost_per_mtoken": 0.4, "deployments": [ { - "name": "OpenAI GPT-4.1 Nano", + "name": "OpenAI GPT-4.1 Nano Deployment", "provider": "openai", "routing_id": "openai/gpt-4.1-nano", "api_base": "" @@ -69,37 +72,61 @@ "id": "openai/gpt-4o", "name": "OpenAI GPT-4o", "type": "llm", - "context_length": 1047576, + "context_length": 128000, + "max_output_tokens": 16384, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 2.5, "llm_output_cost_per_mtoken": 10.0, "deployments": [ { - "name": "OpenAI GPT-4o", + "name": "OpenAI GPT-4o Deployment", "provider": "openai", "routing_id": "openai/gpt-4o", "api_base": "" } ] }, + { + "meta": { + "icon": "openai" + }, + "id": "openai/gpt-4o-mini", + "name": "OpenAI GPT-4o Mini", + "type": "llm", + "context_length": 128000, + "max_output_tokens": 16384, + "capabilities": ["chat", "image"], + "languages": ["en", "mul"], + "llm_input_cost_per_mtoken": 0.15, + "llm_output_cost_per_mtoken": 0.6, + "deployments": [ + { + "name": "OpenAI GPT-4o Mini Deployment", + "provider": "openai", + "routing_id": "openai/gpt-4o-mini", + "api_base": "" + } + ] + }, { "meta": { "icon": "anthropic" }, - "id": "anthropic/claude-3.5-haiku", - "name": "Anthropic Claude 3.5 Haiku", + "id": "anthropic/claude-opus-4", + "name": "Anthropic Claude Opus 4", "type": "llm", "context_length": 200000, + "max_output_tokens": 32000, "capabilities": ["chat", "image"], "languages": ["en", "mul"], - "llm_input_cost_per_mtoken": 0.8, - "llm_output_cost_per_mtoken": 4.0, + "llm_input_cost_per_mtoken": 3.0, + "llm_output_cost_per_mtoken": 15.0, "deployments": [ { - "name": "Anthropic Claude 3.5 Haiku", + "name": "Anthropic Claude Opus 4 Deployment", "provider": "anthropic", - "routing_id": "anthropic/claude-3-5-haiku-20241022", + "routing_id": "anthropic/claude-opus-4-0", "api_base": "" } ] @@ -108,19 +135,20 @@ "meta": { "icon": "anthropic" }, - "id": "anthropic/claude-3.5-sonnet", - "name": "Anthropic Claude 3.5 Sonnet", + "id": "anthropic/claude-sonnet-4", + "name": "Anthropic Claude Sonnet 4", "type": "llm", "context_length": 200000, + "max_output_tokens": 64000, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 3.0, "llm_output_cost_per_mtoken": 15.0, "deployments": [ { - "name": "Anthropic Claude 3.5 Sonnet", + "name": "Anthropic Claude Sonnet 4 Deployment", "provider": "anthropic", - "routing_id": "anthropic/claude-3-5-sonnet-20241022", + "routing_id": "anthropic/claude-sonnet-4-0", "api_base": "" } ] @@ -130,22 +158,67 @@ "icon": "anthropic" }, "id": "anthropic/claude-3.7-sonnet", - "name": "Anthropic Claude 3.7 Sonnet", + "name": "Anthropic Claude Sonnet 3.7", "type": "llm", "context_length": 200000, + "max_output_tokens": 64000, "capabilities": ["chat", "image"], "languages": ["en", "mul"], "llm_input_cost_per_mtoken": 3.0, "llm_output_cost_per_mtoken": 15.0, "deployments": [ { - "name": "Anthropic Claude 3.7 Sonnet", + "name": "Anthropic Claude Sonnet 3.7 Deployment", "provider": "anthropic", "routing_id": "anthropic/claude-3-7-sonnet-latest", "api_base": "" } ] }, + { + "meta": { + "icon": "anthropic" + }, + "id": "anthropic/claude-3.5-sonnet", + "name": "Anthropic Claude Sonnet 3.5", + "type": "llm", + "context_length": 200000, + "max_output_tokens": 8192, + "capabilities": ["chat", "image"], + "languages": ["en", "mul"], + "llm_input_cost_per_mtoken": 3.0, + "llm_output_cost_per_mtoken": 15.0, + "deployments": [ + { + "name": "Anthropic Claude Sonnet 3.5 Deployment", + "provider": "anthropic", + "routing_id": "anthropic/claude-3-5-sonnet-latest", + "api_base": "" + } + ] + }, + { + "meta": { + "icon": "anthropic" + }, + "id": "anthropic/claude-3.5-haiku", + "name": "Anthropic Claude Haiku 3.5", + "type": "llm", + "context_length": 200000, + "max_output_tokens": 8192, + "capabilities": ["chat", "image"], + "languages": ["en", "mul"], + "llm_input_cost_per_mtoken": 0.8, + "llm_output_cost_per_mtoken": 4.0, + "deployments": [ + { + "name": "Anthropic Claude Haiku 3.5 Deployment", + "provider": "anthropic", + "routing_id": "anthropic/claude-3-5-haiku-latest", + "api_base": "" + } + ] + }, { "meta": { "icon": "google" @@ -160,7 +233,7 @@ "llm_output_cost_per_mtoken": 15.0, "deployments": [ { - "name": "Google Gemini 2.5 Pro Preview", + "name": "Google Gemini 2.5 Pro Preview Deployment", "provider": "gemini", "routing_id": "gemini/gemini-2.5-pro-preview-03-25", "api_base": "" @@ -181,7 +254,7 @@ "llm_output_cost_per_mtoken": 0.6, "deployments": [ { - "name": "Google Gemini 2.5 Flash Preview", + "name": "Google Gemini 2.5 Flash Preview Deployment", "provider": "gemini", "routing_id": "gemini/gemini-2.5-flash-preview-04-17", "api_base": "" @@ -202,13 +275,13 @@ "llm_output_cost_per_mtoken": 0.5, "deployments": [ { - "name": "Meta Llama 4 Scout (109B-A17B, MoE)", + "name": "Meta Llama 4 Scout (109B-A17B, MoE) Deployment", "huggingface_id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "cpu_count": "4", "memory_gb": "24", "required_vram": "140", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -226,13 +299,13 @@ "llm_output_cost_per_mtoken": 0.95, "deployments": [ { - "name": "Meta Llama 4 Maverick (400B-A17B, MoE)", + "name": "Meta Llama 4 Maverick (400B-A17B, MoE) Deployment", "huggingface_id": "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", "cpu_count": "4", "memory_gb": "24", "required_vram": "320", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -250,13 +323,13 @@ "llm_output_cost_per_mtoken": 1.2, "deployments": [ { - "name": "DeepSeek V3 (685B-A22B, MoE)", + "name": "DeepSeek V3 (685B-A22B, MoE) Deployment", "huggingface_id": "deepseek-ai/DeepSeek-V3-0324", "cpu_count": "4", "memory_gb": "8", "required_vram": "1100", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -274,13 +347,13 @@ "llm_output_cost_per_mtoken": 2.5, "deployments": [ { - "name": "DeepSeek R1 (685B-A22B, MoE)", + "name": "DeepSeek R1 (685B-A22B, MoE) Deployment", "huggingface_id": "deepseek-ai/DeepSeek-R1", "cpu_count": "4", "memory_gb": "8", "required_vram": "1100", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -289,7 +362,7 @@ "icon": "qwen" }, "id": "Qwen/Qwen3-235B-A22B-FP8", - "name": "Qwen3 (235B-A22B, MoE)", + "name": "Qwen 3 (235B-A22B, MoE)", "type": "llm", "context_length": 40960, "capabilities": ["chat"], @@ -298,13 +371,13 @@ "llm_output_cost_per_mtoken": 0.45, "deployments": [ { - "name": "Qwen3 (235B-A22B, MoE)", + "name": "Qwen 3 (235B-A22B, MoE) Deployment", "huggingface_id": "Qwen/Qwen3-235B-A22B-FP8", "cpu_count": "4", "memory_gb": "8", "required_vram": "280", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -313,7 +386,7 @@ "icon": "qwen" }, "id": "Qwen/Qwen3-32B-FP8", - "name": "Qwen3 (32B)", + "name": "Qwen 3 (32B)", "type": "llm", "context_length": 40960, "capabilities": ["chat"], @@ -322,13 +395,13 @@ "llm_output_cost_per_mtoken": 0.45, "deployments": [ { - "name": "Qwen3 (32B)", + "name": "Qwen 3 (32B) Deployment", "huggingface_id": "Qwen/Qwen3-32B-FP8", "cpu_count": "4", "memory_gb": "8", "required_vram": "42", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -337,7 +410,7 @@ "icon": "qwen" }, "id": "Qwen/Qwen3-8B-FP8", - "name": "Qwen3 (8B)", + "name": "Qwen 3 (8B)", "type": "llm", "context_length": 40960, "capabilities": ["chat"], @@ -346,13 +419,13 @@ "llm_output_cost_per_mtoken": 0.22, "deployments": [ { - "name": "Qwen3 (8B)", + "name": "Qwen 3 (8B) Deployment", "huggingface_id": "Qwen/Qwen3-8B-FP8", "cpu_count": "4", "memory_gb": "8", "required_vram": "15", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -361,7 +434,7 @@ "icon": "qwen" }, "id": "Qwen/Qwen2.5-VL-32B-Instruct", - "name": "Qwen2.5 VL (32B)", + "name": "Qwen 2.5 VL (32B)", "type": "llm", "context_length": 32768, "capabilities": ["chat"], @@ -370,13 +443,13 @@ "llm_output_cost_per_mtoken": 0.4, "deployments": [ { - "name": "Qwen2.5 VL (32B)", + "name": "Qwen 2.5 VL (32B) Deployment", "huggingface_id": "Qwen/Qwen2.5-VL-32B-Instruct", "cpu_count": "4", "memory_gb": "16", "required_vram": "52", "num_replicas": 1, - "service_type": "vllm" + "provider": "vllm" } ] }, @@ -394,7 +467,7 @@ "embedding_cost_per_mtoken": 0.13, "deployments": [ { - "name": "OpenAI Text Embedding 3 Large (3072-dim)", + "name": "OpenAI Text Embedding 3 Large (3072-dim) Deployment", "provider": "openai", "routing_id": "openai/text-embedding-3-large", "api_base": "" @@ -411,12 +484,12 @@ "context_length": 8192, "capabilities": ["embed"], "languages": ["en", "mul"], - "embedding_size": 256, + "embedding_size": 3072, "embedding_dimensions": 256, "embedding_cost_per_mtoken": 0.13, "deployments": [ { - "name": "OpenAI Text Embedding 3 Large (256-dim)", + "name": "OpenAI Text Embedding 3 Large (256-dim) Deployment", "provider": "openai", "routing_id": "openai/text-embedding-3-large", "api_base": "" @@ -434,16 +507,60 @@ "capabilities": ["embed"], "languages": ["en", "mul"], "embedding_size": 1536, - "embedding_cost_per_mtoken": 0.022, + "embedding_cost_per_mtoken": 0.02, "deployments": [ { - "name": "OpenAI Text Embedding 3 Small (1536-dim)", + "name": "OpenAI Text Embedding 3 Small (1536-dim) Deployment", "provider": "openai", "routing_id": "openai/text-embedding-3-small", "api_base": "" } ] }, + { + "meta": { + "icon": "openai" + }, + "id": "openai/text-embedding-3-small-256", + "name": "OpenAI Text Embedding 3 Small (256-dim)", + "type": "embed", + "context_length": 8192, + "capabilities": ["embed"], + "languages": ["en", "mul"], + "embedding_size": 1536, + "embedding_dimensions": 256, + "embedding_cost_per_mtoken": 0.02, + "deployments": [ + { + "name": "OpenAI Text Embedding 3 Small (256-dim) Deployment", + "provider": "openai", + "routing_id": "openai/text-embedding-3-small", + "api_base": "" + } + ] + }, + { + "meta": { + "icon": "cohere" + }, + "id": "cohere/embed-v4.0-256", + "name": "Cohere Embed v4.0 (256-dim)", + "type": "embed", + "context_length": 128000, + "capabilities": ["embed"], + "languages": ["en", "mul"], + "embedding_size": 1536, + "embedding_dimensions": 256, + "embedding_cost_per_mtoken": 0.12, + "deployments": [ + { + "name": "Cohere Embed v4.0 (256-dim) Deployment", + "provider": "cohere", + "routing_id": "embed-v4.0", + "api_base": "" + } + ] + }, { "meta": { "icon": "cohere" @@ -458,7 +575,7 @@ "embedding_cost_per_mtoken": 0.11, "deployments": [ { - "name": "Cohere Embed Multilingual v3.0", + "name": "Cohere Embed Multilingual v3.0 Deployment", "provider": "cohere", "routing_id": "embed-multilingual-v3.0", "api_base": "" @@ -470,7 +587,7 @@ "icon": "generic" }, "id": "BAAI/bge-m3", - "name": "BAAI bge-m3", + "name": "BAAI BGE-M3", "type": "embed", "context_length": 8192, "capabilities": ["embed"], @@ -478,13 +595,13 @@ "embedding_cost_per_mtoken": 0.022, "deployments": [ { - "name": "BAAI bge-m3", + "name": "BAAI BGE-M3 Deployment", "huggingface_id": "BAAI/bge-m3", "cpu_count": "2", "memory_gb": "4", "required_vram": "14", "num_replicas": 1, - "service_type": "infinity" + "provider": "infinity" } ] }, @@ -501,7 +618,7 @@ "reranking_cost_per_ksearch": 2, "deployments": [ { - "name": "Cohere Rerank Multilingual v3.0", + "name": "Cohere Rerank Multilingual v3.0 Deployment", "provider": "cohere", "routing_id": "rerank-multilingual-v3.0", "api_base": "" @@ -521,13 +638,13 @@ "reranking_cost_per_ksearch": 2, "deployments": [ { - "name": "BGE Reranker V2 M3", + "name": "BGE Reranker V2 M3 Deployment", "huggingface_id": "BAAI/bge-reranker-v2-m3", "cpu_count": "2", "memory_gb": "4", "required_vram": "14", "num_replicas": 1, - "service_type": "infinity" + "provider": "infinity" } ] } diff --git a/services/api/src/owl/db/__init__.py b/services/api/src/owl/db/__init__.py index 9af6c28..1b47542 100644 --- a/services/api/src/owl/db/__init__.py +++ b/services/api/src/owl/db/__init__.py @@ -1,88 +1,572 @@ +from contextlib import asynccontextmanager, contextmanager from functools import lru_cache -from os import makedirs -from os.path import dirname -from typing import Type -from urllib.parse import urlsplit +from typing import AsyncGenerator, Callable, Generator +from async_lru import alru_cache from loguru import logger -from sqlalchemy import Engine, NullPool, Pool, QueuePool, event +from sqlalchemy import Connection, Engine, NullPool, TextClause, text from sqlalchemy.exc import OperationalError -from sqlmodel import MetaData, SQLModel, create_engine, text +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlmodel import Session, create_engine +from sqlmodel.ext.asyncio.session import AsyncSession -from owl.configs.manager import ENV_CONFIG +from owl.configs import CACHE, ENV_CONFIG +from owl.db.models import TEMPLATE_ORG_ID, JamaiSQLModel # noqa: F401 +from owl.utils import uuid7_str +SCHEMA = JamaiSQLModel.metadata.schema -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( +def _create_db_engine( db_url: str, *, connect_args: dict | None = None, - poolclass: Type[Pool] | None = None, + engine_create_fn: Callable[..., Engine | AsyncEngine] | None = None, echo: bool = False, - **kwargs, + dialect: str = "sqlite", ) -> 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}, - poolclass=poolclass or NullPool, - echo=echo, - **kwargs, + if connect_args is None: + if dialect == "postgresql": + connect_args = {} + else: + connect_args = {"check_same_thread": False} + if engine_create_fn is None: + engine_create_fn = create_engine + if dialect == "postgresql": + logger.debug("Using PostgreSQL DB.") + if "asyncpg" in db_url: + connect_args["prepared_statement_name_func"] = lambda: f"__asyncpg_{uuid7_str()}__" + engine = engine_create_fn( + db_url, + connect_args=connect_args, + poolclass=NullPool, + echo=echo, + ) + else: + raise ValueError(f'Dialect "{dialect}" is not supported.') + try: + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor + except ImportError: + logger.warning("Skip sqlalchemy instrumentation.") + else: + SQLAlchemyInstrumentor().instrument( + engine=engine if isinstance(engine, Engine) else engine.sync_engine, + enable_commenter=True, + commenter_options={}, + ) + return engine + + +@lru_cache(maxsize=1) +def create_db_engine() -> Engine: + engine = _create_db_engine( + ENV_CONFIG.db_path, + dialect=ENV_CONFIG.db_dialect, + ) + return engine + + +@alru_cache(maxsize=1) +async def create_db_engine_async() -> AsyncEngine: + engine = _create_db_engine( + ENV_CONFIG.db_path, + engine_create_fn=create_async_engine, + dialect=ENV_CONFIG.db_dialect, ) - 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): +def yield_session() -> Generator[Session, None, None]: + with Session(create_db_engine()) as session: + yield session + + +async def yield_async_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSession(await create_db_engine_async(), expire_on_commit=False) as session: + yield session + + +# Sync Session context manager +sync_session = contextmanager(yield_session) +# Async Session context manager +async_session = asynccontextmanager(yield_async_session) + + +@lru_cache(maxsize=10000) +def cached_text(query: str) -> TextClause: + return text(query) + + +async def reset_db(*, reset_max_users: int = 3): + from sqlmodel import func, select + + from owl.db.models import User + + # Only allow DB reset in dev with localhost + if "@localhost:" not in ENV_CONFIG.db_path: + raise ValueError("DB reset is only allowed in dev with localhost DB.") + + async with async_session() as session: + # As a safety measure, reset DB only if it has less than `init_max_users` users + # Just in case we accidentally tried to nuke a prod DB + user_table_exists = ( + await session.exec( + text( + ( + f"SELECT EXISTS (" + f"SELECT FROM information_schema.tables WHERE table_schema = '{SCHEMA}' AND table_name = 'User'" + ");" + ) + ) + ) + ).scalar() + if user_table_exists: + user_count = (await session.exec(select(func.count(User.id)))).one() + if user_count >= reset_max_users: + logger.info( + f"Found {user_count:,d} users, abort database reset (>= {reset_max_users} users)." + ) + return + + # Delete all tables + logger.warning(f'Resetting database (dropping schema "{SCHEMA}")...') + await session.exec(text(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE")) + await session.exec(text(f"CREATE SCHEMA {SCHEMA}")) + # Reapply default privileges for the new schema OID + await _grant_auditor_privilege(await create_db_engine_async()) + await session.commit() + stmt = """ + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name ~ '^proj_.*(_action|_knowledge|_chat)$'; + """ + schemas = [r[0] for r in (await session.exec(text(stmt))).all()] + logger.warning(f'Dropping Generative Table schemas: "{schemas}"') + for schema in schemas: + await session.exec(text(f"DROP SCHEMA {schema} CASCADE")) + await session.commit() + conn = await session.connection() + await conn.run_sync(JamaiSQLModel.metadata.create_all) + await conn.commit() + logger.success("All application tables dropped and recreated.") + + +async def _create_schema(engine: AsyncEngine) -> bool: + async with engine.begin() as conn: + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")) + await conn.commit() + return False + + +async def _create_tables(engine: AsyncEngine) -> bool: try: - db_class.metadata.create_all(engine) + async with engine.begin() as conn: + await conn.run_sync(JamaiSQLModel.metadata.create_all) + await conn.commit() except Exception as e: logger.exception(f"Failed to create DB tables: {e}") if not isinstance(e, OperationalError): raise + return False -@lru_cache(maxsize=1000) -def cached_text(query: str): - return text(query) +async def _create_pg_functions(engine: AsyncEngine) -> bool: + async with engine.connect() as conn: + await conn.execute( + text(f""" + CREATE OR REPLACE FUNCTION {SCHEMA}.deduct_cost( + organization_id TEXT, + cost NUMERIC(21, 12) + ) + RETURNS {SCHEMA}."Organization" AS $$ + DECLARE + updated_org {SCHEMA}."Organization"%ROWTYPE; + BEGIN + -- Ensure the cost is a positive number to prevent misuse + IF cost < 0 THEN + RAISE EXCEPTION 'Cost must be a non-negative number.'; + END IF; + + UPDATE {SCHEMA}."Organization" + SET + -- Logic for credit_grant column + credit_grant = CASE + -- If grant is enough to cover the cost, deduct from grant + WHEN credit_grant >= cost THEN credit_grant - cost + -- Otherwise, the grant is fully used up + ELSE 0 + END, + -- Logic for credit column + credit = CASE + -- If grant is enough, credit is unchanged + WHEN credit_grant >= cost THEN credit + -- Otherwise, deduct the remainder of the cost from credit + ELSE credit - (cost - credit_grant) + END + WHERE id = organization_id + RETURNING * INTO updated_org; -- Capture the updated row into a variable + + RETURN updated_org; + END; + $$ LANGUAGE plpgsql; + """) + ) + await conn.execute( + text(f""" + CREATE OR REPLACE FUNCTION {SCHEMA}.add_credit_grant( + organization_id TEXT, + grant_to_add NUMERIC(21, 12) + ) + RETURNS {SCHEMA}."Organization" AS $$ + DECLARE + updated_org {SCHEMA}."Organization"%ROWTYPE; + BEGIN + -- Treat negative grant amounts as zero + grant_to_add := GREATEST(grant_to_add, 0); + + -- Atomically update the organization's credits. + UPDATE {SCHEMA}."Organization" + SET + credit_grant = GREATEST(credit_grant + grant_to_add + LEAST(credit, 0), 0), + credit = CASE + -- Case 1: No debt. Credit is unchanged. + WHEN credit >= 0 THEN credit + + -- Case 2: Debt exists + ELSE LEAST(credit + credit_grant + grant_to_add, 0) + END + WHERE id = organization_id + RETURNING * INTO updated_org; + + RETURN updated_org; + END; + $$ LANGUAGE plpgsql; + """) + ) + await conn.commit() + return False + + +async def _check_column_exists( + conn: Connection, + table_name: str, + column_name: str, +) -> bool: + sql = text(f""" + SELECT 1 + FROM information_schema.columns + WHERE table_schema = '{SCHEMA}' AND table_name = '{table_name}' AND column_name = '{column_name}' + LIMIT 1; + """) + exists = (await conn.execute(sql)).scalar() + if exists: + logger.info(f'Column "{column_name}" found in "{table_name}" table.') + return True + return False + + +async def _add_egress_updated_at_column(engine: AsyncEngine) -> bool: + async with engine.connect() as conn: + if await _check_column_exists(conn, "Organization", "egress_usage_updated_at"): + return False + await conn.execute( + text(f""" + ALTER TABLE {SCHEMA}."Organization" + ADD COLUMN egress_usage_updated_at TIMESTAMPTZ DEFAULT NOW(); + """) + ) + await conn.commit() + return True + + +async def _add_project_description_column(engine: AsyncEngine) -> bool: + """ + Add project description column. + """ + table_name = "Project" + column_name = "description" + + async with engine.connect() as conn: + # Check if the column already exists + if await _check_column_exists(conn, table_name, column_name): + return False + await conn.execute( + text( + f"""ALTER TABLE {SCHEMA}."{table_name}" ADD COLUMN {column_name} TEXT DEFAULT ''""" + ) + ) + await conn.commit() + logger.success(f'Successfully added column "{column_name}" to "{table_name}".') + return True + + +async def _grant_auditor_privilege(engine: AsyncEngine) -> bool: + """ + Apply the necessary grants to allow the auditor role to audit the database. + """ + auditor_role = "jamaibase_auditor" + audit_statement = "UPDATE, DELETE" + async with engine.connect() as conn: + role_exists = await conn.scalar( + text(f"SELECT 1 FROM pg_roles WHERE rolname = '{auditor_role}'") + ) + if role_exists is None: + return False + + # alter default privileges for FUTURE tables + await conn.execute( + text( + f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{SCHEMA}" ' + f"GRANT {audit_statement} ON TABLES TO {auditor_role};" + ) + ) + + # grant privileges for existing tables right now + await conn.exec_driver_sql( + f'GRANT {audit_statement} ON ALL TABLES IN SCHEMA "{SCHEMA}" TO {auditor_role};' + ) + await conn.commit() + return False + + +async def _migrate_verification_codes(engine: AsyncEngine) -> bool: + """ + - Add columns: + - `purpose`: str | None + - `used_at`: DatetimeUTC | None + - `revoked_at`: DatetimeUTC | None + - If `meta` JSONB contains "purpose" key, update `purpose` column and delete "purpose" key + """ + if ENV_CONFIG.is_oss: + return False + + table_name = "VerificationCode" + async with engine.connect() as conn: + if ( + await _check_column_exists(conn, table_name, "purpose") + and await _check_column_exists(conn, table_name, "revoked_at") + and await _check_column_exists(conn, table_name, "used_at") + ): + return False + async with engine.begin() as conn: + await conn.execute(text(f'LOCK TABLE {SCHEMA}."{table_name}" IN SHARE MODE;')) + # Add columns + await conn.execute( + text( + f""" + ALTER TABLE {SCHEMA}."{table_name}" + ADD COLUMN IF NOT EXISTS purpose TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS used_at TIMESTAMPTZ DEFAULT NULL, + ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ DEFAULT NULL; + """ + ) + ) + # If `meta` JSONB contains "purpose" key, update `purpose` column and delete "purpose" key + await conn.execute( + text( + f""" + UPDATE {SCHEMA}."{table_name}" SET purpose = meta ->> 'purpose' WHERE meta ->> 'purpose' IS NOT NULL; + UPDATE {SCHEMA}."{table_name}" SET meta = meta - 'purpose' WHERE meta ->> 'purpose' IS NOT NULL; + """ + ) + ) + logger.info(f'Successfully migrated "{table_name}".') + return True + + +async def migrate_db(): + engine = await create_db_engine_async() + migrated = [ + await _create_schema(engine), + await _grant_auditor_privilege(engine), + await _create_tables(engine), + await _create_pg_functions(engine), + await _add_egress_updated_at_column(engine), + await _add_project_description_column(engine), + await _migrate_verification_codes(engine), + ] + if any(migrated): + logger.success("DB migrations performed.") + else: + logger.success("No DB migrations performed.") + # Clean up connection pool + # https://docs.sqlalchemy.org/en/20/core/pooling.html#using-connection-pools-with-multiprocessing-or-os-fork + await engine.dispose() + # Always clear cache + await CACHE.clear_all_async() + await CACHE.aclose() + + +async def init_db(*, init_max_users: int = 3): + from fastapi import Request + from sqlmodel import func, select + from starlette.datastructures import URL, Headers + + from owl.db.models import ModelConfig, Organization, User + from owl.routers import models + from owl.routers.organizations import oss as organizations_oss + from owl.routers.projects import oss as projects_oss + from owl.routers.users import oss as users_oss + from owl.types import OrganizationRead, UserRead + from owl.utils.exceptions import ResourceNotFoundError + from owl.utils.test import ( + GPT_41_NANO_CONFIG, + TEXT_EMBEDDING_3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_CONFIG, + ) + + async with async_session() as session: + # As a safety measure, init DB only if it has less than `init_max_users` users + # Just in case we accidentally tried to nuke a prod DB + user_count = (await session.exec(select(func.count(User.id)))).one() + if user_count >= init_max_users: + logger.info( + f"Found {user_count:,d} users, abort database initialisation (>= {init_max_users} users)." + ) + return + + # Only enforce OSS check if db_init=False + if ENV_CONFIG.is_oss and user_count != 0: + logger.info("OSS mode: Skipping initialization (non-empty DB).") + return + + logger.info("Initialising database...") + + # Create a mock Request object + request = Request( + { + "type": "http", + "method": "POST", + "headers": Headers({"content-type": "application/json"}).raw, + "url": URL("/v2/users"), + "state": {"id": uuid7_str()}, + } + ) + + # User + try: + user = await User.get(session, "0") + except ResourceNotFoundError: + await users_oss.create_user( + request=request, + token="", + session=session, + body=users_oss.UserCreate( + name="Admin user", + email="user@local.com", + password="jambubu", + ), + ) + user = await User.get(session, "0", populate_existing=True) + + # Manually verify email + user.email_verified = True + session.add(user) + await session.commit() + await session.refresh(user) + user = UserRead.model_validate(user) + + # Organization + if await session.get(Organization, "0") is None: + await organizations_oss.create_organization( + request=request, + user=user, + session=session, + body=organizations_oss.OrganizationCreate( + name="Admin org", + external_keys={ + "anthropic": ENV_CONFIG.anthropic_api_key_plain, + "azure": ENV_CONFIG.azure_api_key_plain, + "azure_ai": ENV_CONFIG.azure_ai_api_key_plain, + "bedrock": ENV_CONFIG.bedrock_api_key_plain, + "cerebras": ENV_CONFIG.cerebras_api_key_plain, + "cohere": ENV_CONFIG.cohere_api_key_plain, + "deepseek": ENV_CONFIG.deepseek_api_key_plain, + "gemini": ENV_CONFIG.gemini_api_key_plain, + "groq": ENV_CONFIG.groq_api_key_plain, + "hyperbolic": ENV_CONFIG.hyperbolic_api_key_plain, + "jina_ai": ENV_CONFIG.jina_ai_api_key_plain, + "openai": ENV_CONFIG.openai_api_key_plain, + "openrouter": ENV_CONFIG.openrouter_api_key_plain, + "sagemaker": ENV_CONFIG.sagemaker_api_key_plain, + "sambanova": ENV_CONFIG.sambanova_api_key_plain, + "together_ai": ENV_CONFIG.together_ai_api_key_plain, + "vertex_ai": ENV_CONFIG.vertex_ai_api_key_plain, + "voyage": ENV_CONFIG.voyage_api_key_plain, + }, + ), + ) + if ENV_CONFIG.is_oss: + return + # Continue creating sample data for Cloud mode + user = UserRead.model_validate(await User.get(session, user.id, populate_existing=True)) + # Add credit grant + org = await session.get(Organization, "0", populate_existing=True) + org.credit_grant = 150.0 + session.add(org) + await session.commit() + await session.refresh(org) + org = OrganizationRead.model_validate(org) -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, -) + # Project + await projects_oss.create_project( + request=request, + user=user, + session=session, + body=projects_oss.ProjectCreate( + organization_id=org.id, + name="Admin project", + ), + project_id="proj_bee957b5881f35e120909510", + ) + model_count = (await session.exec(select(func.count(ModelConfig.id)))).one() + model_list: list[models.ModelConfig] = [] + if model_count == 0: + # Chat models + model_list.append( + await models.create_model_config( + request=request, + user=user, + session=session, + body=GPT_41_NANO_CONFIG, + ) + ) + # Embedding model + model_list.append( + await models.create_model_config( + request=request, + user=user, + session=session, + body=TEXT_EMBEDDING_3_SMALL_CONFIG, + ) + ) + # Reranking model + model_list.append( + await models.create_model_config( + request=request, + user=user, + session=session, + body=RERANK_ENGLISH_v3_SMALL_CONFIG, + ) + ) -class UserSQLModel(SQLModel): - metadata = MetaData() + # Model Deployments + for model in model_list: + provider = model.id.split("/")[0] + # We need to deploy non-standard models manually + if provider not in models.CloudProvider: + continue + await models.create_deployment( + request=request, + user=user, + session=session, + body=models.DeploymentCreate( + model_id=model.id, + name=f"{model.name} deployment 1", + provider=provider, + routing_id=model.id, + api_base="", + ), + ) diff --git a/services/api/src/owl/db/gen_executor.py b/services/api/src/owl/db/gen_executor.py index 493ce07..79112ae 100644 --- a/services/api/src/owl/db/gen_executor.py +++ b/services/api/src/owl/db/gen_executor.py @@ -1,163 +1,139 @@ -import asyncio import base64 import re -from dataclasses import dataclass -from os.path import splitext -from time import time +from asyncio import Queue, TaskGroup +from os.path import basename, splitext +from time import perf_counter, time from typing import Any, AsyncGenerator, Literal import numpy as np +from async_lru import alru_cache from fastapi import Request from fastapi.exceptions import RequestValidationError from loguru import logger - -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 ( +from pydantic import BaseModel + +from owl.configs import ENV_CONFIG +from owl.db.gen_table import GenerativeTableCore, KnowledgeTable +from owl.docparse import GeneralDocLoader +from owl.types import ( + AUDIO_FILE_EXTENSIONS, + DOCUMENT_FILE_EXTENSIONS, GEN_CONFIG_VAR_PATTERN, - ChatCompletionChoiceDelta, - ChatCompletionChunk, + IMAGE_FILE_EXTENSIONS, + AudioContent, + AudioContentData, + CellCompletionResponse, + CellReferencesResponse, + ChatCompletionChoice, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, ChatEntry, ChatRequest, + ChatRole, + ChatThreadEntry, + Chunk, CodeGenConfig, + ColumnDtype, + DiscriminatedGenConfig, EmbedGenConfig, - ExternalKeys, - GenTableChatCompletionChunks, - GenTableRowsChatCompletionChunks, - GenTableStreamChatCompletionChunk, - GenTableStreamReferences, + ImageContent, + ImageContentData, LLMGenConfig, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowRegenRequest, + OrganizationRead, + ProjectRead, + PythonGenConfig, + References, RegenStrategy, RowAdd, - RowAddRequest, + RowCompletionResponse, RowRegen, - RowRegenRequest, - TableMeta, + TextContent, ) from owl.utils import mask_string, uuid7_draft2_str +from owl.utils.billing import BillingManager from owl.utils.code import code_executor +from owl.utils.exceptions import ( + BadInputError, + JamaiException, + ResourceNotFoundError, + UpStreamError, +) from owl.utils.io import open_uri_async +from owl.utils.lm import LMEngine -@dataclass(slots=True) -class Task: - type: Literal["embed", "chat", "code"] +class Task(BaseModel, validate_assignment=True): output_column_name: str - body: ChatRequest | EmbedGenConfig | CodeGenConfig dtype: str + body: DiscriminatedGenConfig + status: Literal["pending", "running", "done"] = "pending" + + +class Result(BaseModel, validate_assignment=True): + row_id: str + + +class TaskResult(Result): + response: CellReferencesResponse | CellCompletionResponse | ChatCompletionResponse + output_column_name: str + + +class RowResult(Result): + data: dict[str, Any] + +ResultT = TaskResult | RowResult -class MultiRowsGenExecutor: + +class _Executor: def __init__( self, *, - table: GenerativeTable, - meta: TableMeta, request: Request, - body: RowAddRequest | RowRegenRequest, - rows_batch_size: int, - cols_batch_size: int, - max_write_batch_size: int, + table: GenerativeTableCore, + organization: OrganizationRead, + project: ProjectRead, + body: MultiRowAddRequest | MultiRowRegenRequest | RowAdd | RowRegen, ) -> None: - self.table = table - self.meta = meta self.request = request + self._request_id: str = request.state.id + self.table = table + self._table_id = table.table_id + self._col_map = {c.column_id: c for c in self.table.column_metadata} + self.organization = organization + self.project = project + if body.table_id != table.table_id: + raise ValueError(f"{body.table_id=} but {table.table_id=}") self.body = body - self.is_regen = isinstance(body, RowRegenRequest) - self.bodies = ( - [ - RowAdd( - table_id=self.body.table_id, - data=row_data, - stream=self.body.stream, - concurrent=self.body.concurrent, - ) - for row_data in self.body.data - ] - if isinstance(body, RowAddRequest) - else [ - 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=self.body.concurrent, - ) - for row_id in body.row_ids - ] + self._stream = self.body.stream + # Determine batch sizes + self._multi_turn = ( + sum(getattr(col.gen_config, "multi_turn", False) for col in table.column_metadata) > 0 ) - self.rows_batch_size = rows_batch_size - self.cols_batch_size = cols_batch_size - 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, - ) - - 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 as e: - self._log_exception(e, f'Error executing task "{tmp_id}" with body: {body_}') - await self.queue.put("data: [DONE]\n\n") + self._col_batch_size = ENV_CONFIG.concurrent_cols_batch_size if body.concurrent else 1 + self._row_batch_size = 1 if self._multi_turn else ENV_CONFIG.concurrent_rows_batch_size + + @classmethod + def _log(cls, msg: str, level: str = "INFO", request_id: str = "", **kwargs): + _log = f"{cls.__name__}: {msg}" + if request_id: + _log = f"{request_id} - {_log}" + logger.log(level, _log, **kwargs) + + def log(self, msg: str, level: str = "INFO", **kwargs): + self._log(msg, level, request_id=self._request_id, **kwargs) + + def log_exception(self, message: str, exc: Exception, **kwargs) -> None: + if isinstance(exc, (JamaiException, RequestValidationError)): + logger.info(f"{self._request_id} - {self.__class__.__name__}: {message}", **kwargs) else: - return await self.executor.gen_row() - - async def _gen_stream_rows(self): - content_length = 0 - 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] - # 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 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)) + logger.exception( + f"{self._request_id} - {self.__class__.__name__}: {message}", **kwargs + ) @staticmethod def _log_item(x: Any) -> str: @@ -168,905 +144,1091 @@ def _log_item(x: Any) -> str: 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.") +class MultiRowGenExecutor(_Executor): + def __init__( + self, + *, + request: Request, + table: GenerativeTableCore, + organization: OrganizationRead, + project: ProjectRead, + body: MultiRowAddRequest | MultiRowRegenRequest, + ) -> None: + _kwargs = dict(request=request, table=table, organization=organization, project=project) + super().__init__(body=body, **_kwargs) + # Executors + if isinstance(body, MultiRowAddRequest): + self._is_regen = False + self._executors = [ + GenExecutor( + body=RowAdd( + table_id=body.table_id, + data=row_data, + stream=body.stream, + concurrent=body.concurrent, + ), + **_kwargs, + ) + for row_data in body.data + ] 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).") + self._is_regen = True + self._executors = [ + GenExecutor( + body=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, + ), + **_kwargs, + ) + for row_id in body.row_ids + ] + # Determine write batch size + if self._multi_turn: + self._write_batch_size = 1 + else: + # Write batch size will be [10, max_write_batch_size] + _bs = len(self._executors) // 10 + self._write_batch_size = max(min(_bs, ENV_CONFIG.max_write_batch_size), 10) + # Task result queue + self._queue: Queue[ResultT | None] = Queue() + # Accumulated rows for batch write + self._batch_rows: list[dict[str, Any]] = [] + # Billing + self.content_length = 0 + self._billing: BillingManager = self.request.state.billing + + async def generate(self) -> AsyncGenerator[str, None] | MultiRowCompletionResponse: + if self._stream: + return self._generate() + else: + return await anext(self._generate()) - return write_batch_size + async def _generate(self) -> AsyncGenerator[str | MultiRowCompletionResponse, None]: + rows = { + exe.row_id: RowCompletionResponse(columns={}, row_id=exe.row_id) + for exe in self._executors + } + async with TaskGroup() as tg: + pending_executors = [exe for exe in self._executors] + while len(pending_executors) > 0: + _execs = pending_executors[: self._row_batch_size] + for exe in _execs: + tg.create_task(exe.generate(self._queue)) + done_rows = 0 + while done_rows < len(_execs): + res = await self._queue.get() + self.log( + "len(_execs)={a} done_rows={b} res={c}", + "DEBUG", + a=len(_execs), + b=done_rows, + c=res, + ) + if res is None: + pass + elif isinstance(res, TaskResult): + # logger.debug(f"{res.response.content=}") + if self._stream: + _sse = f"data: {res.response.model_dump_json()}\n\n" + self.content_length += len(_sse.encode("utf-8")) + yield _sse + else: + rows[res.row_id].columns[res.output_column_name] = res.response + else: + self._batch_rows.append(res.data) + if len(self._batch_rows) >= self._write_batch_size: + await self._write_rows_to_table() + done_rows += 1 + pending_executors = pending_executors[self._row_batch_size :] + # Write any remaining rows + await self._write_rows_to_table() + # End of all tasks + if self._stream: + _sse = "data: [DONE]\n\n" + yield _sse + self.content_length += len(_sse.encode("utf-8")) + self._billing.create_egress_events(self.content_length / (1024**3)) + else: + yield MultiRowCompletionResponse(rows=list(rows.values())) - async def _write_rows_to_table(self): + async def _write_rows_to_table(self) -> None: """ 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}'" + if len(self._batch_rows) == 0: + return + if self._is_regen: + self.log(f'Table "{self._table_id}": Updating {len(self._batch_rows):,d} rows.') + try: + await self.table.update_rows( + {row["ID"]: row for row in self._batch_rows}, ignore_state_columns=False + ) + 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( + f'Table "{self._table_id}": Failed to update {len(self._batch_rows):,d} rows: {_data}', + e, ) - 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: 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_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: - if self.body.stream: - return self._gen_stream_rows() else: - return await self._gen_nonstream_rows() + self.log(f'Table "{self._table_id}": Writing {len(self._batch_rows):,d} rows.') + try: + await self.table.add_rows( + self._batch_rows, ignore_info_columns=False, ignore_state_columns=False + ) + 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( + f'Table "{self._table_id}": Failed to add {len(self._batch_rows):,d} rows: {_data}', + e, + ) + self._batch_rows.clear() -class GenExecutor: +class GenExecutor(_Executor): def __init__( self, *, - table: GenerativeTable, - meta: TableMeta, request: Request, + table: GenerativeTableCore, + organization: OrganizationRead, + project: ProjectRead, body: RowAdd | RowRegen, - cols_batch_size: int, ) -> None: - self.table = table - self.meta = meta - self.body = body - self.is_row_add = isinstance(self.body, RowAdd) - self.column_dict = {} - self.regen_column_dict = {} - self.tasks = [] - self.table_id = body.table_id - self.request = request - if isinstance(body, RowAdd): - body.data["ID"] = body.data.get("ID", uuid7_draft2_str()) - self.row_id = body.data["ID"] - else: - self.row_id = body.row_id - 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 = [] - self.tag_regen_columns = [] - self.skip_regen_columns = [] - self.image_columns = [] - self.audio_columns = [] - self.audio_gen_columns = [] - self.image_column_dict = {} - self.document_column_dict = {} - self.audio_column_dict = {} - - def _log_exception(self, exc: Exception, error_message: str): - if not isinstance(exc, (JamaiException, RequestValidationError)): - 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() - - # TODO: resolve duplicated code - async def _convert_uri_to_base64(self, uri: str, col_id: str) -> tuple[dict, bool]: - """ - Converts a URI to a base64-encoded string with the appropriate prefix and determines the file type. - - Args: - uri (str): The URI of the file. - col_id (str): The column ID for error context. - - Returns: - tuple: A tuple containing: - - dict: A dictionary with the base64-encoded data and its prefix. - - bool: A boolean indicating whether the file is audio. - - Raises: - ValueError: If the file format is unsupported. - """ - if not uri.startswith(("file://", "s3://")): - raise ValueError( - f"Invalid URI format for column {col_id}. URI must start with 'file://' or 's3://'" - ) - - # uri -> file binary -> base64 - file_binary = await self._get_file_binary(uri) - base64_data = self._binary_to_base64(file_binary) - - # uri -> file extension -> prefix - extension = splitext(uri)[1].lower() - - if extension in [".mp3", ".wav"]: - prefix = f"data:audio/{"mpeg" if extension == ".mp3" else "x-wav"};base64," - return { - "data": base64_data, - "format": extension[1:], - "url": prefix + base64_data, - }, True - elif extension in [".jpeg", ".jpg", ".png", ".gif", ".webp"]: - extension = ".jpeg" if extension == ".jpg" else extension - prefix = f"data:image/{extension[1:]};base64," - return {"url": prefix + base64_data}, False + super().__init__( + request=request, table=table, organization=organization, project=project, body=body + ) + # Engines + self.lm = LMEngine(organization=organization, project=project, request=request) + # Tasks + self._tasks: list[Task] = [] + if isinstance(self.body, RowAdd): + self.body.data["ID"] = uuid7_draft2_str() + self.body.data.pop("Updated at", None) + self._row_id = self.body.data["ID"] + self._regen_strategy = None else: - raise ValueError( - "Unsupported file type. Supported formats are: " - "['jpeg/jpg', 'png', 'gif', 'webp'] for images and ['mp3', 'wav'] for audio." - ) - - async def gen_row(self) -> Any | tuple[GenTableChatCompletionChunks, dict]: - cols = self.meta.cols_schema - col_ids = set(c.id for c in cols) - if self.is_row_add: - self.column_dict = {k: v for k, v in self.body.data.items() if k in col_ids} + self._row_id = self.body.row_id + self._regen_strategy = self.body.regen_strategy + if not self.body.output_column_id: + if self._regen_strategy != RegenStrategy.RUN_ALL: + raise BadInputError( + f'`output_column_id` is required when `regen_strategy` is not "{str(RegenStrategy.RUN_ALL)}".' + ) + else: + output_column_ids = [ + col.column_id for col in self.table.column_metadata if col.is_output_column + ] + if self.body.output_column_id not in output_column_ids: + output_column_ids = [f'"{c}"' for c in output_column_ids] + raise ResourceNotFoundError( + ( + f'Column "{self.body.output_column_id}" not found in table "{self._table_id}". ' + f"Available output columns: {output_column_ids}" + ) + ) + self._column_dict: dict[str, Any] = {} + self._error_columns: list[str] = [] + self._task_signal: Queue[None] = Queue() + + @property + def row_id(self) -> str: + return self._row_id + + @property + def tasks(self) -> list[Task]: + return self._tasks + + @property + def column_dict(self) -> dict[str, Any]: + return self._column_dict + + # @property + # def done(self) -> bool: + # return all(task.status == "done" for task in self._tasks) + + async def _setup_tasks(self) -> None: + cols = self.table.column_metadata + # Process inputs and dependencies + if self._regen_strategy is None: + _body: RowAdd = self.body + self._column_dict = {k: v for k, v in _body.data.items() if k in self._col_map} else: - self.column_dict = self.table.get_row(self.table_id, self.row_id) + _body: RowRegen = self.body + _row = await self.table.get_row(self._row_id) + match self._regen_strategy: + case RegenStrategy.RUN_ALL: + # Keep all input columns + self._column_dict = { + k: v + for k, v in _row.items() + if not ( + self._col_map[k].is_output_column + or self._col_map[k.rstrip("_")].is_output_column + ) + } + case RegenStrategy.RUN_SELECTED: + # Keep all columns except the one being generated + self._column_dict = { + k: v + for k, v in _row.items() + if k not in (_body.output_column_id, f"{_body.output_column_id}_") + } + case RegenStrategy.RUN_BEFORE | RegenStrategy.RUN_AFTER: + _cols = [col.column_id for col in cols if col.is_output_column] + try: + idx = _cols.index(_body.output_column_id) + except ValueError as e: + raise BadInputError( + f'Column "{_body.output_column_id}" not found in table "{self._table_id}".' + ) from e + # Keep columns that are not being generated + if self._regen_strategy == RegenStrategy.RUN_BEFORE: + _cols = _cols[idx + 1 :] + else: + _cols = _cols[:idx] + _cols += [f"{c}_" for c in _cols] + _cols += [col.column_id for col in cols if not col.is_output_column] + self._column_dict = { + k: v for k, v in _row.items() if k in _cols or k.lower() == "id" + } + case _: + raise BadInputError(f'Invalid regen strategy: "{str(self._regen_strategy)}".') + # # Filter out state columns + # self._column_dict = {k: v for k, v in self._column_dict.items() if not k.endswith("_")} + self.log("self._column_dict={column_dict}", "DEBUG", column_dict=self._column_dict) - self.tasks = [] + self._tasks = [] for col in cols: - # Skip info columns - if col.id.lower() in ("id", "updated at"): + # Skip info and state columns + if col.is_info_column or col.is_state_column: continue - # Skip state column - if col.id.endswith("_"): - continue - # If user provides value, skip - if self.is_row_add and col.id in self.column_dict: - continue - # If gen_config not defined, set None and skip + # Create task if col.gen_config is None: - if self.is_row_add: - self.column_dict[col.id] = None + # Default value for missing column during row add + # Even though this is also handled by `GenerativeTableCore`, + # we need this to avoid hanging tasks due to missing inputs + self._column_dict[col.column_id] = self._column_dict.get(col.column_id) continue - 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() + if col.column_id in self._column_dict: + self.log(f'Skipped generation for column "{col.column_id}".') + continue + self._tasks.append( + Task( + output_column_name=col.column_id, + dtype=col.dtype, + body=col.gen_config, ) - if gen_config.model != "": - model_config = self.request.state.all_models.get_llm_model_info( - gen_config.model + ) + self.log("self._tasks={tasks}", "DEBUG", tasks=self._tasks) + column_dict_keys = set(self._column_dict.keys()) + col_ids = set(self._col_map.keys()) + if len(column_dict_keys - col_ids) > 0: + logger.warning( + f'Table "{self._table_id}": There are unexpected columns: {column_dict_keys - col_ids}' + ) + self.log(f"Prepared {len(self._tasks):,d} tasks.", "DEBUG") + + async def generate(self, q: Queue[ResultT | None]) -> None: + await self._setup_tasks() + async with TaskGroup() as tg: + pending_tasks = [task for task in self._tasks if task.status == "pending"] + self.log("Pending tasks: {pending_tasks}", "DEBUG", pending_tasks=pending_tasks) + while len(pending_tasks) > 0: + # Go through pending tasks + ready_tasks = [task for task in pending_tasks if self._is_task_ready(task)] + for task in ready_tasks[: self._col_batch_size]: + if not self._is_task_ready(task): + continue + task.status = "running" + tg.create_task(self._execute_task(task, q)) + # Wait for a task to complete + await self._task_signal.get() + pending_tasks = [task for task in self._tasks if task.status == "pending"] + self.log("Pending tasks: {pending_tasks}", "DEBUG", pending_tasks=pending_tasks) + # Put row data + await q.put(RowResult(data=self._column_dict, row_id=self._row_id)) + self.log("All tasks completed.", "DEBUG") + + def _is_task_ready(self, task: Task) -> bool: + match task.body: + case LLMGenConfig(): + inputs = self._extract_upstream_columns(task.body.prompt) + case EmbedGenConfig() | CodeGenConfig(): + inputs = [task.body.source_column] + case PythonGenConfig(): + inputs = self._extract_all_upstream_columns(task.output_column_name) + case _: + raise ValueError(f'Table "{self._table_id}": Unexpected task type: {task.body}') + # Only consider input references that exist in table + inputs = [i for i in inputs if i in self._col_map] + task_ready = all(col in self._column_dict for col in inputs) + return task_ready + + async def _execute_task(self, task: Task, q: Queue[ResultT | None]) -> None: + logger.debug(f"Processing column: {task.output_column_name}") + match task.body: + case LLMGenConfig(): + await self._execute_chat_task(task, q) + case EmbedGenConfig(): + await self._execute_embed_task(task, q) + case CodeGenConfig(): + await self._execute_code_task(task, q) + case PythonGenConfig(): + await self._execute_python_task(task, q) + case _: + raise ValueError(f'Table "{self._table_id}": Unexpected task type: {task.body}') + + async def _execute_chat_task(self, task: Task, q: Queue[ResultT | None]) -> None: + output_column = task.output_column_name + body: LLMGenConfig = task.body + # Check if a value is provided + try: + # TODO: Perhaps we need to emit references too + result = self._column_dict[output_column] + # response_kwargs = dict( + # id=self._request_id, + # created=int(time()), + # model="", + # usage=ChatCompletionUsage(), + # choices=[ + # ChatCompletionChoice( + # message=ChatCompletionMessage(content=result), + # index=0, + # ) + # ], + # ) + # if self._stream: + # response = CellCompletionResponse( + # **response_kwargs, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # else: + # response = ChatCompletionResponse(**response_kwargs) + # self.log(f'Skipped completion for column "{output_column}".') + # if self._regen_strategy is not None: + # # TODO: Perhaps we should always emit column value even if it is provided? + # await q.put( + # TaskResult( + # response=response, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # ) + await q.put(None) + await self._signal_task_completion(task, result) + return + except KeyError: + pass + + # Perform completion + result = "" + references = None + try: + # Error circuit breaker + self._check_upstream_error(self._extract_upstream_columns(body.prompt)) + # Form the request body + if body.multi_turn: + messages = ( + await self.table.get_conversation_thread( + column_id=output_column, + row_id="" if self._regen_strategy is None else self._row_id, + include_row=False, ) - if ( - "audio" in model_config.capabilities - and model_config.deployments[0].provider == "openai" - ): - self.audio_gen_columns.append(col.id) - elif isinstance(col.gen_config, CodeGenConfig): - task_type = "code" - gen_config = col.gen_config + ).thread else: - raise ValueError(f'Unexpected "gen_config" type: {type(col.gen_config)}') - self.tasks.append( - Task(type=task_type, output_column_name=col.id, body=gen_config, dtype=col.dtype) + messages = [ChatThreadEntry.system(body.system_prompt)] + messages.append( + ChatThreadEntry.user( + content=self.table.interpolate_column( + body.prompt if body.prompt else ".", self._column_dict + ) + ) ) - - self.image_columns = [col.id for col in cols if col.dtype == "image"] - self.audio_columns = [col.id for col in cols if col.dtype == "audio"] - for col_id in self.image_columns + self.audio_columns: - if self.column_dict.get(col_id, None) is not None: - uri = self.column_dict[col_id] - b64, is_audio = await self._convert_uri_to_base64(uri, col_id) - - if is_audio: - if col_id not in self.audio_columns: - raise ValueError( - f"Column {col_id} is not marked as an audio column but contains audio data." + # Load files for each user message + messages = [await self._load_files(m) for m in messages] + req = ChatRequest( + id=self._request_id, + messages=[ChatEntry.model_validate(m.model_dump()) for m in messages], + **body.model_dump(), + ) + req, references = await self._setup_rag(req) + if self._stream: + reasoning = "" + result = "" + if references is not None: + ref = CellReferencesResponse( + **references.model_dump(exclude=["object"]), + output_column_name=output_column, + row_id=self._row_id, + ) + await q.put( + TaskResult( + response=ref, + output_column_name=output_column, + row_id=self._row_id, ) - self.audio_column_dict[col_id] = ( - { - "data": b64["data"], - "format": b64["format"], - }, # for audio gen model - {"url": b64["url"]}, # for audio model ) - else: - if col_id not in self.image_columns: - raise ValueError( - f"Column {col_id} is not marked as a file column but contains image data." + async for chunk in self.lm.chat_completion_stream( + messages=req.messages, + **req.hyperparams, + ): + reasoning += chunk.reasoning_content + result += chunk.content + # if chunk.content is None and chunk.usage is None: + # continue + chunk = CellCompletionResponse( + **chunk.model_dump(exclude={"object"}), + output_column_name=output_column, + row_id=self._row_id, + ) + await q.put( + TaskResult( + response=chunk, + output_column_name=output_column, + row_id=self._row_id, ) - self.image_column_dict[col_id] = b64 - - 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: - return self._stream_concurrent_execution() - else: - return await self._nonstream_concurrent_execution() - - async def _run_embed_tasks(self): - """ - Executes embedding tasks sequentially. - """ - 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: 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] - ) - 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 - - def _extract_upstream_columns(self, text: str) -> list[str]: - matches = re.findall(GEN_CONFIG_VAR_PATTERN, text) - # return the content inside ${...} - return matches - - 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, base_column_name: str) -> str | dict[str, Any]: - """ - 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 | dict[str, Any]): The prompt with column references replaced. - """ - - image_column_names = [] - audio_column_names = [] + ) + if chunk.finish_reason == "error": + self._error_columns.append(output_column) + else: + response = await self.lm.chat_completion( + messages=req.messages, + **req.hyperparams, + ) + response.references = references + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, + ) + ) + result = response.content + reasoning = response.reasoning_content - def replace_match(match): - column_name = match.group(1) # Extract the column_name from the match - try: - if column_name in self.image_column_dict: - image_column_names.append(column_name) - return "" - elif column_name in self.audio_column_dict: - audio_column_names.append(column_name) - if base_column_name in self.audio_gen_columns: - return "" # follow the content type - else: - return "" - elif column_name in self.document_column_dict: - return self.document_column_dict[column_name] - return str(self.column_dict[column_name]) # Data can be non-string - except KeyError as e: - raise BadInputError(f"Requested column '{column_name}' is not found.") from e - - content_ = re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, prompt) - content = [{"type": "text", "text": content_}] - - if len(image_column_names) > 0 and len(audio_column_names) > 0: - raise BadInputError("Either image or audio is supported per completion.") - - if len(image_column_names) > 0: - if len(image_column_names) > 1: - raise BadInputError("Only one image is supported per completion.") - - content.append( - { - "type": "image_url", - "image_url": self.image_column_dict[image_column_names[0]], - } + except Exception as e: + response_kwargs = dict( + id=self._request_id, + created=int(time()), + model="", + usage=ChatCompletionUsage(), + choices=[ + ChatCompletionChoice( + message=ChatCompletionMessage(content=f"[ERROR] {str(e)}"), + index=0, + finish_reason="error", + ) + ], ) - return content - elif len(audio_column_names) > 0: - if len(audio_column_names) > 1: - raise BadInputError("Only one audio is supported per completion.") - - if base_column_name in self.audio_gen_columns: - content.append( - { - "type": "input_audio", - "input_audio": self.audio_column_dict[audio_column_names[0]][0], - } + if self._stream: + response = CellCompletionResponse( + **response_kwargs, + output_column_name=output_column, + row_id=self._row_id, ) else: - content.append( - { - "type": "audio_url", - "audio_url": self.audio_column_dict[audio_column_names[0]][1], - } + response = ChatCompletionResponse(**response_kwargs) + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, ) - return content - else: - return content_ - - 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 - - def _validate_model(self, body: LLMGenConfig, output_column_name: str): - for input_column_name in self.dependencies[output_column_name]: - if input_column_name in self.image_column_dict: - try: - body.model = self.llm.validate_model_id(body.model, ["image"]) - break - except ResourceNotFoundError as e: - raise BadInputError( - f'Column "{output_column_name}" referred to image file input but using a chat model ' - f'"{self.llm.get_model_name(body.model) if self.llm.is_browser else body.model}", ' - "select image model instead.", - ) from e - if input_column_name in self.audio_column_dict: - try: - body.model = self.llm.validate_model_id(body.model, ["audio"]) - break - except ResourceNotFoundError as e: - raise BadInputError( - f'Column "{output_column_name}" referred to audio file input but using a chat model ' - f'"{self.llm.get_model_name(body.model) if self.llm.is_browser else body.model}", ' - "select audio model instead.", - ) from e - - async def _execute_code(self, task: Task) -> str: - output_column_name = task.output_column_name - body: CodeGenConfig = task.body - dtype = task.dtype - source_code = self.column_dict[body.source_column] - + ) + result = response.content + reasoning = response.reasoning_content + self._error_columns.append(output_column) + self.log_exception( + f'Table "{self._table_id}": Failed to generate completion for column "{output_column}": {repr(e)}', + e, + ) + finally: + await q.put(None) + state_col = f"{task.output_column_name}_" + state = self._column_dict.get(state_col, {}) + if references is not None: + state["references"] = references.model_dump(mode="json") + if reasoning: + state["reasoning"] = reasoning + self._column_dict[state_col] = state + await self._signal_task_completion(task, result) + self.log(f'Streamed completion for column "{output_column}": <{mask_string(result)}>.') + + async def _execute_embed_task(self, task: Task, q: Queue[ResultT | None]) -> None: + output_column = task.output_column_name + # Check if a value is provided try: - new_column_value = await code_executor(source_code, dtype, self.request) - except Exception as e: - new_column_value = f"[ERROR] {str(e)}" - self._log_exception(e, f'Error executing code for column "{output_column_name}": {e}') - - if dtype == "image" and new_column_value is not None: + embedding = self._column_dict[output_column] + if isinstance(embedding, np.ndarray): + pass + elif isinstance(embedding, list): + embedding = np.asarray(embedding) + else: + raise TypeError( + f"Unexpected embedding type, expected `np.ndarray` or `list`, got `{type(embedding)}`." + ) + # Perform embedding + except (KeyError, TypeError): + body: EmbedGenConfig = task.body try: - ( - self.image_column_dict[output_column_name], - _, - ) = await self._convert_uri_to_base64(new_column_value, output_column_name) - except ValueError as e: - self._log_exception(e, f"Invalid file path for column '{output_column_name}'") - new_column_value = None - - return new_column_value + # Error circuit breaker + self._check_upstream_error([body.source_column]) + # TODO: We can find a way to batch embedding tasks + source = self._column_dict.get(body.source_column, None) + embedding = await self.lm.embed_documents( + model=body.embedding_model, + texts=["." if source is None else source], + ) + embedding = np.asarray(embedding.data[0].embedding, dtype=task.dtype) + embedding = embedding / np.linalg.norm(embedding) + except Exception as e: + self.log_exception( + f'Table "{self._table_id}": Failed to embed for column "{output_column}": {repr(e)}', + e, + ) + embedding = None + # TODO: Perhaps we need to emit embeddings + await q.put(None) + await self._signal_task_completion(task, embedding) - async def _execute_task_stream(self, task: Task) -> AsyncGenerator[str, None]: - """ - Executes a single task in a streaming manner, returning an asynchronous generator of chunks. - """ - output_column_name = task.output_column_name - body: ChatRequest = task.body + async def _execute_code_task(self, task: Task, q: Queue[ResultT | None]) -> None: + output_column = task.output_column_name + body: CodeGenConfig = task.body + # Check if a value is provided try: - logger.debug(f"Processing column: {output_column_name}") + result = self._column_dict[output_column] + # response_kwargs = dict( + # id=self._request_id, + # created=int(time()), + # model="code_execution", + # usage=ChatCompletionUsage(), + # choices=[ + # ChatCompletionChoice( + # message=ChatCompletionMessage(content=result), + # index=0, + # ) + # ], + # ) + # if self._stream: + # response = CellCompletionResponse( + # **response_kwargs, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # else: + # response = ChatCompletionResponse(**response_kwargs) + + # self.log(f'Skipped code execution for column "{output_column}".') + # if self._regen_strategy is not None: + # await q.put( + # TaskResult( + # response=response, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # ) + await q.put(None) + await self._signal_task_completion(task, result) + return + except KeyError: + pass - 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}" + # Perform code execution + result = "" + try: + # Error circuit breaker + self._check_upstream_error([body.source_column]) + source_code = self._column_dict.get(body.source_column, "") + + # Extract bytes from ColumnDtype.AUDIO and ColumnDtype.IMAGE and put it into a dictionary + row_data = self._column_dict.copy() + self.table.postprocess_rows([row_data], include_state=False) + for k, v in row_data.items(): + col = next((col for col in self.table.column_metadata if col.column_id == k), None) + if col and (col.dtype == ColumnDtype.AUDIO or col.dtype == ColumnDtype.IMAGE): + row_data[k] = await _load_uri_as_bytes(v) + + if source_code and row_data: + result = await code_executor( + request=self.request, + organization_id=self.organization.id, + project_id=self.project.id, + source_code=source_code, + output_column=output_column, + row_data=row_data, + dtype=task.dtype, ) + else: + result = "" - elif isinstance(body, CodeGenConfig): - new_column_value = await self._execute_code(task) - logger.info(f"Executed Code Execution Column: '{output_column_name}'") - chunk = GenTableStreamChatCompletionChunk( - id=self.request.state.id, - object="gen_table.completion.chunk", - created=int(time()), - model="code_execution", - usage=None, - choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(new_column_value), - index=0, - ) - ], - output_column_name=output_column_name, - row_id=self.row_id, + response_kwargs = dict( + id=self._request_id, + created=int(time()), + model="code_execution", + usage=ChatCompletionUsage(), + choices=[ + ChatCompletionChoice( + message=ChatCompletionMessage(content=result), + index=0, + ) + ], + ) + if self._stream: + response = CellCompletionResponse( + **response_kwargs, + output_column_name=output_column, + row_id=self._row_id, ) - yield f"data: {chunk.model_dump_json()}\n\n" + else: + response = ChatCompletionResponse(**response_kwargs) - elif isinstance(body, ChatRequest): - self._check_upstream_error_chunk(body.messages[-1].content) - body.messages[-1].content = self._interpolate_column( - body.messages[-1].content, output_column_name + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, ) + ) - if isinstance(body.messages[-1].content, list): - self._validate_model(body, output_column_name) - - if output_column_name in self.image_columns + self.audio_columns: - new_column_value = None - logger.info( - f"Identified output column `{output_column_name}` as image / audio type, set value to {new_column_value}" - ) - chunk = GenTableStreamChatCompletionChunk( - 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" - else: - new_column_value = "" - kwargs = body.model_dump() - messages, references = await self.llm.retrieve_references( - messages=kwargs.pop("messages"), - rag_params=kwargs.pop("rag_params", None), - **kwargs, - ) - if references is not None: - ref = GenTableStreamReferences( - **references.model_dump(exclude=["object"]), - output_column_name=output_column_name, - ) - yield f"data: {ref.model_dump_json()}\n\n" - async for chunk in self.llm.generate_stream(messages=messages, **kwargs): - new_column_value += chunk.text - chunk = GenTableStreamChatCompletionChunk( - **chunk.model_dump(exclude=["object"]), - output_column_name=output_column_name, - row_id=self.row_id, - ) - yield f"data: {chunk.model_dump_json()}\n\n" - if chunk.finish_reason == "error": - self.error_columns.append(output_column_name) - else: - raise ValueError(f"Unsupported task type: {type(body)}") + self.log(f'Executed code for column "{output_column}": <{mask_string(result)}>.') except Exception as e: - error_chunk = GenTableStreamChatCompletionChunk( - id=self.request.state.id, - object="gen_table.completion.chunk", + response_kwargs = dict( + id=self._request_id, created=int(time()), - model="", - usage=None, + model="code_execution", + usage=ChatCompletionUsage(), choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {e}"), + ChatCompletionChoice( + message=ChatCompletionMessage(content=f"[ERROR] {str(e)}"), index=0, finish_reason="error", ) ], - output_column_name=output_column_name, - row_id=self.row_id, ) - yield f"data: {error_chunk.model_dump_json()}\n\n" - new_column_value = error_chunk.text - self.error_columns.append(output_column_name) - self._log_exception( - e, f'Error generating completion for column "{output_column_name}": {e}' + response = ( + CellCompletionResponse( + **response_kwargs, output_column_name=output_column, row_id=self._row_id + ) + if self._stream + else ChatCompletionResponse(**response_kwargs) ) - finally: - # Append new column data for subsequent tasks - self.column_dict[output_column_name] = new_column_value - self.regen_column_dict[output_column_name] = new_column_value - logger.info( - f"{self.request.state.id} - Streamed completion for " - f"{output_column_name}: <{mask_string(new_column_value)}>" + + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, + ) + ) + result = response.content + self._error_columns.append(output_column) + self.log_exception( + f'Table "{self._table_id}": Failed to execute code for column "{output_column}": {repr(e)}', + e, ) + finally: + await q.put(None) + await self._signal_task_completion(task, result) - 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: ChatRequest = task.body + async def _execute_python_task(self, task: Task, q: Queue[ResultT | None]) -> None: + output_column = task.output_column_name + body: PythonGenConfig = task.body + # Check if a value is provided try: - 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}" - ) + result = self._column_dict[output_column] + # response_kwargs = dict( + # id=self._request_id, + # created=int(time()), + # model="python_fixed_function", + # usage=ChatCompletionUsage(), + # choices=[ + # ChatCompletionChoice( + # message=ChatCompletionMessage(content=result), + # index=0, + # ) + # ], + # ) + # if self._stream: + # response = CellCompletionResponse( + # **response_kwargs, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # else: + # response = ChatCompletionResponse(**response_kwargs) + + # self.log(f'Skipped python fixed function execution for column "{output_column}".') + # if self._regen_strategy is not None: + # await q.put( + # TaskResult( + # response=response, + # output_column_name=output_column, + # row_id=self._row_id, + # ) + # ) + await q.put(None) + await self._signal_task_completion(task, result) + return + except KeyError: + pass - elif isinstance(body, CodeGenConfig): - new_column_value = await self._execute_code(task) - response = ChatCompletionChunk( - id=self.request.state.id, - object="chat.completion.chunk", - created=int(time()), - model="code_execution", - usage=None, - choices=[ - ChatCompletionChoiceDelta( - index=0, - message=ChatEntry.assistant(new_column_value), - ) - ], - ) - logger.debug( - f"Identified as Code Execution Column: {task.output_column_name}, executing code." + # Perform python fixed function execution + result = "" + try: + # Error circuit breaker + # Extract all columns to the left and check for upstream errors + self._check_upstream_error(self._extract_all_upstream_columns(output_column)) + + # Extract bytes from ColumnDtype.AUDIO and ColumnDtype.IMAGE and put it into a dictionary + row_data = self._column_dict.copy() + self.table.postprocess_rows([row_data], include_state=False) + for k, v in row_data.items(): + col = next((col for col in self.table.column_metadata if col.column_id == k), None) + if col and (col.dtype == ColumnDtype.AUDIO or col.dtype == ColumnDtype.IMAGE): + row_data[k] = await _load_uri_as_bytes(v) + + if body.python_code and row_data: + result = await code_executor( + request=self.request, + organization_id=self.organization.id, + project_id=self.project.id, + source_code=body.python_code, + output_column=output_column, + row_data=row_data, + dtype=task.dtype, ) - elif isinstance(body, ChatRequest): - self._check_upstream_error_chunk(body.messages[-1].content) - try: - body.messages[-1].content = self._interpolate_column( - body.messages[-1].content, output_column_name - ) - except IndexError: - pass - - if isinstance(body.messages[-1].content, list): - self._validate_model(body, output_column_name) - - if output_column_name in self.image_columns + self.audio_columns: - new_column_value = None - response = ChatCompletionChunk( - id=self.request.state.id, - object="chat.completion.chunk", - created=int(time()), - model="", - usage=None, - choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(new_column_value), - index=0, - ) - ], - ) - logger.debug( - f"Identified output column `{output_column_name}` as image / audio type, set value to {new_column_value}" + + response_kwargs = dict( + id=self._request_id, + created=int(time()), + model="python_fixed_function", + usage=ChatCompletionUsage(), + choices=[ + ChatCompletionChoice( + message=ChatCompletionMessage(content=result), + index=0, ) - else: - response = await self.llm.rag(**body.model_dump()) - new_column_value = response.text - else: - raise ValueError(f"Unsupported task type: {type(body)}") - - # append new column data for subsequence tasks - self.column_dict[output_column_name] = new_column_value - self.regen_column_dict[output_column_name] = new_column_value - logger.info( - ( - f"{self.request.state.id} - Generated completion for {output_column_name}: " - f"<{mask_string(new_column_value)}>" + ], + ) + response = ( + CellCompletionResponse( + **response_kwargs, output_column_name=output_column, row_id=self._row_id + ) + if self._stream + else ChatCompletionResponse(**response_kwargs) + ) + + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, ) ) - return response + + self.log( + f'Executed python code for column "{output_column}": <{mask_string(result)}>.' + ) except Exception as e: - error_chunk = ChatCompletionChunk( - id=self.request.state.id, - object="gen_table.completion.chunk", + response_kwargs = dict( + id=self._request_id, created=int(time()), - model="", - usage=None, + model="python_fixed_function", + usage=ChatCompletionUsage(), 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}"), + ChatCompletionChoice( + message=ChatCompletionMessage(content=f"[ERROR] {str(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}' + response = ( + CellCompletionResponse( + **response_kwargs, output_column_name=output_column, row_id=self._row_id + ) + if self._stream + else ChatCompletionResponse(**response_kwargs) ) - return error_chunk - - def _setup_dependencies(self) -> None: - """ - Sets up dependencies for the tasks. - - This method initializes the dependencies for the tasks that need to be executed. It creates a dictionary - called `llm_tasks` where the keys are the output column names of the tasks and the values are the tasks themselves. - It also creates a dictionary called `dependencies` where the keys are the output column names of the tasks and the - values are the dependencies for each task. The dependencies are extracted from the content of the last message in the task's body. - - Examples: - ```python - # Example usage of _setup_dependencies method - llm_tasks = { - "task1_output": Task(...), - "task2_output": Task(...), - # ... - } - dependencies = { - "task1_output": self._extract_upstream_columns(task1_body["messages"][-1]["content"]), - "task2_output": self._extract_upstream_columns(task2_body["messages"][-1]["content"]), - # ... - } - ``` - """ - self.llm_tasks = { - task.output_column_name: task for task in self.tasks if task.type == "chat" - } - self.code_tasks = { - task.output_column_name: task for task in self.tasks if task.type == "code" - } - self.dependencies = { - task.output_column_name: self._extract_upstream_columns(task.body.messages[-1].content) - for task in self.llm_tasks.values() - } - self.dependencies.update( - { - task.output_column_name: [task.body.source_column] - for task in self.code_tasks.values() - } - ) - logger.debug(f"Initial dependencies: {self.dependencies}") - - self.input_column_names = [ - key - for key in self.column_dict.keys() - if key not in self.llm_tasks.keys() and key not in self.code_tasks.keys() - ] - - def _mark_regen_columns(self) -> None: - """ - Tag columns to regenerate based on the chosen regeneration strategy. - """ - if self.is_row_add: - return - - # Get the current column order from the table metadata - cols = self.meta.cols_schema - col_ids = [col.id for col in cols] - - if self.body.regen_strategy == RegenStrategy.RUN_ALL: - self.tag_regen_columns = set(self.llm_tasks.keys()).union(self.code_tasks.keys()) - - elif self.body.regen_strategy == RegenStrategy.RUN_SELECTED: - self.tag_regen_columns.append(self.body.output_column_id) - - elif self.body.regen_strategy in ( - RegenStrategy.RUN_BEFORE, - RegenStrategy.RUN_AFTER, - ): - if self.body.regen_strategy == RegenStrategy.RUN_BEFORE: - for column_name in col_ids: - self.tag_regen_columns.append(column_name) - if column_name == self.body.output_column_id: - break - else: # RegenStrategy.RUN_AFTER - reached_column = False - for column_name in col_ids: - if column_name == self.body.output_column_id: - reached_column = True - if reached_column: - self.tag_regen_columns.append(column_name) + await q.put( + TaskResult( + response=response, + output_column_name=output_column, + row_id=self._row_id, + ) + ) + result = response.content + self._error_columns.append(output_column) + self.log_exception( + f'Table "{self._table_id}": Failed to execute python code for column "{output_column}": {repr(e)}', + e, + ) + finally: + await q.put(None) + await self._signal_task_completion(task, result) + + async def _signal_task_completion(self, task: Task, result: Any) -> None: + self._column_dict[task.output_column_name] = result + task.status = "done" + await self._task_signal.put(None) + + async def _load_files(self, message: ChatThreadEntry) -> ChatThreadEntry | ChatEntry: + if not isinstance(message, ChatThreadEntry): + raise TypeError(f"Unexpected message type: {type(message)}") + if message.role != ChatRole.USER: + return message + ### Text-only + if isinstance(message.content, str): + # logger.error(f"{message.content=}") + return ChatEntry.user(content=message.content.strip()) else: - raise ValueError(f"Invalid regeneration strategy: {self.body.regen_strategy}") + content = message.content + ### Multi-modal + contents: list[TextContent, ImageContent, AudioContent] = [] + replacements: dict[str, str] = {} + # Load file + # logger.error(f"{content=}") + for c in content: + if isinstance(c, TextContent): + contents.append(c) + else: + data = await _load_uri_as_base64(str(c.uri)) + if getattr(self._col_map.get(c.column_name, None), "is_document_column", False): + # Document (data could be None) + replacements[c.column_name] = str(data) + # prompt = re.sub(_regex, str(data), prompt) + else: + # Image or audio + if isinstance(data, (ImageContent, AudioContent)): + contents.append(data) + replacements[c.column_name] = "" + # prompt = re.sub(_regex, "", prompt) + # Replace column references + for c in contents: + if not isinstance(c, TextContent): + continue + for col_name, data in replacements.items(): + _regex = r"(? list[str]: + col_ids = re.findall(GEN_CONFIG_VAR_PATTERN, prompt) + # return the content inside ${...} + return col_ids - self.skip_regen_columns = [ - column_name for column_name in col_ids if column_name not in self.tag_regen_columns + def _extract_all_upstream_columns(self, output_column_name: str) -> list[str]: + cols = self.table.column_metadata + try: + idx = next(i for i, c in enumerate(cols) if c.column_id == output_column_name) + except StopIteration: + return [] + return [ + c.column_id + for c in cols[:idx] + if not (c.is_info_column or c.is_state_column or c.is_vector_column) ] - 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() - responses = {} - - async def execute_task(task_name): - try: - task = self.llm_tasks[task_name] - except Exception: - task = self.code_tasks[task_name] - - try: - responses[task_name] = await self._execute_task_nonstream(task) - except Exception as e: - self._log_exception(e, f'Error executing task "{task_name}": {e}') - finally: - completed.add(task_name) - tasks_in_progress.remove(task_name) - - while len(completed) < ( - len(self.llm_tasks) + len(self.code_tasks) + len(self.input_column_names) - ): - ready_tasks = [ - task_name - for task_name, deps in self.dependencies.items() - if all(dep in completed for dep in deps) - and task_name not in completed - and task_name not in tasks_in_progress - ] + def _check_upstream_error(self, upstream_cols: list[str]) -> None: + if not isinstance(upstream_cols, list): + raise TypeError(f"`upstream_cols` must be a list, got: {type(upstream_cols)}") + error_cols = [f'"{col}"' for col in upstream_cols if col in self._error_columns] + if len(error_cols) > 0: + raise UpStreamError(f"Upstream columns errored out: {', '.join(error_cols)}") - # 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] - exe_tasks = [execute_task(task) for task in batched_tasks] - tasks_in_progress.update(batched_tasks) - await asyncio.gather(*exe_tasks) - completed.update(batched_tasks) - tasks_in_progress.difference_update(batched_tasks) - - # Post-execution steps - 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, + @classmethod + async def setup_rag( + cls, + *, + project: ProjectRead, + lm: LMEngine, + body: ChatRequest, + request_id: str = "", + ) -> tuple[ChatRequest, References | None]: + if body.rag_params is None: + return body, None + kt_id = body.rag_params.table_id.strip() + if kt_id == "": + raise BadInputError( + "`rag_params.table_id` is required when `rag_params` is specified." + ) + kt = await KnowledgeTable.open_table( + project_id=project.id, table_id=kt_id, request_id=request_id ) - - async def _stream_concurrent_execution(self) -> AsyncGenerator[str, None]: - """ - 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() - tasks_in_progress = set() - - ready_tasks = [ - task_name - for task_name, deps in self.dependencies.items() - if all(dep in completed for dep in deps) - and task_name not in completed - and task_name not in tasks_in_progress + kt_cols = {c.column_id for c in kt.column_metadata if not c.is_state_column} + t0 = perf_counter() + fts_query, vs_query = await lm.generate_search_query( + messages=body.messages, + rag_params=body.rag_params, + **body.hyperparams, + ) + cls._log( + f'Query rewrite using "{body.model}" took t={(perf_counter() - t0) * 1e3:,.2f} ms.', + request_id=request_id, + ) + rows = await kt.hybrid_search( + fts_query=fts_query, + vs_query=vs_query, + embedding_fn=lm.embed_query_as_vector, + vector_column_names=None, + limit=body.rag_params.k, + offset=0, + remove_state_cols=True, + ) + chunks = [ + Chunk( + text=row.get("Text", "") or "", # could be None + title=row.get("Title", "") or "", # could be None + page=row.get("Page", None), + document_id=row.get("File ID", "") or "", # could be None + chunk_id=str(row.get("ID", "")), + # Context will contain extra columns + context={ + k: str(v) + for k, v in row.items() + if k not in kt.FIXED_COLUMN_IDS and k in kt_cols + }, + # Metadata will contain things like RRF score + metadata={ + k: str(v) + for k, v in row.items() + if k not in kt.FIXED_COLUMN_IDS and k not in kt_cols + }, + ) + for row in rows ] + # Add project and table ID + for chunk in chunks: + chunk.metadata["project_id"] = project.id + chunk.metadata["table_id"] = body.rag_params.table_id + if len(rows) > 0 and body.rag_params.reranking_model is not None: + order = ( + await lm.rerank_documents( + model=body.rag_params.reranking_model, + query=vs_query, + documents=kt.rows_to_documents(rows), + ) + ).results + chunks = [chunks[i.index] for i in order] + chunks = chunks[: body.rag_params.k] + references = References(chunks=chunks, search_query=vs_query) + if body.messages[-1].role == ChatRole.USER: + replacement_idx = -1 + elif body.messages[-2].role == ChatRole.USER: + replacement_idx = -2 + else: + raise BadInputError("The message list should end with user or assistant message.") + rag_prompt = await lm.generate_rag_prompt( + messages=body.messages, + references=references, + inline_citations=body.rag_params.inline_citations, + ) + body.messages[replacement_idx].content = rag_prompt + return body, references + + async def _setup_rag(self, body: ChatRequest) -> tuple[ChatRequest, References | None]: + return await self.setup_rag( + project=self.project, + lm=self.lm, + body=body, + request_id=self._request_id, + ) - async def execute_task(task_name): - try: - task = self.llm_tasks[task_name] - except Exception: - task = self.code_tasks[task_name] - - try: - async for chunk in self._execute_task_stream(task): - await queue.put((task_name, chunk)) - 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)) - tasks_in_progress.remove(task_name) - - while len(completed) < ( - len(self.llm_tasks) + len(self.code_tasks) + len(self.input_column_names) - ): - ready_tasks = [ - task_name - for task_name, deps in self.dependencies.items() - if all(dep in completed for dep in deps) - and task_name not in completed - and task_name not in tasks_in_progress - ] - # Process tasks in batches - for i in range(0, len(ready_tasks), self.cols_batch_size): - batch_tasks = ready_tasks[i : i + self.cols_batch_size] - for task in batch_tasks: - tasks_in_progress.add(task) - asyncio.create_task(execute_task(task)) - - none_count = 0 - while none_count < len(batch_tasks): - task_name, chunk = await queue.get() - if chunk is None: - none_count += 1 - continue - yield chunk +@alru_cache(maxsize=ENV_CONFIG.max_file_cache_size, ttl=ENV_CONFIG.document_loader_cache_ttl_sec) +async def _load_uri_as_base64(uri: str | None) -> str | AudioContent | ImageContent | None: + """ + Loads a file from URI for LLM inference. - # Post-execution steps - await self._run_embed_tasks() + Args: + uri (str | None): The URI of the file. - # 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) + Returns: + content (str | AudioContent | ImageContent): The file content. - # Signal the end of stream for a row - yield "data: [DONE]\n\n" + Raises: + BadInputError: If the file format is unsupported. + """ + if not uri: + return None + try: + extension = splitext(uri)[1].lower() + async with open_uri_async(uri) as (file_handle, _): + file_binary = await file_handle.read() + except BadInputError: + raise + except Exception as e: + logger.warning(f'Failed to load file "{uri}" due to error: {repr(e)}') + return None + try: + # Load as document + if extension in DOCUMENT_FILE_EXTENSIONS: + return await GeneralDocLoader().load_document(basename(uri), file_binary) + # Load as audio or image + else: + base64_data = base64.b64encode(file_binary).decode("utf-8") + if extension in AUDIO_FILE_EXTENSIONS: + return AudioContent( + input_audio=AudioContentData(data=base64_data, format=extension[1:]) + ) + elif extension in IMAGE_FILE_EXTENSIONS: + extension = ".jpeg" if extension == ".jpg" else extension + prefix = f"data:image/{extension[1:]};base64," + return ImageContent(image_url=ImageContentData(url=prefix + base64_data)) + else: + raise BadInputError( + ( + "Unsupported file type. Supported formats are: " + f"{', '.join(DOCUMENT_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS + IMAGE_FILE_EXTENSIONS)}" + ) + ) + except BadInputError: + raise + except Exception as e: + logger.warning(f'Failed to parse file "{uri}" due to error: {repr(e)}') + return None + + +@alru_cache(maxsize=ENV_CONFIG.max_file_cache_size, ttl=ENV_CONFIG.document_loader_cache_ttl_sec) +async def _load_uri_as_bytes(uri: str | None) -> bytes | None: + """ + Loads a file from URI as raw bytes. + Args: + uri (str): The URI of the file. + Returns: + content (bytes | None): The raw file content as bytes, or None if loading fails. + Raises: + BadInputError: If the URI is invalid or file cannot be accessed. + """ + if not uri: + return None + + try: + async with open_uri_async(str(uri)) as (file_handle, _): + file_binary = await file_handle.read() + return file_binary + except BadInputError: + raise + except Exception as e: + logger.warning(f'Failed to load file "{uri}" due to error: {repr(e)}') + return None diff --git a/services/api/src/owl/db/gen_table.py b/services/api/src/owl/db/gen_table.py index 49e32ad..9af611e 100644 --- a/services/api/src/owl/db/gen_table.py +++ b/services/api/src/owl/db/gen_table.py @@ -1,1398 +1,3934 @@ -import os +import asyncio +import contextlib import re +from asyncio import Semaphore from collections import defaultdict -from copy import deepcopy -from datetime import datetime, timedelta -from os import listdir -from os.path import exists, isdir, join +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from functools import lru_cache +from inspect import iscoroutinefunction from pathlib import Path -from shutil import copytree, ignore_patterns, move, rmtree -from time import perf_counter, sleep -from typing import Any, BinaryIO, Literal, override +from time import perf_counter +from typing import ( + Any, + AsyncIterator, + Awaitable, + BinaryIO, + Callable, + ClassVar, + Literal, + Self, + Type, + override, +) +from uuid import UUID -import filetype -import lancedb +import asyncpg +import bm25s +import nltk import numpy as np +import orjson import pandas as pd import pyarrow as pa -from filelock import FileLock -from lancedb.table import LanceTable +import pyarrow.parquet as pq +from asyncpg import Connection, Pool +from asyncpg.exceptions import ( + DataError, + DuplicateColumnError, + DuplicateTableError, + InvalidParameterValueError, + PostgresSyntaxError, + UndefinedColumnError, + UndefinedFunctionError, + UndefinedTableError, + UniqueViolationError, +) from loguru import logger -from sqlmodel import Session, select -from tenacity import retry, stop_after_attempt, wait_exponential -from typing_extensions import Self - -from jamaibase.exceptions import ( - BadInputError, - ResourceExistsError, - ResourceNotFoundError, - TableSchemaFixedError, - make_validation_error, +from numpy import array, ndarray +from pgvector.asyncpg import register_vector +from pydantic import ( + BaseModel, + Field, + GetCoreSchemaHandler, + ValidationError, + create_model, + field_validator, + model_validator, ) -from jamaibase.utils.io import df_to_csv, json_loads -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.protocol import ( - COL_NAME_PATTERN, +from pydantic_core import core_schema + +from owl.configs import CACHE, ENV_CONFIG +from owl.db import async_session +from owl.db.models.oss import ModelConfig, Project +from owl.types import ( GEN_CONFIG_VAR_PATTERN, - AddChatColumnSchema, - AddKnowledgeColumnSchema, - ChatEntry, - ChatTableSchemaCreate, - ChatThread, - Chunk, + ChatThreadEntry, + ChatThreadResponse, + CodeGenConfig, ColName, ColumnDtype, ColumnSchema, CSVDelimiter, + DatetimeUTC, + DiscriminatedGenConfig, EmbedGenConfig, - GenConfig, - GenConfigUpdateRequest, - GenTableOrderBy, - KnowledgeTableSchemaCreate, - ModelListConfig, + LLMGenConfig, + ModelCapability, + ModelConfig_, + ModelConfigRead, + Page, PositiveInt, - RowAddData, - RowUpdateData, + ProgressState, + Project_, + PythonGenConfig, + S3Content, + SanitisedNonEmptyStr, + SanitisedStr, + TableImportProgress, TableMeta, TableMetaResponse, TableName, - TableSchema, - TableSchemaCreate, - TableSQLModel, TableType, + TextContent, +) +from owl.utils import merge_dict, uuid7_draft2_str, validate_where_expr +from owl.utils.crypt import hash_string_blake2b as blake2b_hash +from owl.utils.dates import now, utc_datetime_from_iso +from owl.utils.exceptions import ( + BadInputError, + JamaiException, + ModelCapabilityError, + ResourceExistsError, + ResourceNotFoundError, +) +from owl.utils.io import ( + df_to_csv, + guess_mime, + json_dumps, + json_loads, + open_uri_async, + s3_upload, ) -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 = { - "int": 0, - "int8": 0, - "float": 0.0, - "float32": 0.0, - "float16": 0.0, - "bool": False, - "str": "''", - "image": "''", - "audio": "''", -} - - -class GenerativeTable: +from owl.version import __version__ as owl_version + +# Regex for tokenization +digits = r"([0-9]+)" +letters = r"([a-zA-Z]+)" +hanzi = r"([\u4e00-\u9fff])" +# Other non-whitespace, non-letter, non-digit, non-hanzi characters +other = r"([^\s0-9a-zA-Z\u4e00-\u9fff])" +# Combine patterns with OR (|) +TOKEN_PATTERN = re.compile(f"{digits}|{letters}|{hanzi}|{other}") +stemmer = nltk.stem.SnowballStemmer("english") + + +""" +Postgres has limitation for identifier length at 63 characters. + +We need to support up to 100. + +But we cannot set the limit at 63 since Postgres will add suffix like `_id_pkey` ("ID" column as primary key). + +Solution is to use a mapping from `id` (len <= 46) to `table_id` (len <= 100). + +Consumers of a table will use `table_id`, `id` is for internal use. + +1. `len(table_id) <= 100`: + - `id` will be a truncated version of `table_id`: + 1. If `len(table_id) <= 29`: `id = table_id` + 2. If `len(table_id) > 29`: `id = f"{table_id[:29]}-{blake2b_hash(table_id, 16)}"` where the hash is 16 characters. + - During table duplication with auto-naming: + 1. `len(table_id) <= 70`: Suffix will be appended `{table_id} 2025-10-06-22-03-18 (9999)` + 2. `len(table_id) > 70`: `table_id` will be truncated as `f"{table_id[:53]}-{blake2b_hash(table_id, 16)}"` before appending suffix + 3. In both cases, `id` will be a truncated version of `table_id` as usual +2. `len(table_id) > 100`: + - Raise validation error + +Column ID works the same way with a mapping from `id` (len <= 46) to `column_id` (len <= 100), but care has to be taken for state column IDs. + +Index naming: + +1. FTS index: `f"{table_id[:25]}_{blake2b_hash(table_id, 24)}_fts_idx"` +2. Vector index: `f"{short_table_id[:25]}_{blake2b_hash(f"{short_table_id}_{short_column_id}", 24)}_vec_idx"` +""" + + +TABLE_ID_DST_MAX_ITER = 9_999 +IMPORT_BATCH_SIZE = 100 +S3_MAX_CONCURRENCY = 20 + + +def get_internal_id(long_id: str) -> str: + is_file_col = long_id.endswith("__") + is_state_col = long_id.endswith("_") + if is_file_col: + long_id = long_id[:-2] + elif is_state_col: + long_id = long_id[:-1] + else: + pass + if len(long_id) <= 29: + short_id = long_id + else: + short_id = f"{long_id[:29]}-{blake2b_hash(long_id, 16)}" + if is_file_col: + short_id = f"{short_id}__" + elif is_state_col: + short_id = f"{short_id}_" + else: + pass + return short_id + + +def truncate_table_id(table_id: str) -> str: + if len(table_id) <= 70: + return table_id + return f"{table_id[:53]}-{blake2b_hash(table_id, 16)}" + + +def fts_index_id(table_id: str) -> str: + return f"{table_id[:25]}_{blake2b_hash(table_id, 24)}_fts_idx" + + +def vector_index_id(table_id: str, col_id: str) -> str: + return f"{table_id[:25]}_{blake2b_hash(f'{table_id}_{col_id}', 24)}_vec_idx" + + +class NumpyArray: + """Wrapper class for numpy arrays with Pydantic schema support""" + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function( + cls.validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(ndarray), + core_schema.list_schema(core_schema.float_schema()), + ] + ), + ) + + @staticmethod + def validate(value: Any) -> ndarray: + if isinstance(value, list): + # Convert list of floats to a NumPy array + return array(value, dtype=float) + elif isinstance(value, ndarray): + return value + else: + raise ValueError("Value must be a numpy array or a list of floats") + + +class _TableBase(BaseModel): + version: str = Field( + default=owl_version, + description="Table version, following owl version.", + ) + meta: dict[str, Any] = Field( + default={}, + description="Additional metadata about the table.", + ) + + +class TableMetadata(_TableBase): + """ + Table metadata + - Primary key: table_id + - Data table name: table_id + * Remember to update the SQL when making changes to this model """ - Smart 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. + table_id: TableName = Field( + description="Table name.", + ) + short_id: SanitisedNonEmptyStr = Field( + "", + description="Internal short table ID derived from `table_id`.", + ) + title: SanitisedStr = Field( + "", + description='Chat title. Defaults to "".', + ) + parent_id: TableName | None = Field( + None, + description="The parent table ID. If None (default), it means this is a parent table.", + ) + created_by: SanitisedNonEmptyStr | None = Field( + None, + description="ID of the user that created this table. Defaults to None.", + ) + updated_at: DatetimeUTC = Field( + default_factory=now, + description="Table last update datetime (UTC).", + ) + + @model_validator(mode="after") + def generate_internal_id(self) -> Self: + if not self.short_id: + self.short_id = get_internal_id(self.table_id) + return self + + @staticmethod + def sql_create(schema_id: str) -> str: + return f""" + CREATE TABLE IF NOT EXISTS "{schema_id}"."TableMetadata" ( + table_id TEXT PRIMARY KEY, + short_id TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + parent_id TEXT, + created_by TEXT, + updated_at TIMESTAMPTZ NOT NULL, + version TEXT NOT NULL, + meta JSONB NOT NULL + ); + """ + + @classmethod + @lru_cache(maxsize=1) + def str_cols(cls) -> list[str]: + """Return every column name that is a string.""" + return [k for k, v in cls.model_fields.items() if v.annotation is str] + + +class ColumnMetadata(_TableBase): + """ + Column metadata + - Primary key: table_id, column_id + - Foreign key: table_id + * Remember to update the SQL when making changes to this model """ - FIXED_COLUMN_IDS = [] + INFO_COLUMNS: ClassVar[set[str]] = {"id", "updated at"} - def __init__( - self, - db_url: str, - vector_db_url: str, - *, - read_consistency_interval: timedelta | None = None, - create_sqlite_tables: bool = True, - ) -> None: - 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 = {} + table_id: TableName = Field( + description="Associated Table name.", + ) + column_id: str = Field( + pattern=r"^[A-Za-z0-9]([A-Za-z0-9.?!@#$%^&*_()\- ]*[A-Za-z0-9.?!()\-])?_*$", + min_length=1, + max_length=101, + description="Column name.", + ) + short_table_id: SanitisedNonEmptyStr = Field( + "", + description="Internal short table ID derived from `table_id`.", + ) + short_id: SanitisedNonEmptyStr = Field( + "", + description="Internal short column ID derived from `column_id`.", + ) + dtype: ColumnDtype = Field( + ColumnDtype.STR, + description=f"Column data type, one of {list(map(str, ColumnDtype))}.", + ) + vlen: PositiveInt = Field( + 0, + description=( + "_Optional_. vector length. If provided, then this column will be a VECTOR column type." + "ex: embedding size." + ), + examples=[1024], + ) + gen_config: DiscriminatedGenConfig | None = Field( + None, + description=( + '_Optional_. Generation config. If provided, then this column will be an "Output Column". ' + "Table columns on its left can be referenced by `${column-name}`." + ), + ) + column_order: int = Field( + 0, + description="Order of the column in the table. Usually you don't need to set this.", + examples=[0, 1], + ) + + @model_validator(mode="after") + def generate_internal_id(self) -> Self: + if not self.short_table_id: + self.short_table_id = get_internal_id(self.table_id) + if not self.short_id: + self.short_id = get_internal_id(self.column_id) + return self + @field_validator("dtype", mode="before") @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, - ) + def validate_dtype(cls, value: Any) -> str: + """ + Handles some special cases for dtype. + """ + if value in ["float32", "float16"]: + return ColumnDtype.FLOAT + if value == "int8": + return ColumnDtype.INT + return value @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 is_output_column(self) -> bool: + return self.gen_config is not None - 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] + @property + def is_text_column(self) -> bool: + return self.dtype == ColumnDtype.STR and self.column_id.lower() not in self.INFO_COLUMNS - def create_session(self): - return Session(self.sqlite_engine) + @property + def is_chat_column(self) -> bool: + return getattr(self.gen_config, "multi_turn", False) - def has_info_col_names(self, names: list[str]) -> bool: - return sum(n.lower() in ("id", "updated at") for n in names) > 0 + @property + def is_vector_column(self) -> bool: + return self.dtype in (ColumnDtype.FLOAT,) and self.vlen > 0 - def has_state_col_names(self, names: list[str]) -> bool: - return any(n.endswith("_") for n in names) + @property + def is_image_column(self) -> bool: + return self.dtype == ColumnDtype.IMAGE - 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] - ) + @property + def is_audio_column(self) -> bool: + return self.dtype == ColumnDtype.AUDIO - def _create_table( - self, - session: Session, - schema: TableSchemaCreate, - remove_state_cols: bool = False, - add_info_state_cols: bool = True, - ) -> tuple[LanceTable, TableMeta]: - table_id = schema.id - 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 + @property + def is_document_column(self) -> bool: + return self.dtype == ColumnDtype.DOCUMENT - def create_table( - self, - session: Session, - schema: TableSchemaCreate, - remove_state_cols: bool = False, - add_info_state_cols: bool = True, - ) -> tuple[LanceTable, TableMeta]: - if not isinstance(schema, TableSchema): - raise TypeError("`schema` must be an instance of `TableSchema`.") - fixed_cols = set(c.lower() for c in self.FIXED_COLUMN_IDS) - if len(fixed_cols.intersection(set(c.id.lower() for c in schema.cols))) != len(fixed_cols): - raise BadInputError(f"Schema must contain fixed columns: {self.FIXED_COLUMN_IDS}") - return self._create_table( - session=session, - schema=schema, - remove_state_cols=remove_state_cols, - add_info_state_cols=add_info_state_cols, - ) + @property + def is_file_column(self) -> bool: + return self.dtype in (ColumnDtype.IMAGE, ColumnDtype.AUDIO, ColumnDtype.DOCUMENT) - def open_table(self, table_id: TableName) -> LanceTable: - try: - table = self.lance_db.open_table(table_id) - except FileNotFoundError as e: - raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e - return table + @property + def is_info_column(self) -> bool: + return self.column_id.lower() in self.INFO_COLUMNS - def open_meta( - self, - session: Session, - table_id: TableName, - remove_state_cols: bool = False, - ) -> TableMeta: - meta = session.get(TableMeta, table_id) - if meta is None: - 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 + @property + def is_state_column(self) -> bool: + return self.column_id.endswith("_") - def open_table_meta( - self, - session: Session, - table_id: TableName, - remove_state_cols: bool = False, - ) -> 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 + @staticmethod + def sql_create(schema_id: str) -> str: + return f""" + CREATE TABLE IF NOT EXISTS "{schema_id}"."ColumnMetadata" ( + table_id TEXT NOT NULL, + column_id TEXT NOT NULL, + short_table_id TEXT NOT NULL, + short_id TEXT NOT NULL, + dtype TEXT NOT NULL, + vlen INT DEFAULT 0 NOT NULL, + gen_config JSONB, + column_order INT NOT NULL, + version TEXT, + meta JSONB NOT NULL, + PRIMARY KEY (table_id, column_id), + UNIQUE (short_table_id, short_id), + CONSTRAINT "fk_ColumnMetadataTable_table_id" + FOREIGN KEY (table_id) + REFERENCES "{schema_id}"."TableMetadata" (table_id) + ON UPDATE CASCADE + ON DELETE CASCADE, + CONSTRAINT "fk_ColumnMetadataTable_short_id" + FOREIGN KEY (short_table_id) + REFERENCES "{schema_id}"."TableMetadata" (short_id) + ON UPDATE CASCADE + ); + """ - def list_meta( - self, - session: Session, + +class DataTableRow(BaseModel, coerce_numbers_to_str=True): + @classmethod + def get_column_ids( + cls, *, - offset: int, - limit: int, - parent_id: str | None = None, - 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( - 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) if count_rows else -1 - except Exception: - 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: - logger.warning(f"Lance table MISSING, removing metadata: {meta.id}") - session.delete(meta) - continue - meta_responses.append( - 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" + exclude_info: bool = False, + exclude_state: bool = False, + ) -> list[str]: + columns = list(cls.model_fields.keys()) + if exclude_info: + columns = [c for c in columns if c.lower() not in ("id", "updated at")] + if exclude_state: + columns = [c for c in columns if not c.endswith("_")] + return columns + + +class DBengine: + _instance = None + _conn_pool: Pool = None + _initialized = False + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + async def get_conn_pool(self) -> Pool: + """Get or create a PostgreSQL connection pool with proper configuration.""" + if self._conn_pool is None: + self._conn_pool = await asyncpg.create_pool( + dsn=re.sub(r"\+\w+", "", ENV_CONFIG.db_path), + min_size=2, + max_size=5, + max_inactive_connection_lifetime=300.0, + timeout=30.0, + command_timeout=60.0, + max_queries=1000, + # Do not cache statement plan since Generative Table's schema can change + statement_cache_size=0, + init=self._setup_connection, ) + self._initialized = True + return self._conn_pool + + async def close(self): + """Close the connection pool.""" + if self._conn_pool and not self._conn_pool._closed: + await self._conn_pool.close() + self._conn_pool = None + self._initialized = False + + async def _setup_connection(self, conn: Connection) -> None: + """Configure a new connection with required settings.""" + # Remember to update the InitApplicationSQL in the yaml for extension creation + await conn.execute("CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;") + await conn.execute("CREATE EXTENSION IF NOT EXISTS pgroonga;") + # If `transaction_timeout` <= `idle_in_transaction_session_timeout` or `statement_timeout` + # then the longer timeout is ignored. + await conn.execute("SET statement_timeout = 20000") + await conn.execute("SET transaction_timeout = 20000") + await conn.execute("SET idle_in_transaction_session_timeout = 20000") + await register_vector(conn) + await conn.set_type_codec( + "jsonb", + encoder=lambda obj: orjson.dumps(obj).decode("utf-8"), + decoder=orjson.loads, + schema="pg_catalog", ) - 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: TableName, filter: str | None = None) -> int: - return self.open_table(table_id).count_rows(filter) + @contextlib.asynccontextmanager + async def transaction(self, schema_id: str = None) -> AsyncIterator[Connection]: + """Provide a transactional scope for a series of operations.""" + async with (await self.get_conn_pool()).acquire() as conn: + async with conn.transaction(): + try: + if schema_id: + await conn.execute(f'SET search_path TO "{schema_id}"') + yield conn + except JamaiException: + # No need to log these errors + raise + except Exception as e: + logger.error(f"Transaction failed: {e}") + raise + + +GENTABLE_ENGINE = DBengine() - def duplicate_table( + +class GenerativeTableCore: + """ + Core class for managing generative tables in PostgreSQL with schema-based organization. + Devs should use `GenerativeTable` instead. + """ + + INFO_COLUMNS = {"id", "updated at"} + FIXED_COLUMN_IDS = ["ID", "Updated at"] + + def __init__( self, - session: Session, - table_id_src: TableName, - table_id_dst: TableName, - include_data: bool = True, - 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.') - # Duplicate metadata - with self.lock(table_id_src): - meta = self.open_meta(session, table_id_src) - 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() - session.refresh(new_meta) - # Duplicate LanceTable - if include_data: - copytree( - self.vector_db_url / f"{table_id_src}.lance", - self.vector_db_url / f"{table_id_dst}.lance", - ignore=ignore_patterns("_indices"), + *, + # TODO: We should directly pass in `Project_` instead of fetching it again + project_id: str, + table_type: TableType, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + num_rows: int = -1, + request_id: str = "", + ) -> None: + self.project_id = project_id + self.table_type = table_type + self.table_metadata = table_metadata + self.column_metadata = column_metadata_list + self.num_rows = num_rows + self.request_id = request_id + self.schema_id = f"{project_id}_{table_type}" + self.data_table_model = self._create_data_table_row_model( + table_metadata.table_id, column_metadata_list + ) + self.text_column_names = [ + col.column_id for col in self.column_metadata if col.is_text_column + ] + self.vector_column_names = [ + col.column_id for col in self.column_metadata if col.is_vector_column + ] + self.map_to_short_col_id = {c.column_id: c.short_id for c in column_metadata_list} + self.map_to_long_col_id = {c.short_id: c.column_id for c in column_metadata_list} + + @property + def table_id(self) -> str: + return self.table_metadata.table_id + + @table_id.setter + def table_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("`table_id` must be a string.") + short_id = get_internal_id(value) + self.table_metadata.table_id = value + self.table_metadata.short_id = short_id + for col in self.column_metadata: + col.table_id = value + col.short_table_id = short_id + + @property + def short_table_id(self) -> str: + return self.table_metadata.short_id + + @property + def v1_meta(self) -> TableMeta: + meta = TableMeta( + id=self.table_id, + version=self.table_metadata.version, + meta=self.table_metadata.meta, + cols=[ + ColumnSchema( + id=col.column_id, + dtype=col.dtype, + vlen=col.vlen, + gen_config=col.gen_config, ) - with self.create_session() as session: - self.create_indexes(session, table_id_dst, force=True) - else: - schema = TableSchema.model_validate(new_meta) - self.lance_db.create_table(table_id_dst, schema=schema.pyarrow) - return new_meta + for col in self.column_metadata + ], + parent_id=self.table_metadata.parent_id, + created_by=self.table_metadata.created_by, + title=self.table_metadata.title, + updated_at=self.table_metadata.updated_at, + num_rows=self.num_rows, + ) + return meta - def rename_table( - self, - session: Session, - table_id_src: TableName, - table_id_dst: TableName, - ) -> TableMeta: - # Check - dst_meta = session.get(TableMeta, table_id_dst) - if dst_meta is not None: - 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_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}'" + @property + def v1_meta_response(self) -> TableMetaResponse: + meta = TableMetaResponse( + id=self.table_id, + version=self.table_metadata.version, + meta=self.table_metadata.meta, + cols=[ + ColumnSchema( + id=col.column_id, + dtype=col.dtype, + vlen=col.vlen, + gen_config=col.gen_config, ) - ) - session.commit() - session.refresh(meta) - # Rename LanceTable - # 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", - ) + for col in self.column_metadata + ], + parent_id=self.table_metadata.parent_id, + created_by=self.table_metadata.created_by, + title=self.table_metadata.title, + updated_at=self.table_metadata.updated_at, + num_rows=self.num_rows, + ) return meta - def delete_table(self, session: Session, table_id: TableName) -> None: - with self.lock(table_id): - # Delete LanceTable - for _ in range(10): - # Try 10 times - try: - 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(0.5) - else: - 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: GenConfigUpdateRequest) -> TableMeta: - table_id = updates.table_id - 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 make_validation_error( - ValueError( - f"Some columns are not found in the table: {update_col_ids - meta_col_ids}" - ), - loc=("body", "column_map"), + def _log(self, msg: str, level: str = "INFO"): + _log = f"{self.__class__.__name__}: {msg}" + if self.request_id: + _log = f"{self.request_id} - {_log}" + logger.log(level, _log) + + @staticmethod + async def _fetch_project(project_id: str) -> Project_: + async with async_session() as session: + project = await Project.get(session, project_id) + return Project_.model_validate(project) + + @staticmethod + async def _fetch_model(model: str, organization_id: str) -> ModelConfigRead: + async with async_session() as session: + cfg = await ModelConfig.get(session, model) + cfg = ModelConfigRead.model_validate(cfg) + if (not cfg.is_active) or (not cfg.allowed(organization_id)): + raise ResourceNotFoundError(f'Model "{model}" is not found.') + return cfg + + @staticmethod + async def _fetch_model_with_capabilities( + *, + capabilities: list[ModelCapability], + organization_id: str, + ) -> ModelConfig_: + from owl.utils.lm import LMEngine + + async with async_session() as session: + models = ( + await ModelConfig.list_( + session=session, + return_type=ModelConfig_, + organization_id=organization_id, + capabilities=capabilities, + exclude_inactive=True, + ) + ).items + if len(models) == 0: + raise ModelCapabilityError( + f"No model found with capabilities: {list(map(str, capabilities))}" ) - cols = deepcopy(meta.cols) - for c in cols: - # Validate and update - gen_config = updates.column_map.get(c["id"], c["gen_config"]) - c["gen_config"] = ( - gen_config.model_dump() if isinstance(gen_config, GenConfig) else gen_config + model = LMEngine.pick_best_model(models, capabilities) + return model + + @classmethod + async def _check_columns( + cls, + conn: Connection, + *, + project_id: str, + table_type: TableType, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + set_default_prompts: bool, + replace_unavailable_models: bool, + allow_nonexistent_refs: bool = False, + ) -> list[ColumnMetadata]: + del table_type # Not used for now + table_id = table_metadata.table_id + if len(set(c.column_id.lower() for c in column_metadata_list)) != len( + column_metadata_list + ): + raise BadInputError( + f'Table "{table_id}": There are repeated column names (case-insensitive).' ) - 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 + project = await cls._fetch_project(project_id) + column_map = {c.column_id: c for c in column_metadata_list} + for i, col in enumerate(column_metadata_list): + gen_config = col.gen_config + if gen_config is None: + continue + available_cols = [ + c + for c in column_metadata_list[:i] + if not (c.is_info_column or c.is_state_column or c.is_vector_column) + ] + valid_col_ids = [c.column_id for c in available_cols] + if isinstance(gen_config, EmbedGenConfig): + if not col.is_vector_column: + raise BadInputError( + f'Table "{table_id}": ' + f'Embedding column "{col.column_id}" must be a vector column with float data type.' + ) + if (not allow_nonexistent_refs) and ( + gen_config.source_column not in valid_col_ids + ): + raise BadInputError( + ( + f'Table "{table_id}": ' + f'Embedding config of column "{col.column_id}" referenced ' + f'an invalid source column "{gen_config.source_column}". ' + "Make sure you only reference non-vector columns on its left. " + f"Available columns: {valid_col_ids}." + ) + ) + # Validate and assign default model + embedding_model = gen_config.embedding_model.strip() + if embedding_model: + # Validate model capabilities + try: + model = await cls._fetch_model(embedding_model, project.organization_id) + if ModelCapability.EMBED not in model.capabilities: + raise ModelCapabilityError( + ( + f'Table "{table_id}": Model "{model.id}" used in Embedding column "{col.column_id}" ' + f"does not support embedding." + ) + ) + except ModelCapabilityError: + # Embedding model is not interchangeable + raise + except ResourceNotFoundError as e: + # Embedding model is not interchangeable + raise BadInputError( + f'Table "{table_id}": ' + f'Embedding model "{embedding_model}" used by column "{col.column_id}" is not found.' + ) from e + # Do not use `elif` here + if not embedding_model: + # Assign default model + try: + model = await cls._fetch_model_with_capabilities( + capabilities=[ModelCapability.EMBED], + organization_id=project.organization_id, + ) + except ModelCapabilityError as e: + raise ModelCapabilityError(f'Table "{table_id}": {e}') from e + gen_config.embedding_model = model.id + elif isinstance(gen_config, LLMGenConfig): + if col.is_vector_column: + raise BadInputError( + f'Table "{table_id}": ' + f'LLM column "{col.column_id}" must not be a vector column.' + ) + if not col.is_text_column: + raise BadInputError( + f'Table "{table_id}": ' + f'LLM column "{col.column_id}" must be a string (text) column.' + ) + # Insert default prompts if needed + if set_default_prompts: + # We only put input columns into default prompt + _input_cols = [c for c in available_cols if c.gen_config is None] + _text_cols = "\n\n".join( + f"{c.column_id}: ${{{c.column_id}}}" + for c in _input_cols + if not (c.is_image_column or c.is_audio_column) + ) + _image_audio_cols = " ".join( + f"${{{c.column_id}}}" + for c in _input_cols + if (c.is_image_column or c.is_audio_column) + ) + # We place image and audio columns first, which will then be replaced with "" and stripped out + if gen_config.multi_turn: + default_system_prompt = ( + f'You are an agent named "{table_id}". Be helpful. ' + "Ensure that your reply is easy to understand and is accessible to all users. " + "Provide answers based on the information given. " + "Be factual and do not hallucinate." + ).strip() + default_user_prompt = f"{_image_audio_cols}\n\n{_text_cols}".strip() + else: + default_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." + ).strip() + if _text_cols: + _text_cols = f"{_text_cols}\n\n" + default_user_prompt = ( + f"{_image_audio_cols}\n\n" + f'Table name: "{table_id}"\n\n' + f"{_text_cols}" + "Based on the available information, " + f'provide an appropriate response for the column "{col.column_id}".\n' + "Be factual and do not hallucinate. " + "Remember to act as a cell in a spreadsheet and provide concise, " + "relevant information without explanations unless specifically requested." + ).strip() + if not gen_config.system_prompt: + gen_config.system_prompt = default_system_prompt + if not gen_config.prompt: + gen_config.prompt = default_user_prompt + # Check references + ref_cols = re.findall(GEN_CONFIG_VAR_PATTERN, gen_config.prompt) + if allow_nonexistent_refs: + ref_cols = [c for c in ref_cols if c in column_map] + if len(invalid_cols := [c for c in ref_cols if c not in valid_col_ids]) > 0: + raise BadInputError( + ( + f'Table "{table_id}": ' + f'LLM Generation prompt of column "{col.column_id}" referenced ' + f"invalid source columns: {invalid_cols}. " + "Make sure you only reference non-vector columns on its left. " + f"Available columns: {valid_col_ids}." + ) + ) + # Validate and assign default model + ref_image_cols = [c for c in ref_cols if column_map[c].is_image_column] + ref_audio_cols = [c for c in ref_cols if column_map[c].is_audio_column] + capabilities = [ModelCapability.CHAT] + if len(ref_image_cols) > 0: + capabilities.append(ModelCapability.IMAGE) + if len(ref_audio_cols) > 0: + capabilities.append(ModelCapability.AUDIO) + chat_model = gen_config.model.strip() + if chat_model: + # Validate model capabilities + try: + model = await cls._fetch_model(gen_config.model, project.organization_id) + unsupported = list(set(capabilities) - set(model.capabilities)) + if len(unsupported) > 0: + raise ModelCapabilityError( + ( + f'Table "{table_id}": Model "{model.id}" used in LLM column "{col.column_id}" ' + f"lack these capabilities: {', '.join(unsupported)}." + ) + ) + except ModelCapabilityError: + if replace_unavailable_models: + # We replace the unavailable model with a default model below + chat_model = "" + else: + raise + except ResourceNotFoundError as e: + if replace_unavailable_models: + # We replace the unavailable model with a default model below + chat_model = "" + else: + raise BadInputError( + f'Table "{table_id}": ' + f'LLM model "{gen_config.model}" used by column "{col.column_id}" is not found.' + ) from e + # Do not use `elif` here + if not chat_model: + # Assign default model + try: + model = await cls._fetch_model_with_capabilities( + capabilities=capabilities, + organization_id=project.organization_id, + ) + except ModelCapabilityError as e: + raise ModelCapabilityError(f'Table "{table_id}": {e}') from e + gen_config.model = model.id + # Check RAG params + if gen_config.rag_params is not None: + kt_id = gen_config.rag_params.table_id + if not allow_nonexistent_refs: + if kt_id.strip() == "": + raise BadInputError( + ( + f'Table "{table_id}": Column "{col.column_id}" ' + f"referenced a Knowledge Table with an empty ID." + ) + ) + kt_metadata = await conn.fetchrow( + f'SELECT * FROM "{project_id}_knowledge"."TableMetadata" WHERE table_id = $1', + kt_id, + ) + if kt_metadata is None: + raise BadInputError( + ( + f'Table "{table_id}": Column "{col.column_id}" ' + f'referenced a Knowledge Table "{kt_id}" that does not exist.' + ) + ) + # Validate and assign default Reranking Model + reranking_model = gen_config.rag_params.reranking_model + if reranking_model is not None: + reranking_model = reranking_model.strip() + if reranking_model: + # Validate model capabilities + try: + model = await cls._fetch_model( + reranking_model, project.organization_id + ) + if ModelCapability.RERANK not in model.capabilities: + raise ModelCapabilityError( + ( + f'Table "{table_id}": Model "{reranking_model}" ' + f'used in LLM column "{col.column_id}" ' + f"does not support reranking." + ) + ) + except ModelCapabilityError: + if replace_unavailable_models: + # We replace the unavailable model with a default model below + reranking_model = "" + else: + raise + except ResourceNotFoundError as e: + if replace_unavailable_models: + # We replace the unavailable model with a default model below + reranking_model = "" + else: + raise BadInputError( + f'Table "{table_id}": ' + f'Reranking model "{gen_config.model}" used by column "{col.column_id}" is not found.' + ) from e + # Do not use `elif` here + if not reranking_model: + model = await cls._fetch_model_with_capabilities( + capabilities=[str(ModelCapability.RERANK)], + organization_id=project.organization_id, + ) + gen_config.rag_params.reranking_model = model.id + elif isinstance(gen_config, CodeGenConfig): + if col.is_vector_column: + raise BadInputError( + f'Table "{table_id}": ' + f'Code Execution column "{col.column_id}" must not be a vector column.' + ) + if col.dtype not in (ColumnDtype.STR, ColumnDtype.IMAGE, ColumnDtype.AUDIO): + raise BadInputError( + f'Table "{table_id}": ' + f'Code Execution column "{col.column_id}" must be a string (text) or image column or audio column.' + ) + valid_col_ids = [c.column_id for c in available_cols if c.dtype == ColumnDtype.STR] + if (not allow_nonexistent_refs) and ( + gen_config.source_column not in valid_col_ids + ): + raise BadInputError( + ( + f'Table "{table_id}": ' + f'Code Execution config of column "{col.column_id}" referenced ' + f'an invalid source column "{gen_config.source_column}". ' + "Make sure you only reference string (text) columns on its left. " + f"Available columns: {valid_col_ids}." + ) + ) + elif isinstance(gen_config, PythonGenConfig): + if col.is_vector_column: + raise BadInputError( + f'Table "{table_id}": ' + f'Python Function column "{col.column_id}" must not be a vector column.' + ) + if col.dtype not in (ColumnDtype.STR, ColumnDtype.IMAGE, ColumnDtype.AUDIO): + raise BadInputError( + f'Table "{table_id}": ' + f'Python Function column "{col.column_id}" must be a string (text) or image column or audio column.' + ) - def add_columns( - self, session: Session, schema: TableSchemaCreate - ) -> tuple[LanceTable, TableMeta]: + return column_metadata_list + + @classmethod + async def _create_table( + cls, + *, + project_id: str, + table_type: TableType, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + request_id: str = "", + set_default_prompts: bool = True, + replace_unavailable_models: bool = False, + allow_nonexistent_refs: bool = False, + create_indexes: bool = True, + ) -> Self: """ - Adds one or more input or output column. + Create a new table. + This method is created so that the public method `create_table` can be overridden + without affecting table creation logic. Args: - session (Session): SQLAlchemy session. - schema (TableSchemaCreate): Schema of the columns to be added. - - Raises: - ResourceNotFoundError: If the table is not found. - ValueError: If any of the columns exists. + project_id (str): Project ID. + table_type (str): Table type. + table_metadata (TableMetadata): Table metadata. + column_metadata_list (list[ColumnMetadata]): List of column metadata. + request_id (str, optional): Request ID for logging. Defaults to "". + set_default_prompts (bool, optional): Set default prompts. + Useful when importing table which does not need to set prompts. Defaults to True. + replace_unavailable_models (bool, optional): Replace unavailable models with default models. + Useful when importing old tables. Defaults to False. + allow_nonexistent_refs (bool, optional): Ignore non-existent column and Knowledge Table references. + Otherwise will raise an error. Useful when importing old tables and performing maintenance. + Defaults to False. + create_indexes (bool, optional): Create indexes for the table. + Setting to False can be useful when importing tables + where you want to create indexes after all rows are added. + Defaults to True. Returns: - table (LanceTable): Lance table. - meta (TableMeta): Table metadata. - """ - 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 make_validation_error( - ValueError("Schema and table contain overlapping column names."), - loc=("body", "cols"), + self (GenerativeTableCore): The table instance. + """ + schema_id = f"{project_id}_{table_type}" + + ### --- VALIDATIONS --- ### + # Override info and state columns + column_metadata_list = [ + col for col in column_metadata_list if not (col.is_info_column or col.is_state_column) + ] + state_columns = [ + ColumnMetadata( + table_id=table_metadata.table_id, + column_id=f"{col.column_id}_", + dtype=ColumnDtype.JSON, ) - meta.cols = [ - c.model_dump() - for c in TableSchema(id=meta.id, cols=[c.model_dump() for c in cols]).cols + for col in column_metadata_list ] + info_columns = [ + ColumnMetadata( + table_id=table_metadata.table_id, + column_id="ID", + dtype=ColumnDtype.STR, + ), + ColumnMetadata( + table_id=table_metadata.table_id, + column_id="Updated at", + dtype=ColumnDtype.DATE_TIME, + ), + ] + column_metadata_list = info_columns + column_metadata_list + state_columns - with self.lock(table_id): - # Add columns to LanceDB - table = self.open_table(table_id) - # Non-vector columns can be added using SQL statement - # TODO: Investigate adding vector columns using BatchUDF - cols_to_add = { - c.id: f"{_py_type_default[c.dtype]}" for c in schema.cols if c.vlen == 0 - } - if len(cols_to_add) > 0: - table.add_columns(cols_to_add) - # Add vector columns to Lance Table using merge op (this is very slow) - vectors = [ - [np.zeros(shape=[c.vlen], dtype=c.dtype)] for c in schema.cols if c.vlen > 0 - ] - if len(vectors) > 0: - _id = table.search().limit(1).to_list() - _id = _id[0]["ID"] if len(_id) > 0 else "0" - vec_schema = schema.pa_vec_schema - vec_schema = vec_schema.insert(0, table.schema.field("ID")) - pa_table = pa.table([[_id]] + vectors, schema=vec_schema) - table.merge(pa_table, left_on="ID") - - # Add Table Metadata - meta.updated_at = datetime_now_iso() - session.add(meta) - session.commit() - session.refresh(meta) - return table, meta - - def _drop_columns( - self, - session: Session, - 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 + ### --- Create metadata tables --- ### + await cls.create_schemas(project_id) + async with GENTABLE_ENGINE.transaction() as conn: + # Validate column metadata + await cls._check_columns( + conn=conn, + project_id=project_id, + table_type=table_type, + table_metadata=table_metadata, + column_metadata_list=column_metadata_list, + set_default_prompts=set_default_prompts, + replace_unavailable_models=replace_unavailable_models, + allow_nonexistent_refs=allow_nonexistent_refs, + ) + # Override column order + for i, col_meta in enumerate(column_metadata_list): + col_meta.column_order = i + ### --- Create data table --- ### + # Create the data table + await cls._create_data_table( + conn=conn, + schema_id=schema_id, + table_metadata=table_metadata, + column_metadata_list=column_metadata_list, + create_indexes=create_indexes, + ) + # Create metadata entries + await cls._upsert_table_metadata(conn, schema_id, table_metadata) + for col_metadata in column_metadata_list: + await cls._upsert_column_metadata(conn, schema_id, col_metadata) + # Reload table + async with GENTABLE_ENGINE.transaction() as conn: + return await cls._open_table( + conn=conn, + project_id=project_id, + table_type=table_type, + table_id=table_metadata.table_id, + request_id=request_id, + ) - Drops one or more input or output column. + async def _count_rows(self, conn: Connection) -> int: + """ + Count the number of rows. Args: - session (Session): SQLAlchemy session. - table_id (str): Table ID. - col_names (list[str]): List of column ID to drop. - - Raises: - TypeError: If `col_names` is not a list. - ResourceNotFoundError: If the table is not found. - ResourceNotFoundError: If any of the columns is not found. + conn (Connection): PostgreSQL connection. Returns: - 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 make_validation_error( - ValueError("Cannot drop state columns."), - loc=("body", "column_names"), - ) - if self.has_info_col_names(col_names): - raise make_validation_error( - ValueError('Cannot drop "ID" or "Updated at".'), - loc=("body", "column_names"), + num_rows (int): Number of rows in the table. + """ + # If we don't need a 100% exact count and a very fast, rough estimate is good enough + # SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'your_table'; + try: + self.num_rows = await conn.fetchval( + f'SELECT COUNT("ID") FROM "{self.schema_id}"."{self.short_table_id}"' ) - with self.lock(table_id): - meta = self.open_meta(session, table_id) - col_names += [f"{n}_" for n in col_names] - table = self.open_table(table_id) + except (UndefinedTableError, UndefinedColumnError) as e: + logger.error( + ( + f'Data table `"{self.schema_id}"."{self.short_table_id}"` ' + "is not found but table and column metadata exist !!! " + f"Error: {repr(e)}" + ) + ) + raise ResourceNotFoundError( + f'Table "{self.table_id}" is not found. Please contact support if this is unexpected.' + ) from e + # await conn.fetch("SET LOCAL enable_seqscan = off;") + return self.num_rows + + @classmethod + async def _open_table( + cls, + conn: Connection, + *, + project_id: str, + table_type: TableType, + table_id: str, + request_id: str = "", + ) -> Self: + """ + Open an existing table. + + Args: + conn (Connection): PostgreSQL connection. + project_id (str): Project ID. + table_type (str): Table type. + table_id (str): Name of the table. + request_id (str, optional): Request ID for logging. Defaults to "". + + Returns: + self (GenerativeTableCore): The table instance. + """ + schema_id = f"{project_id}_{table_type}" + + ### --- Read table and column metadata --- ### + try: + table_metadata = await conn.fetchrow( + f'SELECT * FROM "{schema_id}"."TableMetadata" WHERE table_id = $1', table_id + ) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e + except Exception as e: + raise BadInputError(e) from e + if table_metadata is None: + raise ResourceNotFoundError(f'Table metadata for "{table_id}" is not found.') + try: + column_metadata = await conn.fetch( + f'SELECT * FROM "{schema_id}"."ColumnMetadata" WHERE table_id = $1 ORDER BY column_order ASC', + table_id, + ) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{table_id}" is not found.') from e + except Exception as e: + raise BadInputError(e) from e + if len(column_metadata) == 0: + raise ResourceNotFoundError(f'Column metadata for "{table_id}" is not found.') + self = cls( + project_id=project_id, + table_type=table_type, + table_metadata=TableMetadata.model_validate(dict(table_metadata)), + column_metadata_list=[ + ColumnMetadata.model_validate(dict(col)) for col in column_metadata + ], + request_id=request_id, + ) + await self._count_rows(conn) + return self + + async def _reload_table(self, conn: Connection) -> Self: + self = await self._open_table( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_id=self.table_id, + request_id=self.request_id, + ) + await self._check_columns( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_metadata=self.table_metadata, + column_metadata_list=self.column_metadata, + set_default_prompts=False, + replace_unavailable_models=False, + ) + return self + + @staticmethod + async def _recreate_fts_index( + conn: Connection, + *, + schema_id: str, + table_id: str, + columns: list[str], + ) -> None: + if len(columns) == 0: + return + index_id = fts_index_id(table_id) + await conn.execute(f'DROP INDEX IF EXISTS "{schema_id}"."{index_id}"') + await conn.execute( + f""" + CREATE INDEX "{index_id}" + ON "{schema_id}"."{get_internal_id(table_id)}" + USING pgroonga ((ARRAY[{", ".join(f'"{get_internal_id(col)}"' for col in columns)}])); + """, + timeout=300.0, + ) + + @staticmethod + async def _recreate_vector_index( + conn: Connection, + *, + schema_id: str, + table_id: str, + columns: list[str], + ) -> None: + if len(columns) == 0: + return + # pgvector doesn't support multi-column index, as of: 2025-03-04 + for col in columns: + index_id = vector_index_id(table_id, col) + await conn.execute(f'DROP INDEX IF EXISTS "{schema_id}"."{index_id}"') + await conn.execute( + f""" + CREATE INDEX "{index_id}" + ON "{schema_id}"."{get_internal_id(table_id)}" + USING diskann ("{get_internal_id(col)}" vector_cosine_ops); + """, + timeout=600.0, + ) + + @staticmethod + def _state_column_sql(short_column_id: str) -> str: + return f""""{short_column_id}_" JSONB NOT NULL DEFAULT '{{}}'::JSONB""" + + @classmethod + async def _create_data_table( + cls, + conn: Connection, + *, + schema_id: str, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + create_indexes: bool = True, + ) -> None: + table_id = table_metadata.table_id + # All data table have "ID" and "Updated at" columns + column_defs = [] + column_defs.append('"ID" UUID PRIMARY KEY') + column_defs.append('"Updated at" TIMESTAMPTZ') + + # Generate the SQL column definitions for the CREATE TABLE statement + text_cols = [] + vec_cols = [] + for col in column_metadata_list: + if col.is_info_column or col.is_state_column: + continue + dtype = col.dtype + if col.is_vector_column: + dtype = f"VECTOR({col.vlen})" + vec_cols.append(col.column_id) + else: + dtype = dtype.to_postgres_type() + if col.is_text_column: + text_cols.append(col.column_id) + column_defs.append(f'"{col.short_id}" {dtype}') + column_defs.append(cls._state_column_sql(col.short_id)) + try: + # Create the table in the database + await conn.execute(f""" + CREATE TABLE "{schema_id}"."{table_metadata.short_id}" ( + {", ".join(column_defs)} + ); + """) + if create_indexes: + await cls._recreate_fts_index( + conn, + schema_id=schema_id, + table_id=table_id, + columns=text_cols, + ) + await cls._recreate_vector_index( + conn, + schema_id=schema_id, + table_id=table_id, + columns=vec_cols, + ) + except DuplicateTableError as e: + raise ResourceExistsError(f'Table "{table_id}" already exists.') from e + + @staticmethod + async def _upsert_table_metadata( + conn: Connection, + schema_id: str, + table_metadata: TableMetadata, + ) -> None: + query = f""" + INSERT INTO "{schema_id}"."TableMetadata" ( + table_id, short_id, title, parent_id, created_by, updated_at, version, meta + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (table_id) DO UPDATE SET + title = COALESCE(EXCLUDED.title, "TableMetadata".title), + parent_id = EXCLUDED.parent_id, + created_by = EXCLUDED.created_by, + updated_at = COALESCE(EXCLUDED.updated_at, "TableMetadata".updated_at), + version = COALESCE(EXCLUDED.version, "TableMetadata".version), + meta = COALESCE(EXCLUDED.meta, "TableMetadata".meta); + """ + values = [ + table_metadata.table_id, + table_metadata.short_id, + table_metadata.title, + table_metadata.parent_id, + table_metadata.created_by, + table_metadata.updated_at, + table_metadata.version, + table_metadata.meta, + ] + await conn.execute(query, *values) + + @staticmethod + async def _upsert_column_metadata( + conn: Connection, + schema_id: str, + column_metadata: ColumnMetadata, + ) -> None: + query = f""" + INSERT INTO "{schema_id}"."ColumnMetadata" ( + table_id, column_id, short_table_id, short_id, dtype, vlen, gen_config, column_order, version, meta + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (table_id, column_id) DO UPDATE SET + dtype = COALESCE(EXCLUDED.dtype, "ColumnMetadata".dtype), + vlen = COALESCE(EXCLUDED.vlen, "ColumnMetadata".vlen), + gen_config = EXCLUDED.gen_config, + column_order = EXCLUDED.column_order, + version = COALESCE(EXCLUDED.version, "ColumnMetadata".version), + meta = COALESCE(EXCLUDED.meta, "ColumnMetadata".meta); + """ + values = [ + column_metadata.table_id, + column_metadata.column_id, + column_metadata.short_table_id, + column_metadata.short_id, + column_metadata.dtype, + column_metadata.vlen, + column_metadata.gen_config.model_dump() if column_metadata.gen_config else None, + column_metadata.column_order, + column_metadata.version, + column_metadata.meta, + ] + await conn.execute(query, *values) + + async def _set_updated_at( + self, + conn: Connection, + updated_at: datetime | None = None, + ) -> None: + if updated_at is None: + updated_at = now() + stmt = f'UPDATE "{self.schema_id}"."TableMetadata" SET "updated_at" = $1 WHERE "table_id" = $2;' + await conn.execute(stmt, updated_at, self.table_id) + self.table_metadata.updated_at = updated_at + + @staticmethod + def _create_data_table_row_model( + table_id: str, + columns: list["ColumnMetadata"], + ) -> Type[DataTableRow]: + """ + Dynamically creates the Pydantic model class for a data table row. + + Args: + table_id (str): Table ID. + columns (list[ColumnMetadata]): List of column metadata. + + Returns: + model_cls (Type[DataTableRow]): The Pydantic model class. + """ + + @field_validator("ID", mode="before") + @classmethod + def id_validator(cls, v: Any): + if isinstance(v, UUID): + return str(v) + return v + + field_definitions = { + "ID": ( + str, + Field(default_factory=uuid7_draft2_str, description="Row ID."), + ), + "Updated at": ( + DatetimeUTC, + Field(default_factory=now, description="Last updated timestamp."), + ), + } + validators = { + "validate_id": id_validator, + } + + for col in columns: + if col.is_info_column or col.is_state_column: + continue + if col.is_vector_column: + # Create vector validator + def create_vector_validator(col: ColumnMetadata): + @field_validator(col.column_id, mode="after") + @classmethod + def vector_validator(cls, v: np.ndarray | None): + if v is not None and len(v) != col.vlen: + raise ValueError( + f"Array input for column {col.column_id} must have length {col.vlen}" + ) + return v + + return vector_validator + + validators[f"validate_{col.column_id}"] = create_vector_validator(col) + field_definitions[col.column_id] = (NumpyArray | None, Field(default=None)) + else: + # Get the Python type from ColumnDtype + py_type = col.dtype.to_python_type() + field_definitions[col.column_id] = (py_type | None, Field(default=None)) + # Add state column (ending with '_') + state_col_id = f"{col.column_id}_" + field_definitions[state_col_id] = ( + dict[str, Any], + Field(default={}, description=f"State of {col.column_id} column."), + ) + + return create_model( + table_id, + **field_definitions, + __base__=DataTableRow, + __validators__=validators, + ) + + @classmethod + async def create_schemas(cls, project_id: str) -> None: + """ + Create the project's schemas and metadata tables. + """ + try: + async with GENTABLE_ENGINE.transaction() as conn: + for table_type in TableType: + schema_id = f"{project_id}_{table_type}" + await conn.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema_id}"') + await conn.execute(TableMetadata.sql_create(schema_id)) + await conn.execute(ColumnMetadata.sql_create(schema_id)) + except (UniqueViolationError, DuplicateTableError): + # Just to be safe, even though catching `UniqueViolationError` is sufficient + return + + @classmethod + async def drop_schemas(cls, project_id: str) -> None: + """ + Drops the project's schemas along with all metadata and data tables. + """ + async with GENTABLE_ENGINE.transaction() as conn: + for table_type in TableType: + schema_id = f"{project_id}_{table_type}" + await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_id}" CASCADE') + + @classmethod + async def drop_schema( + cls, + *, + project_id: str, + table_type: TableType, + ) -> None: + """ + Drops the project's schema along with all metadata and data tables. + """ + schema_id = f"{project_id}_{table_type}" + async with GENTABLE_ENGINE.transaction() as conn: + await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_id}" CASCADE') + + ### --- Table CRUD --- ### + + # Table Create Ops + @classmethod + async def create_table( + cls, + *, + project_id: str, + table_type: TableType, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + set_default_prompts: bool = True, + ) -> Self: + """ + Create a new table. + + Args: + project_id (str): Project ID. + table_type (str): Table type. + table_metadata (TableMetadata): Table metadata. + column_metadata_list (list[ColumnMetadata]): List of column metadata. + set_default_prompts (bool, optional): If True, set default prompts. + Useful when importing table which does not need to set prompts. Defaults to True. + + Returns: + self (GenerativeTableCore): The table instance. + """ + return await cls._create_table( + project_id=project_id, + table_type=table_type, + table_metadata=table_metadata, + column_metadata_list=column_metadata_list, + set_default_prompts=set_default_prompts, + ) + + @classmethod + async def duplicate_table( + cls, + *, + project_id: str, + table_type: TableType, + table_id_src: str, + table_id_dst: str | None = None, + include_data: bool = True, + create_as_child: bool = False, + created_by: str | None = None, + request_id: str = "", + ) -> Self: + """ + Duplicate an existing table including schema, data and metadata. + + Args: + project_id (str): Project ID. + table_type (str): Table type. + table_id_src (str): Name of the table to be duplicated. + table_id_dst (str | None, optional): Name for the new table. + Defaults to None (automatically find the next available table name). + include_data (bool, optional): If True, include data. Defaults to True. + create_as_child (bool, optional): If True, create the new table as a child of the source table. + Defaults to False. + created_by (str | None, optional): User ID of the user who created the table. + Defaults to None. + request_id (str, optional): Request ID for logging. Defaults to "". + + Raises: + BadInputError: If `table_id_dst` is not None or a non-empty string. + ResourceNotFoundError: If table or column metadata cannot be found. + + Returns: + self (GenerativeTableCore): The duplicated table instance. + """ + schema_id = f"{project_id}_{table_type}" + if create_as_child: + include_data = True + if isinstance(table_id_dst, str): + table_id_dst = table_id_dst.strip() + async with GENTABLE_ENGINE.transaction() as conn: + try: + if table_id_dst: + try: + table_metadata = await conn.fetchrow( + f'SELECT * FROM "{schema_id}"."TableMetadata" WHERE table_id = $1', + table_id_dst, + ) + except UndefinedTableError as e: + # TableMetadata does not exist, meaning this schema is empty + raise ResourceNotFoundError(f'Table "{table_id_src}" not found.') from e + if table_metadata is not None: + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') + else: + # Might need to truncate table name + now_str = now().strftime("%Y-%m-%d-%H-%M-%S") + base_name = f"{truncate_table_id(table_id_src)} {now_str}" + # Automatically find the next available table name + # The function will raise UndefinedTableError if the table does not exist + await conn.execute( + f""" + CREATE OR REPLACE FUNCTION duplicate_table() + RETURNS TEXT AS $$ + DECLARE + new_table_name TEXT; + suffix INTEGER := 1; + max_iterations INTEGER := {TABLE_ID_DST_MAX_ITER}; + BEGIN + -- Loop to find the next available table name + WHILE suffix <= max_iterations LOOP + new_table_name := format('%s (%s)', '{base_name}', suffix); + -- Check if the new table name already exists + IF NOT EXISTS ( + SELECT 1 FROM "{schema_id}"."TableMetadata" + WHERE table_id = new_table_name + ) THEN + RETURN new_table_name; -- Return the new table name + END IF; + suffix := suffix + 1; + END LOOP; + -- If we've reached the maximum number of iterations without finding an available name + RETURN NULL; -- Return NULL to indicate failure + END; + $$ LANGUAGE plpgsql; + """, + ) + table_id_dst: str | None = await conn.fetchval("SELECT duplicate_table();") + if table_id_dst is None: + raise ResourceExistsError( + f'Could not find a name for table "{table_id_src}" after {TABLE_ID_DST_MAX_ITER:,d} attempts.' + ) + # Create the data table + # Exclude indexes to set our own index name + short_id_src = get_internal_id(table_id_src) + short_id_dst = get_internal_id(table_id_dst) + if include_data: + await conn.execute( + f'CREATE TABLE "{schema_id}"."{short_id_dst}" AS TABLE "{schema_id}"."{short_id_src}"' + ) + else: + await conn.execute( + ( + f'CREATE TABLE "{schema_id}"."{short_id_dst}" ' + f'(LIKE "{schema_id}"."{short_id_src}" INCLUDING ALL EXCLUDING INDEXES)' + ) + ) + + # It's required to explicitly add primary key + await conn.execute( + f'ALTER TABLE "{schema_id}"."{short_id_dst}" ADD PRIMARY KEY ("ID")' + ) + # Copy metadata + table_meta = await conn.fetchrow( + f'SELECT * FROM "{schema_id}"."TableMetadata" WHERE table_id = $1', + table_id_src, + ) + if table_meta is None: + raise ResourceNotFoundError( + f'Table metadata for "{table_id_src}" is not found.' + ) + table_meta = dict(table_meta) + table_meta["table_id"] = table_id_dst + table_meta.pop("short_id", None) + table_meta["created_by"] = created_by + if create_as_child: + table_meta["parent_id"] = table_id_src + table_meta = TableMetadata.model_validate(table_meta) + await cls._upsert_table_metadata(conn, schema_id, table_meta) + + # Copy column metadata + column_metas = await conn.fetch( + f'SELECT * FROM "{schema_id}"."ColumnMetadata" WHERE table_id = $1', + table_id_src, + ) + if len(column_metas) == 0: + raise ResourceNotFoundError( + f'Column metadata for "{table_id_src}" is not found.' + ) + column_metas = [ColumnMetadata.model_validate(dict(m)) for m in column_metas] + for meta in column_metas: + meta.table_id = table_meta.table_id + meta.short_table_id = table_meta.short_id + await cls._upsert_column_metadata(conn, schema_id, meta) + + # Recreate indexes + text_cols = [col.column_id for col in column_metas if col.is_text_column] + vector_cols = [col.column_id for col in column_metas if col.is_vector_column] + await cls._recreate_fts_index( + conn, schema_id=schema_id, table_id=table_id_dst, columns=text_cols + ) + await cls._recreate_vector_index( + conn, schema_id=schema_id, table_id=table_id_dst, columns=vector_cols + ) + + return await cls._open_table( + conn=conn, + project_id=project_id, + table_type=table_type, + table_id=table_id_dst, + request_id=request_id, + ) + + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{table_id_src}" is not found.') from e + except DuplicateTableError as e: + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') from e + except ValidationError as e: + raise BadInputError(str(e)) from e + + # Table Create Ops + @classmethod + async def open_table( + cls, + *, + project_id: str, + table_type: TableType, + table_id: str, + created_by: str | None = None, + request_id: str = "", + ) -> Self: + """ + Open an existing table. + + Args: + project_id (str): Project ID. + table_type (str): Table type. + table_id (str): Name of the table. + created_by (str | None, optional): User who created the table. + If provided, will check if the table was created by the user. Defaults to None (any user). + request_id (str, optional): Request ID for logging. Defaults to "". + + Returns: + self (GenerativeTableCore): The table instance. + """ + async with GENTABLE_ENGINE.transaction() as conn: + table = await cls._open_table( + conn=conn, + project_id=project_id, + table_type=table_type, + table_id=table_id, + request_id=request_id, + ) + if created_by is not None and table.table_metadata.created_by != created_by: + raise ResourceNotFoundError(f'Table "{table_id}" not found.') + return table + + @classmethod + async def list_tables( + cls, + *, + project_id: str, + table_type: TableType, + limit: int | None = 100, + offset: int = 0, + order_by: Literal["id", "updated_at"] = "updated_at", + order_ascending: bool = True, + created_by: str | None = None, + parent_id: str | None = None, + search_query: str = "", + search_columns: list[str] = None, + count_rows: bool = False, + ) -> Page[TableMetaResponse]: + """ + List tables. + + Args: + project_id (str): Project ID. + limit (int | None, optional): Maximum number of tables to return. + Defaults to 100. Pass None to return all tables. + offset (int, optional): Offset for pagination. Defaults to 0. + order_by (Literal["id", "updated_at"], optional): Sort tables by this attribute. + Defaults to "updated_at". + order_ascending (bool, optional): Whether to sort by ascending order. + Defaults to True. + created_by (str | None, optional): Return tables created by this user. + Defaults to None (return all tables). + parent_id (str | None, optional): Parent ID of tables to return. + Defaults to None (no parent ID filtering). + Additionally for Chat Table, you can list: + (1) all chat agents by passing in "_agent_"; or + (2) all chats by passing in "_chat_". + search_query (str, optional): A string to search for within table names. + The string is interpreted as both POSIX regular expression and literal string. + Defaults to "". + search_columns (list[str], optional): List of columns to search within. + Defaults to None (search table ID). + count_rows (bool, optional): Whether to count the rows of the tables. + Defaults to False. + + Returns: + tables (Page[TableMetaResponse]): List of tables. + """ + schema_id = f"{project_id}_{table_type}" + search_query = search_query.strip() + filters = [] + params = [] + if search_columns is None: + search_columns = ["table_id"] + if created_by: + params.append(str(created_by)) + filters.append(f"(created_by = ${len(params)})") + if parent_id: + if parent_id == "_agent_": + filters.append("(parent_id IS NULL)") + elif parent_id == "_chat_": + filters.append("(parent_id IS NOT NULL)") + else: + params.append(parent_id) + filters.append(f"(parent_id = ${len(params)})") + if search_query: + search_filters = [] + for search_column in search_columns: + search_column = "table_id" if search_column == "id" else search_column + # Literal (escaped) search + params.append(re.escape(search_query)) + literal_expr = f"({search_column}::text ~* ${len(params)})" + # Regex search + params.append(search_query) + regex_expr = f"({search_column}::text ~* ${len(params)})" + search_filters.append(f"({literal_expr} OR {regex_expr})") + filters.append("(" + " OR ".join(search_filters) + ")") + if order_by == "id": + order_by = "table_id" + if order_by in TableMetadata.str_cols(): + order_by = f'LOWER("{order_by}")' + else: + order_by = f'"{order_by}"' + order_direction = "ASC" if order_ascending else "DESC" + where = f"WHERE {' AND '.join(filters)}" if len(filters) > 0 else "" + async with GENTABLE_ENGINE.transaction() as conn: + try: + total = await conn.fetchval( + f'SELECT COUNT(*) FROM "{schema_id}"."TableMetadata" {where}', + *params, + ) + sql = f""" + SELECT * FROM "{schema_id}"."TableMetadata" {where} + ORDER BY {order_by} {order_direction} + """ + if limit is not None: + params.append(limit) + sql += f" LIMIT ${len(params)}" + table_metas = await conn.fetch(f"{sql} OFFSET ${len(params) + 1}", *params, offset) + except UndefinedColumnError as e: + # raise ResourceNotFoundError(f'Attribute "{order_by}" is not found.') from e + raise ResourceNotFoundError(str(e)) from e + except UndefinedTableError: + total = 0 + return Page[TableMetaResponse]( + items=[], + offset=offset, + limit=total if limit is None else limit, + total=total, + ) + meta_responses = [] + for table_meta in table_metas: + table_meta = TableMetadata.model_validate(dict(table_meta)) + column_metas = await conn.fetch( + f""" + SELECT * FROM "{schema_id}"."ColumnMetadata" + WHERE table_id = $1 ORDER BY column_order ASC + """, + table_meta.table_id, + ) + column_metas = [ColumnMetadata.model_validate(dict(col)) for col in column_metas] + if count_rows: + num_rows = await conn.fetchval( + f'SELECT COUNT("ID") FROM "{schema_id}"."{table_meta.short_id}"' + ) + else: + num_rows = -1 + meta_responses.append( + TableMetaResponse( + id=table_meta.table_id, + cols=[ + ColumnSchema( + id=col.column_id, + dtype=col.dtype, + vlen=col.vlen, + gen_config=col.gen_config, + ) + for col in column_metas + ], + parent_id=table_meta.parent_id, + title=table_meta.title, + created_by=table_meta.created_by, + updated_at=table_meta.updated_at.isoformat(), + num_rows=num_rows, + version=table_meta.version, + meta=table_meta.meta, + ) + ) + return Page[TableMetaResponse]( + items=meta_responses, + offset=offset, + limit=total if limit is None else limit, + total=total, + ) + + async def count_rows(self) -> int: + """ + Count the number of rows. + + Returns: + num_rows (int): Number of rows in the table. + """ + async with GENTABLE_ENGINE.transaction() as conn: + return await self._count_rows(conn) + return self.num_rows + + # Table Update Ops + async def rename_table(self, table_id_dst: TableName) -> Self: + """ + Rename a table. + + Args: + table_id_dst (str): New name for the table. + + Raises: + ResourceNotFoundError: If the table is not found. + ResourceExistsError: If the table already exists. + + Returns: + self (GenerativeTableCore): The renamed table instance. + """ + table_id_src = self.table_id + short_id_src = self.short_table_id + short_id_dst = get_internal_id(table_id_dst) + async with GENTABLE_ENGINE.transaction() as conn: + try: + # Rename data table + await conn.execute( + f'ALTER TABLE "{self.schema_id}"."{short_id_src}" RENAME TO "{short_id_dst}"' + ) + # Rename primary key index (only for consistency purposes, no operational impact even without rename) + await conn.execute( + f""" + ALTER TABLE "{self.schema_id}"."{short_id_dst}" + RENAME CONSTRAINT "{short_id_src}_pkey" TO "{short_id_dst}_pkey" + """ + ) + # Rename indexes + await conn.execute( + f""" + ALTER INDEX "{self.schema_id}"."{fts_index_id(table_id_src)}" + RENAME TO "{fts_index_id(table_id_dst)}" + """ + ) + for col in self.vector_column_names: + await conn.execute( + f""" + ALTER INDEX "{self.schema_id}"."{vector_index_id(table_id_src, col)}" + RENAME TO "{vector_index_id(table_id_dst, col)}" + """ + ) + # Update table metadata entry + await conn.execute( + f""" + UPDATE "{self.schema_id}"."TableMetadata" + SET table_id = $1, short_id = $2 WHERE table_id = $3 + """, + table_id_dst, + short_id_dst, + table_id_src, + ) + # Update any child tables' parent_id references + await conn.execute( + f'UPDATE "{self.schema_id}"."TableMetadata" SET parent_id = $1 WHERE parent_id = $2', + table_id_dst, + table_id_src, + ) + self.table_id = table_id_dst + # Set updated at time + await self._set_updated_at(conn) + return self + except UndefinedTableError as e: + # Index or table not found + raise ResourceNotFoundError(f'Table "{table_id_src}" is not found.') from e + except DuplicateTableError as e: + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') from e + + async def update_table_title(self, title: str) -> Self: + """ + Update the table title. + """ + updated_at = now() + query = f""" + UPDATE "{self.schema_id}"."TableMetadata" + SET title = $1, updated_at = $2 + WHERE table_id = $3; + """ + async with GENTABLE_ENGINE.transaction() as conn: + await conn.execute(query, title, updated_at, self.table_id) + self.table_metadata.title = title + self.table_metadata.updated_at = updated_at + return self + + # Table Delete Ops + async def drop_table(self) -> None: + """ + Drop the table. + + Raises: + ResourceNotFoundError: If the table is not found. + """ + async with GENTABLE_ENGINE.transaction() as conn: + try: + # Drop the data table + await conn.execute( + f'DROP TABLE IF EXISTS "{self.schema_id}"."{self.short_table_id}" CASCADE' + ) + # Drop row from table metadata, this will cascade to the associated column metadata + await conn.execute( + f'DELETE FROM "{self.schema_id}"."TableMetadata" WHERE table_id = $1', + self.table_id, + ) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + + @staticmethod + def _coerce_column_to_pa_dtype( + data: list[Any], + dtype: pa.DataType, + ) -> pa.Array: + """Convert column data to appropriate Arrow array type""" + if len(data) == 0: + return pa.array([], dtype) + if isinstance(data[0], UUID): + data = [str(d) for d in data] + elif isinstance(data[0], dict): + data = [json_dumps(d) for d in data] + return pa.array(data, dtype) + + # Table Import Export Ops + async def export_table( + self, + dest: str | Path | BinaryIO, + *, + compression: Literal["NONE", "ZSTD", "LZ4", "SNAPPY"] = "ZSTD", + verbose: bool = False, + ) -> None: + """ + Export a table's data and metadata to a specified output path. + + Args: + output_path (str | Path): Path to save the exported data. + compression (str, optional): Compression type for the output file. + Options are "NONE", "ZSTD", "LZ4", or "SNAPPY". Defaults to "ZSTD". + verbose (bool, optional): If True, will produce verbose logging messages. + Defaults to False. + + Raises: + ResourceNotFoundError: If the output path is invalid. + """ + log_level = "INFO" if verbose else "DEBUG" + if isinstance(dest, (str, Path)): + dest = Path(dest) + if dest.is_dir(): + dest = dest / f"{self.table_id}.parquet" + else: + if (suffix := Path(dest).suffix) != ".parquet": + raise BadInputError(f'Output extension "{suffix}" is invalid.') + rows: list[dict[str, Any]] = ( + await self.list_rows( + limit=None, + offset=0, + order_by=["ID"], + order_ascending=True, + columns=None, + remove_state_cols=False, + ) + ).items + col_dtype_map = { + col.column_id: pa.list_(pa.float32()) + if col.is_vector_column + else col.dtype.to_pyarrow_type() + for col in self.column_metadata + } + + # Add file data into Arrow Table + async def _download(uri: str) -> tuple[str, bytes, str]: + async with semaphore: + try: + async with open_uri_async(uri) as (f, mime): + return (uri, await f.read(), mime) + except ResourceNotFoundError: + return (uri, b"", "") + + async def _download_files(col_ids: list[str]) -> dict[str, tuple[bytes, str]]: + download_coros = [] + _uri_bytes: dict[str, tuple[bytes, str]] = {} + for col_id in col_ids: + if f"{col_id}__" in col_dtype_map: + raise BadInputError(f'Table "{self.table_id}" has bad column "{col_id}__".') + for row in rows: + uri = row[col_id] + if uri in _uri_bytes: + continue + # Create the coroutine + download_coros.append(_download(uri)) + _uri_bytes[uri] = (b"", "") + self._log( + ( + f'Importing table "{self.table_id}": ' + f"Downloading {len(download_coros):,d} files " + f"with concurrency limit of {S3_MAX_CONCURRENCY}." + ), + log_level, + ) + for fut in asyncio.as_completed(download_coros): + uri, content, mime = await fut + _uri_bytes[uri] = (content, mime) + return _uri_bytes + + semaphore = Semaphore(S3_MAX_CONCURRENCY) + pa_file_columns = [] + self._log( + f'Importing table "{self.table_id}": Downloading files in file columns.', + log_level, + ) + file_col_ids = [col.column_id for col in self.column_metadata if col.is_file_column] + uri_bytes = await _download_files(file_col_ids) + uris_seen = set() + for col_id in file_col_ids: + col_bytes = [] + for row in rows: + uri = row[col_id] + if uri in uris_seen: + col_bytes.append(b"") + continue + content, mime = uri_bytes.get(uri, (b"", "")) + col_bytes.append(content) + if mime: + row[f"{col_id}_"].update({"_mime_type": mime}) + uris_seen.add(uri) + if len(col_bytes) > 0: + pa_file_columns.append((pa.field(f"{col_id}__", pa.binary()), [col_bytes])) + + # Add Knowledge Table file data + if self.table_type == TableType.KNOWLEDGE: + self._log( + f'Importing table "{self.table_id}": Downloading Knowledge Table files.', + log_level, + ) + file_col_ids = ["File ID"] + uri_bytes = await _download_files(file_col_ids) + uris_seen = set() + for col_id in file_col_ids: + col_bytes = [] + for row in rows: + uri = row[col_id] + if uri in uris_seen: + col_bytes.append(b"") + continue + content, mime = uri_bytes.get(uri, (b"", "")) + col_bytes.append(content) + if mime: + row[f"{col_id}_"].update({"_mime_type": mime}) + uris_seen.add(uri) + if len(col_bytes) > 0: + pa_file_columns.append((pa.field(f"{col_id}__", pa.binary()), [col_bytes])) + # Create Parquet table + self._log(f'Importing table "{self.table_id}": Creating Parquet table.', log_level) + pa_table = pa.table( + { + col.column_id: self._coerce_column_to_pa_dtype( + [row[col.column_id] for row in rows], col_dtype_map[col.column_id] + ) + for col in self.column_metadata + }, + metadata=dict(gen_table_meta=self.v1_meta.model_dump_json()), + ) + # Append byte column + for pa_col in pa_file_columns: + pa_table = pa_table.append_column(*pa_col) + # Write to Parquet + self._log(f'Importing table "{self.table_id}": Writing Parquet table.', log_level) + try: + pq.write_table(pa_table, dest, compression=compression) + except (FileNotFoundError, OSError) as e: + raise ResourceNotFoundError(f'Output path "{dest}" is invalid.') from e + self._log(f'Importing table "{self.table_id}": Export completed.', log_level) + + @classmethod + async def _import_table( + cls, + *, + project_id: str, + table_type: TableType, + source: str | Path | BinaryIO, + table_id_dst: str | None, + reupload_files: bool = True, + progress_key: str = "", + verbose: bool = False, + ) -> Self: + def _measure_ram() -> str: + import psutil + + GiB = 1024**3 + mem = psutil.virtual_memory() + return f"RAM usage: {mem.used / GiB:,.2f} / {mem.total / GiB:,.2f} GiB ({mem.percent:.1f} %)" + + # Check if project exists + project = await cls._fetch_project(project_id) + organization_id = project.organization_id + + # Load Parquet file + filename = source if isinstance(source, str) else getattr(source, "name", "") + try: + pa_table: pa.Table = pq.read_table( + source, columns=None, use_threads=False, memory_map=True + ) + except FileNotFoundError as e: + raise ResourceNotFoundError(f'Parquet file "{filename}" is not found.') from e + except Exception as e: + logger.info(f'Parquet file "{filename}" contains bad data: {repr(e)}') + raise BadInputError(f'Parquet file "{filename}" contains bad data.') from e + try: + pa_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: + logger.warning(f"Invalid table metadata in the Parquet file: {repr(e)}") + raise BadInputError("Invalid table metadata in the Parquet file.") from e + # Check for existing table + if table_id_dst is None: + table_id_dst = pa_meta.id + if verbose: + logger.info( + f'Importing table "{table_id_dst}": Parquet data loaded successfully. {_measure_ram()}' + ) + prog = TableImportProgress(key=progress_key) + if not (await CACHE.set_progress(prog, nx=True)): + raise ResourceExistsError( + f'There is an in-progress import for table "{table_id_dst}".' + ) + prog.data["table_id_dst"] = table_id_dst + prog.load_data.progress = 100 + await CACHE.set_progress(prog) + + async with GENTABLE_ENGINE.transaction() as conn: + schema_id = f"{project_id}_{table_type}" + try: + table_metadata = await conn.fetchrow( + f'SELECT * FROM "{schema_id}"."TableMetadata" WHERE table_id = $1', + table_id_dst, + ) + except UndefinedTableError: + table_metadata = None + if table_metadata is not None: + raise ResourceExistsError(f'Table "{table_id_dst}" already exists.') + # Check for required columns + pa_meta_cols = {c.id for c in pa_meta.cols} + # Sometimes Chat Table has "user" instead of "User" + if table_type == TableType.CHAT and "user" in pa_meta_cols and "User" not in pa_meta_cols: + for col in pa_meta.cols: + if col.id == "user": + col.id = "User" + break + pa_meta_cols = {c.id for c in pa_meta.cols} + required_columns = set(cls.FIXED_COLUMN_IDS) + if len(required_columns - pa_meta_cols) > 0: + raise BadInputError( + f"Missing table columns in the Parquet file: {list(required_columns - pa_meta_cols)}." + ) + # Recreate table and column metadata + table_metadata = TableMetadata( + table_id=table_id_dst, + title=pa_meta.title, + parent_id=pa_meta.parent_id, + updated_at=pa_meta.updated_at, + ) + column_metadata = [] + for col in pa_meta.cols: + if isinstance(col.gen_config, LLMGenConfig): + # LLM columns are always string typed + col.dtype = ColumnDtype.STR + # Handle RAG params + if col.gen_config.rag_params: + params = col.gen_config.rag_params.model_dump(exclude_unset=True) + col.gen_config.rag_params.inline_citations = params.get( + "inline_citations", False + ) + column_metadata.append( + ColumnMetadata( + table_id=table_id_dst, + column_id=col.id, + dtype=col.dtype, + vlen=col.vlen, + gen_config=col.gen_config, + ) + ) + + # Create the new table + if verbose: + logger.info( + f'Importing table "{table_id_dst}": Creating Generative Table. {_measure_ram()}' + ) + prog.parse_data.progress = 50 + await CACHE.set_progress(prog) + self = await cls._create_table( + project_id=project_id, + table_type=table_type, + table_metadata=table_metadata, + column_metadata_list=column_metadata, + set_default_prompts=False, + replace_unavailable_models=True, # Old tables may have deprecated models + allow_nonexistent_refs=True, # Old tables may have non-existent columns + create_indexes=False, + ) + + # Load data + if verbose: + logger.info( + f'Importing table "{self.table_id}": Pre-processing Parquet data. {_measure_ram()}' + ) + rows: list[dict[str, Any]] = pa_table.to_pylist() + # Process state JSON + for row in rows: + for col_id in row: + if col_id.endswith("__"): + # File byte column + continue + if not col_id.endswith("_"): + # Regular column + continue + state = json_loads(row[col_id] or "{}") + # Legacy attribute + if state.pop("is_null", False): + row[col_id[:-1]] = None + row[col_id] = state + + # Upload files to S3 + if verbose: + if reupload_files: + logger.info(f'Importing table "{self.table_id}": Uploading files to S3.') + else: + logger.info(f'Importing table "{self.table_id}": Skipped S3 upload.') + prog.parse_data.progress = 100 + await CACHE.set_progress(prog) + + async def _upload( + old_uri: str, + content: bytes, + content_type: str, + filename: str, + ) -> tuple[str, str]: + async with semaphore: + new_uri = await s3_upload( + organization_id, + project_id, + content, + content_type=content_type, + filename=filename, + ) + return (old_uri, new_uri) + + uris_seen: dict[str, str] = {} # Old URI to new URI + semaphore = Semaphore(S3_MAX_CONCURRENCY) + upload_coros = [] + for row in rows: + file_byte_cols = [c for c in row.keys() if c.endswith("__")] + for col_id in file_byte_cols: + state_col_id = col_id[:-1] + uri_col_id = col_id[:-2] + uri: str = row[uri_col_id] + if uri in uris_seen: + continue + if not reupload_files: + uris_seen[uri] = uri + continue + file_bytes = row[col_id] + if len(file_bytes) == 0: + # Could be file download error or duplicate URI + continue + mime_type = row[state_col_id].pop("_mime_type", None) + # Attempt MIME type detection based on URI + if mime_type is None: + mime_type = guess_mime(uri) + # Attempt MIME type detection based on file content + if mime_type is None: + mime_type = guess_mime(file_bytes) + # Create the coroutine + upload_coros.append(_upload(uri, file_bytes, mime_type, uri.split("/")[-1])) + # Set to old URI for now + uris_seen[uri] = uri + total, completed = len(upload_coros), 0 + if verbose: + logger.info( + ( + f'Importing table "{self.table_id}": Uploading {total:,d} files ' + f"with concurrency limit of {S3_MAX_CONCURRENCY}. {_measure_ram()}" + ) + ) + for fut in asyncio.as_completed(upload_coros): + old_uri, new_uri = await fut + uris_seen[old_uri] = new_uri + completed += 1 + prog.upload_files.progress = int((completed / total) * 100) + await CACHE.set_progress(prog) + # Set new URI and remove file byte column from row + for row in rows: + file_byte_cols = [c for c in row.keys() if c.endswith("__")] + for col_id in file_byte_cols: + uri_col_id = col_id[:-2] + row[uri_col_id] = uris_seen.get(row[uri_col_id], None) + state_col_id = col_id[:-1] + row[state_col_id].pop("_mime_type", None) + row.pop(col_id, None) + prog.upload_files.progress = 100 + await CACHE.set_progress(prog) + + # Add data to table batch by batch + n = len(rows) + if verbose: + logger.info(f'Importing table "{self.table_id}": Adding {n:,d} rows. {_measure_ram()}') + for i in range(0, n, IMPORT_BATCH_SIZE): + j = min(i + IMPORT_BATCH_SIZE, n) + self = await self.add_rows( + rows[i:j], + ignore_info_columns=False, + ignore_state_columns=False, + set_updated_at=False, + ) + if verbose: + logger.info( + f'Importing table "{self.table_id}": Added {j:,d} / {n:,d} rows. {_measure_ram()}' + ) + prog.add_rows.progress = int((j / n) * 100) + await CACHE.set_progress(prog) + prog.add_rows.progress = 100 + # Perform indexing + async with GENTABLE_ENGINE.transaction() as conn: + await self._recreate_fts_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=self.text_column_names, + ) + logger.info(f'Importing table "{self.table_id}": Created FTS index.') + async with GENTABLE_ENGINE.transaction() as conn: + await self._recreate_vector_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=self.vector_column_names, + ) + logger.info(f'Importing table "{self.table_id}": Created vector index.') + prog.index.progress = 100 + prog.state = ProgressState.COMPLETED + prog.data["table_meta"] = self.v1_meta_response.model_dump(mode="json") + await CACHE.set_progress(prog) + return self + + @classmethod + async def import_table( + cls, + *, + project_id: str, + table_type: TableType, + source: str | Path | BinaryIO, + table_id_dst: TableName | None, + reupload_files: bool = True, + progress_key: str = "", + verbose: bool = False, + ) -> Self: + """ + Recreate a table (data and metadata) from a Parquet file. + + Args: + project_id (str): Project ID. + table_type (str): Table type. + input_path (str | Path): The path to the import file. + table_id_dst (TableName): Name or ID of the new table. + If None, the table ID in the Parquet metadata will be used. + reupload_files (bool, optional): If True, will reupload files to S3 with new URI. + Otherwise skip reupload and keep the original S3 paths for file columns. + Defaults to True. + progress_key (str, optional): Progress publish key. Defaults to "" (disabled). + verbose (bool, optional): If True, will produce verbose logging messages. + Defaults to False. + + Raises: + ResourceExistsError: If the table already exists. + + Returns: + self (GenerativeTableCore): The table instance. + """ + try: + self = await cls._import_table( + project_id=project_id, + table_type=table_type, + source=source, + table_id_dst=table_id_dst, + reupload_files=reupload_files, + progress_key=progress_key, + verbose=verbose, + ) + except Exception as e: + if not isinstance(e, JamaiException): + logger.exception(repr(e)) + try: + prog = await CACHE.get_progress(progress_key, TableImportProgress) + if table_id := (prog.data.get("table_id_dst", None)): + # Might need to clean up + async with GENTABLE_ENGINE.transaction() as conn: + try: + schema_id = f"{project_id}_{table_type}" + # Drop the data table + await conn.execute(f'DROP TABLE IF EXISTS "{schema_id}"."{table_id}"') + # Drop row from table metadata, this will automatically drop the associated column metadata + await conn.execute( + f'DELETE FROM "{schema_id}"."TableMetadata" WHERE table_id = $1', + table_id, + ) + except Exception as e: + logger.info( + f'Encountered error cleaning up table "{table_id}" after failed import: {repr(e)}' + ) + prog.state = ProgressState.FAILED + prog.error = repr(e) + await CACHE.set_progress(prog) + except Exception as e: + logger.error(f"Encountered error setting progress after failed import: {repr(e)}") + logger.error(repr(e)) + raise + return self + + def _filter_columns( + self, + columns: list[str] | None, + *, + exclude_state: bool, + ) -> list[str]: + data_columns = self.data_table_model.get_column_ids(exclude_state=exclude_state) + if columns: + if not exclude_state: + columns += [f"{c}_" for c in columns] + columns = [c for c in data_columns if c in columns] + if "Updated at" not in columns: + columns.insert(0, "Updated at") + if "ID" not in columns: + columns.insert(0, "ID") + else: + columns = data_columns + return columns + + async def export_data( + self, + output_path: str | Path, + *, + columns: list[str] | None = None, + where: str = "", + limit: int | None = None, + offset: int = 0, + delimiter: CSVDelimiter = CSVDelimiter.COMMA, + ) -> None: + """ + Export table data to CSV file. + + Args: + output_path (str | Path): Path to save the CSV file. + columns (list[str] | None, optional): A list of column names to include in the returned rows. + Defaults to None (return all columns). + where (str, optional): SQL WHERE clause to filter rows. Defaults to "". + limit (int | None, optional): Maximum number of rows to export. Defaults to None. + offset (int | None, optional): Offset for pagination. Defaults to None. + delimiter (str, optional): CSV delimiter, either "," or "\\t". Defaults to ",". + + Raises: + BadInputError: If the delimiter is invalid. + ResourceNotFoundError: If the table is not found. + """ + if delimiter not in CSVDelimiter: + raise BadInputError(f"Invalid delimiter: {delimiter}") + columns = self._filter_columns(columns, exclude_state=True) + # Get table data + rows = ( + await self.list_rows( + limit=limit, + offset=offset, + order_by=["ID"], + order_ascending=True, + columns=columns, + where=where, + remove_state_cols=True, + ) + ).items + try: + df = pd.DataFrame(rows, columns=columns) + # Convert special types + col_meta_map = {col.column_id: col for col in self.column_metadata} + dtype = {} + for col in columns: + if col_meta_map[col].dtype == ColumnDtype.DATE_TIME: + df[col] = df[col].apply(lambda x: x.isoformat()) + dtype[col] = pd.StringDtype() + elif col_meta_map[col].is_vector_column: + df[col] = df[col].apply(lambda x: x.tolist()) + dtype[col] = pd.StringDtype() + else: + dtype[col] = col_meta_map[col].dtype.to_pandas_type() + df = df.astype(dtype, errors="raise") + except Exception as e: + raise BadInputError( + f'Failed to export table "{self.table_id}" due to error: {e}' + ) from e + try: + df_to_csv(df=df, file_path=output_path, sep=delimiter) + except (FileNotFoundError, OSError) as e: + raise BadInputError(f'Output path "{output_path}" is not found.') from e + + async def read_csv( + self, + input_path: str | Path | BinaryIO, + *, + column_id_mapping: dict[str, str] | None = None, + delimiter: CSVDelimiter = CSVDelimiter.COMMA, + ignore_info_columns: bool = True, + ) -> Self: + col_meta_map = {col.column_id: col for col in self.column_metadata} + dtype = { + col.column_id: pd.StringDtype() if col.is_vector_column else col.dtype.to_pandas_type() + for col in self.column_metadata + } + # Read CSV file + try: + df = pd.read_csv(input_path, dtype=dtype, delimiter=delimiter, keep_default_na=True) + except FileNotFoundError as e: + raise ResourceNotFoundError(f'Input file "{input_path}" is not found.') from e + except pd.errors.EmptyDataError as e: + raise BadInputError(f'Input file "{input_path}" is empty.') from e + if len(df) == 0: + raise BadInputError(f'Input file "{input_path}" has no rows.') + try: + # Apply column mapping if provided + if column_id_mapping: + df = df.rename(columns=column_id_mapping) + # Remove "ID" and "Updated at" columns if needed + if ignore_info_columns: + df = df[[col for col in df.columns if col.lower() not in self.INFO_COLUMNS]] + + # Create a mapping of column names to their metadata for faster lookup + col_meta_map = {col.column_id: col for col in self.column_metadata} + # Keep only valid columns + df = df[[col for col in df.columns if col in col_meta_map]] + # Convert special types + for col in df.columns: + if col_meta_map[col].dtype == ColumnDtype.DATE_TIME: + df[col] = df[col].apply(lambda x: utc_datetime_from_iso(x)) + elif col_meta_map[col].is_vector_column: + df[col] = df[col].apply(json_loads) + # Check vector length + array_lengths = df[col].apply(len) + if array_lengths.nunique() != 1: + raise BadInputError("All vectors must have the same length.") + array_length = int(array_lengths[0]) + if array_length != col_meta_map[col].vlen: + raise BadInputError( + ( + f'Vector column "{col}" expects vectors of length {col_meta_map[col].vlen:,d} ' + f"but got vectors of length {array_length:,d}." + ) + ) + # Convert to list of dicts + rows = df.to_dict(orient="records") + except Exception as e: + raise BadInputError( + f'Failed to import data into table "{self.table_id}" due to error: {e}' + ) from e + return rows + + async def import_data( + self, + input_path: str | Path, + *, + column_id_mapping: dict[str, str] | None = None, + delimiter: CSVDelimiter = CSVDelimiter.COMMA, + ignore_info_columns: bool = True, + verbose: bool = False, + ) -> Self: + """ + Import data into the Generative Table from a CSV file. + + Args: + input_path (str | Path): Path to the CSV file. + column_id_mapping (dict[str, str] | None, optional): Mapping of CSV column ID to table column ID. + Defaults to None. + delimiter (str, optional): CSV delimiter, either "," or "\\t". Defaults to ",". + ignore_info_columns (bool, optional): Whether to ignore "ID" and "Updated at" columns. + Defaults to True. + verbose (bool, optional): If True, will produce verbose logging messages. + Defaults to False. + + Raises: + ResourceNotFoundError: If the file or table is not found. + + Returns: + self (GenerativeTableCore): The table instance. + """ + rows = await self.read_csv( + input_path=input_path, + column_id_mapping=column_id_mapping, + delimiter=delimiter, + ignore_info_columns=ignore_info_columns, + ) + if verbose: + self._log(f'Importing table "{self.table_id}": Import data loaded successfully.') + # Insert rows + n = len(rows) + if verbose: + self._log(f'Importing table "{self.table_id}": Adding {n:,d} rows.') + for i in range(0, n, IMPORT_BATCH_SIZE): + j = min(i + IMPORT_BATCH_SIZE, n) + self = await self.add_rows(rows[i:j]) + if verbose: + self._log(f'Importing table "{self.table_id}": Added {j:,d} / {n:,d} rows.') + return self + + ### --- Column CRUD --- ### + + # Column Create Ops + async def add_column( + self, + metadata: ColumnMetadata, + request_id: str = "", + ) -> Self: + """ + Add a new column to the table. + + Args: + metadata (ColumnMetadata): Metadata for the new column. + request_id (str, optional): Request ID for logging. Defaults to "". + + Raises: + BadInputError: If the column is a state column. + ResourceNotFoundError: If table cannot be found. + ResourceExistsError: If the column already exists in the table. + + Returns: + self (GenerativeTableCore): The table instance. + """ + if self.table_metadata.parent_id is not None: + # TODO: Test this + raise BadInputError(f'Table "{self.table_id}": Cannot add column to a child table.') + if metadata.is_state_column: + # TODO: Test this + raise BadInputError(f'Table "{self.table_id}": Cannot add state column.') + async with GENTABLE_ENGINE.transaction() as conn: + column_metadata_list = await self._check_columns( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_metadata=self.table_metadata, + column_metadata_list=self.column_metadata + [metadata], + set_default_prompts=True, + replace_unavailable_models=False, + ) + metadata = column_metadata_list[-1] + # Define column definition + if metadata.is_vector_column: + column_def = f'"{metadata.short_id}" VECTOR({metadata.vlen})' + else: + column_def = f'"{metadata.short_id}" {metadata.dtype.to_postgres_type()}' + # Add new and state column to the data table + try: + await conn.execute( + f""" + ALTER TABLE "{self.schema_id}"."{self.short_table_id}" + ADD COLUMN {column_def}, + ADD COLUMN {self._state_column_sql(metadata.short_id)}; + """ + ) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except DuplicateColumnError as e: + raise ResourceExistsError( + f"Column {metadata.column_id} already exists in table {self.table_id}" + ) from e + # Add column metadata + metadata.column_order = len(self.column_metadata) + await self._upsert_column_metadata(conn, self.schema_id, metadata) + state_meta = ColumnMetadata( + table_id=self.table_id, + column_id=f"{metadata.column_id}_", + dtype=ColumnDtype.JSON, + column_order=len(self.column_metadata) + 1, + ) + await self._upsert_column_metadata(conn, self.schema_id, state_meta) + # Set updated at time + await self._set_updated_at(conn) + # Reload table + self = await self._open_table( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_id=self.table_id, + request_id=request_id, + ) + if metadata.is_text_column: + await self._recreate_fts_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=self.text_column_names, + ) + elif metadata.is_vector_column: + await self._recreate_vector_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=self.vector_column_names, + ) + return self + + # Column Read ops are implemented as table ops + # Column Update Ops + async def rename_columns( + self, + column_map: dict[str, ColName], + ) -> Self: + """ + Rename columns of the Generative Table. + + Args: + column_map (dict[str, str]): Mapping of old column names to new column names. + + Raises: + ResourceNotFoundError: If the table or any of the columns cannot be found. + ResourceExistsError: If any of the new column names already exists in the table. + + Returns: + self (GenerativeTableCore): The table instance. + """ + if self.table_metadata.parent_id is not None: + # TODO: Test this + raise BadInputError( + f'Table "{self.table_id}": Cannot rename columns of a child table.' + ) + fixed_cols = {c.lower() for c in self.FIXED_COLUMN_IDS} + if invalid_cols := {c.lower() for c in column_map}.intersection(fixed_cols): + # TODO: Test this especially for Knowledge Table + raise BadInputError( + f'Table "{self.table_id}": Cannot rename fixed columns: {list(invalid_cols)}' + ) + if invalid_cols := [c for c in column_map if c.endswith("_")]: + # TODO: Test this + raise BadInputError( + f'Table "{self.table_id}": Cannot rename state columns: {invalid_cols}' + ) + async with GENTABLE_ENGINE.transaction() as conn: + for col_id_src, col_id_dst in column_map.items(): + col_meta = next( + (col for col in self.column_metadata if col.column_id == col_id_src), None + ) + if col_meta is None: + continue + # Rename data and state columns + short_table_id = self.short_table_id + short_id_src = get_internal_id(col_id_src) + short_id_dst = get_internal_id(col_id_dst) + try: + await conn.execute( + f""" + ALTER TABLE "{self.schema_id}"."{short_table_id}" + RENAME COLUMN "{short_id_src}" TO "{short_id_dst}" + """ + ) + await conn.execute( + f""" + ALTER TABLE "{self.schema_id}"."{short_table_id}" + RENAME COLUMN "{short_id_src}_" TO "{short_id_dst}_" + """ + ) + # Rename vector index + if col_meta.is_vector_column: + await conn.execute( + ( + f'ALTER INDEX "{self.schema_id}"."{vector_index_id(self.table_id, col_id_src)}" ' + f'RENAME TO "{vector_index_id(self.table_id, col_id_dst)}"' + ) + ) + except UndefinedTableError as e: + # Index or table not found + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except (UndefinedColumnError, IndexError) as e: + raise ResourceNotFoundError( + f'Column "{col_id_src}" is not found in table "{self.table_id}".' + ) from e + except DuplicateColumnError as e: + raise ResourceExistsError( + f'Column "{col_id_dst}" already exists in table "{self.table_id}".' + ) from e + # Update column metadata entries + await conn.execute( + f""" + UPDATE "{self.schema_id}"."ColumnMetadata" + SET column_id = $1, short_id = $2 + WHERE table_id = $3 AND column_id = $4 + """, + col_id_dst, + short_id_dst, + self.table_id, + col_id_src, + ) + await conn.execute( + f""" + UPDATE "{self.schema_id}"."ColumnMetadata" + SET column_id = $1, short_id = $2 + WHERE table_id = $3 AND column_id = $4 + """, + f"{col_id_dst}_", + f"{short_id_dst}_", + self.table_id, + f"{col_id_src}_", + ) + # Update gen config references + for col in self.column_metadata: + if col.column_id == col_id_dst or col.column_id == col_id_src: + continue + if not isinstance(col.gen_config, LLMGenConfig): + continue + for k in ("system_prompt", "prompt"): + setattr( + col.gen_config, + k, + re.sub( + GEN_CONFIG_VAR_PATTERN, + lambda m: f"${{{column_map.get(m.group(1), m.group(1))}}}", + getattr(col.gen_config, k), + ), + ) + await conn.execute( + f""" + UPDATE "{self.schema_id}"."ColumnMetadata" SET gen_config = $1 + WHERE table_id = $2 AND column_id = $3 + """, + col.gen_config.model_dump(), + self.table_id, + col.column_id, + ) + # Set updated at time + await self._set_updated_at(conn) + return await self._reload_table(conn) + + async def update_gen_config( + self, + update_mapping: dict[str, DiscriminatedGenConfig | None], + *, + allow_nonexistent_refs: bool = False, + request_id: str = "", + ) -> Self: + """ + Update the generation configuration for a column. + + Args: + update_mapping (dict[str, DiscriminatedGenConfig]): Mapping of column IDs to new generation configurations. + allow_nonexistent_refs (bool, optional): Ignore non-existent column and Knowledge Table references. + Otherwise will raise an error. Useful when importing old tables and performing maintenance. + Defaults to False. + request_id (str, optional): Request ID for logging. Defaults to "". + + Raises: + ResourceNotFoundError: If the column is not found. + + Returns: + self (GenerativeTableCore): The table instance. + """ + # Verify column exists + columns_to_update = [] + async with GENTABLE_ENGINE.transaction() as conn: + for column_id, config in update_mapping.items(): + column = next( + (col for col in self.column_metadata if col.column_id == column_id), None + ) + if not column: + # TODO: Test this + raise ResourceNotFoundError( + f'Column "{column_id}" is not found in table "{self.table_id}".' + ) + if column.is_state_column: + # TODO: Test this + raise BadInputError( + f'Column "{column_id}" is a state column and cannot be updated.' + ) + # Disallow update of vector column if the table has data + has_data: bool = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{self.schema_id}"."{self.short_table_id}" LIMIT 1)' + ) + if column.is_vector_column and has_data: + # TODO: Test this + raise BadInputError( + f'Column "{column_id}" contains data thus its Embedding config cannot be updated.' + ) + # Update column metadata in-place + if config is None or column.gen_config is None: + column.gen_config = config + else: + column.gen_config = type(column.gen_config).model_validate( + merge_dict( + column.gen_config.model_dump(), + config.model_dump(exclude_unset=True), + ) + ) + columns_to_update.append(column) + # Validate + await self._check_columns( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_metadata=self.table_metadata, + column_metadata_list=self.column_metadata, + set_default_prompts=False, + replace_unavailable_models=False, + allow_nonexistent_refs=allow_nonexistent_refs, + ) + for column in columns_to_update: + await self._upsert_column_metadata(conn, self.schema_id, column) + # Set updated at time + await self._set_updated_at(conn) + self = await self._open_table( + conn=conn, + project_id=self.project_id, + table_type=self.table_type, + table_id=self.table_id, + request_id=request_id, + ) + return self + + async def reorder_columns( + self, + column_names: list[str], + ) -> Self: + """ + Reorder columns in the table. + + Args: + column_names (list[str]): List of column name in the desired order. + + Raises: + BadInputError: If the list of columns to reorder does not match the table columns. + + Returns: + self (GenerativeTableCore): The table instance. + """ + if column_names[0].lower() != "id": + raise BadInputError('First column must be "ID".') + if column_names[1].lower() != "updated at": + raise BadInputError('Second column must be "Updated at".') + if len(set(n.lower() for n in column_names)) != len(column_names): + raise BadInputError("Column names must be unique (case-insensitive).") + columns = self.data_table_model.get_column_ids(exclude_state=True) + if set(column_names) != set(columns): + raise BadInputError("The list of columns to reorder does not match the table columns.") + state_columns = [f"{col}_" for col in column_names if col.lower() not in self.INFO_COLUMNS] + async with GENTABLE_ENGINE.transaction() as conn: + # Update column order + for idx, column_id in enumerate(column_names + state_columns): + await conn.execute( + f""" + UPDATE "{self.schema_id}"."ColumnMetadata" + SET column_order = $1 + WHERE table_id = $2 AND column_id = $3 + """, + idx, + self.table_id, + column_id, + ) + # Set updated at time + await self._set_updated_at(conn) + return await self._reload_table(conn) + + # Column Delete Ops + async def drop_columns( + self, + column_ids: list[str], + ) -> Self: + """ + Drop columns from the Generative Table. + + Args: + column_ids (list[str]): List of column IDs to drop. + + Raises: + ResourceNotFoundError: If any of the columns is not found. + """ + if self.table_metadata.parent_id is not None: + # TODO: Test this + raise BadInputError(f'Table "{self.table_id}": Cannot drop column from a child table.') + fixed_cols = {c.lower() for c in self.FIXED_COLUMN_IDS} + if invalid_cols := {c.lower() for c in column_ids}.intersection(fixed_cols): + # TODO: Test this especially for Knowledge Table + raise BadInputError( + f'Table "{self.table_id}": Cannot drop fixed columns: {list(invalid_cols)}' + ) + if len(invalid_cols := [c for c in column_ids if c.endswith("_")]) > 0: + # TODO: Test this + raise BadInputError( + f'Table "{self.table_id}": Cannot drop state columns: {invalid_cols}' + ) + async with GENTABLE_ENGINE.transaction() as conn: + short_table_id = self.short_table_id + for column_id in column_ids: + # Drop column and state column + short_id = get_internal_id(column_id) + try: + await conn.execute( + f'ALTER TABLE "{self.schema_id}"."{short_table_id}" DROP COLUMN "{short_id}"' + ) + await conn.execute( + f'ALTER TABLE "{self.schema_id}"."{short_table_id}" DROP COLUMN "{short_id}_"' + ) + except UndefinedColumnError as e: + raise ResourceNotFoundError( + f'Column "{column_id}" is not found in table "{self.table_id}".' + ) from e + except Exception as e: + raise ResourceNotFoundError( + f'Column "{column_id}" is not found in table "{self.table_id}".' + ) from e + # Remove column metadata and the associated state column + await conn.execute( + f'DELETE FROM "{self.schema_id}"."ColumnMetadata" WHERE table_id = $1 AND column_id = $2', + self.table_id, + column_id, + ) + await conn.execute( + f'DELETE FROM "{self.schema_id}"."ColumnMetadata" WHERE table_id = $1 AND column_id = $2', + self.table_id, + f"{column_id}_", + ) + # Update column order + columns = self.data_table_model.get_column_ids(exclude_state=False) + columns = [col for col in columns if col not in column_ids] + for idx, column_id in enumerate(columns): + await conn.execute( + f""" + UPDATE "{self.schema_id}"."ColumnMetadata" + SET column_order = $1 + WHERE table_id = $2 AND column_id = $3 + """, + idx, + self.table_id, + column_id, + ) + # Set updated at time + await self._set_updated_at(conn) + # Rebuild indexes if needed + if any(c.is_text_column for c in self.column_metadata if c.column_id in column_ids): + await self._recreate_fts_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=[ + c.column_id + for c in self.column_metadata + if c.column_id not in column_ids and c.is_text_column + ], + ) + if any(c.is_vector_column for c in self.column_metadata if c.column_id in column_ids): + await self._recreate_vector_index( + conn, + schema_id=self.schema_id, + table_id=self.table_id, + columns=[ + c.column_id + for c in self.column_metadata + if c.column_id not in column_ids and c.is_vector_column + ], + ) + return await self._reload_table(conn) + + ### --- Row CRUD --- ### + @staticmethod + def _jsonify(x: Any) -> Any: + return x.tolist() if isinstance(x, np.ndarray) else x + + def _validate_row_data(self, data: dict[str, Any]) -> DataTableRow: + try: + row = self.data_table_model.model_validate(data, strict=False) + except ValidationError as e: + # Set invalid value to None, and save original value to state + for error in e.errors(): + if len(error["loc"]) > 1: + raise BadInputError(f"Input data contains errors: {e}") from e + col = error["loc"][0] + state = data.get(f"{col}_", {}) + data[col], data[f"{col}_"] = ( + None, + {"original": self._jsonify(data[col]), "error": error.get("msg", ""), **state}, + ) + # Try validating again try: - table.drop_columns(col_names) - except ValueError as 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_iso() - session.add(meta) - session.commit() - session.refresh(meta) - return table, meta - - # Look at this instead !! - def drop_columns( + row = self.data_table_model.model_validate(data, strict=False) + except ValidationError as e: + raise BadInputError(f"Input data contains errors: {e}") from e + return row + + # Row Create Ops + async def add_rows( self, - session: Session, - table_id: TableName, - column_names: list[ColName], - ) -> tuple[LanceTable, TableMeta]: + data_list: list[dict[str, Any]], + *, + ignore_info_columns: bool = True, + ignore_state_columns: bool = True, + set_updated_at: bool = True, + ) -> Self: """ - Drops one or more input or output column. + Add multiple rows to the Generative Table. Args: - session (Session): SQLAlchemy session. - table_id (str): Table ID. - column_names (list[str]): List of column ID to drop. + data_list (list[dict[str, Any]]): List of row data dictionaries. + ignore_info_columns (bool, optional): Whether to ignore "ID" and "Updated at" columns. + Defaults to True. + ignore_state_columns (bool, optional): Whether to ignore state columns. + Defaults to True. + set_updated_at (bool, optional): Whether to set the "Updated at" time to now. + Defaults to True. Raises: - TypeError: If `column_names` is not a list. + TypeError: If the data is not a list of dictionaries. ResourceNotFoundError: If the table is not found. - ResourceNotFoundError: If any of the columns is not found. Returns: - table (LanceTable): Lance table. - meta (TableMeta): Table metadata. - """ - if not isinstance(column_names, list): - raise TypeError("`column_names` must be a list.") - if self.has_state_col_names(column_names): - raise BadInputError("Cannot drop state columns.") - if self.has_info_col_names(column_names): - raise BadInputError('Cannot drop "ID" or "Updated at".') - fixed_cols = set(c.lower() for c in self.FIXED_COLUMN_IDS) - if len(fixed_cols.intersection(set(c.lower() for c in column_names))) > 0: - raise BadInputError(f"Cannot drop fixed columns: {self.FIXED_COLUMN_IDS}") - - 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_{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 column_names], - ) - new_table, new_meta = self._create_table( - session, new_schema, add_info_state_cols=False - ) - - # Copy data from old table to new table - old_table = self.open_table(table_id) - if old_table.count_rows() > 0: - data = old_table._dataset.to_table( - columns=[c.id for c in new_schema.cols] - ).to_pylist() - new_table.add(data) - - # Delete old table and rename - self.delete_table(session, table_id) - new_meta = self.rename_table(session, new_table_id, table_id) - new_table = self.open_table(table_id) - return new_table, new_meta - - def rename_columns( - 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 BadInputError("Cannot rename state columns.") - if self.has_info_col_names(column_map.keys()): - raise BadInputError('Cannot rename "ID" or "Updated at".') - fixed_cols = set(c.lower() for c in self.FIXED_COLUMN_IDS) - if len(fixed_cols.intersection(set(c.lower() for c in column_map))) > 0: - raise BadInputError(f"Cannot rename fixed columns: {self.FIXED_COLUMN_IDS}") - if len(new_col_names) != len(column_map): - raise BadInputError("`column_map` contains repeated new column names.") - if not all(re.match(COL_NAME_PATTERN, v) for v in column_map.values()): - raise BadInputError("`column_map` contains invalid new column names.") - meta = self.open_meta(session, table_id) - 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 BadInputError( - ( - "`column_map` contains new column names that " - f"overlap with existing column names: {overlap_col_names}" - ) + self (GenerativeTableCore): The table instance. + """ + if not (isinstance(data_list, list) and all(isinstance(row, dict) for row in data_list)): + # We raise TypeError here since this is a programming error + raise TypeError("`data_list` must be a list of dicts.") + # Filter out non-existent fields + columns = set( + self.data_table_model.get_column_ids( + exclude_info=ignore_info_columns, + exclude_state=ignore_state_columns, ) - 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(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"] = 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_iso() - session.add(meta) - session.commit() - session.refresh(meta) - # Modify LanceTable - 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: 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 column_names: - order += [c, f"{c}_"] - meta = self.open_meta(session, table_id) - try: - meta.cols = [ - c.model_dump() for c in sorted(meta.cols_schema, key=lambda x: order.index(x.id)) - ] - except ValueError as e: - raise ResourceNotFoundError(e) from e - meta.updated_at = datetime_now_iso() - # Validate changes - TableSchema.model_validate(meta.model_dump()) - session.add(meta) - session.commit() - session.refresh(meta) - return meta - - async def add_rows( - self, - session: Session, - 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): - with await lancedb.connect_async( - uri=self.vector_db_url, - read_consistency_interval=self.read_consistency_interval, - ) as db: + ) + data_list = [{k: v for k, v in row.items() if k in columns} for row in data_list] + data_list = [row for row in data_list if len(row) > 0] + if len(data_list) == 0: + return self + rows = [self._validate_row_data(data) for data in data_list] + # Build SQL statement + all_columns = self.data_table_model.get_column_ids() + _sql_cols = [f'"{self.map_to_short_col_id[c]}"' for c in all_columns] + stmt = ( + f'INSERT INTO "{self.schema_id}"."{self.short_table_id}" ({", ".join(_sql_cols)}) ' + f"VALUES ({', '.join(f'${i + 1}' for i in range(len(all_columns)))})" + ) + values = [[getattr(row, c) for c in all_columns] for row in rows] + async with GENTABLE_ENGINE.transaction() as conn: + # Insert rows with retries + for _ in range(3): 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 + # Use executemany for batch operations + await conn.executemany(stmt, values) + break + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except DataError as e: + self._log( + f"Failed to insert {len(rows):,d} rows due to: {repr(e)}.\nSQL:\n{stmt}\nValues:\n{values}", + "WARNING", + ) + if isinstance(e, InvalidParameterValueError) and "pgroonga" in str(e): + pass + else: + raise BadInputError(f"Bad input: {e}") from e + # Set updated at time + if set_updated_at: + await self._set_updated_at(conn) return self - def update_rows( + # Row Read Ops + async def list_rows( self, - session: Session, - table_id: TableName, *, - where: str | None, - values: dict[str, Any], - errors: list[str] | None = None, - ) -> Self: - 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 - values = RowUpdateData( - table_meta=meta, - data=[values], - errors=None if errors is None else [errors], - ) - values = values.sql_escape().data[0] - # TODO: Vector column update seems to be broken - 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_iso() - session.add(meta) - session.commit() - return self - - @staticmethod - def _filter_col( - col_id: str, + limit: int | None = None, + offset: int = 0, + order_by: list[str] | None = None, + order_ascending: bool = True, columns: list[str] | None = None, + where: str = "", + search_query: str = "", + search_columns: list[str] | None = None, remove_state_cols: bool = False, - ) -> bool: - if remove_state_cols and col_id.endswith("_"): - return False - # Hybrid search distance and match scores - if col_id.startswith("_"): - return False - if columns is not None: - columns = {"id", "updated at"} | {c.lower() for c in columns} - return col_id.lower() in columns - return True - - @staticmethod - def _process_cell( - row: dict[str, Any], - col_id: str, - convert_null: bool, - include_original: bool, - float_decimals: int, - vec_decimals: int, - ): - state_id = f"{col_id}_" - data = row[col_id] - if state_id not in row: - # Some columns like "ID", "Updated at" do not have state cols - return data - # Process precision - if float_decimals > 0 and isinstance(data, float): - data = round(data, float_decimals) - 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: - data = None if convert_null else data - return {"value": data} if include_original else data - 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"] - return ret - else: - return data + ) -> Page[dict[str, Any]]: + """ + List rows with filtering and sorting. - @staticmethod - def _post_process_rows( - rows: list[dict[str, Any]], - *, - 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, - ): - if json_safe: - rows = [ - {k: v.isoformat() if isinstance(v, datetime) else v for k, v in row.items()} - for row in rows - ] - rows = [ - { - k: GenerativeTable._process_cell( - row, - k, - convert_null=convert_null, - include_original=include_original, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ) - for k in row - if not (vec_decimals < 0 and isinstance(row[k], list)) - } - for row in rows - ] - rows = [ - { - k: v - for k, v in row.items() - if GenerativeTable._filter_col( - k, columns=columns, remove_state_cols=remove_state_cols - ) - } - for row in rows - ] - return rows + Args: + limit (int | None, optional): Maximum number of rows to return. Defaults to None. + offset (int, optional): Offset for pagination. Defaults to 0. + order_by (list[str] | None, optional): Order the rows by these columns. Defaults to None (order by row ID). + order_ascending (bool, optional): Order the rows in ascending order. Defaults to True. + columns (list[str] | None, optional): A list of column names to include in the returned rows. + Defaults to None (return all columns). + where (str, optional): SQL where clause. Defaults to "" (no filter). + It will be combined other filters using `AND`. + search_query (str, optional): A string to search for within row data. + The string is interpreted as both POSIX regular expression and literal string. + Defaults to "". + search_columns (list[str] | None, optional): A list of column names to search for search_query. + Defaults to None (search all columns). + remove_state_cols (bool, optional): If True, remove state columns. Defaults to False. - @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()) + Raises: + ResourceNotFoundError: If the table or column(s) is not found. - 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 + Returns: + rows (Page[dict[str, Any]]): A page of row data dictionaries. + """ + columns = self._filter_columns(columns, exclude_state=remove_state_cols) + # Build SQL query + params = [] + query = f""" + SELECT {",".join([f'"{self.map_to_short_col_id[c]}"' for c in columns])} + FROM "{self.schema_id}"."{self.short_table_id}" + """ + total = f'SELECT COUNT("ID") FROM "{self.schema_id}"."{self.short_table_id}"' + filters = [] + where = where.strip() + if where: + try: + where = f"({validate_where_expr(where, id_map=self.map_to_short_col_id)})" + except Exception as e: + raise BadInputError(str(e)) from e + filters.append(where) + if search_query: + _cols = search_columns or [ + col.column_id + for col in self.column_metadata + if not ( + col.is_info_column + or col.is_file_column + or col.is_vector_column + or col.is_state_column + ) + ] + search_filters = [] + for c in _cols: + c = self.map_to_short_col_id.get(c, None) + if c is None: continue - state = row[state_id] - # Process precision - if isinstance(data, np.ndarray): - if vec_decimals < 0: - row.drop([col_id, state_id], inplace=True) + # Literal (escaped) search + params.append(re.escape(search_query)) + literal_expr = f'("{c}"::text ~* ${len(params)})' + # Regex search + params.append(search_query) + regex_expr = f'("{c}"::text ~* ${len(params)})' + search_filters.append(f"({literal_expr} OR {regex_expr})") + filters.append(f"({' OR '.join(search_filters)})") + if filters: + query += f" WHERE {' AND '.join(filters)}" + total += f" WHERE {' AND '.join(filters)}" + async with GENTABLE_ENGINE.transaction() as conn: + # Row count + try: + total = await conn.fetchval(total, *params) + except UndefinedColumnError as e: + raise ResourceNotFoundError( + f'One or more columns is not found in table "{self.table_id}".' + ) from e + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except (PostgresSyntaxError, UndefinedFunctionError) as e: + raise BadInputError(f"Bad SQL statement: `{query}`") from e + # Sorting + order_direction = "ASC" if order_ascending else "DESC" + order_clauses = [] + if order_by: + for c in order_by: + cs = self.map_to_short_col_id.get(c, None) + if cs is None: 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( + if c in self.text_column_names: + order_clauses.append(f'LOWER("{cs}") {order_direction}') + else: + order_clauses.append(f'"{cs}" {order_direction}') + order_clauses.append(f'"ID" {order_direction}') + query += " ORDER BY " + ", ".join(order_clauses) + # Pagination + if limit: + params.append(limit) + query += f" LIMIT ${len(params)}" + if offset: + params.append(offset) + query += f" OFFSET ${len(params)}" + # Execute query + try: + rows = await conn.fetch(query, *params) + except UndefinedColumnError as e: + raise ResourceNotFoundError( + f'One or more columns is not found in table "{self.table_id}".' + ) from e + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except (PostgresSyntaxError, UndefinedFunctionError) as e: + raise BadInputError(f"Bad SQL statement: `{query}`") from e + # Map short column IDs back to long column IDs + rows = [{self.map_to_long_col_id[k]: v for k, v in dict(row).items()} for row in rows] + return Page[dict[str, Any]]( + items=rows, + offset=offset, + limit=total if limit is None else limit, + total=total, + ) + + async def get_row( self, - table_id: TableName, row_id: str, *, 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, ) -> dict[str, Any]: - 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(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( - rows, - columns=columns, - convert_null=convert_null, - remove_state_cols=remove_state_cols, - json_safe=json_safe, - include_original=include_original, - float_decimals=float_decimals, - vec_decimals=vec_decimals, - ) - return rows[0] + """ + Get a single row by its row ID. - @staticmethod - def _count_rows_query(table_name: str) -> str: - return f"SELECT COUNT(*) FROM '{table_name}'" + Args: + row_id (str): ID of the row to be retrieved. + columns (list[str] | None, optional): A list of column names to include in the returned rows. + Defaults to None (return all columns). + remove_state_cols (bool, optional): If True, remove state columns. Defaults to False. - @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 + Raises: + ResourceNotFoundError: If the table or row is not found. + + Returns: + row (dict[str, Any]): The row data dictionary. + """ + columns = self._filter_columns(columns, exclude_state=remove_state_cols) + query = f""" + SELECT {",".join([f'"{self.map_to_short_col_id[c]}"' for c in columns])} + FROM "{self.schema_id}"."{self.short_table_id}" + """ + # Get row + row = None + async with GENTABLE_ENGINE.transaction() as conn: + try: + row = await conn.fetchrow(f'{query} WHERE "ID" = $1', row_id) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + if not row: + raise ResourceNotFoundError( + f'Row "{row_id}" is not found in table "{self.table_id}".' + ) + # Map short column ID back to long column ID + row = {self.map_to_long_col_id[k]: v for k, v in dict(row).items()} + return row - def list_rows( + def postprocess_rows( self, - table_id: TableName, + rows: list[dict[str, Any]], *, - offset: int = 0, - limit: int = 1_000, - 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, - ) -> tuple[list[dict[str, Any]], int]: - 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 order_descending: - offset = max(0, total - limit - offset) - if columns is not None: - if "ID" not in columns: - columns.insert(0, "ID") - if "Updated at" not in columns: - columns.insert(1, "Updated at") - rows = table._dataset.to_table(columns=columns, offset=offset, limit=limit).to_pylist() - rows = sorted(rows, reverse=order_descending, key=lambda r: r["ID"]) - rows = self._post_process_rows( - rows, - columns=columns, - convert_null=convert_null, - remove_state_cols=remove_state_cols, - json_safe=json_safe, - include_original=include_original, - float_decimals=float_decimals, - vec_decimals=vec_decimals, + include_state: bool = True, + ) -> list[dict[str, Any]]: + if not (isinstance(rows, list) and all(isinstance(r, dict) for r in rows)): + # We raise TypeError here since this is a programming error + raise TypeError("`rows` must be a list of dicts.") + for row in rows: + columns = list(row.keys()) + # Process data + for col_name in columns: + if col_name.endswith("_"): + continue + col_value = row[col_name] + # Process UUID and datetime + if isinstance(col_value, UUID): + col_value = str(col_value) + elif isinstance(col_value, datetime): + col_value = col_value.isoformat() + else: + # Rounding logic + if float_decimals > 0 and isinstance(col_value, float): + col_value = round(col_value, float_decimals) + if isinstance(col_value, np.ndarray): + if vec_decimals < 0: + del row[col_name] + continue + if vec_decimals > 0: + col_value = [round(v, vec_decimals) for v in col_value.tolist()] + else: + col_value = col_value.tolist() + # Process state + state = row.get(f"{col_name}_", None) + if state is None: + # Columns like "ID", "Updated at" do not have state + row[col_name] = col_value + continue + try: + state.pop("is_null", None) # Legacy attribute + except Exception as e: + self._log( + f'Failed to process state of column "{col_name}" due to {repr(e)} {type(state)=} {state=}', + "WARNING", + ) + row[col_name] = {"value": col_value, **state} if include_state else col_value + # Remove state + for col_name in columns: + if col_name.endswith("_"): + del row[col_name] + return rows + + def check_multiturn_column(self, column_id: str) -> LLMGenConfig: + cols = {c.column_id: c for c in self.column_metadata} + multiturn_cols = [c.column_id for c in self.column_metadata if c.is_chat_column] + column = cols.get(column_id, None) + if column is None: + raise ResourceNotFoundError( + ( + f'Table "{self.table_id}": Column "{column_id}" is not found. ' + f"Available multi-turn columns: {multiturn_cols}" + ) ) - return rows, total - - 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_iso() - session.add(meta) - session.commit() - return self + gen_config = column.gen_config + if not (isinstance(gen_config, LLMGenConfig) and gen_config.multi_turn): + raise ResourceNotFoundError( + ( + f'Table "{self.table_id}": Column "{column_id}" is not a multi-turn LLM column. ' + f"Available multi-turn columns: {multiturn_cols}" + ) + ) + return gen_config - def delete_rows( + def interpolate_column( self, - session: Session, - table_id: TableName, - row_ids: list[str] | None = None, - where: str | None = "", - ) -> Self: - if row_ids is None: - row_ids = [] - with self.lock(table_id): - table = self.open_table(table_id) - for row_id in row_ids: - table.delete(f"`ID` = '{row_id}'") - if where: - table.delete(where) - # Update metadata - meta = self.open_meta(session, table_id) - 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: + row: dict[str, Any], + ) -> str | list[TextContent | S3Content]: """ Replaces / interpolates column references in the prompt with their contents. Args: prompt (str): The original prompt with zero or more column references. + row (dict[str, Any]): The row data containing column values. + content_injection (bool, optional): If True, injects column content in the prompt. + If False, user prompt will be unchanged. Defaults to True. Returns: - new_prompt (str): The prompt with column references replaced. + content (str | list[TextContent | S3Content]): Message content with column references replaced. """ + column_map = {c.column_id: c for c in self.column_metadata} + s3_contents: list[S3Content] = [] - def replace_match(match): + def _replace(match: re.Match) -> str: col_id = match.group(1) try: - if column_dtypes[col_id] == "image": - return "" - elif column_dtypes[col_id] == "audio": - return "" - return str(column_contents[col_id]) - except KeyError as e: - raise KeyError(f'Referenced column "{col_id}" is not found.') from e + # Referenced column is found + col = column_map[col_id] + col_data = row.get(col_id, None) + if col.is_file_column: + # File references will be loaded and interpolated in `GenExecutor` + if col_data is None: + # If file URI is None, we treat it as no content injection + return "" + else: + # Return URI and retain column reference for downstream interpolation + s3_contents.append(S3Content(uri=row[col_id], column_name=col_id)) + return f"${{{col_id}}}" + # Non-file references can interpolate directly + return str(col_data) + except KeyError: + # Referenced column is not found + # Maybe injected contents accidentally contain references + # We escape it here just in case + return f"\\${{{col_id}}}" - return re.sub(GEN_CONFIG_VAR_PATTERN, replace_match, prompt) + prompt = re.sub(GEN_CONFIG_VAR_PATTERN, _replace, prompt).strip() + if len(s3_contents) == 0: + return prompt + return s3_contents + [TextContent(text=prompt)] - def get_conversation_thread( + async 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 + include_row: bool = True, + ) -> ChatThreadResponse: + """ + Get a conversation thread for a multi-turn LLM column. + + Args: + column_id (str): ID of the multi-turn LLM column. + row_id (str, optional): ID of the last row in the thread. + Defaults to "" (export all rows).. + include_row (bool, optional): Whether to include the row specified by `row_id`. + Defaults to True. + + Returns: + response (ChatThreadResponse): _description_ + """ + gen_config = self.check_multiturn_column(column_id) 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, - ) + columns = ref_col_ids + [column_id] 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 + where = '"ID" ' + (f"<= '{row_id}'" if include_row else f"< '{row_id}'") + else: + where = "" + rows = ( + await self.list_rows( + limit=None, + offset=0, + order_by=None, + order_ascending=True, + columns=columns, + where=where, + remove_state_cols=False, + ) + ).items + ref_cols = set(re.findall(GEN_CONFIG_VAR_PATTERN, gen_config.prompt)) + has_user_prompt = "User" in ref_cols thread = [] if gen_config.system_prompt: - thread.append(ChatEntry.system(gen_config.system_prompt)) + thread.append(ChatThreadEntry.system(gen_config.system_prompt)) for row in rows: + if has_user_prompt: + user_prompt = row.get("User", None) or None # Map "" to None + else: + user_prompt = None + row_id = str(row["ID"]) thread.append( - ChatEntry.user( - self._interpolate_column( - gen_config.prompt, - {c.id: c.dtype for c in cols.values()}, - row, - ) + ChatThreadEntry.user( + self.interpolate_column(gen_config.prompt, row), + user_prompt=user_prompt, + row_id=row_id, ) ) - thread.append(ChatEntry.assistant(row[column_id])) - return ChatThread(thread=thread) + thread.append( + ChatThreadEntry.assistant( + row[column_id], + references=row.get(f"{column_id}_", {}).get("references", None), + row_id=row_id, + ) + ) + return ChatThreadResponse(thread=thread, column_id=column_id) - def export_csv( - self, - table_id: TableName, - columns: list[ColName], - file_path: str = "", - delimiter: CSVDelimiter | str = ",", - ) -> pd.DataFrame: - if isinstance(delimiter, str): - try: - 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, - limit=self.count_rows(table_id), - columns=columns, - convert_null=True, - remove_state_cols=True, - json_safe=True, - include_original=False, - float_decimals=0, - vec_decimals=0, - order_descending=False, + @staticmethod + def _tokenize_regex_simple(text): + tokens = [] + for match in TOKEN_PATTERN.finditer(text): + # Figure out which group matched to determine category and get the string + if match.group(1): # Digits + token_str = match.group(1) + tokens.append(token_str) + elif match.group(2): # Letters + token_str = match.group(2).lower() # Lowercase letters + tokens.append(token_str) + elif match.group(3): # Hanzi + token_str = match.group(3) + tokens.append(token_str) # Append Hanzi directly + elif match.group(4): # Other + token_str = match.group(4) + tokens.append(token_str) # Append other char directly + return tokens + + @staticmethod + def _bm25_ranking( + fts_results: list[dict[str, Any]], + *, + query: str, + text_column_names: list[str], + weights: list[int] | None = None, + ascending: bool = False, + ) -> list[dict[str, Any]]: + corpus = [res[col] for res in fts_results for col in text_column_names] + tokenizer = bm25s.tokenization.Tokenizer( + splitter=GenerativeTableCore._tokenize_regex_simple, + stopwords=[ + "english", + ], + stemmer=stemmer.stem, ) - df = pd.DataFrame.from_dict(rows, orient="columns", dtype=None, columns=None) - if len(df) != total: - logger.error( - f"Table {table_id} has {total:,d} rows but exported DF has {len(df):,d} rows !!!" - ) - if file_path == "": - return df - if delimiter == CSVDelimiter.COMMA and not file_path.endswith(".csv"): - file_path = f"{file_path}.csv" - 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 - - def dump_parquet( + corpus = ["" if c is None else c for c in corpus] + corpus_tokens = tokenizer.tokenize(corpus, show_progress=False) + retriever = bm25s.BM25(backend="numpy") + retriever.index(corpus_tokens, show_progress=False) + query_tokens = tokenizer.tokenize([query], show_progress=False) + results, scores = retriever.retrieve( + query_tokens, k=len(corpus), show_progress=False, n_threads=1, sorted=True + ) + # Reshape scores into (n_docs, n_columns) and apply weights + scores_reshaped = scores[0, results.argsort()].reshape(-1, len(text_column_names)) + if weights: + scores_reshaped *= np.array(weights) + # Sum scores across columns + doc_scores = scores_reshaped.sum(axis=1) + + # Get sorted indices (ascending or descending) + sorted_indices = np.argsort(doc_scores) + if not ascending: + sorted_indices = sorted_indices[::-1] # Reverse for descending + + # Build sorted results with scores + ranked_results = [fts_results[i] for i in sorted_indices] + + for res, score in zip(ranked_results, doc_scores[sorted_indices], strict=True): + res["score"] = float(score) # Convert numpy.float32 to native Python float + return ranked_results + + async def fts_search( self, - session: Session, - table_id: TableName, - dest: str | BinaryIO, + query: str, *, - 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) - # Add file data into Arrow Table - file_col_ids = [col.id for col in meta.cols_schema if col.dtype in ["image", "audio"]] - for col_id in file_col_ids: - file_bytes = [] - for uri in pa_table.column(col_id).to_pylist(): - 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 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, - source: str | BinaryIO, - table_id_dst: str | None, - ) -> tuple[LanceTable, TableMeta]: - from pyarrow.parquet import read_table + weights: dict[str, int] | None = None, + limit: int = 100, + offset: int = 0, + remove_state_cols: bool = False, + force_use_index: bool = False, + use_bm25_ranking: bool = False, + explain: bool = False, + ) -> list[dict[str, Any]]: + """ + Perform full-text search across all text columns using pgroonga. - # 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 in ["image", "audio"]] - 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, + Args: + query (str): Search query string. + limit (int, optional): Maximum number of rows to return. Defaults to 100. + offset (int, optional): Offset for pagination. Defaults to 0. + remove_state_cols (bool, optional): If True, remove state columns. Defaults to False. + force_use_index (bool, optional): If True, force using pgroonga index. Defaults to False. + use_bm25_ranking (bool, optional): If True, use BM25 ranking. Defaults to False. + explain (bool, optional): If True, return explain query. Defaults to False. + + Raises: + ResourceNotFoundError: If the table or column(s) is not found. + + Returns: + rows (list[dict[str, Any]]): List of row data dictionaries. + """ + t0 = perf_counter() + if weights is None: + weights = [1 for _ in self.text_column_names] + else: + weights = [weights.get(n, 1) for n in self.text_column_names] + if len(weights) == 0: # if no text columns fts return empty list + return [] + # Build query + select_cols = self.data_table_model.get_column_ids(exclude_state=remove_state_cols) + # Do not enforce idx like: ($1, ARRAY{weights}, '{fts_index_id(self.table_id)}')::pgroonga_full_text_search_condition + # Pg planner will choose the best plan to run the query efficiently (for smaller number of rows might just use seq scan) + # for duplicated table with CTAS, if number of rows is small it might always use seq scan regardless, so forcing the index will fail + # tested a simple 3 col table, if number rows is 1000 then even with NULL index will be used. + index_name = f"'{fts_index_id(self.table_id)}'" if force_use_index else "NULL" + stmt = f""" + SELECT + {",".join(f'"{self.map_to_short_col_id[c]}"' for c in select_cols)}, + pgroonga_score(tableoid, ctid) AS score + FROM + "{self.schema_id}"."{self.short_table_id}" + WHERE + ARRAY[{", ".join(f'"{self.map_to_short_col_id[n]}"' for n in self.text_column_names)}] &@~ + ($1, ARRAY{weights}, {index_name})::pgroonga_full_text_search_condition + ORDER BY score DESC + LIMIT $2 OFFSET $3 + """ + if explain: + stmt = f"EXPLAIN ANALYZE {stmt}" + async with GENTABLE_ENGINE.transaction() as conn: + # Execute query + try: + rows = await conn.fetch(stmt, query, limit, offset) + except UndefinedColumnError as e: + raise ResourceNotFoundError( + f'One or more columns is not found in table "{self.table_id}".' + ) from e + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except DataError as e: + raise BadInputError(f"Bad input: {e}") from e + # Map short column IDs back to long column IDs + # Keys contain non-column IDs like "score" + results = [ + {self.map_to_long_col_id.get(k, k): v for k, v in dict(row).items()} for row in rows + ] + if len(results) > 0 and use_bm25_ranking: + results = self._bm25_ranking( + fts_results=results, + query=query, + text_column_names=self.text_column_names, + weights=weights, + ascending=False, ) - session.refresh(meta) - return table, meta + self._log(f"FTS search took t={(perf_counter() - t0) * 1e3:,.2f} ms.") + return results - @retry( - wait=wait_exponential(multiplier=1, min=2, max=10), - stop=stop_after_attempt(4), - reraise=True, - ) - def _run_query( + async def vector_search( self, - session: Session, - table_id: TableName, - table: LanceTable, - query: np.ndarray | list | str | None = None, - column_name: str | None = None, - where: str | None = None, - limit: PositiveInt = 10_000, - metric: str = "cosine", - nprobes: PositiveInt = 50, - refine_factor: PositiveInt = 20, + query: str, + *, + embedding_fn: Callable[[str, str], list[float] | Awaitable[list[float]]], + vector_column_names: list[str] | None = None, + limit: int = 100, + offset: int = 0, + remove_state_cols: bool = False, + explain: bool = False, ) -> list[dict[str, Any]]: - is_vector = isinstance(query, (list, np.ndarray)) - if query is None: - column_name = None - query_type = "auto" - elif is_vector: - query_type = "vector" - elif isinstance(query, str): - query = re.sub(r"[\W\s]", " ", query.lower()) - query_type = "fts" + """Perform vector similarity search using cosine distance. + + Args: + query (str): Search query string. + embedding_fn (Callable[[str, str], list[float] | Awaitable[list[float]]]): Embedding function that + takes two string parameters (`str`, `str`) and returns a list of floats. + Can be either synchronous or asynchronous. + The first argument is the model ID and the second argument is the query, ie `embedding_fn(model, query)`. + vector_column_names (list[str] | None, optional): List of vector column name to search. + Defaults to None (all vector columns are used). + limit (int, optional): Maximum number of rows to return. Defaults to 100. + offset (int, optional): Offset for pagination. Defaults to 0. + remove_state_cols (bool, optional): If True, remove state columns. Defaults to False. + explain (bool, optional): If True, return explain query. Defaults to False. + + Raises: + TypeError: If `vector_column_names` is not a list of strings. + BadInputError: If not all columns are vector columns. + ResourceNotFoundError: If the table or column(s) is not found. + + Returns: + rows (list[dict[str, Any]]): List of row data dictionaries. + """ + t0 = perf_counter() + if vector_column_names is None: + vector_column_names = self.vector_column_names else: - raise TypeError("`query` must be one of [np.ndarray | list | str | None].") - query_builder = table.search( - query=query, - vector_column_name=column_name, - query_type=query_type, + if not ( + isinstance(vector_column_names, list) + and all(isinstance(n, str) for n in vector_column_names) + ): + # We raise TypeError here since this is a programming error + raise TypeError("`vector_column_names` must be a list of strings.") + # Ensure all columns are vector columns + if len(invalid_cols := set(vector_column_names) - set(self.vector_column_names)) > 0: + raise BadInputError( + ( + f'Table "{self.table_id}": All columns to be searched must be vector columns. ' + f"Invalid columns: {list(invalid_cols)}" + ) + ) + if len(vector_column_names) == 0: + return [] + # Get query vectors + models: list[str] = list( + { + getattr(c.gen_config, "embedding_model", "") + for c in self.column_metadata + if c.column_id in vector_column_names + } ) - if is_vector: - query_builder = ( - query_builder.metric(metric).nprobes(nprobes).refine_factor(refine_factor) - ) - if where: - query_builder = query_builder.where(where, prefilter=True) - try: - results = query_builder.limit(limit).to_list() - except ValueError: - 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 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 ...' + self._log(f"Embedding using models: {models}") + if iscoroutinefunction(embedding_fn): + query_vectors = await asyncio.gather(*[embedding_fn(m, query) for m in models]) + else: + with ThreadPoolExecutor() as executor: + query_vectors = list(executor.map(embedding_fn, models, [query] * len(models))) + query_vectors = {m: v for m, v in zip(models, query_vectors, strict=True)} + self._log(f"Embedding using {models} took t={(perf_counter() - t0) * 1e3:,.2f} ms.") + + t0 = perf_counter() + columns = [] + for c in self.column_metadata: + if c.column_id not in vector_column_names: + continue + vec = query_vectors[getattr(c.gen_config, "embedding_model", "")] + if len(vec) != c.vlen: + raise BadInputError( + f"Vector length mismatch for column {c.column_id}. Expected {c.vlen}, got {len(vec)}." ) - results = query_builder.limit(limit).to_list() + columns.append((self.map_to_short_col_id[c.column_id], vec)) + if len(columns) == 0: + return [] + # CTE query + # https://learn.microsoft.com/en-us/answers/questions/2118689/how-to-search-across-multiple-vector-indexes-in-po + subqueries = [ + f""" + "{col_id}_results" AS ( + SELECT + "ID", ("{col_id}" <=> ${i + 1}) AS score + FROM + "{self.schema_id}"."{self.short_table_id}" + ORDER BY + score ASC + ) + """ + for i, (col_id, _) in enumerate(columns) + ] + select_cols = self.data_table_model.get_column_ids(exclude_state=remove_state_cols) + selects = [f't."{self.map_to_short_col_id[col]}"' for col in select_cols] + joins = [ + f'JOIN "{col_id}_results" ON "{columns[0][0]}_results"."ID" = "{col_id}_results"."ID"' + for col_id, _ in columns[1:] + ] + join_expr = "\n".join(joins) + stmt = f""" + WITH + {", ".join(subqueries)} + SELECT + {", ".join(selects)}, + {" + ".join(f'"{col_id}_results".score' for col_id, _ in columns)} AS score + FROM + "{columns[0][0]}_results" + {join_expr} + JOIN + "{self.schema_id}"."{self.short_table_id}" t + ON + t."ID" = "{columns[0][0]}_results"."ID" + ORDER BY + score ASC + LIMIT ${len(columns) + 1} OFFSET ${len(columns) + 2}; + """ + if explain: + stmt = f"EXPLAIN ANALYZE {stmt}" + async with GENTABLE_ENGINE.transaction() as conn: + # Execute query + try: + rows = await conn.fetch(stmt, *[vec for _, vec in columns], limit, offset) + except UndefinedColumnError as e: + raise ResourceNotFoundError( + f'One or more columns is not found in table "{self.table_id}".' + ) from e + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except DataError as e: + raise BadInputError(f"Bad input: {e}") from e + # Map short column IDs back to long column IDs + # Keys contain non-column IDs like "score" + results = [ + {self.map_to_long_col_id.get(k, k): v for k, v in dict(row).items()} for row in rows + ] + self._log(f"Vector search took t={(perf_counter() - t0) * 1e3:,.2f} ms.") return results @staticmethod def _reciprocal_rank_fusion( - search_results: list[list[dict]], result_key: str = "ID", K: int = 60 - ): + search_results: list[list[dict]], + result_key: str = "ID", + K: int = 60, + ) -> list[dict]: """ - Perform reciprocal rank fusion to merge the rank of the search results (arbitrary number of results and can be varying in length) + Perform reciprocal rank fusion to merge the rank of the search results + (arbitrary number of results and can vary in length). + Args: - search_results: list of search results from lance query, search result is a sorted list of dict (descending order of closeness) - result_key: dict key of the search result - K: const (def=60) for reciprocal rank fusion + search_results (list[list[dict]]): List of search results, + where each result is a sorted list of dict (descending order of closeness). + result_key (str, optional): Dictionary key of each item's ID. Defaults to "ID". + K (int, optional): Const for reciprocal rank fusion. Defaults to 60. Return: - A list of dict of original result with the rrf scores (higher scores, higher ranking) + rows (list[dict]): A list of dict of original result with the rrf scores (higher scores, higher ranking). """ rrf_scores = defaultdict(lambda: {"rrf_score": 0.0}) for search_result in search_results: @@ -1403,593 +3939,701 @@ def _reciprocal_rank_fusion( sorted_rrf = sorted(rrf_scores.values(), key=lambda x: x["rrf_score"], reverse=True) return sorted_rrf - def regex_search( + async def hybrid_search( self, - session: Session, - table_id: TableName, - query: str | None, + fts_query: str, + vs_query: str, *, - columns: list[ColName] | None = None, - convert_null: bool = True, + embedding_fn: Callable[[str, str], Awaitable[list[float] | np.ndarray]], + vector_column_names: list[str] | None = None, + limit: int = 100, + offset: int = 0, + use_bm25_ranking: 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: - return [] - if not isinstance(query, str): - raise TypeError(f"`query` must be string, received: {type(query)}") - rows = [] + """ + Perform vector similarity search using cosine distance. + + Args: + fts_query (str): FTS search query string. + vs_query (str): Vector search query string. + embedding_fn (Callable[[str, str], Awaitable[list[float] | np.ndarray]]): Async embedding function that + takes two string parameters (`str`, `str`) and returns a NumPy array or a list of floats. + The first argument is the model ID and the second argument is the query, ie `embedding_fn(model, query)`. + The returned NumPy array should be one-dimensional (ie a single vector). + vector_column_names (list[str] | None, optional): List of vector column name to search. + Defaults to None (all vector columns are used). + limit (int, optional): Maximum number of rows to return from FTS and vector searches. + Note that this means that hybrid search can return more than `limit` rows. Defaults to 100. + offset (int, optional): Offset for pagination. Defaults to 0. + use_bm25_ranking (bool, optional): If True, use BM25 ranking. Defaults to True. + remove_state_cols (bool, optional): If True, remove state columns. Defaults to False. + + Raises: + BadInputError: If not all columns are vector columns. + ResourceNotFoundError: If the table or column(s) is not found. + + Returns: + rows (list[dict[str, Any]]): List of row data dictionaries. + """ t0 = perf_counter() - cols = self.fts_cols(meta) - for col in cols: - rows += ( - table.search() - .where(f"regexp_match(`{col.id}`, '{query}')") - .limit(table.count_rows()) - .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, - convert_null=convert_null, + fts_task = self.fts_search( + query=fts_query, + limit=limit, + offset=offset, + use_bm25_ranking=use_bm25_ranking, + remove_state_cols=remove_state_cols, + ) + vs_task = self.vector_search( + query=vs_query, + embedding_fn=embedding_fn, + vector_column_names=vector_column_names, + limit=limit, + offset=offset, remove_state_cols=remove_state_cols, - json_safe=json_safe, - include_original=include_original, - float_decimals=float_decimals, - vec_decimals=vec_decimals, ) + # Run both tasks concurrently and wait for them to complete + # asyncio.gather returns results in the order the tasks were passed + fts_result, vs_result = await asyncio.gather(fts_task, vs_task) + search_results = [fts_result, vs_result] + # RRF + rows = self._reciprocal_rank_fusion(search_results) + self._log(f"Hybrid search took t={(perf_counter() - t0) * 1e3:,.2f} ms.") return rows - async def hybrid_search( + def rows_to_documents(self, rows: list[dict[str, Any]]) -> list[str]: + cols = {c.column_id for c in self.column_metadata if not c.is_state_column} + documents = [ + ( + f"Title: {r.get('Title', '')}\nContent: {r.get('Text', '')}\n" + + "\n".join( + f"{k}: {v}" + for k, v in r.items() + if k not in self.FIXED_COLUMN_IDS and k in cols + ) + ) + for r in rows + ] + return documents + + # Row Update Ops + async def update_rows( self, - session: Session, - table_id: TableName, - query: str | None, + updates: dict[str, dict[str, Any]], *, - where: str | None = None, - limit: PositiveInt = 100, - columns: list[ColName] | None = None, - metric: str = "cosine", - 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, - json_safe: bool = False, - include_original: bool = False, - float_decimals: int = 0, - vec_decimals: int = 0, - ) -> 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 - # https://github.com/lancedb/lancedb/issues/1151 - raise TypeError("`limit` must be a positive non-zero integer.") - t0 = perf_counter() - table, meta = self.open_table_meta(session, table_id) - if self.count_rows(table_id) == 0: - return [] - timings = {} - if query is None: - t1 = perf_counter() - rows = self._run_query( - session=session, - table_id=table_id, - table=table, - query=None, - column_name=None, - where=where, - limit=limit, + ignore_state_columns: bool = True, + ) -> None: + """ + Update multiple rows in the Generative Table. + + Args: + updates (dict[str, dict[str, Any]]): A dictionary mapping row ID to update data. + Each update data is a dictionary of column name to value. + ignore_state_columns (bool, optional): Whether to ignore state columns. Defaults to True. + + Raises: + TypeError: If the data is not a list of dictionaries. + BadInputError: If any row does not have an "ID" field. + ResourceNotFoundError: If the table is not found. + + Returns: + self (GenerativeTableCore): The table instance. + """ + if not ( + isinstance(updates, dict) and all(isinstance(row, dict) for row in updates.values()) + ): + # We raise TypeError here since this is a programming error + raise TypeError("`updates` must be a dict of dicts.") + # Filter out non-existent fields + columns = set( + self.data_table_model.get_column_ids( + exclude_info=True, + exclude_state=ignore_state_columns, ) - timings["no_query"] = perf_counter() - t1 - else: - if not isinstance(query, str): - raise TypeError(f"`query` must be string, received: {type(query)}") - search_results = [] - # 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() - fts_result = self._run_query( - session=session, - table_id=table_id, - table=table, - query=query, - # column_name=c.id, - where=where, - limit=limit, - metric=metric, - nprobes=nprobes, - refine_factor=refine_factor, - ) - timings["FTS:"] = perf_counter() - t1 - search_results.append(fts_result) - for c in self.embedding_cols(meta): - t1 = perf_counter() - embedding = await embedder.embed_queries( - c.gen_config.embedding_model, 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 ({c.gen_config.embedding_model}): {c.id}"] = perf_counter() - t1 - t1 = perf_counter() - sub_rows = self._run_query( - session=session, - table_id=table_id, - table=table, - query=embedding, - column_name=c.id, - where=where, - limit=limit, - metric=metric, - nprobes=nprobes, - refine_factor=refine_factor, - ) - # vector_score from lance is 1.0 - cosine similarity (0. exact match) - search_results.append(sub_rows) - timings[f"VS: {c.id}"] = perf_counter() - t1 - # list of search results with rrf_score - rows = self._reciprocal_rank_fusion(search_results) - 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() - chunks = await reranker.rerank_chunks( - reranking_model, - chunks=[ - Chunk( - text="" if row["Text"] is None else row["Text"], - title="" if row["Title"] is None else row["Title"], - ) - for row in rows - ], - query=query, - ) - rerank_order = [c[2] for c in chunks] - rows = [rows[idx] for idx in rerank_order] - timings[f"Rerank ({reranking_model})"] = perf_counter() - t1 - rows = rows[:limit] - rows = self._post_process_rows( - rows, - columns=columns, - convert_null=convert_null, - remove_state_cols=remove_state_cols, - json_safe=json_safe, - include_original=include_original, - float_decimals=float_decimals, - vec_decimals=vec_decimals, ) - timings["Total"] = perf_counter() - t0 - timings = {k: f"{v:,.3f}" for k, v in timings.items()} - logger.info(f"Hybrid search timings: {timings}") - return rows + # Validate and convert all rows + try: + updates = { + row_id: self._validate_row_data( + {k: v for k, v in row.items() if k in columns and k.lower() != "id"} + ).model_dump(exclude_unset=True) + for row_id, row in updates.items() + } + except ValidationError as e: + raise BadInputError(f"Input data contains errors: {e}") from e + async with GENTABLE_ENGINE.transaction() as conn: + try: + for row_id, update in updates.items(): + if len(update) == 0: + continue + _cols = [k for k in update.keys()] + # Build SQL statement + set_expr = ", ".join( + f'"{self.map_to_short_col_id[col]}" = ${i + 1}' + for i, col in enumerate(_cols) + ) + query = ( + f'UPDATE "{self.schema_id}"."{self.short_table_id}" ' + f'SET "Updated at" = statement_timestamp(), {set_expr} ' + f'WHERE "ID" = ${len(_cols) + 1}' + ) + # Update rows + await conn.execute(query, *(update[col] for col in _cols), row_id) + # Set updated at time + await self._set_updated_at(conn) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except DataError as e: + raise BadInputError(f"Bad input: {e}") from e - def scalar_cols(self, meta: TableMeta) -> list[ColumnSchema]: - return [c for c in meta.cols_schema if c.id.lower() in ("id", "updated at")] + # Row Delete Ops + async def delete_rows( + self, + *, + row_ids: list[str] | None = None, + where: str = "", + ) -> Self: + """ + Delete one or more rows from the Generative Table. - def embedding_cols(self, meta: TableMeta) -> list[ColumnSchema]: - return [c for c in meta.cols_schema if c.vlen > 0] + Args: + row_ids (list[str] | None, optional): List of row IDs to be deleted. + Defaults to None (match rows using `where`). + where (str, optional): SQL where clause. Defaults to "" (no filter). + It will be combined with `row_ids` using `AND`. - 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"] + Raises: + ResourceNotFoundError: If the table is not found. - def create_fts_index( - self, - session: Session, - table_id: TableName, + Returns: + self (GenerativeTableCore): The table instance. + """ + if row_ids is None: + row_ids = [] + if not (isinstance(row_ids, list) and all(isinstance(i, (str, UUID)) for i in row_ids)): + # We raise TypeError here since this is a programming error + raise TypeError("`row_ids` must be a list of strings.") + + # Build SQL query + filters = [] + if row_ids: + filters.append('("ID" = $1)') + row_ids = [(row_id,) for row_id in row_ids] + where = where.strip() + if where: + try: + where = f"({validate_where_expr(where, id_map=self.map_to_short_col_id)})" + except Exception as e: + raise BadInputError(str(e)) from e + filters.append(where) + if len(filters) == 0: + raise BadInputError("Either `row_ids` or `where` must be provided.") + async with GENTABLE_ENGINE.transaction() as conn: + try: + sql = f'DELETE FROM "{self.schema_id}"."{self.short_table_id}" WHERE {" AND ".join(filters)}' + if row_ids: + await conn.executemany(sql, row_ids) + else: + await conn.execute(sql) + # Set updated at time + await self._set_updated_at(conn) + except UndefinedTableError as e: + raise ResourceNotFoundError(f'Table "{self.table_id}" is not found.') from e + except PostgresSyntaxError as e: + raise BadInputError(f"Bad SQL statement: `{sql}`") from e + return self + + +class ActionTable(GenerativeTableCore): + TABLE_TYPE = TableType.ACTION + + @override + @classmethod + async def drop_schema( + cls, *, - force: bool = False, - ) -> bool: - table, meta = self.open_table_meta(session, table_id) - fts_cols = [c.id for c in self.fts_cols(meta)] - # 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: TableName, + project_id: str, + ) -> None: + """ + Drops the project's schema along with all data tables. + """ + return await super().drop_schema( + project_id=project_id, + table_type=cls.TABLE_TYPE, + ) + + @override + @classmethod + async def create_table( + cls, *, - force: bool = False, - ) -> bool: - table, meta = self.open_table_meta(session, table_id) - # 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): - table.create_scalar_index(c.id, replace=True) - # Update metadata - meta.indexed_at_sca = index_datetime - session.add(meta) - session.commit() - return True - - def create_vector_index( - self, - session: Session, - table_id: TableName, - force: bool = False, + project_id: str, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + ) -> Self: + """ + Create a new Action Table with default prompts (if prompts are not provided). + + Args: + project_id (str): Project ID. + table_metadata (TableMetadata): Table metadata. + column_metadata_list (list[ColumnMetadata]): List of column metadata. + + Returns: + self (GenerativeTableCore): The table instance. + """ + return await cls._create_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + table_metadata=table_metadata, + column_metadata_list=column_metadata_list, + set_default_prompts=True, + ) + + @classmethod + async def duplicate_table( + cls, *, - metric: str = "cosine", - num_partitions: int | None = None, - num_sub_vectors: int | None = None, - accelerator: str | None = None, - index_cache_size: int | None = None, - ) -> bool: + project_id: str, + table_id_src: str, + table_id_dst: TableName | None = None, + include_data: bool = True, + create_as_child: bool = False, + created_by: str | None = None, + ) -> Self: """ - Creates a vector IVF-PQ index for each vector column. Existing indexes will be replaced. - This is a no-op if number of rows is less than 1,000. + Duplicate an existing table including schema, data and metadata. Args: - session (Session): SQLAlchemy session. - table_id (TableName): Table ID. - force (bool, optional): If True, force reindex. Defaults to False. - metric (str, optional): The distance metric type. - "L2" (alias to "euclidean"), "cosine" or "dot" (dot product). Defaults to "dot". - num_partitions (int, optional): The number of IVF partitions to create. - By default the number of partitions is the square root of the number of rows. - for example the square root of the number of rows. Defaults to None. - num_sub_vectors (int, optional): Number of sub-vectors of PQ. - This value controls how much the vector is compressed during the quantization step. - The more sub vectors there are the less the vector is compressed. - The default is the dimension of the vector divided by 16. - If the dimension is not evenly divisible by 16 we use the dimension divided by 8. - The above two cases are highly preferred. - Having 8 or 16 values per subvector allows us to use efficient SIMD instructions. - If the dimension is not visible by 8 then we use 1 subvector. - This is not ideal and will likely result in poor performance. + project_id (str): Project ID. + table_id_src (str): Name of the table to be duplicated. + table_id_dst (str | None, optional): Name for the new table. + Defaults to None (automatically find the next available table name). + include_data (bool, optional): If True, include data. Defaults to True. + create_as_child (bool, optional): If True, create the new table as a child of the source table. + Defaults to False. + created_by (str | None, optional): User ID of the user who created the table. Defaults to None. - accelerator (str | None, optional): str or `torch.Device`, optional. - If set, use an accelerator to speed up the index training process. - Accepted accelerator: "cuda" (Nvidia GPU) and "mps" (Apple Silicon GPU). - If not set, use the CPU. Defaults to None. - index_cache_size (int | None, optional): The size of the index cache in number of entries. Defaults to None. - index_cache_size (int | None, optional): The size of the index cache in number of entries. Defaults to None. + + Raises: + BadInputError: If `table_id_dst` is not None or a non-empty string. + ResourceNotFoundError: If table or column metadata cannot be found. Returns: - reindexed (bool): Whether the reindex operation is performed. - """ - table, meta = self.open_table_meta(session, table_id) - # 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: - if c.vlen % 16 == 0: - num_sub_vectors = c.vlen // 16 - elif c.vlen % 8 == 0: - num_sub_vectors = c.vlen // 8 - else: - num_sub_vectors = 1 - 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) - session.commit() - return True + self (GenerativeTableCore): The duplicated table instance. + """ + return await super().duplicate_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + table_id_src=table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + created_by=created_by, + ) - def create_indexes( - self, - session: Session, - table_id: TableName, + # Read + @classmethod + async def open_table( + cls, *, - force: bool = False, - ) -> bool: - """Creates scalar, vector, FTS indexes. + project_id: str, + table_id: str, + created_by: str | None = None, + request_id: str = "", + ) -> Self: + """ + Open an existing table. Args: - session (Session): SQLAlchemy session. - table_id (TableName): Table ID. - force (bool, optional): If True, force reindex. Defaults to False. + project_id (str): Project ID. + table_id (str): Name of the table. + created_by (str | None, optional): User who created the table. + If provided, will check if the table was created by the user. Defaults to None (any user). + request_id (str, optional): Request ID for logging. Defaults to "". Returns: - index_ok (bool): Whether at least one reindexing operation is performed. + self (GenerativeTableCore): The table instance. """ - t0 = perf_counter() - sca_reindexed = self.create_scalar_index(session, table_id, force=force) - t1 = perf_counter() - fts_reindexed = self.create_fts_index(session, table_id, force=force) - t2 = perf_counter() - vec_reindexed = self.create_vector_index(session, table_id, force=force) - t3 = perf_counter() - timings = [] - if sca_reindexed: - timings.append(f"scalar={t1-t0:,.2f} s") - if fts_reindexed: - timings.append(f"FTS={t2-t1:,.2f} s") - if vec_reindexed: - timings.append(f"vector={t3-t2:,.2f} s") - if len(timings) > 0: - timings = ", ".join(timings) - 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"({timings})." - ) - ) - return len(timings) > 0 - - 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() - if num_rows < 10: - return False - table.compact_files(*args, **kwargs) - return True - - def cleanup_old_versions( - self, - table_id: TableName, - older_than: timedelta | None = None, - delete_unverified: bool = False, - ) -> bool: - with self.lock(table_id): - table = self.open_table(table_id) - 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 + return await super().open_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + table_id=table_id, + created_by=created_by, + request_id=request_id, + ) + + @classmethod + async def list_tables( + cls, + *, + project_id: str, + limit: int | None = 100, + offset: int = 0, + order_by: Literal["id", "updated_at"] = "updated_at", + order_ascending: bool = True, + created_by: str | None = None, + parent_id: str | None = None, + search_query: str = "", + search_columns: list[str] = None, + count_rows: bool = False, + ) -> Page[TableMetaResponse]: + """ + List tables. + + Args: + project_id (str): Project ID. + limit (int | None, optional): Maximum number of tables to return. + Defaults to 100. Pass None to return all tables. + offset (int, optional): Offset for pagination. Defaults to 0. + order_by (Literal["id", "updated_at"], optional): Sort tables by this attribute. + Defaults to "updated_at". + order_ascending (bool, optional): Whether to sort by ascending order. + Defaults to True. + created_by (str | None, optional): Return tables created by this user. + Defaults to None (return all tables). + parent_id (str | None, optional): Parent ID of tables to return. + Defaults to None (no parent ID filtering). + Additionally for Chat Table, you can list: + (1) all chat agents by passing in "_agent_"; or + (2) all chats by passing in "_chat_". + search_query (str, optional): A string to search for within table names. + The string is interpreted as both POSIX regular expression and literal string. + Defaults to "". + search_columns (list[str], optional): List of columns to search within. + Defaults to None (search table ID). + count_rows (bool, optional): Whether to count the rows of the tables. + Defaults to False. + + Returns: + tables (Page[TableMetaResponse]): List of tables. + """ + return await super().list_tables( + project_id=project_id, + table_type=cls.TABLE_TYPE, + limit=limit, + offset=offset, + order_by=order_by, + order_ascending=order_ascending, + created_by=created_by, + parent_id=parent_id, + search_query=search_query, + search_columns=search_columns, + count_rows=count_rows, + ) + + @classmethod + async def import_table( + cls, + *, + project_id: str, + source: str | Path | BinaryIO, + table_id_dst: TableName | None, + reupload_files: bool = True, + progress_key: str = "", + verbose: bool = False, + ) -> Self: + """ + Recreate a table (data and metadata) from a Parquet file. - 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() + Args: + project_id (str): Project ID. + input_path (str | Path): The path to the import file. + table_id_dst (TableName | None): Name or ID of the new table. + If None, the table ID in the Parquet metadata will be used. + reupload_files (bool, optional): If True, will reupload files to S3 with new URI. + Otherwise skip reupload and keep the original S3 paths for file columns. + Defaults to True. + progress_key (str, optional): Progress publish key. Defaults to "" (disabled). + verbose (bool, optional): If True, will produce verbose logging messages. + Defaults to False. + Raises: + ResourceExistsError: If the table already exists. -class ActionTable(GenerativeTable): - pass + Returns: + self (GenerativeTableCore): The table instance. + """ + return await super().import_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + source=source, + table_id_dst=table_id_dst, + reupload_files=reupload_files, + progress_key=progress_key, + verbose=verbose, + ) -class KnowledgeTable(GenerativeTable): - FIXED_COLUMN_IDS = ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] +class KnowledgeTable(ActionTable): + TABLE_TYPE = TableType.KNOWLEDGE + FIXED_COLUMN_IDS = [ + "ID", + "Updated at", + "Title", + "Title Embed", + "Text", + "Text Embed", + "File ID", + "Page", + ] @override - def create_table( - self, - session: Session, - schema: KnowledgeTableSchemaCreate, - model_list: ModelListConfig, - remove_state_cols: bool = False, - add_info_state_cols: bool = True, - ) -> tuple[LanceTable, TableMeta]: - if not isinstance(schema, KnowledgeTableSchemaCreate): - raise TypeError("`schema` must be an instance of `KnowledgeTableSchemaCreate`.") - schema = TableSchema( - id=schema.id, - cols=[ - 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=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", - ), + @classmethod + async def create_table( + cls, + *, + project_id: str, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + embedding_model: str, + ) -> Self: + """ + Create a new Knowledge Table with default prompts (if prompts are not provided). + + Args: + project_id (str): Project ID. + table_type (str): Table type. + table_metadata (TableMetadata): Table metadata. + column_metadata_list (list[ColumnMetadata]): List of column metadata. + embedding_model (str): ID of the embedding model. + + Returns: + self (GenerativeTableCore): The table instance. + """ + table_id = table_metadata.table_id + # Fetch model config + project = await cls._fetch_project(project_id) + try: + # If model is empty string, select a model based on capabilities + if embedding_model.strip() == "": + model = await cls._fetch_model_with_capabilities( + capabilities=[str(ModelCapability.EMBED)], + organization_id=project.organization_id, + ) + else: + model = await cls._fetch_model(embedding_model, project.organization_id) + except ResourceNotFoundError as e: + raise BadInputError( + f'Table "{table_id}": Model "{embedding_model}" is not found.' + ) from e + # Use `dimensions` if specified; otherwise use `size` + embed_size = model.final_embedding_size + fixed_columns = [ + ColumnMetadata( + table_id=table_id, + column_id="Title", + dtype=ColumnDtype.STR, + ), + ColumnMetadata( + table_id=table_id, + column_id="Title Embed", + dtype=ColumnDtype.FLOAT, + vlen=embed_size, + gen_config=EmbedGenConfig( + embedding_model=model.id, + source_column="Title", ), - ColumnSchema(id="Text", dtype=ColumnDtype.STR), - ColumnSchema( - id="Text Embed", - 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", - ), + ), + ColumnMetadata( + table_id=table_id, + column_id="Text", + dtype=ColumnDtype.STR, + ), + ColumnMetadata( + table_id=table_id, + column_id="Text Embed", + dtype=ColumnDtype.FLOAT, + vlen=embed_size, + gen_config=EmbedGenConfig( + embedding_model=model.id, + source_column="Text", ), - ColumnSchema(id="File ID", dtype=ColumnDtype.STR), - ColumnSchema(id="Page", dtype=ColumnDtype.INT), - ] - + schema.cols, + ), + ColumnMetadata( + table_id=table_id, + column_id="File ID", + dtype=ColumnDtype.STR, + ), + ColumnMetadata( + table_id=table_id, + column_id="Page", + dtype=ColumnDtype.INT, + ), + ] + return await cls._create_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + table_metadata=table_metadata, + column_metadata_list=fixed_columns + column_metadata_list, + set_default_prompts=True, ) - return super().create_table(session, schema, remove_state_cols, add_info_state_cols) - @override - def update_gen_config( + async def update_gen_config( 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() - id2col = {c["id"]: c for c in meta.cols} - for col_id in updates.column_map: - if num_rows > 0 and id2col[col_id]["vlen"] > 0: - raise TableSchemaFixedError( - "Knowledge Table contains data, cannot update embedding config." - ) - return super().update_gen_config(session, updates) + update_mapping: dict[str, DiscriminatedGenConfig | None], + *, + allow_nonexistent_refs: bool = False, + ) -> Self: + """ + Update the generation configuration for a column. - @override - def add_columns( + Args: + update_mapping (dict[str, DiscriminatedGenConfig]): Mapping of column IDs to new generation configurations. + allow_nonexistent_refs (bool, optional): Ignore non-existent column and Knowledge Table references. + Otherwise will raise an error. Useful when importing old tables and performing maintenance. + Defaults to False. + + Raises: + ResourceNotFoundError: If the column is not found. + + Returns: + self (GenerativeTableCore): The table instance. + """ + # "Title Embed" and "Text Embed" columns must always have gen config + filtered = { + column_id: config + for column_id, config in update_mapping.items() + if not ( + column_id.lower() in {"title embed", "text embed"} + and not isinstance(config, EmbedGenConfig) + ) + } + + if not filtered: + return self + + return await super().update_gen_config( + update_mapping=filtered, allow_nonexistent_refs=allow_nonexistent_refs + ) + + async def update_rows( self, - session: Session, - schema: AddKnowledgeColumnSchema, - ) -> tuple[LanceTable, TableMeta]: + updates: dict[str, dict[str, Any]], + *, + ignore_state_columns: bool = True, + ) -> None: """ - Adds one or more input or output column. + Update multiple rows in the Generative Table. Args: - session (Session): SQLAlchemy session. - schema (AddKnowledgeColumnSchema): Schema of the columns to be added. + updates (dict[str, dict[str, Any]]): A dictionary mapping row ID to update data. + Each update data is a dictionary of column name to value. + ignore_state_columns (bool, optional): Whether to ignore state columns. Defaults to True. Raises: + TypeError: If the data is not a list of dictionaries. + BadInputError: If any row does not have an "ID" field. ResourceNotFoundError: If the table is not found. - ValueError: If any of the columns exists. Returns: - table (LanceTable): Lance table. - meta (TableMeta): Table metadata. + self (GenerativeTableCore): The table instance. """ - 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) + return await super().update_rows( + updates=updates, ignore_state_columns=ignore_state_columns + ) -class ChatTable(GenerativeTable): - FIXED_COLUMN_IDS = ["User"] +class ChatTable(ActionTable): + TABLE_TYPE = TableType.CHAT + FIXED_COLUMN_IDS = [ + "ID", + "Updated at", + "User", + ] @override - def create_table( - self, - session: Session, - schema: ChatTableSchemaCreate, - remove_state_cols: bool = False, - add_info_state_cols: bool = True, - ) -> tuple[LanceTable, TableMeta]: - if not isinstance(schema, ChatTableSchemaCreate): - raise TypeError("`schema` must be an instance of `ChatTableSchemaCreate`.") - num_chat_cols = len([c for c in schema.cols if c.gen_config and c.gen_config.multi_turn]) + @classmethod + async def create_table( + cls, + *, + project_id: str, + table_metadata: TableMetadata, + column_metadata_list: list[ColumnMetadata], + ) -> Self: + """ + Create a new Chat Table with default prompts (if prompts are not provided). + + Args: + project_id (str): Project ID. + table_type (str): Table type. + table_metadata (TableMetadata): Table metadata. + column_metadata_list (list[ColumnMetadata]): List of column metadata. + + Returns: + self (GenerativeTableCore): The table instance. + """ + table_id = table_metadata.table_id + for col in column_metadata_list: + if col.column_id.lower() == "ai": + if isinstance(col.gen_config, LLMGenConfig): + col.gen_config.multi_turn = True + else: + col.gen_config = LLMGenConfig(multi_turn=True) + num_chat_cols = len([c for c in column_metadata_list if c.is_chat_column]) if num_chat_cols == 0: - raise BadInputError("The table must have at least one multi-turn column.") - return super().create_table(session, schema, remove_state_cols, add_info_state_cols) + raise BadInputError( + f'Chat Table "{table_id}" must have at least one multi-turn column.' + ) + return await cls._create_table( + project_id=project_id, + table_type=cls.TABLE_TYPE, + table_metadata=table_metadata, + column_metadata_list=column_metadata_list, + set_default_prompts=True, + ) - @override - def add_columns( + async def update_gen_config( self, - session: Session, - schema: AddChatColumnSchema, - ) -> tuple[LanceTable, TableMeta]: + update_mapping: dict[str, DiscriminatedGenConfig | None], + *, + allow_nonexistent_refs: bool = False, + ) -> Self: """ - Adds one or more input or output column. + Update the generation configuration for a column. Args: - session (Session): SQLAlchemy session. - schema (AddChatColumnSchema): Schema of the columns to be added. + update_mapping (dict[str, DiscriminatedGenConfig]): Mapping of column IDs to new generation configurations. + allow_nonexistent_refs (bool, optional): Ignore non-existent column and Knowledge Table references. + Otherwise will raise an error. Useful when importing old tables and performing maintenance. + Defaults to False. Raises: - ResourceNotFoundError: If the table is not found. - ValueError: If any of the columns exists. + ResourceNotFoundError: If the column is not found. Returns: - table (LanceTable): Lance table. - meta (TableMeta): Table metadata. + self (GenerativeTableCore): The table instance. """ - 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) + for column_id, config in update_mapping.items(): + if column_id.lower() == "ai" and isinstance(config, LLMGenConfig): + config.multi_turn = True # in-place mutation is fine + filtered = { + column_id: config + for column_id, config in update_mapping.items() + if not (column_id.lower() == "ai" and not isinstance(config, LLMGenConfig)) + } + return await super().update_gen_config( + update_mapping=filtered, allow_nonexistent_refs=allow_nonexistent_refs + ) - @override - def drop_columns( + async def drop_columns( self, - session: Session, - table_id: TableName, - column_names: list[ColName], - ) -> tuple[LanceTable, TableMeta]: + column_ids: list[str], + ) -> Self: """ - Drops one or more input or output column. + Drop columns from the Chat Table. Args: - session (Session): SQLAlchemy session. - table_id (str): The ID of the table. - column_names (list[str]): List of column ID to drop. + column_ids (list[str]): List of column IDs to drop. Raises: - TypeError: If `column_names` is not a list. - ResourceNotFoundError: If the table is not found. ResourceNotFoundError: If any of the columns is not found. - - Returns: - table (LanceTable): Lance table. - meta (TableMeta): Table metadata. """ - 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.") num_chat_cols = len( - [ - c - for c in meta.cols_schema - if c.id not in column_names and c.gen_config and c.gen_config.multi_turn - ] + [c for c in self.column_metadata if c.column_id not in column_ids and c.is_chat_column] ) if num_chat_cols == 0: - raise BadInputError("The table must have at least one multi-turn column.") - return super().drop_columns(session, table_id, column_names) - - @override - def rename_columns( - self, - session: Session, - table_id: TableName, - column_map: dict[ColName, ColName], - ) -> TableMeta: - 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, column_map) + raise BadInputError( + f'Chat Table "{self.table_id}" must have at least one multi-turn column after column drop.' + ) + return await super().drop_columns(column_ids) diff --git a/services/api/src/owl/db/gen_table_v2.py b/services/api/src/owl/db/gen_table_v2.py deleted file mode 100644 index 0bd2066..0000000 --- a/services/api/src/owl/db/gen_table_v2.py +++ /dev/null @@ -1,126 +0,0 @@ -from typing import Self - -import numpy as np - - -class GenerativeTableCore: - ### --- Table CRUD --- ### - - # Create - @classmethod - async def create_table(cls, table_id: str) -> Self: - pass - - @classmethod - async def duplicate_table(cls, table_id: str) -> Self: - pass - - # Read - @classmethod - async def list_tables(cls, table_id: str) -> list[Self]: - pass - - @classmethod - async def get_table(cls, table_id: str) -> Self: - pass - - async def count_rows(self): - pass - - # Update - async def rename_table(self): - pass - - async def recreate_fts_index(self): - # Optional - pass - - async def recreate_vector_index(self): - # Optional - pass - - async def drop_fts_index(self): - # Optional - pass - - async def drop_vector_index(self): - # Optional - pass - - # Delete - async def drop_table(self): - pass - - # Import Export - async def export_table(self): - pass - - async def import_table(self): - pass - - async def export_data(self): - pass - - async def import_data(self): - pass - - ### --- Column CRUD --- ### - - # Create - async def add_column(self): - pass - - # Read ops are implemented as table ops - # Update - async def update_gen_config(self): - pass - - async def rename_column(self): - pass - - async def reorder_columns(self): - # Need to ensure that length of new order list matches the number of columns - pass - - # Delete - async def drop_column(self): - pass - - ### --- Row CRUD --- ### - - # Create - async def add_row(self): - pass - - async def add_rows(self): - # Optional, if batch operation is supported - pass - - # Read - async def list_rows(self): - pass - - async def get_row(self): - pass - - async def fts_search(self, query: str): - pass - - async def vector_search(self, query: list[float] | np.ndarray): - pass - - # Update - async def update_row(self): - pass - - async def update_rows(self): - # Optional, if batch operation is supported - pass - - # Delete - async def delete_row(self): - pass - - async def delete_rows(self): - # Optional, if batch operation is supported - pass diff --git a/services/api/src/owl/db/models/__init__.py b/services/api/src/owl/db/models/__init__.py new file mode 100644 index 0000000..eb135ea --- /dev/null +++ b/services/api/src/owl/db/models/__init__.py @@ -0,0 +1,22 @@ +from owl.db.models.oss import ( # noqa: F401 + BASE_PLAN_ID, + TEMPLATE_ORG_ID, + Deployment, + JamaiSQLModel, + ModelConfig, + ModelInfo, + Organization, + OrgMember, + PricePlan, + Project, + ProjectMember, + User, +) + +""" +Cloud-only models + +VerificationCode, +ProjectKey, +StripeEvent, +""" diff --git a/services/api/src/owl/db/models/oss.py b/services/api/src/owl/db/models/oss.py new file mode 100644 index 0000000..5f97165 --- /dev/null +++ b/services/api/src/owl/db/models/oss.py @@ -0,0 +1,1473 @@ +from base64 import urlsafe_b64decode, urlsafe_b64encode +from datetime import datetime +from decimal import Decimal +from functools import lru_cache +from typing import Any, Self, Type, TypeVar + +from pydantic import BaseModel, computed_field +from pydantic_extra_types.currency_code import ISO4217 +from pydantic_extra_types.timezone_name import TimeZoneName +from sqlalchemy.orm import declared_attr, selectinload +from sqlalchemy.sql.base import ExecutableOption +from sqlmodel import ( + VARCHAR, + AutoString, + Boolean, + DateTime, + ForeignKey, + Integer, + MetaData, + Numeric, + Relationship, + SQLModel, + String, + Unicode, + and_, + asc, + desc, + exists, + func, + literal, + nulls_first, + nulls_last, + or_, + select, + text, + tuple_, +) +from sqlmodel import Field as SqlField +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql._expression_select_cls import SelectBase +from sqlmodel.sql.expression import SelectOfScalar + +from owl.configs import ENV_CONFIG +from owl.types import ( + DEFAULT_MUL_LANGUAGES, + CloudProvider, + DatetimeUTC, + LanguageCodeList, + ModelCapability, + ModelType, + Page, + PaymentState, + PositiveNonZeroInt, + Role, + SanitisedNonEmptyStr, + SanitisedStr, +) +from owl.utils import uuid7_str +from owl.utils.crypt import generate_key +from owl.utils.dates import now +from owl.utils.exceptions import ( + BadInputError, + InsufficientCreditsError, + NoTierError, + ResourceNotFoundError, +) +from owl.utils.io import json_dumps, json_loads +from owl.utils.types import JSON + +TEMPLATE_ORG_ID = "template" +BASE_PLAN_ID = "base" + + +def _encode_cursor(values: dict[str, Any]) -> str: + return urlsafe_b64encode(json_dumps(values).encode()).decode() + + +def _decode_cursor(token: str) -> dict[str, Any]: + raw = json_loads(urlsafe_b64decode(token.encode()).decode()) + if "created_at" in raw and isinstance(raw["created_at"], str): + raw["created_at"] = datetime.fromisoformat(raw["created_at"]) + elif "updated_at" in raw and isinstance(raw["updated_at"], str): + raw["updated_at"] = datetime.fromisoformat(raw["updated_at"]) + return raw + + +def _relationship( + back_populates: str | None = None, + link_model: Any | None = None, + *, + selectin: bool = True, + cascade: str | None = "all, delete-orphan", + sa_kwargs: dict[str, Any] | None = None, +): + sa_relationship_kwargs = dict(viewonly=True) + if isinstance(sa_kwargs, dict): + sa_relationship_kwargs.update(sa_kwargs) + if selectin: + sa_relationship_kwargs["lazy"] = "selectin" + if cascade: + sa_relationship_kwargs["cascade"] = cascade + return Relationship( + back_populates=back_populates, + link_model=link_model, + sa_relationship_kwargs=sa_relationship_kwargs, + ) + + +class JamaiSQLModel(SQLModel): + metadata = MetaData(schema="jamai") + + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__ + + +ItemType = TypeVar("ItemType", bound=BaseModel) + + +class _TableBase(JamaiSQLModel, str_strip_whitespace=True): + meta: dict[str, Any] = SqlField( + {}, + sa_type=JSON, + description="Metadata.", + ) + created_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Creation datetime (UTC).", + ) + updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Update datetime (UTC).", + ) + + @classmethod + @lru_cache(maxsize=1) + def pk(cls) -> list[str]: + """Return every column name that is a primary key.""" + return [c.name for c in cls.__table__.primary_key] + + @classmethod + @lru_cache(maxsize=1) + def str_cols(cls) -> list[str]: + """Return every column name that is a string.""" + return [ + c.name + for c in cls.__table__.columns + if isinstance(c.type, (AutoString, VARCHAR, Unicode, String)) + ] + + @classmethod + @lru_cache(maxsize=1) + def nullable_cols(cls) -> list[str]: + """Return every column name that is nullable.""" + return [c.name for c in cls.__table__.columns if c.nullable] + + @classmethod + @lru_cache(maxsize=1) + def indexed_cols(cls) -> list[str]: + """ + Return every column name that participates in any declared index. + + Even though for Postgres, unique constraint creates an index automatically, + we still only list columns that explicitly declare an index. + https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#postgresql-index-reflection + """ + tbl = cls.__table__ + # flagged = {c.name for c in tbl.columns if c.index or c.unique or c.primary_key} + cols = {c.name for idx in tbl.indexes for c in idx.columns} + return [c.name for c in tbl.columns if c.name in cols] + + @classmethod + def _where_filter( + cls, + selection: SelectBase, + filters: dict[str, Any | list[Any]] | None, + ) -> SelectBase: + if filters: + selection = selection.where( + and_( + *[ + or_(*[getattr(cls, k) == vv for vv in v]) + if isinstance(v, list) + else getattr(cls, k) == v + for k, v in filters.items() + ] + ) + ) + return selection + + @classmethod + def _search_query_filter( + cls, + selection: SelectBase, + *, + search_query: str | None, + search_columns: list[str] | None, + ) -> SelectBase: + # Apply search filters + if search_query and search_columns: + search_conditions = [] + for column_name in search_columns: + if (column := getattr(cls, column_name, None)) is not None: + # Using case-insensitive regex match (~*) + search_conditions.append(column.op("~*")(search_query)) + if search_conditions: + selection = selection.where(or_(*search_conditions)) + return selection + + @classmethod + def _allow_block_list_filter( + cls, + selection: SelectBase, + filter_id: str, + *, + allow_list_attr: str = "allowed_orgs", + block_list_attr: str = "blocked_orgs", + ) -> SelectBase: + allow_list = getattr(cls, allow_list_attr) + block_list = getattr(cls, block_list_attr, None) + # Allow list + allow = or_(allow_list == [], allow_list.contains([filter_id])) + if block_list is None: + # No block list, just allow list + selection = selection.where(allow) + else: + # Block list + selection = selection.where(and_(allow, ~block_list.contains([filter_id]))) + return selection + + @classmethod + def _pagination( + cls, + selection: SelectBase, + *, + offset: int, + limit: int | None, + order_by: str, + order_ascending: bool, + after: str | None = None, + ) -> SelectBase: + # Apply ordering + order_col = getattr(cls, order_by, None) + if order_col is None: + raise BadInputError(f'Unable to order by column "{order_by}" as it does not exist.') + is_nullable = order_col.nullable + # Postgres index sorts nulls last (nulls are larger than non-null) + # But it is hard to get a string null coalesce value, so we sort null first + null_order_func = nulls_first if order_ascending else nulls_last + # Keyset pagination + # cursor = before or after + cursor = after + if cursor: + # if before: + # op = "__lt__" if order_ascending else "__gt__" + # else: + # op = "__gt__" if order_ascending else "__lt__" + op = "__gt__" if order_ascending else "__lt__" + try: + vals = _decode_cursor(cursor) + except Exception as e: + raise BadInputError(f'Pagination failed due to invalid cursor: "{cursor}"') from e + try: + pk_cols = tuple(getattr(cls, pk) for pk in cls.pk()) + pk_vals = tuple(vals[pk] for pk in cls.pk()) + cmp_val = vals[order_by] + except KeyError as e: + raise BadInputError( + f'Unable to order by column "{order_by}" as it is not found in the cursor.' + ) from e + if is_nullable: + # This is mainly for JamaiBase rather than TokenVisor + if isinstance(order_col.type, Integer): + coalesce_val = literal(-(2**31 - 1)) # Standard 32-bit signed integer + elif isinstance(order_col.type, Numeric): + coalesce_val = literal(float("-inf")) + elif isinstance(order_col.type, Boolean): + coalesce_val = False + else: + coalesce_val = "" + # else: + # raise BadInputError( + # f'Unable to order by nullable column "{order_by}" of type {order_col.type}.' + # ) + if cmp_val is None: + cmp_val = coalesce_val + order_by_expr = func.coalesce(order_col, coalesce_val) + else: + order_by_expr = order_col + filter_cond = or_( + getattr(order_by_expr, op)(cmp_val), + and_(order_by_expr == cmp_val, getattr(tuple_(*pk_cols), op)(pk_vals)), + ) + selection = selection.where(filter_cond) + else: + selection = selection.offset(offset) + # Postgres ordering on Linux seems to be case-insensitive by default + # https://dba.stackexchange.com/a/131471 + # Apply LOWER() on text columns + if order_by in cls.str_cols(): + order_col = func.lower(order_col) + # Determine order function based on sort direction + order_func = asc if order_ascending else desc + # Pagination + if is_nullable: + order_by_expr = null_order_func(order_func(order_col)) + else: + order_by_expr = order_func(order_col) + selection = selection.order_by( + order_by_expr, *(order_func(getattr(cls, pk)) for pk in cls.pk()) + ) + if limit is not None: + selection = selection.limit(limit) + return selection + + def _generate_cursor(self, order_by: str) -> str: + cursor_keys = [order_by, *self.pk()] + cursor_values = {k: getattr(self, k) for k in cursor_keys} + return _encode_cursor(cursor_values) + + @classmethod + def _list( + cls, + *, + offset: int, + limit: int | None, + order_by: str, + order_ascending: bool, + search_query: str | None, + search_columns: list[str] | None, + filters: dict[str, Any | list[Any]] | None = None, + options: list[ExecutableOption] | None = None, + after: str | None = None, + ) -> tuple[SelectOfScalar[Self], SelectOfScalar[int]]: + ### --- Main query --- ### + items = cls._search_query_filter( + cls._where_filter(select(cls), filters), + search_query=search_query, + search_columns=search_columns, + ) + if options: + items = items.options(*options) + items = cls._pagination( + items, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + after=after, + ) + ### --- Count --- ### + # Same filters but without pagination + total = cls._search_query_filter( + cls._where_filter(select(func.count(getattr(cls, cls.pk()[0]))), filters), + search_query=search_query, + search_columns=search_columns, + ) + return items, total + + @classmethod + async def _fetch_list_and_cursor( + cls, + session: AsyncSession, + items: SelectOfScalar[Self], + total: SelectOfScalar[int], + order_by: str, + ) -> tuple[list[Self], int, str | None]: + items: list[Self] = (await session.exec(items)).all() + total: int = (await session.exec(total)).one() + if items: + end_cursor = items[-1]._generate_cursor(order_by) + else: + end_cursor = None + return items, total, end_cursor + + @classmethod + async def create( + cls, + session: AsyncSession, + body: dict[str, Any] | BaseModel, + ) -> Self: + item = cls.model_validate(body) + session.add(item) + await session.commit() + await session.refresh(item) + return item + + @classmethod + async def list_( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + offset: int = 0, + limit: int | None = None, + order_by: str | None = None, + order_ascending: bool = True, + search_query: str | None = None, + search_columns: list[str] | None = None, + filters: dict[str, Any | list[Any]] | None = None, + options: list[ExecutableOption] | None = None, + after: str | None = None, + ) -> Page[ItemType]: + if order_by is None: + order_by = cls.pk()[0] + items, total = cls._list( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + options=options, + after=after, + ) + items, total, end_cursor = await cls._fetch_list_and_cursor( + session=session, + items=items, + total=total, + order_by=order_by, + ) + return Page[return_type]( + items=items, + offset=offset, + limit=total if limit is None else limit, + total=total, + end_cursor=end_cursor, + ) + + @classmethod + async def get( + cls, + session: AsyncSession, + item_id: str, + *, + name: str = "", + **kwargs, + ) -> Self: + item = await session.get(cls, item_id, **kwargs) + if item is None: + raise ResourceNotFoundError( + f'{name if name else cls.__name__} "{item_id}" is not found.' + ) + return item + + @classmethod + async def _update( + cls, + session: AsyncSession, + item_id: str, + updates: dict[str, Any], + *, + name: str = "", + ) -> Self: + item = await cls.get(session, item_id, name=name) + for key, value in updates.items(): + setattr(item, key, value) + item.updated_at = now() + session.add(item) + return item + + @classmethod + async def update( + cls, + session: AsyncSession, + item_id: str, + body: BaseModel, + *, + name: str = "", + ) -> tuple[Self, dict[str, Any]]: + updates = body.model_dump(exclude_unset=True) + item = await cls._update(session, item_id, updates, name=name) + await session.commit() + await session.refresh(item) + return item, updates + + @classmethod + async def delete( + cls, + session: AsyncSession, + item_id: str, + *, + name: str = "", + ) -> None: + item = await cls.get(session, item_id, name=name) + await session.delete(item) + await session.commit() + + +class PricePlan(_TableBase, table=True): + id: SanitisedNonEmptyStr = SqlField( + default_factory=lambda: generate_key(8, "plan_"), + primary_key=True, + description="Price plan ID.", + ) + name: str = SqlField( + unique=True, + description="Price plan name. Must be unique.", + ) + stripe_price_id_live: str = SqlField( + index=True, + unique=True, + description="Stripe price ID (live mode). Must be unique.", + ) + stripe_price_id_test: str = SqlField( + index=True, + unique=True, + description="Stripe price ID (test mode). Must be unique.", + ) + flat_cost: float = SqlField( + description="Base price for the entire tier (in USD decimal terms).", + ) + credit_grant: float = SqlField( + description="Credit amount included (in USD decimal terms).", + ) + max_users: int | None = SqlField( + description="Maximum number of users per organization. `None` means no limit.", + ) + products: dict[str, Any] = SqlField( + sa_type=JSON, + description="Mapping of product ID to product.", + ) + allowed_orgs: list[str] = SqlField( + [], + index=True, + sa_type=JSON, + description=( + "List of IDs of organizations allowed to use this price plan. " + "If empty, all orgs are allowed." + ), + ) + organizations: "Organization" = _relationship("price_plan", selectin=False) + + @computed_field(description="Stripe Price ID.") + @property + def stripe_price_id(self) -> str: + return ( + self.stripe_price_id_live + if ENV_CONFIG.stripe_api_key_plain.startswith("sk_live") + else self.stripe_price_id_test + ) + + @computed_field( + description="Whether this is a private price plan visible only to select organizations." + ) + @property + def is_private(self) -> bool: + return len(self.allowed_orgs) > 0 + + @classmethod + async def list_public( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + offset: int, + limit: int | None, + order_by: str, + order_ascending: bool, + search_query: str | None, + search_columns: list[str] | None, + filters: dict[str, Any | list[Any]] | None = None, + after: str | None = None, + ) -> Page[ItemType]: + # List + items, total = cls._list( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + after=after, + ) + # Filter + items = items.where(cls.allowed_orgs == []) + total = total.where(cls.allowed_orgs == []) + items, total, end_cursor = await cls._fetch_list_and_cursor( + session=session, + items=items, + total=total, + order_by=order_by, + ) + return Page[return_type]( + items=items, + offset=offset, + limit=total if limit is None else limit, + total=total, + end_cursor=end_cursor, + ) + + @classmethod + async def list_( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + offset: int = 0, + limit: int | None = None, + order_by: str | None = None, + order_ascending: bool = True, + search_query: str | None = None, + search_columns: list[str] | None = None, + filters: dict[str, Any | list[Any]] | None = None, + after: str | None = None, + ) -> Page[ItemType]: + if order_by is None: + order_by = cls.pk()[0] + items, total = cls._list( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + after=after, + ) + items, total, end_cursor = await cls._fetch_list_and_cursor( + session=session, + items=items, + total=total, + order_by=order_by, + ) + return Page[return_type]( + items=items, + offset=offset, + limit=total if limit is None else limit, + total=total, + end_cursor=end_cursor, + ) + + +class Deployment(_TableBase, table=True): + id: SanitisedNonEmptyStr = SqlField( + default_factory=uuid7_str, + primary_key=True, + description="Deployment ID.", + ) + model_id: str = SqlField( + sa_column_args=[ForeignKey("ModelConfig.id", ondelete="CASCADE", onupdate="CASCADE")], + index=True, + description="Model ID.", + ) + name: str = SqlField( + description="Name for the deployment.", + ) + routing_id: str = SqlField( + "", + description=( + "Model ID that the inference provider expects (whereas `model_id` is what the users will see). " + "OpenAI example: `model_id` CAN be `openai/gpt-5` but `routing_id` SHOULD be `gpt-5`." + ), + ) + api_base: str = SqlField( + "", + description=( + "(Optional) Hosting url. " + "Required for creating external cloud deployment using custom providers. " + "Example: `http://vllm-endpoint.xyz/v1`." + ), + ) + provider: str = SqlField( + "", + description=( + f"Inference provider of the model. " + f"Standard cloud providers are {CloudProvider.list_()}." + ), + ) + weight: int = SqlField( + 1, + ge=0, + description="Routing weight. Must be >= 0. A deployment is selected according to its relative weight.", + ) + cooldown_until: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Cooldown until datetime (UTC).", + ) + model: "ModelConfig" = _relationship("deployments") + + +class ModelInfo(_TableBase): + id: str = SqlField( + primary_key=True, + description=( + "Unique identifier. " + "Users will specify this to select a model. " + "Must follow the following format: `{provider}/{model_id}`. " + "Examples=['openai/gpt-4o-mini', 'Qwen/Qwen2.5-0.5B']" + ), + ) + type: ModelType = SqlField( + ModelType.LLM, + description="Model type. Can be completion, llm, embed, or rerank.", + ) + name: str = SqlField( + "", + description="Model name that is more user friendly.", + ) + owned_by: str = SqlField( + "", + description="Model provider (usually organization that trained the model).", + ) + capabilities: list[ModelCapability] = SqlField( + [ModelCapability.CHAT], + sa_type=JSON, + description="List of capabilities of model.", + ) + context_length: int = SqlField( + 4096, + description="Context length of model.", + ) + languages: LanguageCodeList = SqlField( + ["en"], + sa_type=JSON, + description=f'List of languages which the model is well-versed in. "*" and "mul" resolves to {DEFAULT_MUL_LANGUAGES}.', + ) + max_output_tokens: int | None = SqlField( + None, + description="Maximum number of output tokens, if not specified, will be based on context length.", + # examples=[8192], + ) + + +class ModelConfig(ModelInfo, table=True): + # --- All models --- # + type: ModelType = SqlField( + description="Model type. Can be completion, chat, embed, or rerank.", + ) + name: str = SqlField( + description="Model name that is more user friendly.", + ) + context_length: int = SqlField( + description="Context length of model. Examples=[4096]", + ) + capabilities: list[ModelCapability] = SqlField( + sa_type=JSON, + description="List of capabilities of model.", + ) + owned_by: str = SqlField( + "", + description="Model provider (usually organization that trained the model).", + ) + timeout: float = SqlField( + 15 * 60, + gt=0, + nullable=False, + description="Timeout in seconds. Must be greater than 0. Defaults to 15 minutes.", + ) + priority: int = SqlField( + 0, + description="Priority for fallback model selection. The larger the number, the higher the priority.", + ) + allowed_orgs: list[str] = SqlField( + [], + index=True, + sa_type=JSON, + description=( + "List of IDs of organizations allowed to use this model. " + "If empty, all orgs are allowed. Allow list is applied first, followed by block list." + ), + ) + blocked_orgs: list[str] = SqlField( + [], + index=True, + sa_type=JSON, + description=( + "List of IDs of organizations NOT allowed to use this model. " + "If empty, no org is blocked. Allow list is applied first, followed by block list." + ), + ) + # --- Chat models --- # + llm_input_cost_per_mtoken: float = SqlField( + -1.0, + description=( + "Cost in USD per million (mega) input / prompt token. " + "Can be zero. Negative values will be overridden with a default value." + ), + ) + llm_output_cost_per_mtoken: float = SqlField( + -1.0, + description=( + "Cost in USD per million (mega) output / completion token. " + "Can be zero. Negative values will be overridden with a default value." + ), + ) + # --- Embedding models --- # + embedding_size: PositiveNonZeroInt | None = SqlField( + None, + description=( + "The default embedding size of the model. " + "For example: `openai/text-embedding-3-large` has `embedding_size` of 3072 " + "but can be shortened to `embedding_dimensions` of 256; " + "`cohere/embed-v4.0` has `embedding_size` of 1536 " + "but can be shortened to `embedding_dimensions` of 256." + ), + ) + # Matryoshka embedding dimension + embedding_dimensions: PositiveNonZeroInt | None = SqlField( + None, + description=( + "The number of dimensions the resulting output embeddings should have. " + "Can be overridden by `dimensions` for each request. " + "Defaults to None (no reduction). " + "Note that this parameter will only be used when using models that support Matryoshka embeddings. " + "For example: `openai/text-embedding-3-large` has `embedding_size` of 3072 " + "but can be shortened to `embedding_dimensions` of 256; " + "`cohere/embed-v4.0` has `embedding_size` of 1536 " + "but can be shortened to `embedding_dimensions` of 256." + ), + ) + # Most likely only useful for HuggingFace models + embedding_transform_query: str | None = SqlField( + None, + description="Transform query that might be needed, esp. for hf models", + ) + embedding_cost_per_mtoken: float = SqlField( + -1.0, + description=( + "Cost in USD per million (mega) embedding tokens. " + "Can be zero. Negative values will be overridden with a default value." + ), + ) + # --- Reranking models --- # + reranking_cost_per_ksearch: float = SqlField( + -1.0, + description=( + "Cost in USD per thousand (kilo) searches. " + "Can be zero. Negative values will be overridden with a default value." + ), + ) + deployments: list[Deployment] = _relationship("model") + + @computed_field( + description="Whether this is a private model visible only to select organizations." + ) + @property + def is_private(self) -> bool: + return len(self.allowed_orgs) > 0 or len(self.blocked_orgs) > 0 + + @computed_field(description="Whether this model is active and ready for inference.") + @property + def is_active(self) -> bool: + return len(self.deployments) > 0 + + @classmethod + async def list_( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + organization_id: str | None, + offset: int = 0, + limit: int | None = None, + order_by: str | None = None, + order_ascending: bool = True, + search_query: str | None = None, + search_columns: list[str] | None = None, + filters: dict[str, Any | list[Any]] | None = None, + after: str | None = None, + capabilities: list[ModelCapability] | None = None, + exclude_inactive: bool = False, + ) -> Page[ItemType]: + if order_by is None: + order_by = cls.pk()[0] + items, total = cls._list( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + after=after, + ) + # Filter + if organization_id: + items = cls._allow_block_list_filter(items, organization_id) + total = cls._allow_block_list_filter(total, organization_id) + # Filter by capability + if capabilities is not None: + items = items.where(cls.capabilities.contains(capabilities)) + total = total.where(cls.capabilities.contains(capabilities)) + if exclude_inactive: + subquery = select(Deployment).where(Deployment.model_id == cls.id) + items = items.where(exists(subquery)) + total = total.where(exists(subquery)) + items, total, end_cursor = await cls._fetch_list_and_cursor( + session=session, + items=items, + total=total, + order_by=order_by, + ) + return Page[return_type]( + items=items, + offset=offset, + limit=total if limit is None else limit, + total=total, + end_cursor=end_cursor, + ) + + +class OrgMember(_TableBase, table=True): + user_id: str = SqlField( + foreign_key="User.id", + primary_key=True, + ondelete="CASCADE", + description="User ID.", + ) + organization_id: str = SqlField( + foreign_key="Organization.id", + primary_key=True, + ondelete="CASCADE", + description="Organization ID.", + ) + role: Role = SqlField( + Role.GUEST, + description="Organization role.", + ) + user: "User" = _relationship("org_memberships") + organization: "Organization" = _relationship("members") + + +class ProjectMember(_TableBase, table=True): + user_id: str = SqlField( + foreign_key="User.id", + primary_key=True, + ondelete="CASCADE", + description="User ID.", + ) + project_id: str = SqlField( + foreign_key="Project.id", + primary_key=True, + ondelete="CASCADE", + description="Project ID.", + ) + role: Role = SqlField( + Role.GUEST, + description="Project role.", + ) + user: "User" = _relationship("proj_memberships") + project: "Project" = _relationship("members") + + +class User(_TableBase, table=True): + id: str = SqlField( + default_factory=uuid7_str, + primary_key=True, + description="User ID.", + ) + name: str = SqlField( + index=True, + description="User's preferred name.", + ) + email: str = SqlField( + unique=True, + index=True, + description="User's email.", + ) + email_verified: bool = SqlField( + False, + description="Whether the email address is verified.", + ) + password_hash: str | None = SqlField( + None, + index=True, + description="Password hash.", + ) + picture_url: str | None = SqlField( + None, + description="User picture URL.", + ) + refresh_counter: int = SqlField( + 0, + description="Counter used as refresh token version for invalidation.", + ) + google_id: str | None = SqlField( + None, + index=True, + description="Google user ID.", + ) + google_name: str | None = SqlField( + None, + description="Google user's preferred name.", + ) + google_username: str | None = SqlField( + None, + description="Google username.", + ) + google_email: str | None = SqlField( + None, + description="Google email.", + ) + google_picture_url: str | None = SqlField( + None, + description="Google user picture URL.", + ) + google_updated_at: DatetimeUTC | None = SqlField( + None, + sa_type=DateTime(timezone=True), + description="Google user info update datetime (UTC).", + ) + github_id: str | None = SqlField( + None, + index=True, + description="GitHub user ID.", + ) + github_name: str | None = SqlField( + None, + description="GitHub user's preferred name.", + ) + github_username: str | None = SqlField( + None, + description="GitHub username.", + ) + github_email: str | None = SqlField( + None, + description="GitHub email.", + ) + github_picture_url: str | None = SqlField( + None, + description="GitHub user picture URL.", + ) + github_updated_at: DatetimeUTC | None = SqlField( + None, + sa_type=DateTime(timezone=True), + description="GitHub user info update datetime (UTC).", + ) + org_memberships: list[OrgMember] = _relationship("user") + proj_memberships: list[ProjectMember] = _relationship("user") + organizations: list["Organization"] = _relationship(None, link_model=OrgMember, selectin=False) + projects: list["Project"] = _relationship(None, link_model=ProjectMember, selectin=False) + # keys: list["ProjectKey"] = _relationship("user") + + @computed_field(description="Name for display.") + @property + def preferred_name(self) -> str: + return self.name or self.google_name or self.github_name + + @computed_field(description="Email for display.") + @property + def preferred_email(self) -> str: + return self.email or self.google_email or self.github_email + + @computed_field(description="Picture URL for display.") + @property + def preferred_picture_url(self) -> str | None: + return self.picture_url or self.google_picture_url or self.github_picture_url + + @computed_field(description="Username for display.") + @property + def preferred_username(self) -> str | None: + return self.google_username or self.github_username + + @classmethod + async def list_( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + offset: int = 0, + limit: int | None = None, + order_by: str | None = None, + order_ascending: bool = True, + search_query: str | None = None, + search_columns: list[str] | None = None, + filters: dict[str, Any | list[Any]] | None = None, + after: str | None = None, + ) -> Page[ItemType]: + return await super().list_( + session=session, + return_type=return_type, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + options=[selectinload(cls.organizations), selectinload(cls.projects)], + after=after, + ) + + @classmethod + async def get( + cls, + session: AsyncSession, + item_id: str, + *, + name: str = "", + **kwargs, + ) -> Self: + where_expr = cls.id == item_id + if item_id.startswith("google-oauth2|"): + where_expr = or_(where_expr, cls.google_id == item_id.split("|")[1]) + elif item_id.startswith("github|"): + where_expr = or_(where_expr, cls.github_id == item_id.split("|")[1]) + item = ( + await session.exec( + select(User) + .where(where_expr) + .options(selectinload(cls.organizations), selectinload(cls.projects)), + execution_options=kwargs, + ) + ).one_or_none() + if item is None: + raise ResourceNotFoundError( + f'{name if name else cls.__name__} "{item_id}" is not found.' + ) + return item + + +class Organization(_TableBase, table=True): + id: SanitisedNonEmptyStr = SqlField( + default_factory=lambda: generate_key(24, "org_"), + primary_key=True, + description="Organization ID.", + ) + name: SanitisedStr = SqlField( + description="Organization name.", + ) + currency: ISO4217 = SqlField( + "USD", + description="Currency of the organization.", + ) + timezone: TimeZoneName | None = SqlField( + None, + description="Timezone specifier.", + ) + external_keys: dict[str, str] = SqlField( + {}, + sa_type=JSON, + description="Mapping of external service provider to its API key.", + ) + stripe_id: str | None = SqlField( + None, + index=True, + description="Stripe Customer ID.", + ) + # stripe_subscription_id: SanitisedIdStr | None = SqlField( + # None, + # description="Stripe Subscription ID.", + # ) + price_plan_id: str | None = SqlField( + None, + foreign_key="PricePlan.id", + index=True, + nullable=True, + description="Subscribed plan ID.", + ) + payment_state: PaymentState = SqlField( + PaymentState.NONE, + description=f"Payment state of the organization, one of {list(map(str, PaymentState))}.", + ) + last_subscription_payment_at: DatetimeUTC | None = SqlField( + None, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful subscription payment (UTC).", + ) + quota_reset_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Quota reset datetime (UTC).", + ) + credit: float = SqlField( + 0.0, + sa_type=Numeric(21, 12), + description=( + "Credit paid by the customer. " + "Unused credit will be carried forward to the next billing cycle. " + "Must be in the range [-999_999_999.0, 999_999_999.0] with up to 12 decimal places." + ), + ) + credit_grant: float = SqlField( + 0.0, + sa_type=Numeric(21, 12), + description=( + "Credit granted to the customer. " + "Unused credit will NOT be carried forward. " + "Must be in the range [-999_999_999.0, 999_999_999.0] with up to 12 decimal places." + ), + ) + llm_tokens_quota_mtok: float | None = SqlField( + 0.0, + description="LLM token quota in millions of tokens.", + ) + llm_tokens_usage_mtok: float = SqlField( + 0.0, + description="LLM token usage in millions of tokens.", + ) + llm_tokens_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful LLM token usage update (UTC).", + ) + embedding_tokens_quota_mtok: float | None = SqlField( + 0.0, + description="Embedding token quota in millions of tokens.", + ) + embedding_tokens_usage_mtok: float = SqlField( + 0.0, + description="Embedding token quota in millions of tokens.", + ) + embedding_tokens_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful Embedding token usage update (UTC).", + ) + reranker_quota_ksearch: float | None = SqlField( + 0.0, + description="Reranker quota for every thousand searches.", + ) + reranker_usage_ksearch: float = SqlField( + 0.0, + description="Reranker usage for every thousand searches.", + ) + reranker_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful Reranker usage update (UTC).", + ) + db_quota_gib: float | None = SqlField( + 0.0, + description="DB storage quota in GiB.", + ) + db_usage_gib: float = SqlField( + 0.0, + description="DB storage usage in GiB.", + ) + db_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful DB usage update (UTC).", + ) + file_quota_gib: float | None = SqlField( + 0.0, + description="File storage quota in GiB.", + ) + file_usage_gib: float = SqlField( + 0.0, + description="File storage usage in GiB.", + ) + file_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful File usage update (UTC).", + ) + egress_quota_gib: float | None = SqlField( + 0.0, + description="Egress quota in GiB.", + ) + egress_usage_gib: float = SqlField( + 0.0, + description="Egress usage in GiB.", + ) + egress_usage_updated_at: DatetimeUTC = SqlField( + default_factory=now, + sa_type=DateTime(timezone=True), + description="Datetime of the last successful egress usage update (UTC).", + ) + created_by: str = SqlField( + description="ID of the user that created this organization.", + ) + owner: str = SqlField( + description="ID of the user that owns this organization.", + ) + users: list[User] = _relationship("organizations", link_model=OrgMember, selectin=False) + members: list[OrgMember] = _relationship("organization", selectin=False) + projects: list["Project"] = _relationship("organization", selectin=False) + price_plan: PricePlan | None = _relationship("organizations") + + @staticmethod + def status_check(org: "Organization", *, raise_error: bool = False) -> bool: + """Whether the organization's quota is active (paid).""" + if ENV_CONFIG.is_oss or ENV_CONFIG.disable_billing: + return True + if org.id in ("0", TEMPLATE_ORG_ID): + return True + if org.price_plan_id is None: + if raise_error: + raise NoTierError + else: + return False + if org.last_subscription_payment_at is None: + payment_on_time = False + else: + payment_on_time = ( + now() - org.last_subscription_payment_at + ).days <= ENV_CONFIG.payment_lapse_max_days + payment_ok = ( + org.payment_state in [PaymentState.SUCCESS, PaymentState.PROCESSING] or payment_on_time + ) + if payment_ok or (float(org.credit) + float(org.credit_grant)) > 0: + return True + elif raise_error: + raise InsufficientCreditsError + else: + return False + + @computed_field(description="Whether the organization's quota is active (paid).") + @property + def active(self) -> bool: + return self.status_check(self, raise_error=False) + + @computed_field(description="Quota snapshot.") + @property + def quotas(self) -> dict[str, dict[str, float | None]]: + return { + "llm_tokens": { + "quota": self.llm_tokens_quota_mtok, + "usage": self.llm_tokens_usage_mtok, + }, + "embedding_tokens": { + "quota": self.embedding_tokens_quota_mtok, + "usage": self.embedding_tokens_usage_mtok, + }, + "reranker_searches": { + "quota": self.reranker_quota_ksearch, + "usage": self.reranker_usage_ksearch, + }, + "db_storage": { + "quota": self.db_quota_gib, + "usage": self.db_usage_gib, + }, + "file_storage": { + "quota": self.file_quota_gib, + "usage": self.file_usage_gib, + }, + "egress": { + "quota": self.egress_quota_gib, + "usage": self.egress_usage_gib, + }, + } + + @classmethod + async def list_base_tier_orgs( + cls, + session: AsyncSession, + user_id: str, + ) -> list[Self]: + return ( + await session.exec( + select(cls).where( + cls.id != "0", # Internal org "0" is not counted against the limit + cls.price_plan_id == BASE_PLAN_ID, + exists( + select(OrgMember).where( + OrgMember.user_id == user_id, + OrgMember.organization_id == cls.id, + ) + ), + ) + ) + ).all() + + async def add_credit_grant( + self, + session: AsyncSession, + amount: float | Decimal, + ) -> None: + await session.exec( + text( + f""" + SELECT id FROM {JamaiSQLModel.metadata.schema}.add_credit_grant( + '{self.id}'::TEXT, + {amount:.12f}::NUMERIC(21, 12) + ); + """ + ) + ) + + +class Project(_TableBase, table=True): + id: str = SqlField( + default_factory=lambda: generate_key(24, "proj_"), + primary_key=True, + description="Project ID.", + ) + organization_id: str = SqlField( + foreign_key="Organization.id", + index=True, + description="Organization ID.", + ondelete="CASCADE", + ) + name: str = SqlField( + description="Project name.", + ) + description: str = SqlField( + description="Project description.", + ) + tags: list[str] = SqlField( + [], + sa_type=JSON, + description="Project tags.", + ) + profile_picture_url: str | None = SqlField( + None, + description="URL of the profile picture.", + ) + cover_picture_url: str | None = SqlField( + None, + description="URL of the cover picture.", + ) + created_by: str = SqlField( + description="ID of the user that created this project.", + ) + quotas: dict[str, Any] = SqlField( + {}, + sa_type=JSON, + description="Quotas allotted to this project.", + ) + owner: str = SqlField( + foreign_key="User.id", + description="ID of the user that owns this organization.", + ) + organization: Organization = _relationship("projects") + users: list[User] = _relationship("projects", link_model=ProjectMember, selectin=False) + members: list[ProjectMember] = _relationship("project", selectin=False) + # keys: list["ProjectKey"] = _relationship("project") + + @classmethod + async def list_( + cls, + session: AsyncSession, + return_type: Type[ItemType], + *, + offset: int = 0, + limit: int | None = None, + order_by: str | None = None, + order_ascending: bool = True, + search_query: str | None = None, + search_columns: list[str] | None = None, + filters: dict[str, Any | list[Any]] | None = None, + after: str | None = None, + filter_by_user: str = "", + ) -> Page[ItemType]: + if order_by is None: + order_by = cls.pk()[0] + items, total = cls._list( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + search_query=search_query, + search_columns=search_columns, + filters=filters, + after=after, + ) + if filter_by_user: + subquery = select(ProjectMember).where( + ProjectMember.user_id == filter_by_user, + ProjectMember.project_id == cls.id, + ) + items = items.where(exists(subquery)) + total = total.where(exists(subquery)) + items, total, end_cursor = await cls._fetch_list_and_cursor( + session=session, + items=items, + total=total, + order_by=order_by, + ) + return Page[return_type]( + items=items, + offset=offset, + limit=total if limit is None else limit, + total=total, + end_cursor=end_cursor, + ) diff --git a/services/api/src/owl/db/oss_admin.py b/services/api/src/owl/db/oss_admin.py deleted file mode 100644 index 6e6ed84..0000000 --- a/services/api/src/owl/db/oss_admin.py +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 5a3f738..0000000 --- a/services/api/src/owl/db/template.py +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index af95b61..0000000 --- a/services/api/src/owl/docio.py +++ /dev/null @@ -1,52 +0,0 @@ -from mimetypes import guess_type - -import httpx -from httpx import Timeout -from langchain.docstore.document import Document - -HTTP_CLIENT = httpx.Client(transport=httpx.HTTPTransport(retries=3), timeout=Timeout(5 * 60)) - - -class DocIOAPIFileLoader: - """Load files using docio API.""" - - def __init__( - self, - file_path: str, - url, - client: httpx.Client = HTTP_CLIENT, - ) -> None: - """Initialize with a file path.""" - self.url = url - self.file_path = file_path - self.client = client - - def load(self) -> list[Document]: - """Load file.""" - # Guess the MIME type of the file based on its extension - mime_type, _ = guess_type(self.file_path) - if mime_type is None: - mime_type = "application/octet-stream" # Default MIME type - - # Extract the filename from the file path - filename = self.file_path.split("/")[-1] - - # Return the response from the forwarded request - documents = [] - # Open the file in binary mode - with open(self.file_path, "rb") as f: - response = self.client.post( - f"{self.url}/v1/load_file", - files={ - "file": (filename, f, mime_type), - }, - timeout=None, - ) - if response.status_code != 200: - err_mssg = response.text - raise RuntimeError(err_mssg) - for doc in response.json(): - documents.append( - Document(page_content=doc["page_content"], metadata=doc["metadata"]) - ) - return documents diff --git a/services/api/src/owl/docparse.py b/services/api/src/owl/docparse.py new file mode 100644 index 0000000..f068e50 --- /dev/null +++ b/services/api/src/owl/docparse.py @@ -0,0 +1,602 @@ +import asyncio +import sys +from hashlib import blake2b +from io import BytesIO +from os.path import basename, splitext + +import httpx +import orjson +import pandas as pd +import xmltodict +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_core.documents.base import Document +from loguru import logger + +from owl.configs import CACHE, ENV_CONFIG +from owl.types import Chunk, SplitChunksParams, SplitChunksRequest +from owl.utils.exceptions import BadInputError, JamaiException, UnexpectedError +from owl.utils.io import get_bytes_size_mb, json_dumps, json_loads + +# Table mapping all non-printable characters to None +NOPRINT_TRANS_TABLE = { + i: None for i in range(0, sys.maxunicode + 1) if not chr(i).isprintable() and chr(i) != "\n" +} + + +def make_printable(s: str) -> str: + """ + Replace non-printable characters in a string using + `translate()` that removes characters that map to None. + + # https://stackoverflow.com/a/54451873 + """ + return s.translate(NOPRINT_TRANS_TABLE) + + +def format_chunks(documents: list[Document], file_name: str, page: int = None) -> list[Chunk]: + if page is not None: + for d in documents: + d.metadata["page"] = page + chunks = [ + # TODO: Probably can use regex for this + # Replace vertical tabs, form feed, Unicode replacement character + # page_content=d.page_content.replace("\x0c", " ") + # .replace("\x0b", " ") + # .replace("\uFFFD", ""), + # For now we use a more aggressive strategy + Chunk( + text=make_printable(d.page_content), + title=d.metadata.get("title", ""), + page=d.metadata.get("page", 0), + file_name=file_name, + file_path=file_name, + metadata=d.metadata, + ) + for d in documents + ] + return chunks + + +class BaseLoader: + """Base loader class for loading documents.""" + + def __init__(self, request_id: str = ""): + """ + Initialize the BaseLoader class. + + Args: + request_id (str, optional): Request ID for logging. Defaults to "". + """ + self.request_id = request_id + + def split_chunks( + self, request: SplitChunksRequest, page_break_placeholder: str | None = None + ) -> list[Chunk]: + """Split a list of chunks using RecursiveCharacterTextSplitter. + + Args: + request (SplitChunksRequest): Request containing chunks and splitting parameters. + page_break_placeholder (str | None): The string that signifies a page break. + + Returns: + list[Chunk]: A list of split chunks. + + Raises: + BadInputError: If the split method is not supported. + UnexpectedError: If chunk splitting fails. + """ + _id = request.id + logger.info(f"{_id} - Split documents request: {request.str_trunc()}") + if request.params.method == "RecursiveCharacterTextSplitter": + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=request.params.chunk_size, + chunk_overlap=request.params.chunk_overlap, + ) + else: + raise BadInputError(f"Split method not supported: {request.params.method}") + + # Pre-process chunks to handle page breaks before splitting by character count. + if page_break_placeholder is not None: + doc_chunks = [] + page_counter = 0 + for chunk in request.chunks: + texts_from_pages = chunk.text.split(page_break_placeholder) + for text in texts_from_pages: + page_counter += 1 + + new_metadata = chunk.metadata.copy() + new_metadata["page"] = page_counter + + doc_chunks.append( + Chunk( + text=text.strip(), + title=chunk.title, + page=page_counter, # Update page number + file_name=chunk.file_name, + file_path=chunk.file_name, + metadata=new_metadata, + ) + ) + else: + # If no page break handling is needed, use the chunks as they are. + doc_chunks = request.chunks + + try: + # Now, split the processed chunks (doc_chunks) by character count. + chunks = [] + for chunk in doc_chunks: + chunks += [ + Chunk( + text=d.page_content, + title=chunk.title, + page=chunk.page, + file_name=chunk.file_name, + file_path=chunk.file_name, + metadata=chunk.metadata, + ) + for d in text_splitter.split_documents([Document(page_content=chunk.text)]) + ] + logger.info( + f"{_id} - {len(request.chunks):,d} chunks split into {len(chunks):,d} chunks.", + ) + return chunks + except Exception as e: + logger.exception(f"{_id} - Failed to split chunks.") + raise UnexpectedError("Failed to split chunks.") from e + + +class GeneralDocLoader(BaseLoader): + """ + General document loader class supporting various file extensions. + + This loader intelligently handles different file types, using DoclingLoader for + formats it supports and falling back to other methods for text-based and structured + data formats like JSON, XML, CSV, and TSV. + """ + + def __init__(self, request_id: str = ""): + """ + Initialize the GeneralDocLoader class. + + Args: + request_id (str, optional): Request ID for logging. Defaults to "". + """ + super().__init__(request_id=request_id) + + async def load_document( + self, + file_name: str, + content: bytes, + ) -> str: + """ + Loads and processes a file, converting it to Markdown format. + + Supports file types: PDF, DOCX, PPTX, XLSX, HTML, MD, TXT, JSON, JSONL, XML, CSV, TSV. + - PDF, DOCX, PPTX, XLSX, HTML: Parsed into Markdown using `DoclingLoader`. + - MD, TXT: Read directly. + - JSON: Formatted as a string with 2-space indenting. + - JSONL: Converted into Markdown table format using `pandas`. + - XML: Formatted as a JSON string with 2-space indenting. + - CSV, TSV: Converted into Markdown table format using `pandas`. + + Args: + file_name (str): The name of the file. + content (bytes): The binary content of the file. + + Returns: + str: The document content in Markdown format, or JSON string for JSON/XML. + + Raises: + BadInputError: If the parsing fails due to unsupported type or other errors. + """ + if len(content) == 0: + raise BadInputError(f'Input file "{file_name}" is empty.') + # Check cache + cache_ttl = ENV_CONFIG.document_loader_cache_ttl_sec + cache_key = "" + if cache_ttl > 0: + content_len = len(content) + content_hash = blake2b(content).hexdigest() + cache_key = f"document:{basename(file_name)}:{content_hash}:{content_len}" + # If multiple rows reference the same file, this lock prevents concurrent parsing + # Only the first row will trigger parsing, the rest will read from cache + # The lock expires after 2 minutes automatically if not released + async with CACHE.alock(f"{cache_key}:lock", blocking=cache_ttl > 0, expire=120): + md = None + if cache_key != "": + md = await CACHE.get(cache_key) + if md is not None: + # Extend cache TTL + await CACHE._redis_async.expire( + cache_key, ENV_CONFIG.document_loader_cache_ttl_sec + ) + logger.info(f'File "{file_name}" loaded from cache (cache key="{cache_key}").') + return md + try: + ext = splitext(file_name)[1].lower() + if ext in [".pdf", ".docx", ".pptx", ".xlsx", ".html"]: + doc_loader = DoclingLoader(self.request_id) + md = await doc_loader.load_document(file_name=file_name, content=content) + elif ext in [".md", ".txt"]: + md = content.decode("utf-8") + elif ext in [".json"]: + md = json_dumps( + json_loads(content.decode("utf-8")), option=orjson.OPT_INDENT_2 + ) + elif ext in [".jsonl"]: + md = pd.read_json( + BytesIO(content), + lines=True, + ).to_markdown() + elif ext in [".xml"]: + md = json_dumps(xmltodict.parse(content), option=orjson.OPT_INDENT_2) + elif ext in [".csv", ".tsv"]: + md = pd.read_csv( + BytesIO(content), + sep="\t" if ext == ".tsv" else ",", + ).to_markdown() + else: + raise BadInputError(f'File type "{ext}" is not supported at the moment.') + if len(md.strip()) == 0: + raise BadInputError(f'Input file "{file_name}" is empty.') + # Set cache + if cache_ttl > 0: + await CACHE.set(cache_key, md, ex=cache_ttl) + logger.info( + f'File "{file_name}" successfully parsed into markdown. (cache key="{cache_key}")' + ) + else: + logger.info(f'File "{file_name}" successfully parsed into markdown.') + return md + + except JamaiException: + raise + except pd.errors.EmptyDataError as e: + raise BadInputError(f'Input file "{file_name}" is empty.') from e + except Exception as e: + logger.error(f'Failed to parse file "{file_name}": {repr(e)}') + raise BadInputError(f'Failed to parse file "{file_name}".') from e + + async def load_document_chunks( + self, + file_name: str, + content: bytes, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> list[Chunk]: + """ + Loads and processes a file, splitting it into chunks. + + Supports file types: PDF, DOCX, PPTX, XLSX, HTML, MD, TXT, JSON, JSONL, XML, CSV, TSV. + - PDF, DOCX, PPTX, XLSX, HTML: Parsed and chunked using `DoclingLoader`. + - MD, TXT: Read directly and chunked using `RecursiveCharacterTextSplitter`. + - JSON, JSONL: Each JSON is formatted as a chunk with 2-space indenting. + - CSV, TSV: Each row is parsed into a JSON and formatted as a chunk with 2-space indenting. + - XML: Each XML is formatted as a JSON chunk with 2-space indenting. + + Args: + file_name (str): The name of the file. + content (bytes): The binary content of the file. + chunk_size (int): The desired size of each chunk in tokens. + chunk_overlap (int): The number of tokens to overlap between chunks. + + Returns: + list[Chunk]: A list of Chunk objects representing the processed file content. + + Raises: + BadInputError: If the parsing and splitting fails due to unsupported type or other errors. + """ + if len(content) == 0: + raise BadInputError(f'Input file "{file_name}" is empty.') + # Check cache + cache_ttl = ENV_CONFIG.document_loader_cache_ttl_sec + cache_key = "" + if cache_ttl > 0: + content_len = len(content) + content_hash = blake2b(content).hexdigest() + cache_key = f"chunks:{basename(file_name)}:{content_hash}:{content_len}" + # If multiple rows reference the same file, this lock prevents concurrent parsing + # Only the first row will trigger parsing, the rest will read from cache + # The lock expires after 2 minutes automatically if not released + async with CACHE.alock(f"{cache_key}:lock", blocking=cache_ttl > 0, expire=120): + chunk_json_str = None + if cache_key != "": + chunk_json_str = await CACHE.get(cache_key) + if chunk_json_str is not None: + # Extend cache TTL + await CACHE._redis_async.expire(cache_key, cache_ttl) + logger.info( + f'File chunks "{file_name}" loaded from cache (cache key="{cache_key}").' + ) + return [Chunk.model_validate(chunk) for chunk in json_loads(chunk_json_str)] + try: + ext = splitext(file_name)[1].lower() + if ext in [".pdf", ".docx", ".pptx", ".xlsx", ".html"]: + if ext in [".pdf", ".pptx", ".xlsx"]: + doc_loader = DoclingLoader( + self.request_id, page_break_placeholder="=====Page===Break=====" + ) + else: + doc_loader = DoclingLoader(self.request_id, page_break_placeholder=None) + chunks = await doc_loader.load_document_chunks( + file_name=file_name, + content=content, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ) + elif ext in [".md", ".txt"]: + content = content.decode("utf-8") + if len(content.strip()) == 0: + raise BadInputError(f'Input file "{file_name}" is empty.') + chunks = format_chunks( + [Document(page_content=content, metadata={"page": 1})], + file_name, + ) + chunks = self.split_chunks( + SplitChunksRequest( + chunks=chunks, + params=SplitChunksParams( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ), + ) + ) + elif ext in [".json", ".jsonl", ".csv", ".tsv"]: + if ext in [".csv", ".tsv"]: + json_list = pd.read_csv( + BytesIO(content), + sep="\t" if ext == ".tsv" else ",", + ).to_dict(orient="records") + else: + content = content.decode("utf-8") + if ext == ".jsonl": + json_list = [ + json_loads(line) + for line in content.split("\n") + if line.strip() != "" + ] + else: + json_list = [json_loads(content)] + docs = [ + Document( + page_content=json_dumps(js, option=orjson.OPT_INDENT_2), + metadata={"page": 1, "row": i}, + ) + for i, js in enumerate(json_list) + ] + chunks = format_chunks(docs, file_name) + elif ext in [".xml"]: + chunks = format_chunks( + [ + Document( + page_content=json_dumps( + xmltodict.parse(content), option=orjson.OPT_INDENT_2 + ), + metadata={"page": 1}, + ) + ], + file_name, + ) + else: + raise BadInputError(f'File type "{ext}" is not supported at the moment.') + if len(chunks) == 0: + raise BadInputError(f'Input file "{file_name}" is empty.') + # Set cache + if cache_ttl > 0: + chunk_json_str = json_dumps([chunk.model_dump() for chunk in chunks]) + await CACHE.set(cache_key, chunk_json_str, ex=cache_ttl) + logger.info( + ( + f'File "{file_name}" successfully parsed and split into ' + f'{len(chunks):,d} chunks (cache key="{cache_key}").' + ) + ) + else: + logger.info( + ( + f'File "{file_name}" successfully parsed and split into ' + f"{len(chunks):,d} chunks." + ) + ) + return chunks + + except JamaiException: + raise + except pd.errors.EmptyDataError as e: + raise BadInputError(f'Input file "{file_name}" is empty.') from e + except Exception as e: + logger.error(f'Failed to parse and split file "{file_name}": {repr(e)}') + raise BadInputError(f'Failed to parse and split file "{file_name}".') from e + + +class DoclingLoader(BaseLoader): + """ + A class for loading and processing documents using Docling-Serve API. + """ + + def __init__( + self, + request_id: str = "", + docling_serve_url: str | None = None, + page_break_placeholder: str | None = None, + ): + """ + Initialize the DoclingLoader class. + + Args: + request_id (str, optional): Request ID for logging. Defaults to "". + """ + super().__init__(request_id=request_id) + self.http_aclient = httpx.AsyncClient( + timeout=60.0 * 10, + transport=httpx.AsyncHTTPTransport(retries=3), + ) + self.docling_serve_url = ( + ENV_CONFIG.docling_url if docling_serve_url is None else docling_serve_url + ) + self.page_break_placeholder = page_break_placeholder + + async def retrieve_document_content( + self, + file_name: str, + content: bytes, + ) -> dict: # Expecting JSON response from docling-serve + """ + Retrieves the content of a document file using Docling-Serve API (async pattern). + + Args: + file_path (str): Path to the document file to be parsed (local temp path). + file_name (str): Original file name. + content (bytes): Binary content of the file. + force_full_page_ocr (bool): Whether to force full-page OCR. + + Returns: + dict: The JSON response from docling-serve. + + Raises: + HTTPException: If the document conversion fails via docling-serve. + """ + logger.info(f'{self.request_id} - Calling Docling-Serve for file "{file_name}".') + + files = {"files": (file_name, content, "application/octet-stream")} + data = { + "to_formats": ["md"], + "image_export_mode": "placeholder", + "pipeline": "standard", + "ocr": True, + "force_ocr": False, + "ocr_engine": "easyocr", + "pdf_backend": "dlparse_v4", + "table_mode": "accurate", + "abort_on_error": False, + "return_as_file": False, + } + + if self.page_break_placeholder is not None: + data["md_page_break_placeholder"] = self.page_break_placeholder + + try: + # Step 1: Start async conversion + response = await self.http_aclient.post( + f"{self.docling_serve_url}/v1alpha/convert/file/async", files=files, data=data + ) + response.raise_for_status() + task_id_data = response.json() + task_id = task_id_data.get("task_id") + if not task_id: + raise UnexpectedError("Docling-Serve did not return a task_id.") + + # Step 2: Poll for completion + poll_url = f"{self.docling_serve_url}/v1alpha/status/poll/{task_id}" + time_slept = 0 + sleep_for = 1 + task_status = None + while time_slept < ENV_CONFIG.docling_timeout_sec: + try: + poll_resp = await self.http_aclient.get(poll_url, timeout=20) + poll_resp.raise_for_status() + status_data = poll_resp.json() + task_status = status_data.get("task_status") + except Exception as e: + logger.error(f"Polling API error: {e}") + + if task_status == "success": + break # Exit polling loop + elif task_status in ("failure", "revoked"): + error_info = status_data.get("task_result", {}).get("error", "Unknown error") + raise UnexpectedError(f"Docling-Serve task failed: {error_info}") + # If not success, failure, or revoked, it's still processing or in another state + await asyncio.sleep(sleep_for) + time_slept += sleep_for + else: # Executed if the while loop completes without a 'break' + logger.error( + f"{self.request_id} - Polling timed out for Docling-Serve task {task_id} after {time_slept} seconds." + ) + raise UnexpectedError( + f"Polling timed out for Docling-Serve task {task_id} after {time_slept} seconds." + ) + + # Step 3: Fetch result + result_url = f"{self.docling_serve_url}/v1alpha/result/{task_id}" + result_resp = await self.http_aclient.get(result_url, timeout=60) + result_resp.raise_for_status() + return result_resp.json() + + except httpx.TimeoutException as e: + logger.error(f"Docling-Serve API timeout error: {e}") + raise UnexpectedError(f"Docling-Serve API timeout error: {e}") from e + except httpx.HTTPError as e: + logger.error(f"Docling-Serve API error: {e}") + raise UnexpectedError(f"Docling-Serve API error: {e}") from e + except Exception as e: + raise UnexpectedError(f"Docling-Serve API error: {e}") from e + + async def convert_document_to_markdown(self, file_name: str, content: bytes) -> str: + """ + Converts a document to Markdown format using Docling-Serve. + """ + docling_response = await self.retrieve_document_content(file_name, content) + logger.info( + f"Converted `{file_name}` to Markdown in {docling_response.get('processing_time', '0'):.3f} seconds, " + f"{get_bytes_size_mb(content):.3f} MB." + ) + return docling_response.get("document", {}).get("md_content", "") + + async def convert_document_to_chunks( + self, file_name: str, content: bytes, chunk_size: int, chunk_overlap: int + ) -> list[Chunk]: + """ + Converts a document to chunks, respecting page and table boundaries, using Docling-Serve. + """ + docling_response = await self.retrieve_document_content(file_name, content) + md_content = docling_response.get("document", {}).get("md_content", "") + + documents = [Document(page_content=md_content, metadata={"page": 1})] + chunks = format_chunks(documents, file_name) + + chunks = self.split_chunks( + SplitChunksRequest( + chunks=chunks, + params=SplitChunksParams( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + ), + ), + page_break_placeholder=self.page_break_placeholder, + ) + + return chunks + + async def load_document(self, file_name: str, content: bytes) -> str: + """ + Loads and processes a document file, converting it to Markdown format using Docling-Serve. + """ + try: + md = await self.convert_document_to_markdown(file_name, content) + logger.info(f'File "{file_name}" loaded as markdown.') + return md + + except Exception as e: + logger.error(f"Failed to process file: {e}") + raise UnexpectedError(f"Failed to process file: {e}") from e + + async def load_document_chunks( + self, + file_name: str, + content: bytes, + chunk_size: int = 1000, + chunk_overlap: int = 200, + ) -> list[Chunk]: + """ + Loads and processes a document file, splitting it into chunks using Docling-Serve. + """ + try: + chunks = await self.convert_document_to_chunks( + file_name, content, chunk_size, chunk_overlap + ) + logger.info(f'File "{file_name}" loaded and split into {len(chunks):,d} chunks.') + return chunks + + except Exception as e: + logger.error(f"Failed to process file: {e}") + raise UnexpectedError(f"Failed to process file: {e}") from e diff --git a/services/api/src/owl/entrypoints/api.py b/services/api/src/owl/entrypoints/api.py index d9f2678..7daae9b 100644 --- a/services/api/src/owl/entrypoints/api.py +++ b/services/api/src/owl/entrypoints/api.py @@ -1,75 +1,104 @@ -""" -API server. -""" +import asyncio +from asyncio.coroutines import iscoroutine +from collections import defaultdict +from contextlib import asynccontextmanager +from time import perf_counter -import os -from typing import Any - -from fastapi import BackgroundTasks, FastAPI, Request, status -from fastapi.exceptions import RequestValidationError, ResponseValidationError +from fastapi import BackgroundTasks, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import ORJSONResponse -from filelock import Timeout +from gunicorn.app.base import BaseApplication from loguru import logger -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 opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor + +from owl.configs import CACHE, ENV_CONFIG +from owl.db import create_db_engine_async, init_db, migrate_db, reset_db +from owl.routers import ( + auth, + conversation, + file, + gen_table, + gen_table_v1, + meters, + models, + organizations, + projects, + serving, + tasks, + templates, + users, ) -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.routers.projects import v1 as projects_v1 +from owl.types import UserAgent from owl.utils import uuid7_str +from owl.utils.billing import CLICKHOUSE_CLIENT, BillingManager +from owl.utils.exceptions import JamaiException +from owl.utils.handlers import exception_handler, make_request_log_str, path_not_found_handler 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, -) - -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 +from owl.utils.mcp import get_mcp_router +from owl.utils.mcp.server import MCP_TOOL_TAG - -NO_AUTH_ROUTES = {"health", "public", "favicon.ico"} - -client = JamAIAsync(token=ENV_CONFIG.service_key_plain, timeout=60.0) -logger.enable("owl") -setup_logger_sinks() +OVERHEAD_LOG_ROUTES = {r.path for r in serving.router.routes} +# logger.enable("owl") +setup_logger_sinks(None) # 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(["uvicorn", "litellm", "openmeter", "azure"], True) +suppress_logging_handlers(["uvicorn", "litellm", "azure", "openmeter", "pottery"], True) + +# --- Setup DB --- # +# Maybe reset DB +if ENV_CONFIG.db_reset: + asyncio.run(reset_db(reset_max_users=ENV_CONFIG.db_init_max_users)) +# Migration +asyncio.run(migrate_db()) +# Maybe populate DB with demo data +# If OSS and first launch, init user, organization and project +if ENV_CONFIG.db_init: + asyncio.run(init_db(init_max_users=ENV_CONFIG.db_init_max_users)) +# Maybe reset cache +if ENV_CONFIG.cache_reset: + CACHE.purge() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup logic + logger.info(f"Using configuration: {ENV_CONFIG}") + yield + logger.info("Shutting down...") + + # Close DB connection + logger.info("Closing DB connection.") + try: + await (await create_db_engine_async()).dispose() + except Exception as e: + logger.warning(f"Failed to close DB connection: {repr(e)}") + + # Close Redis connection + logger.info("Closing Redis connection.") + try: + await CACHE.aclose() + except Exception as e: + logger.warning(f"Failed to close Redis connection: {repr(e)}") + + # Flush buffer + logger.info("Flushing redis buffer to database.") + try: + await CLICKHOUSE_CLIENT.flush_buffer() + except Exception as e: + logger.warning(f"Failed to flush buffer: {repr(e)}") + finally: + ret = CLICKHOUSE_CLIENT.client.close() + if iscoroutine(ret): + await ret + logger.info("Shutdown complete.") app = FastAPI( + title="JamAI Base API", logger=logger, default_response_class=ORJSONResponse, # Should be faster openapi_url="/api/public/openapi.json", @@ -80,37 +109,107 @@ "url": "https://www.apache.org/licenses/LICENSE-2.0.html", }, servers=[dict(url="https://api.jamaibase.com")], + lifespan=lifespan, ) -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"), -] + +# Programmatic Instrumentation +FastAPIInstrumentor.instrument_app(app) +RedisInstrumentor().instrument() +HTTPXClientInstrumentor().instrument() # Mount -for router, tags, prefix in services: +internal_api_tag = "" if ENV_CONFIG.is_oss else " (Internal API)" +app.include_router( + models.router, + prefix="/api", + tags=["Models" + internal_api_tag], +) +app.include_router( + auth.router, + prefix="/api", + tags=["Authentication" + internal_api_tag], +) +app.include_router( + users.router, + prefix="/api", + tags=["Users" + internal_api_tag], +) +app.include_router( + organizations.router, + prefix="/api", + tags=["Organizations" + internal_api_tag], +) +app.include_router( + projects.router, + prefix="/api", + tags=["Projects"], +) +app.include_router( + projects_v1.router, + deprecated=True, + prefix="/api/admin/org", + tags=["Organization Admin (Legacy)"], +) +app.include_router( + templates.router, + prefix="/api", + tags=["Templates"], +) +app.include_router( + conversation.router, + prefix="/api", + tags=["Conversations"], +) +app.include_router( + gen_table.router, + prefix="/api", + tags=["Generative Table (V2)"], +) +app.include_router( + gen_table_v1.router, + prefix="/api", + tags=["Generative Table (V1)"], + deprecated=True, +) +app.include_router( + serving.router, + prefix="/api", + tags=["Serving"], +) +app.include_router( + file.router, + prefix="/api", + tags=["File"], +) +app.include_router( + tasks.router, + prefix="/api", + tags=["Tasks"], +) +app.include_router( + meters.router, + prefix="/api", + tags=["Meters" + internal_api_tag], +) +if ENV_CONFIG.is_cloud: + from owl.routers.cloud import logs, prices + app.include_router( - router, - prefix=prefix, - tags=tags, + prices.router, + prefix="/api", + tags=["Prices"], ) -if cloud_auth is not None: app.include_router( - cloud_auth.router, + logs.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, + tags=["Logs (Internal Cloud-only API)"], ) +app.include_router( + get_mcp_router(app), + prefix="/api", + tags=["Model Context Protocol (MCP)"], +) + # Permissive CORS app.add_middleware( @@ -120,51 +219,9 @@ allow_methods=["*"], allow_headers=["*"], ) - - -@app.on_event("startup") -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}") - # 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, - ) - 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.add_exception_handler(JamaiException, exception_handler) # Suppress starlette traceback +app.add_exception_handler(Exception, exception_handler) +app.add_exception_handler(404, path_not_found_handler) @app.middleware("http") @@ -178,35 +235,48 @@ async def log_request(request: Request, call_next): Returns: response (Response): Response of the path operation. """ + request.state.request_start_time = perf_counter() # Set request state - request.state.id = uuid7_str() + request_id = request.headers.get("x-request-id", uuid7_str()) + request.state.id = request_id 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: - 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) + request.state.timing = defaultdict(float) # Call request + path = request.url.path + if "api/health" not in path: + logger.info(make_request_log_str(request)) response = await call_next(request) - logger.info(make_request_log_str(request, response.status_code)) - - # Add egress events - request.state.billing.create_egress_events( - float(response.headers.get("content-length", 0)) / (1024**3) - ) - # Process billing (this will run AFTER streaming responses are sent) - tasks = BackgroundTasks() - tasks.add_task(request.state.billing.process_all) - response.background = tasks + response.headers["x-request-id"] = request_id + if "api/health" not in path: + logger.info(make_request_log_str(request, response.status_code)) + + # Process billing (this will run BEFORE any responses are sent) + if hasattr(request.state, "billing"): + billing: BillingManager = request.state.billing + # Add egress events + # This does not include SSE egress, and will need to be captured separately + egress_bytes = float(response.headers.get("content-length", 0)) + if egress_bytes > 0: + billing.create_egress_events(egress_bytes / (1024**3)) + # Background tasks will run AFTER streaming responses are sent + tasks = BackgroundTasks() + tasks.add_task(billing.process_all) + response.background = tasks + # Log timing + model_start_time = getattr(request.state, "model_start_time", None) + if ( + ENV_CONFIG.log_timings + and model_start_time + and any(p for p in OVERHEAD_LOG_ROUTES if p in path) + ): + overhead = model_start_time - request.state.request_start_time + breakdown = {k: f"{v * 1e3:,.1f} ms" for k, v in request.state.timing.items()} + logger.info( + f"{request.state.id} - Total overhead: {overhead * 1e3:,.1f} ms. Breakdown: {breakdown}" + ) return response @@ -219,256 +289,134 @@ async def health() -> ORJSONResponse: ) -# --- Order of handlers does not matter --- # - - -@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): - 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): - return forbidden_response(request, str(exc), error="insufficient_credits", exception=exc) - - -@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): - return resource_exists_response(request, str(exc), exception=exc) - - -@app.exception_handler(ResourceExistsError) -async def resource_exists_exc_handler(request: Request, exc: ResourceExistsError): - return resource_exists_response(request, str(exc), exception=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_415_UNSUPPORTED_MEDIA_TYPE, - content={ - "object": "error", - "error": "unsupported_media_type", - "message": str(exc), - "detail": str(exc), - "request_id": request.state.id, - "exception": "", - }, - ) +# Process OpenAPI docs +openapi_schema = app.openapi() +# Remove MCP and permission tags +for path_info in openapi_schema["paths"].values(): + for method_info in path_info.values(): + tags = method_info["tags"] + tags = [ + tag + for tag in tags + if not (tag == MCP_TOOL_TAG or tag.startswith(("system", "organization", "project"))) + ] + method_info["tags"] = tags +# Re-order paths to put internal APIs last +if ENV_CONFIG.is_cloud: + openapi_schema["paths"] = { + k: openapi_schema["paths"][k] + for k in sorted( + openapi_schema["paths"].keys(), + key=lambda p: internal_api_tag + in list(openapi_schema["paths"][p].values())[0]["tags"][0], + ) + } +if ENV_CONFIG.is_cloud: + # 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 -@app.exception_handler(BadInputError) -async def bad_input_exc_handler(request: Request, exc: BadInputError): - return bad_input_response(request, str(exc), exception=exc) +class StandaloneApplication(BaseApplication): + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) -@app.exception_handler(TableSchemaFixedError) -async def table_fixed_exc_handler(request: Request, exc: TableSchemaFixedError): - return bad_input_response(request, str(exc), error="table_schema_fixed", exception=exc) + def load(self): + return self.application -@app.exception_handler(ContextOverflowError) -async def context_overflow_exc_handler(request: Request, exc: ContextOverflowError): - return bad_input_response(request, str(exc), error="context_overflow", exception=exc) +# Gunicorn post_fork hook +def post_fork(server, worker): + from opentelemetry import metrics, trace + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from owl.utils.loguru_otlp_handler import OTLPHandler -class Wrapper(BaseModel): - body: Any + # from opentelemetry.instrumentation.auto_instrumentation import sitecustomize + # trace.set_tracer_provider(trace.get_tracer_provider()) + # metrics.set_meter_provider(metrics.get_meter_provider()) + # for manual instrumentation -@app.exception_handler(RequestValidationError) -async def request_validation_exc_handler(request: Request, exc: RequestValidationError): - content = None - try: - logger.info( - f"{make_request_log_str(request, 422)} - RequestValidationError: {exc.errors()}" - ) - errors, messages = [], [] - for i, e in enumerate(exc.errors()): - try: - msg = str(e["ctx"]["error"]).strip() - except Exception: - msg = e["msg"].strip() - if not msg.endswith("."): - msg = f"{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}" - content = { - "object": "error", - "error": "validation_error", - "message": message, - "detail": errors, - "request_id": request.state.id, - "exception": "", - **Wrapper(body=exc.body).model_dump(), + resource = Resource.create( + { + "service.name": "owl", + "service.instance.id": uuid7_str(), } - return ORJSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content=content, - ) - except Exception: - 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": message, - "detail": message, - "request_id": request.state.id, - "exception": exc.__class__.__name__, - }, + ) + # Meter provider configuration + reader = PeriodicExportingMetricReader( + OTLPMetricExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" + ), + export_interval_millis=1000, + ) + provider = MeterProvider(resource=resource, metric_readers=[reader]) + metrics.set_meter_provider(provider) + # Trace provider configuration + trace_provider = TracerProvider(resource=resource) + trace_provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" + ) ) - - -@app.exception_handler(Exception) -async def exception_handler(request: Request, exc: Exception): - 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"}, ) - - -@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"}, + trace.set_tracer_provider(trace_provider) + + # # for auto-instrumentation + # trace.get_tracer_provider() + # metrics.get_meter_provider() + # Configure the OTLP Exporter + otlp_exporter = OTLPLogExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" ) - -@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, + # Create an instance of OTLPHandler + otlp_handler = OTLPHandler.create( + service_name="owl", + exporter=otlp_exporter, + development_mode=False, # Set to True for development ) - -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 + logger.add(otlp_handler.sink, level="INFO") + server.log.info(f"Worker spawned (pid: {worker.pid})") if __name__ == "__main__": - import uvicorn - - if os.name == "nt": - import asyncio - from multiprocessing import freeze_support - - logger.warning("The system is Windows, performing asyncio and multiprocessing patches.") - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - freeze_support() - - uvicorn.run( - "owl.entrypoints.api:app", - reload=False, - host=ENV_CONFIG.owl_host, - port=ENV_CONFIG.owl_port, - workers=ENV_CONFIG.owl_workers, - limit_concurrency=ENV_CONFIG.owl_max_concurrency, - ) + options = { + "bind": f"{ENV_CONFIG.host}:{ENV_CONFIG.port}", + "workers": ENV_CONFIG.workers, + "worker_class": "uvicorn.workers.UvicornWorker", + "limit_concurrency": ENV_CONFIG.max_concurrency, + "timeout": 600, + "graceful_timeout": 60, + "max_requests": 2000, + "max_requests_jitter": 200, + "keepalive": 60, # AWS ALB and Nginx default to 60 seconds + "post_fork": post_fork, + "loglevel": "error", + } + StandaloneApplication(app, options).run() diff --git a/services/api/src/owl/entrypoints/chat_echo.py b/services/api/src/owl/entrypoints/chat_echo.py deleted file mode 100644 index 71cce87..0000000 --- a/services/api/src/owl/entrypoints/chat_echo.py +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 66788c3..0000000 --- a/services/api/src/owl/entrypoints/chat_python.py +++ /dev/null @@ -1,137 +0,0 @@ -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/llm.py b/services/api/src/owl/entrypoints/llm.py new file mode 100644 index 0000000..2cb3da9 --- /dev/null +++ b/services/api/src/owl/entrypoints/llm.py @@ -0,0 +1,626 @@ +import base64 +import hashlib +import io +import re +from asyncio import sleep +from contextlib import asynccontextmanager +from time import time +from typing import Any + +import httpx +import numpy as np +from fastapi import FastAPI, Request +from fastapi.responses import ORJSONResponse, StreamingResponse +from loguru import logger +from PIL import Image +from pydantic import BaseModel, Field +from pydub import AudioSegment + +from owl.configs import CACHE, ENV_CONFIG +from owl.types import ( + AudioContent, + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionDelta, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, + ChatRequest, + ChatRole, + CompletionUsageDetails, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingResponseData, + EmbeddingUsage, + ImageContent, + PromptUsageDetails, + SanitisedNonEmptyStr, + TextContent, + UserAgent, +) +from owl.utils import uuid7_str +from owl.utils.exceptions import BadInputError, JamaiException +from owl.utils.handlers import exception_handler, make_request_log_str, path_not_found_handler +from owl.utils.logging import setup_logger_sinks, suppress_logging_handlers + +# Setup logging +setup_logger_sinks(None) +suppress_logging_handlers(["uvicorn", "litellm", "pottery"], True) + + +class ChatCompletionRequest(ChatRequest): + stream: bool = False # Set default to False + + +class ModelSpec(BaseModel, validate_assignment=True): + id: SanitisedNonEmptyStr = Field(description="Model ID") + ttft_ms: int = Field(0, description="Time to first token (TTFT)") + tpot_ms: int = Field(0, description="Time per output token (TPOT)") + max_context_length: int = Field(int(1e12), description="Max context length") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup logic + logger.info(f"Using configuration: {ENV_CONFIG}") + yield + logger.info("Shutting down...") + + # Close Redis connection + logger.info("Closing Redis connection.") + try: + await CACHE.aclose() + except Exception as e: + logger.warning(f"Failed to close Redis connection: {repr(e)}") + + +app = FastAPI(title="Mock LLM", lifespan=lifespan) +app.add_exception_handler(JamaiException, exception_handler) # Suppress starlette traceback +app.add_exception_handler(Exception, exception_handler) +app.add_exception_handler(404, path_not_found_handler) + + +def _describe_image(image_content: ImageContent) -> str: + """ + Describe the image based on an `ImageContent` object. + + Args: + image_content (ImageContent): The `ImageContent` object containing the image URL or base64 data. + + Returns: + description (str): A brief description of the image. + """ + # Get image data from URL or base64 + url = image_content.image_url.url + + if url.startswith("data:"): + # Handle base64 encoded image + mime_match = re.match(r"data:([^;]+);base64,", url) + mime_type = mime_match.group(1) if mime_match else "image/unknown" + base64_data = url.split(",", 1)[1] + image_data = base64.b64decode(base64_data) + img = Image.open(io.BytesIO(image_data)) + else: + # Handle URL using httpx instead of requests + with httpx.Client() as client: + response = client.get(url) + response.raise_for_status() + mime_type = response.headers.get("Content-Type", "image/unknown") + img = Image.open(io.BytesIO(response.content)) + + # Convert to numpy array for calculations + img_array = np.asarray(img) + + # Get dimensions (height, width, channels) + if len(img_array.shape) == 2: # Grayscale image + height, width = img_array.shape + channels = 1 + img_array = img_array.reshape((height, width, 1)) + else: + height, width, channels = img_array.shape + + # Calculate mean and standard deviation + mean_value = float(np.mean(img_array)) + std_value = float(np.std(img_array)) + + return ( + f"There is an image with MIME type [{mime_type}], " + f"shape [{(height, width, channels)}], mean [{mean_value:,.1f}] and std [{std_value:,.1f}]." + ) + + +def _describe_audio(audio_content: AudioContent) -> str: + """ + Describe the audio based on an `AudioContent` object. + + Args: + audio_content (AudioContent): The `AudioContent` object containing the base64 encoded audio data. + + Returns: + description (str): A brief description of the audio. + """ + # Format to MIME type mapping + format_to_mime: dict[str, str] = {"mp3": "audio/mpeg", "wav": "audio/wav"} + # Get audio data and format + base64_data = audio_content.input_audio.data + audio_format = audio_content.input_audio.format + # Decode base64 data + audio_data = base64.b64decode(base64_data) + # Get MIME type + mime_type = format_to_mime.get(audio_format, f"audio/{audio_format}") + # Load audio using pydub + audio_file = io.BytesIO(audio_data) + + if audio_format == "mp3": + audio = AudioSegment.from_mp3(audio_file) + elif audio_format == "wav": + audio = AudioSegment.from_wav(audio_file) + else: + # This shouldn't happen due to the Literal type constraint, but just in case + raise BadInputError(f'Unsupported audio format: "{audio_format}".') + + # Calculate duration in seconds + duration_sec = len(audio) / 1000.0 # pydub uses milliseconds + return ( + f"There is an audio with MIME type [{mime_type}], duration [{duration_sec:,.1f}] seconds." + ) + + +def _describe_text(text_content: str | TextContent) -> str: + """ + Describe the text based on a `TextContent` object. + + Args: + text_content (str | TextContent): A string or `TextContent` object containing the text. + + Returns: + description (TextDescription): A `TextDescription` object with text metadata. + """ + if isinstance(text_content, str): + text = text_content + else: + text = text_content.text + text = text.strip() + num_tokens = 0 if text == "" else len(text.split(" ")) + return f"There is a text with [{num_tokens:,d}] tokens." + + +def _execute_python(code: str, context: dict[str, Any] | None = None) -> Any: + """ + Execute a string containing Python code and return its return value. + This version wraps the code in a function to properly capture return values. + + Args: + code (str): The Python code to execute + context (dict[str, Any] | None, optional): + Dictionary of variables to make available in the execution context. + Defaults to None. + + Returns: + value (Any): The return value of the executed code. + """ + if context is None: + context = {} + # Wrap the code in a function to capture return values + wrapped_code = [ + "def __temp_function():", + "\n".join(" " + f"{k} = {repr(v)}" for k, v in context.items()), + "\n".join(" " + line for line in code.strip().split("\n")), + "__return_value__ = __temp_function()", + ] + # Execute the wrapped code + local_namespace = {} + exec("\n".join(wrapped_code), globals(), local_namespace) + return local_namespace.get("__return_value__") + + +def _parse_chat_model_id(model_id: str) -> ModelSpec: + spec = ModelSpec(id=model_id) + # Time to first token (TTFT) + if match := re.search(r"-ttft-(\d+)", model_id): + spec.ttft_ms = int(match.group(1)) + # Time per output token (TPOT) + if match := re.search(r"-tpot-(\d+)", model_id): + spec.tpot_ms = int(match.group(1)) + # Max context length + if match := re.search(r"-context-(\d+)", model_id): + spec.max_context_length = int(match.group(1)) + return spec + + +@app.post("/v1/chat/completions") +async def chat_completion(body: ChatCompletionRequest): + logger.info(f"Chat completion request: {body}") + model_spec = _parse_chat_model_id(body.model) + num_input_tokens = len(" ".join(m.text_content for m in body.messages).split(" ")) + user_messages = [m for m in body.messages if m.role == ChatRole.USER] + + # Test context length error handling + if num_input_tokens > model_spec.max_context_length: + return ORJSONResponse( + status_code=400, + content={ + "error": { + "message": ( + f"This model's maximum context length is {model_spec.max_context_length} tokens. " + f"However, your messages resulted in {num_input_tokens} tokens. " + "Please reduce the length of the messages." + ), + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded", + } + }, + ) + elif num_input_tokens + body.max_tokens > model_spec.max_context_length: + return ORJSONResponse( + status_code=400, + content={ + "error": { + "message": ( + f"This model's maximum context length is {model_spec.max_context_length} tokens. " + f"However, you requested {num_input_tokens + body.max_tokens} tokens " + f"({num_input_tokens} in the messages, {body.max_tokens} in the completion). " + "Please reduce the length of the messages or completion." + ), + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded", + } + }, + ) + + if "lorem" in model_spec.id: + completion_tokens = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.".split(" ") + num_completion_tokens = body.max_tokens + elif "describe" in model_spec.id: + descriptions = [] + if body.messages[0].role == ChatRole.SYSTEM: + descriptions.append(f"System prompt: {_describe_text(body.messages[0].content)}") + if len(user_messages) == 0: + descriptions.append(_describe_text("")) + for message in user_messages: + if isinstance(message.content, str): + descriptions.append(_describe_text(message.content)) + else: + for c in message.content: + if isinstance(c, ImageContent): + descriptions.append(_describe_image(c)) + elif isinstance(c, AudioContent): + descriptions.append(_describe_audio(c)) + elif isinstance(c, TextContent): + descriptions.append(_describe_text(c)) + else: + raise BadInputError(f'Unknown content type: "{type(c)}".') + completion_tokens = "\n".join(descriptions).split(" ") + num_completion_tokens = len(completion_tokens) + elif "echo-request" in model_spec.id: + completion_tokens = body.model_dump_json().split(" ") + num_completion_tokens = len(completion_tokens) + elif "echo-prompt" in model_spec.id: + prompt_concat = " ".join(m.text_content for m in user_messages) + if body.messages[0].role == ChatRole.SYSTEM: + prompt_concat = f"{body.messages[0].text_content} {prompt_concat}" + completion_tokens = prompt_concat.strip().split(" ") + num_completion_tokens = len(completion_tokens) + elif "python" in model_spec.id: + if len(user_messages) == 0: + result = None + else: + result = _execute_python(user_messages[-1].text_content) + completion_tokens = [repr(result)] + num_completion_tokens = len(completion_tokens) + else: + raise BadInputError(f'Unknown model: "{model_spec.id}"') + + if body.stream: + + async def stream_response(): + if model_spec.ttft_ms > 0: + await sleep(model_spec.ttft_ms / 1000) + # Role chunk + for i in range(body.n): + chunk = ChatCompletionChunkResponse( + id=body.id, + model=model_spec.id, + choices=[ + ChatCompletionChoice( + index=i, + delta=ChatCompletionDelta(role="assistant", content="", refusal=None), + logprobs=None, + finish_reason=None, + ) + ], + usage=None, + object="chat.completion.chunk", + created=int(time()), + system_fingerprint=None, + service_tier=None, + ) + yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + # Content chunks + for t in range(num_completion_tokens): + # If this is the last token + if t == num_completion_tokens - 1: + content = f"{completion_tokens[t % len(completion_tokens)]}" + else: + content = f"{completion_tokens[t % len(completion_tokens)]} " + for i in range(body.n): + if model_spec.tpot_ms > 0: + await sleep(model_spec.tpot_ms / 1000) + chunk = ChatCompletionChunkResponse( + id=body.id, + model=model_spec.id, + choices=[ + ChatCompletionChoice( + index=i, + delta=ChatCompletionDelta(content=content), + logprobs=None, + finish_reason=None, + ) + ], + usage=None, + object="chat.completion.chunk", + created=int(time()), + system_fingerprint=None, + service_tier=None, + ) + yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + # Finish reason chunk + for i in range(body.n): + chunk = ChatCompletionChunkResponse( + id=body.id, + model=model_spec.id, + choices=[ + ChatCompletionChoice( + index=i, + logprobs=None, + finish_reason="length" + if num_completion_tokens == body.max_tokens + else "stop", + ) + ], + usage=None, + object="chat.completion.chunk", + created=int(time()), + system_fingerprint=None, + service_tier=None, + ) + yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + # Usage chunk + chunk = ChatCompletionChunkResponse( + id=body.id, + model=model_spec.id, + choices=[], + usage=ChatCompletionUsage( + prompt_tokens=num_input_tokens, + completion_tokens=num_completion_tokens, + total_tokens=num_input_tokens + num_completion_tokens, + prompt_tokens_details=PromptUsageDetails(cached_tokens=0, audio_tokens=0), + completion_tokens_details=CompletionUsageDetails( + audio_tokens=0, + reasoning_tokens=0, + accepted_prediction_tokens=0, + rejected_prediction_tokens=0, + ), + ), + object="chat.completion.chunk", + created=int(time()), + system_fingerprint=None, + service_tier=None, + ) + yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(stream_response(), media_type="text/event-stream") + + # Non-stream + if (model_spec.ttft_ms + model_spec.tpot_ms) > 0: + await sleep((model_spec.ttft_ms + model_spec.tpot_ms * len(completion_tokens)) / 1000) + response = ChatCompletionResponse( + id=body.id, + model=model_spec.id, + choices=[ + ChatCompletionChoice( + index=i, + message=ChatCompletionMessage( + content=" ".join( + completion_tokens[t % len(completion_tokens)] + for t in range(num_completion_tokens) + ) + ), + logprobs=None, + finish_reason="length", + ) + for i in range(body.n) + ], + usage=ChatCompletionUsage( + prompt_tokens=num_input_tokens, + completion_tokens=num_completion_tokens, + total_tokens=num_input_tokens + num_completion_tokens, + ), + ) + return response + + +def _parse_embedding_model_id(model_id: str, fallback_dim: int = 768) -> int: + """ + Extract the embedding dimension from the model_id if present (e.g. '...-dim-768'). + Otherwise return fallback_dim. + """ + if match := re.search(r"-dim-(\d+)", model_id): + return int(match.group(1)) + return fallback_dim + + +@app.post("/v1/embeddings") +async def embeddings(body: EmbeddingRequest) -> EmbeddingResponse: + """ + Mock embedding endpoint that deterministically generates embeddings by + seeding NumPy with a hash derived from each input string. + """ + # Validate inputs + inputs: list[str] + if isinstance(body.input, str): + text = body.input.strip() + if text == "": + raise BadInputError("Input cannot be an empty string.") + inputs = [text] + else: + inputs = [] + for i, s in enumerate(body.input): + t = s.strip() + if t == "": + raise BadInputError(f"Input at index {i} cannot be an empty string.") + inputs.append(t) + + # Determine embedding dimension + dim: int + if body.dimensions is not None: + if body.dimensions <= 0: + raise BadInputError("`dimensions` must be a positive integer.") + dim = body.dimensions + else: + dim = _parse_embedding_model_id(body.model, fallback_dim=768) + + # Generate deterministic embeddings per input + data: list[EmbeddingResponseData] = [] + prompt_token_count = 0 + + for idx, text in enumerate(inputs): + # Naive token counting by whitespace + prompt_token_count += 0 if text == "" else len(text.split()) + # Deterministic seed from SHA-256 + sha = hashlib.blake2b(text.encode("utf-8")).hexdigest() + seed = int(sha[:16], 16) % (2**32) + rng = np.random.default_rng(seed) + vec = rng.standard_normal(size=dim, dtype=np.float32) + if body.encoding_format == "float": + emb_value: list[float] | str = vec.tolist() + else: + # base64 encoding of float32 bytes + emb_value = base64.b64encode(vec.tobytes()).decode("ascii") + data.append(EmbeddingResponseData(embedding=emb_value, index=idx)) + + return EmbeddingResponse( + data=data, + model=body.model, + usage=EmbeddingUsage( + prompt_tokens=prompt_token_count, + total_tokens=prompt_token_count, + ), + ) + + +@app.get("/health", tags=["Health"]) +async def health() -> ORJSONResponse: + """Health check.""" + return ORJSONResponse(status_code=200, content={}) + + +@app.middleware("http") +async def log_request(request: Request, call_next): + """ + Args: + 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. + """ + # Set request state + request_id = request.headers.get("x-request-id", uuid7_str()) + request.state.id = request_id + request.state.user_agent = UserAgent.from_user_agent_string( + request.headers.get("user-agent", "") + ) + # Call request + logger.info(make_request_log_str(request)) + response = await call_next(request) + response.headers["x-request-id"] = request_id + logger.info(make_request_log_str(request, response.status_code)) + return response + + +if __name__ == "__main__": + import uvicorn + + logger.info(f"Starting LLM test server on {ENV_CONFIG.host}:{ENV_CONFIG.port + 1}") + uvicorn.run( + "owl.entrypoints.llm:app", + reload=False, + host=ENV_CONFIG.host, + port=ENV_CONFIG.port + 1, + workers=2, + limit_concurrency=100, + ) + +""" +OpenAI Chat Completion SSE + + +{ +"error": { + "message": "This model's maximum context length is 16385 tokens. However, your messages resulted in 19901 tokens. Please reduce the length of the messages.", + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded" + } +} + +{ + "error": { + "message": "This model's maximum context length is 16385 tokens. However, you requested 18242 tokens (16242 in the messages, 2000 in the completion). Please reduce the length of the messages or completion.", + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded" + } +} + +{ + "id": "chatcmpl-AtBWW4Kf8NoM4WDBaNSBLR8fD0fc6", + "object": "chat.completion", + "created": 1737715700, + "model": "gpt-3.5-turbo-1106", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "\n\nS", + "refusal": null + }, + "logprobs": null, + "finish_reason": "length" + } + ], + "usage": { + "prompt_tokens": 17, + "completion_tokens": 2, + "total_tokens": 19, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_2f141ce944" +} + +data: {"id":"chatcmpl-AtBSi41j2M6DGdAzfHgpTKjKKqtMy","object":"chat.completion.chunk","created":1737715464,"model":"gpt-3.5-turbo-1106","service_tier":"default","system_fingerprint":"fp_7fe28551a8","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-AtBSi41j2M6DGdAzfHgpTKjKKqtMy","object":"chat.completion.chunk","created":1737715464,"model":"gpt-3.5-turbo-1106","service_tier":"default","system_fingerprint":"fp_7fe28551a8","choices":[{"index":0,"delta":{"content":"S"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-AtBSi41j2M6DGdAzfHgpTKjKKqtMy","object":"chat.completion.chunk","created":1737715464,"model":"gpt-3.5-turbo-1106","service_tier":"default","system_fingerprint":"fp_7fe28551a8","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"length"}]} + +data: {"id":"chatcmpl-AtBbXar0rpsdn69L9cIeeu88frXVd","object":"chat.completion.chunk","created":1737716011,"model":"gpt-3.5-turbo-1106","service_tier":"default","system_fingerprint":"fp_2f141ce944","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":2,"total_tokens":19,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + +data: [DONE] +""" diff --git a/services/api/src/owl/entrypoints/starling.py b/services/api/src/owl/entrypoints/starling.py index f36efef..d39d577 100644 --- a/services/api/src/owl/entrypoints/starling.py +++ b/services/api/src/owl/entrypoints/starling.py @@ -8,85 +8,106 @@ ``` """ -import os +from datetime import timedelta -from celery import Celery from celery.schedules import crontab +from celery.signals import worker_process_init from loguru import logger - -from owl.configs.manager import CONFIG, ENV_CONFIG +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.celery import CeleryInstrumentor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from owl.configs import ENV_CONFIG, celery_app +from owl.utils import uuid7_str from owl.utils.logging import ( replace_logging_handlers, setup_logger_sinks, suppress_logging_handlers, ) +from owl.utils.loguru_otlp_handler import OTLPHandler -# 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") +setup_logger_sinks(None) 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"), -) +suppress_logging_handlers(["uvicorn", "litellm", "azure", "openmeter", "pottery"], True) + + +@worker_process_init.connect(weak=False) +def init_celery_tracing(*args, **kwargs): + CeleryInstrumentor().instrument() + + resource = Resource.create( + { + "service.name": "starling", + "service.instance.id": uuid7_str(), + } + ) + # Meter provider configuration + reader = PeriodicExportingMetricReader( + OTLPMetricExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" + ), + export_interval_millis=1, + ) + provider = MeterProvider(resource=resource, metric_readers=[reader]) + metrics.set_meter_provider(provider) + # Trace provider configuration + trace_provider = TracerProvider(resource=resource) + trace_provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" + ), + schedule_delay_millis=1, + ) + ) + trace.set_tracer_provider(trace_provider) + + otlp_exporter = OTLPLogExporter( + endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" + ) + + # Create an instance of OTLPHandler + otlp_handler = OTLPHandler.create( + service_name="starling", + exporter=otlp_exporter, + development_mode=False, # Set to True for development + export_interval_ms=1, + ) + + logger.add(otlp_handler.sink, level="INFO") + # Load task modules -app.conf.imports = [ +celery_app.conf.imports = [ + # "owl.tasks.checks", + "owl.tasks.database", + "owl.tasks.gen_table", "owl.tasks.genitor", - "owl.tasks.storage", ] # Configure the scheduler -app.conf.beat_schedule = {} +# celery_app.conf.beat_schedule = { +# "periodic-model-check": { +# "task": "owl.tasks.checks.test_models", +# "schedule": crontab(minute="*/10"), +# } +# } # 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}"), + celery_app.conf.beat_schedule["periodic-flush-clickhouse-buffer"] = { + "task": "owl.tasks.database.run_periodic_flush_buffer", + "schedule": timedelta(seconds=ENV_CONFIG.flush_clickhouse_buffer_sec), } -# 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 @@ -98,7 +119,7 @@ ] ): logger.info("S3 Backup tasks has been configured.") - app.conf.beat_schedule.update( + celery_app.conf.beat_schedule.update( { "backup-to-s3": { "task": "owl.tasks.genitor.backup_to_s3", diff --git a/services/api/src/owl/llm.py b/services/api/src/owl/llm.py deleted file mode 100644 index f4bf99c..0000000 --- a/services/api/src/owl/llm.py +++ /dev/null @@ -1,698 +0,0 @@ -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 -from fastapi import Request -from litellm import Router -from litellm.router import RetryPolicy -from loguru import logger - -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 ( - ChatCompletionChoiceDelta, - ChatCompletionChoiceOutput, - ChatCompletionChunk, - ChatEntry, - ChatRole, - Chunk, - CompletionUsage, - ExternalKeys, - LLMModelConfig, - ModelInfo, - ModelInfoResponse, - ModelListConfig, - RAGParams, - References, -) -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=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 - return Router( - model_list=[ - { - "model_name": m.id, - "litellm_params": { - "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="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_llm_timeout_sec, - allowed_fails=3, - cooldown_time=5.5, - debug_level="DEBUG", - redis_host=ENV_CONFIG.owl_redis_host, - redis_port=ENV_CONFIG.owl_redis_port, - ) - - -class LLMEngine: - def __init__( - self, - *, - request: Request, - ) -> None: - 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( - 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: - if isinstance(hyperparams.get("stop", None), list) and len(hyperparams["stop"]) == 0: - hyperparams["stop"] = None - hyperparams.update(kwargs) - if model.startswith("anthropic"): - hyperparams["extra_headers"] = {"anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15"} - return hyperparams - - @staticmethod - def _prepare_messages(messages: list[ChatEntry | dict]) -> list[ChatEntry]: - messages: list[ChatEntry] = [ChatEntry.model_validate(m) for m in messages] - if len(messages) == 0: - raise ValueError("`messages` is an empty list.") - elif len(messages) == 1: - # [user] - if messages[0].role in (ChatRole.USER.value, ChatRole.USER): - pass - # [system] - elif messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): - messages.append(ChatEntry.user(content=".")) - # [assistant] - else: - messages = [ChatEntry.system(content="."), ChatEntry.user(content=".")] + messages - else: - # [user, ...] - if messages[0].role in (ChatRole.USER.value, ChatRole.USER): - pass - # [system, ...] - elif messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): - # [system, assistant, ...] - if messages[1].role in (ChatRole.ASSISTANT.value, ChatRole.ASSISTANT): - messages.insert(1, ChatEntry.user(content=".")) - # [assistant, ...] - else: - messages = [ChatEntry.system(content="."), ChatEntry.user(content=".")] + messages - return messages - - def _log_completion_masked( - self, - model: str, - messages: list[ChatEntry], - **hyperparams, - ): - body = dict( - model=model, - messages=[ - {"role": m["role"], "content": mask_content(m["content"])} for m in messages - ], - **hyperparams, - ) - logger.info(f"{self.id} - Generating chat completions: {body}") - - def _log_exception( - self, - model: str, - messages: list[ChatEntry], - api_key: str = "", - **hyperparams, - ): - body = dict( - model=model, - messages=[{"role": m["role"], "content": m["content"]} for m in messages], - api_key=mask_string(api_key), - **hyperparams, - ) - 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 model_info( - self, - model: str = "", - capabilities: list[str] | None = None, - ) -> ModelInfoResponse: - model_list: ModelListConfig = self.request.state.all_models - models = model_list.models - # Filter by name - if model != "": - models = [m for m in models if m.id == model] - # Filter by capability - 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}") - response = ModelInfoResponse( - data=[ModelInfo.model_validate(m.model_dump()) for m in models] - ) - return response - - def model_names( - self, - prefer: str = "", - capabilities: list[str] | None = None, - ) -> list[str]: - models = self.model_info( - model="", - capabilities=capabilities, - ) - names = [m.id for m in models.data] - if prefer in names: - names.remove(prefer) - 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, - model: str, - messages: list[ChatEntry | dict], - capabilities: list[str] | None = None, - **hyperparams, - ) -> AsyncGenerator[ChatCompletionChunk, None]: - api_key = "" - usage = None - try: - model = model.strip() - # check audio model type - is_audio_gen_model = False - if model != "": - model_config: LLMModelConfig = self.request.state.all_models.get_llm_model_info( - model - ) - if ( - "audio" in model_config.capabilities - and model_config.deployments[0].provider == "openai" - ): - is_audio_gen_model = True - hyperparams = self._prepare_hyperparams(model, hyperparams, stream=True) - messages = self._prepare_messages(messages) - # omit system prompt for audio input with audio gen - if is_audio_gen_model and messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): - messages = messages[1:] - messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] - model = self.validate_model_id( - model=model, - capabilities=capabilities, - ) - self._log_completion_masked(model, messages, **hyperparams) - if is_audio_gen_model: - response = await self.router.acompletion( - model=model, - modalities=["text", "audio"], - audio={"voice": "alloy", "format": "pcm16"}, - messages=messages, - # Fixes discrepancy between stream and non-stream token usage - stream_options={"include_usage": True}, - **hyperparams, - ) - else: - response = await self.router.acompletion( - model=model, - messages=messages, - # Fixes discrepancy between stream and non-stream token usage - stream_options={"include_usage": True}, - **hyperparams, - ) - output_text = "" - usage = CompletionUsage() - async for chunk in response: - if hasattr(chunk, "usage"): - usage = CompletionUsage( - prompt_tokens=chunk.usage.prompt_tokens, - completion_tokens=chunk.usage.completion_tokens, - total_tokens=chunk.usage.total_tokens, - ) - yield ChatCompletionChunk( - id=self.id, - object="chat.completion.chunk", - created=int(time()), - model=model, - usage=usage, - choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(choice.delta.audio.get("transcript", "")) - if is_audio_gen_model and choice.delta.audio is not None - else ChatCompletionChoiceOutput.assistant( - choice.delta.content, - tool_calls=[ - tool_call.model_dump() for tool_call in choice.delta.tool_calls - ] - if isinstance(chunk.choices[0].delta.tool_calls, list) - else None, - ), - index=choice.index, - finish_reason=choice.get( - "finish_reason", chunk.get("finish_reason", None) - ), - ) - for choice in chunk.choices - ], - ) - if is_audio_gen_model and chunk.choices[0].delta.audio is not None: - output_text += chunk.choices[0].delta.audio.get("transcript", "") - else: - content = chunk.choices[0].delta.content - output_text += content if content else "" - logger.info(f"{self.id} - Streamed completion: <{mask_string(output_text)}>") - - self._billing.create_llm_events( - model=model, - input_tokens=usage.prompt_tokens, - output_tokens=usage.completion_tokens, - ) - except Exception as e: - self._map_and_log_exception(e, model, messages, api_key, **hyperparams) - yield ChatCompletionChunk( - id=self.id, - object="chat.completion.chunk", - created=int(time()), - model=model, - usage=usage, - choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {e!r}"), - index=0, - finish_reason="error", - ) - ], - ) - - async def generate( - self, - model: str, - messages: list[ChatEntry | dict], - capabilities: list[str] | None = None, - **hyperparams, - ) -> ChatCompletionChunk: - api_key = "" - try: - model = model.strip() - # check audio model type - is_audio_gen_model = False - if model != "": - model_config: LLMModelConfig = self.request.state.all_models.get_llm_model_info( - model - ) - if ( - "audio" in model_config.capabilities - and model_config.deployments[0].provider == "openai" - ): - is_audio_gen_model = True - hyperparams = self._prepare_hyperparams(model, hyperparams, stream=False) - messages = self._prepare_messages(messages) - # omit system prompt for audio input with audio gen - if is_audio_gen_model and messages[0].role in (ChatRole.SYSTEM.value, ChatRole.SYSTEM): - messages = messages[1:] - messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] - model = self.validate_model_id( - model=model, - capabilities=capabilities, - ) - self._log_completion_masked(model, messages, **hyperparams) - if is_audio_gen_model: - completion = await self.router.acompletion( - model=model, - modalities=["text", "audio"], - audio={"voice": "alloy", "format": "pcm16"}, - messages=messages, - **hyperparams, - ) - else: - completion = await self.router.acompletion( - model=model, - messages=messages, - **hyperparams, - ) - self._billing.create_llm_events( - model=model, - input_tokens=completion.usage.prompt_tokens, - output_tokens=completion.usage.completion_tokens, - ) - choices = [] - for choice in completion.choices: - if is_audio_gen_model and choice.message.audio.transcript is not None: - choice.message.content = choice.message.audio.transcript - choices.append(choice.model_dump()) - completion = ChatCompletionChunk( - id=self.id, - object="chat.completion", - created=completion.created, - model=model, - usage=completion.usage.model_dump(), - choices=choices, - ) - logger.info(f"{self.id} - Generated completion: <{mask_string(completion.text)}>") - return completion - except Exception as e: - raise self._map_and_log_exception(e, model, messages, api_key, **hyperparams) from e - - async def retrieve_references( - self, - model: str, - messages: list[ChatEntry | dict], - rag_params: RAGParams | dict | None, - **hyperparams, - ) -> tuple[list[ChatEntry], References | None]: - if rag_params is None: - return messages, None - - 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 - if search_query == "": - 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"{self.id} - `messages[0].role` is not `system` !!!") - rewriter_messages.insert(0, ChatEntry.system("You are a concise assistant.")) - 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) - rewriter_messages[-1] = ChatEntry.user( - ( - f"QUESTION: `{query_ori}`\n\n" - f"Current datetime: {now.isoformat()}\n" - "You need to retrieve documents that are relevant to the user by using a search engine. " - "Use the information provided to generate one good Google search query sentence in English. " - "Do not include any search modifiers or symbols. " - "Make sure all relevant keywords are in the sentence. " - "Convert any ranges into comma-separated list of items. " - "Any date or time in the query should be in numeric format, " - f'for example last year is "{now.year - 1}", last 2 years is "{now.year - 1}, {now.year}". ' - "Reply with only the query. Do not include reasoning, explanations, or notes." - ) - ) - completion = await self.generate( - model=model, - messages=rewriter_messages, - **hyperparams, - ) - search_query = completion.text.strip() - if search_query.startswith('"') and search_query.endswith('"'): - search_query = search_query[1:-1] - logger.info( - ( - f'{self.id} - Rewritten query using "{model}": ' - f"<{mask_string(query_ori)}> -> <{mask_string(search_query)}>" - ) - ) - - # Query - rag_params.search_query = search_query - 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 = 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, - ) - if len(rows) > 1: - logger.info( - ( - 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"{self.id} - Retrieved 1 row from hybrid search: " - f"[{self._mask_retrieved_row(rows[0])}]" - ) - ) - else: - logger.warning(f"{self.id} - Failed to retrieve any rows from hybrid search !") - chunks = [ - Chunk( - text="" if row["Text"] is None else row["Text"], - title="" if row["Title"] is None else row["Title"], - page=row["Page"], - document_id="" if row["File ID"] is None else row["File ID"], - chunk_id=row["ID"], - ) - for row in rows - ] - references = References(chunks=chunks, search_query=search_query) - - # Generate - # https://github.com/langchain-ai/langchain/blob/master/libs/langchain/langchain/chains/retrieval_qa/prompt.py - new_prompt = """UP-TO-DATE CONTEXT:\n\n""" - for chunk in chunks: - new_prompt += f""" -# Document: {chunk.title} -## Document ID: {chunk.chunk_id} - -{chunk.text} - -""" - # new_prompt += f""" - # QUESTION:\n{body.messages[-1].content} - - # 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[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=self.id, - prompt=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 - def _mask_retrieved_row(row: dict[str, str | None]): - return { - "ID": row["ID"], - "File ID": row["File ID"], - "Title": mask_string(row["Title"]), - "Text": mask_string(row["Text"]), - "Page": str(row["Page"]), - } - - async def rag_stream( - self, - model: str, - messages: list[ChatEntry | dict], - rag_params: RAGParams | None = None, - **hyperparams, - ) -> AsyncGenerator[References | ChatCompletionChunk, None]: - try: - hyperparams = self._prepare_hyperparams(model, hyperparams) - messages, references = await self.retrieve_references( - model=model, - messages=messages, - rag_params=rag_params, - **hyperparams, - ) - if references is not None: - yield references - async for chunk in self.generate_stream( - model=model, - messages=messages, - **hyperparams, - ): - yield chunk - except Exception as e: - self._log_exception(model, messages, **hyperparams) - yield ChatCompletionChunk( - id=self.id, - object="chat.completion.chunk", - created=int(time()), - model=model, - usage=None, - choices=[ - ChatCompletionChoiceDelta( - message=ChatEntry.assistant(f"[ERROR] {e!r}"), - index=0, - finish_reason="error", - ) - ], - ) - - async def rag( - self, - 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( - model=model, - messages=messages, - rag_params=rag_params, - **hyperparams, - ) - try: - response = await self.generate( - model=model, - messages=messages, - capabilities=capabilities, - **hyperparams, - ) - response.references = references - except ContextOverflowError: - logger.warning(f"{self.id} - Chat is too long, returning references only.") - response = ChatCompletionChunk( - id=self.id, - object="chat.completion", - created=int(time()), - model=model, - usage=None, - choices=[], - references=references, - ) - return response diff --git a/services/api/src/owl/loaders.py b/services/api/src/owl/loaders.py deleted file mode 100644 index f53a4c0..0000000 --- a/services/api/src/owl/loaders.py +++ /dev/null @@ -1,283 +0,0 @@ -import re -import sys -from os.path import join, splitext -from tempfile import TemporaryDirectory - -from langchain.text_splitter import RecursiveCharacterTextSplitter -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 -from owl.unstructuredio import UnstructuredAPIFileLoader - -# build a table mapping all non-printable characters to None -NOPRINT_TRANS_TABLE = { - i: None for i in range(0, sys.maxunicode + 1) if not chr(i).isprintable() and chr(i) != "\n" -} - - -def make_printable(s: str) -> str: - """ - Replace non-printable characters in a string using - `translate()` that removes characters that map to None. - - # https://stackoverflow.com/a/54451873 - """ - return s.translate(NOPRINT_TRANS_TABLE) - - -def format_chunks(documents: list[Document], file_name: str, page: int = None) -> list[Chunk]: - if page is not None: - for d in documents: - d.metadata["page"] = page - chunks = [ - # TODO: Probably can use regex for this - # Replace vertical tabs, form feed, Unicode replacement character - # page_content=d.page_content.replace("\x0c", " ") - # .replace("\x0b", " ") - # .replace("\uFFFD", ""), - # For now we use a more aggressive strategy - Chunk( - text=make_printable(d.page_content), - title=d.metadata.get("title", ""), - page=d.metadata.get("page", 0), - file_name=file_name, - file_path=file_name, - metadata=d.metadata, - ) - for d in documents - ] - return chunks - - -async def load_file( - 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. - - Args: - file_name (str): The name of the file to be loaded. - content (bytes): The binary content of the file. - chunk_size (int): The desired size of each chunk. - chunk_overlap (int): The amount of overlap between chunks. - - Returns: - list[Chunk]: A list of Chunk objects representing the processed file content. - - Raises: - ValueError: If the file type is not supported. - """ - - ext = splitext(file_name)[1].lower() - with TemporaryDirectory() as tmp_dir_path: - tmp_path = join(tmp_dir_path, f"tmpfile{ext}") - with open(tmp_path, "wb") as tmp: - tmp.write(content) - tmp.flush() - logger.debug(f"Loading from temporary file: {tmp_path}") - - 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) - chunks = format_chunks(documents, file_name, page=1) - if ext == ".json": - chunks = split_chunks( - SplitChunksRequest( - chunks=chunks, - params=SplitChunksParams( - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - ), - ) - ) - - elif ext in (".html", ".xml", ".pptx", ".ppt", ".xlsx", ".xls", ".docx", ".doc"): - loader = UnstructuredAPIFileLoader( - tmp_path, - url=ENV_CONFIG.unstructuredio_url, - api_key=ENV_CONFIG.unstructuredio_api_key_plain, - mode="paged", - xml_keep_tags=True, - ) - documents = await loader.aload() - 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, - params=SplitChunksParams( - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - ), - ) - ) - - elif ext in (".md", ".txt"): - loader = UnstructuredAPIFileLoader( - tmp_path, - url=ENV_CONFIG.unstructuredio_url, - api_key=ENV_CONFIG.unstructuredio_api_key_plain, - mode="elements", - chunking_strategy="by_title", - max_characters=chunk_size, - overlap=chunk_overlap, - ) - documents = await loader.aload() - logger.debug('File "{file_name}" loaded: {docs}', file_name=file_name, docs=documents) - chunks = format_chunks(documents, file_name) - - elif ext == ".pdf": - - 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, - ) - - 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 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 - - -def combine_table_chunks(chunks: list[Chunk]) -> list[Chunk]: - """Combines chunks identified as parts of a table into a single chunk. - - This function iterates through the chunks and identifies consecutive chunks that - belong to the same table based on the presence of "text_as_html" and "is_continuation" - metadata flags. It then merges these chunks into a single chunk, preserving the - table's HTML structure. - - Args: - chunks (List[Chunk]): A list of Chunk objects. - - Returns: - List[Chunk]: A list of Chunk objects with table chunks combined. - """ - table_chunk_idx_groups = {} - current_table_start_idx = 0 - for i, chunk in enumerate(chunks): - if "text_as_html" in chunk.metadata and chunk.metadata.get("is_continuation", False): - table_chunk_idx_groups[current_table_start_idx].append(i) - elif "text_as_html" in chunk.metadata: - current_table_start_idx = i - table_chunk_idx_groups[current_table_start_idx] = [current_table_start_idx] - chunk.metadata.pop("orig_elements", None) - - table_indexes = table_chunk_idx_groups.keys() - processed_chunks = [] - current_table_start_idx = 0 - current_table_end_idx = 0 - table_chunk = Chunk(text="") - for i, chunk in enumerate(chunks): - if i in table_indexes: - current_table_start_idx = i - current_table_end_idx = table_chunk_idx_groups[i][-1] - table_chunk = Chunk( - text=chunk.metadata.get("text_as_html", chunk.text), - title=chunk.title, - page=chunk.page, - file_name=chunk.file_name, - file_path=chunk.file_path, - metadata=chunk.metadata.copy(), - ) - table_chunk.metadata.pop("text_as_html", None) - if current_table_end_idx == current_table_start_idx: - processed_chunks.append(table_chunk) - elif i > current_table_start_idx and i <= current_table_end_idx: - table_chunk.text += chunk.metadata.get("text_as_html", chunk.text) - if i == current_table_end_idx: - processed_chunks.append(table_chunk) - else: - processed_chunks.append(chunk) - - return processed_chunks - - -def split_chunks(request: SplitChunksRequest) -> list[Chunk]: - _id = request.id - logger.info(f"{_id} - Split documents request: {request.str_trunc()}") - if request.params.method == "RecursiveCharacterTextSplitter": - text_splitter = RecursiveCharacterTextSplitter( - chunk_size=request.params.chunk_size, - chunk_overlap=request.params.chunk_overlap, - ) - else: - raise ValueError(f"Split method not supported: {request.params.method}") - - try: - chunks = [] - for chunk in request.chunks: - # Module-level functions store compiled object in a cache - text_tables_parts = re.split(r"(.*?
)", chunk.text, flags=re.DOTALL) - table_split_texts = [part for part in text_tables_parts if part] - for table_split_text in table_split_texts: - if table_split_text.startswith("") and table_split_text.endswith( - "
" - ): - chunks.append(chunk) - else: - chunks += [ - Chunk( - text=d.page_content, - title=chunk.title, - page=chunk.page, - file_name=chunk.file_name, - file_path=chunk.file_name, - metadata=chunk.metadata, - ) - for d in text_splitter.split_documents( - [Document(page_content=chunk.text, metadata={})] - ) - ] - logger.info( - f"{_id} - {len(request.chunks):,d} chunks split into {len(chunks):,d} chunks.", - ) - return chunks - except Exception: - logger.exception("Failed to split chunks.") - raise diff --git a/services/api/src/owl/models.py b/services/api/src/owl/models.py deleted file mode 100644 index f609dc4..0000000 --- a/services/api/src/owl/models.py +++ /dev/null @@ -1,416 +0,0 @@ -import asyncio -import base64 -import imghdr -import io -import itertools -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 ENV_CONFIG -from owl.protocol import ( - Chunk, - ClipInputData, - CompletionUsage, - EmbeddingModelConfig, - EmbeddingResponse, - EmbeddingResponseData, - ExternalKeys, - ModelListConfig, - RerankingModelConfig, -) -from owl.utils import select_external_api_key - -litellm.drop_params = True -litellm.set_verbose = False -litellm.suppress_debug_info = True - -HTTP_CLIENT = httpx.AsyncClient(timeout=60.0, transport=httpx.AsyncHTTPTransport(retries=3)) - - -@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 - return Router( - model_list=[ - { - "model_name": m.id, - "litellm_params": { - "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="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=5.5, - ) - - -# Cached function -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: - @staticmethod - def batch(seq, n): - if n < 1: - raise ValueError("`n` must be > 0") - 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 = { - "cohere": ENV_CONFIG.cohere_api_base, - "voyage": ENV_CONFIG.voyage_api_base, - "jina": ENV_CONFIG.jina_api_base, - } - - def __init__(self, request: Request): - """Reranker router. - - Args: - 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: RerankingModelConfig = ( - self.request.state.all_models.get_rerank_model_info(reranker_name) - ) - reranker_config = reranker_config.model_dump(exclude_none=True) - _, model_name = self._resolve_provider_model_name(reranker_config["id"]) - self.reranker_config = reranker_config - # 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`: {deployment['provider']} not supported please use only following provider: ellm/cohere/voyage/jina" - ) - api_url = ( - 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": model_name, - "api_key": api_key, - "api_url": api_url, - } - - async def rerank_chunks( - self, - reranker_name: str, - chunks: list[Chunk], - query: str, - batch_size: int = 256, - title_weight: float = 0.6, - 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, strict=True) - ] - concat_scores = await self._rerank_by_batch(query, all_concats, batch_size) - scores = [x["relevance_score"] for x in concat_scores] - else: - 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, 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, strict=True))), - key=lambda x: x[1], - reverse=True, - ) - logger.info(f"Reranked order: {[r[2] for r in reranked_chunks]}") - return reranked_chunks - - async def _rerank(self, query, documents: list[str]) -> list[dict]: - headers = { - "Content-Type": "application/json", - "Authorization": ( - f"Bearer {self.reranking_args['api_key']}" - if self.provider_name in self.API_MAP.keys() - else "" - ), - } - data = { - "model": self.reranking_args["model"], - "query": query, - "documents": documents, - "return_documents": False, - } - - 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) - if self.provider_name == "voyage": - return response["data"] - else: - return response["results"] - - 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 = 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 - all_data.extend(_tmp) - return all_data - - -class CloudEmbedder(CloudBase): - def __init__(self, request: Request): - """Embedder router. - - Args: - 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: EmbeddingModelConfig = self.request.state.all_models.get_embed_model_info( - embedder_name - ) - embedder_config = embedder_config.model_dump(exclude_none=True) - self.embedder_config = embedder_config - 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["id"], - "dimensions": self.embedder_config.get("dimensions"), - } - - 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.external_keys.jina}", - } - data = {"input": texts, "model": self.embedding_args["model"]} - response = await HTTP_CLIENT.post( - ENV_CONFIG.jina_api_base + "/embeddings", - headers=headers, - json=data, - ) - if response.status_code != 200: - raise RuntimeError(response.text) - response = EmbeddingResponse.model_validate_json(response.text) - else: - response = await self.embedder_router.aembedding(**self.embedding_args, input=texts) - response = EmbeddingResponse.model_validate(response.model_dump()) - - return response - - 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.embedder_config["owned_by"] == "cohere": - self.embedding_args["input_type"] = "search_document" - batch_size = 96 # limit on cohere server - if self.embedder_config["owned_by"] == "jina": - batch_size = 128 # don't know limit, but too large will timeout - if self.embedder_config["owned_by"] == "voyage": - batch_size = 128 # limit on voyage server - 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), - total_tokens=sum(r.usage.total_tokens for r in responses), - ) - embeddings = EmbeddingResponse( - data=[EmbeddingResponseData(embedding=e, index=i) for i, e in enumerate(embeddings)], - model=responses[0].model, - usage=usages, - ) - self._billing.create_embedding_events( - model=self.embedder_config["id"], - token_usage=usages.total_tokens, - ) - return embeddings - - 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.embedder_config["owned_by"] == "cohere": - self.embedding_args["input_type"] = "search_query" - 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): - def __init__(self): - """ - Args: - client: an httpx client - Info: - Read the clip_api_base from the .env directly - Only use for image embedding - Query can be text/image - can be used for text-to-image search or image-to-image search - DO NOT DO image-to-text-and-image search - same modality would most certainly always result in a higher scores than different modality obj - """ - api_url = ENV_CONFIG.clip_api_base + "/post" - self.embedding_args = { - "api_url": api_url, - } - - 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 = await HTTP_CLIENT.post( - self.embedding_args["api_url"], - headers=headers, - data=orjson.dumps(data), - ) - if response.status_code != 200: - raise RuntimeError(response.text) - return [x["embedding"] for x in json_loads(response)["data"]] - - def _parse_data(self, objects: list[ClipInputData]): - """ - The objects are list of [ClipInputData] - """ - return [ - {"uri": self._get_blob_from_data(obj)} if obj.image_filename else {"text": obj.content} - for obj in objects - ] - - def _get_blob_from_data(self, data: ClipInputData): - """get blob from ClipInputData""" - with io.BytesIO(data.content) as f: - # Get the image format - try: - img_format = imghdr.what(f).lower() - 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 - - 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 = await asyncio.gather( - *[self._embed(obj) for obj in self.batch(objects, batch_size)] - ) - return list(itertools.chain(*embeddings)) - - async def embed_query(self, data: ClipInputData) -> list[float]: - """Embed query text/image.""" - 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 deleted file mode 100644 index 622d23d..0000000 --- a/services/api/src/owl/protocol.py +++ /dev/null @@ -1,2598 +0,0 @@ -""" -NOTES: - -- Pydantic supports setting mutable values as default. - This is in contrast to native `dataclasses` where it is not supported. - -- Pydantic supports setting default fields in any order. - This is in contrast to native `dataclasses` where fields with default values must be defined after non-default fields. -""" - -from __future__ import annotations - -import re -from copy import deepcopy -from datetime import datetime, timezone -from enum import Enum, EnumMeta -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 sqlmodel import JSON, Column, MetaData, SQLModel -from sqlmodel import Field as sql_Field -from typing_extensions import Self - -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.")] - - -def sanitise_document_id(v: str) -> str: - if v.startswith('"') and v.endswith('"'): - v = v[1:-1] - return v - - -def sanitise_document_id_list(v: list[str]) -> list[str]: - return [sanitise_document_id(vv) for vv in v] - - -DocumentID = Annotated[str, AfterValidator(sanitise_document_id)] -DocumentIDList = Annotated[list[str], AfterValidator(sanitise_document_id_list)] - -EXAMPLE_CHAT_MODEL_IDS = ["openai/gpt-4o-mini"] -# for openai embedding models doc: https://platform.openai.com/docs/guides/embeddings -# for cohere embedding models doc: https://docs.cohere.com/reference/embed -# for jina embedding models doc: https://jina.ai/embeddings/ -# for voyage embedding models doc: https://docs.voyageai.com/docs/embeddings -# for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_EMBEDDING_MODEL_IDS = [ - "openai/text-embedding-3-small-512", - "ellm/sentence-transformers/all-MiniLM-L6-v2", -] -# for cohere reranking models doc: https://docs.cohere.com/reference/rerank-1 -# for jina reranking models doc: https://jina.ai/reranker -# for colbert reranking models doc: https://docs.voyageai.com/docs/reranker -# for hf embedding models doc: check the respective hf model page, name should be ellm/{org}/{model} -EXAMPLE_RERANKING_MODEL_IDS = [ - "cohere/rerank-multilingual-v3.0", - "ellm/cross-encoder/ms-marco-TinyBERT-L-2", -] - -IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] -AUDIO_FILE_EXTENSIONS = [".mp3", ".wav"] -DOCUMENT_FILE_EXTENSIONS = [ - ".pdf", - ".txt", - ".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 = "" - deepseek: str = "" - - -class OkResponse(BaseModel): - ok: bool = True - - -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" - AUDIO = "audio" - TOOL = "tool" - 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_IDS, - ) - 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.", - ) - - -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_IDS, - ) - api_base: str = Field( - default="", - description="Hosting url for the model.", - ) - provider: str = Field( - default="", - description="Provider of the model.", - ) - - -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_IDS, - ) - 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=EXAMPLE_EMBEDDING_MODEL_IDS, - ) - 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 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=EXAMPLE_RERANKING_MODEL_IDS, - ) - capabilities: list[ModelCapability] = Field( - default=[ModelCapability.RERANK], - description="List of capabilities of model.", - examples=[[ModelCapability.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): - 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 `capabilities`` is chat only, filter out audio model - if capabilities == ["chat"]: - models = [m for m in models if "audio" not in m.capabilities] - if len(models) == 0: - raise ResourceNotFoundError(f"No model found with capabilities: {capabilities}") - model = natsorted(models, key=self._sort_key_with_priority)[0] - 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( - default="", - description="Request ID for logging purposes.", - examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], - ) - chunks: list[Chunk] = Field( - description="List of `Chunk` where each will be further split into chunks.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - params: SplitChunksParams = Field( - default=SplitChunksParams(), - description="How to split each document. Defaults to `RecursiveCharacterTextSplitter` with chunk_size = 1000 and chunk_overlap = 200.", - examples=[SplitChunksParams()], - ) - - def str_trunc(self) -> str: - return f"id={self.id} len(chunks)={len(self.chunks)} params={self.params}" - - -class RAGParams(BaseModel): - table_id: str = Field(description="Knowledge Table ID", examples=["my-dataset"], min_length=2) - reranking_model: str | None = Field( - default=None, - description="Reranking model to use for hybrid search.", - examples=[EXAMPLE_RERANKING_MODEL_IDS[0], 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.", - ) - k: Annotated[int, Field(gt=0, le=1024)] = Field( - default=3, - gt=0, - le=1024, - description="Top-k closest text in terms of embedding distance. Must be in [1, 1024]. Defaults to 3.", - examples=[3], - ) - rerank: bool = Field( - default=True, - description="Flag to perform rerank on the retrieved results. Defaults to True.", - examples=[True, False], - ) - concat_reranker_input: bool = Field( - default=False, - description="Flag to concat title and content as reranker input. Defaults to False.", - examples=[True, False], - ) - - -class VectorSearchRequest(RAGParams): - id: str = Field( - default="", - description="Request ID for logging purposes.", - examples=["018ed5f1-6399-71f7-86af-fc18d4a3e3f5"], - ) - search_query: str = Field(description="Query used to retrieve items from the KB database.") - - -class VectorSearchResponse(BaseModel): - object: str = Field( - default="kb.search_response", - description="Type of API response object.", - examples=["kb.search_response"], - ) - chunks: list[Chunk] = Field( - default=[], - description="A list of `Chunk`.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - - -class ChatRole(str, Enum): - """Represents who said a chat message.""" - - SYSTEM = "system" - """The message is from the system (usually a steering prompt).""" - USER = "user" - """The message is from the user.""" - ASSISTANT = "assistant" - """The message is from the language model.""" - # FUNCTION = "function" - # """The message is the result of a function call.""" - - def __str__(self) -> str: - return self.value - - -def sanitise_name(v: str) -> str: - """Replace any non-alphanumeric and dash characters with space. - - Args: - v (str): Raw name string. - - Returns: - out (str): Sanitised name string that is safe for OpenAI. - """ - return re.sub(r"[^a-zA-Z0-9_-]", "_", v).strip() - - -MessageName = Annotated[str, AfterValidator(sanitise_name)] - - -class MessageToolCallFunction(BaseModel): - arguments: str - name: str | None - - -class MessageToolCall(BaseModel): - id: str | None - function: MessageToolCallFunction - type: str - - -class ChatEntry(BaseModel): - """Represents a message in the chat context.""" - - model_config = ConfigDict(use_enum_values=True) - - role: ChatRole - """Who said the message?""" - 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).""" - - @classmethod - def system(cls, content: str, **kwargs): - """Create a new system message.""" - return cls(role=ChatRole.SYSTEM, content=content, **kwargs) - - @classmethod - def user(cls, content: str, **kwargs): - """Create a new user message.""" - return cls(role=ChatRole.USER, content=content, **kwargs) - - @classmethod - 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 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 ChatCompletionChoiceOutput(ChatEntry): - tool_calls: list[MessageToolCall] | None = None - """List of tool calls if the message includes tool call responses.""" - - -class ChatThread(BaseModel): - object: str = Field( - default="chat.thread", - description="Type of API response object.", - examples=["chat.thread"], - ) - thread: list[ChatEntry] = Field( - default=[], - description="List of chat messages.", - examples=[ - [ - ChatEntry.system(content="You are an assistant."), - ChatEntry.user(content="Hello."), - ] - ], - ) - - -class CompletionUsage(BaseModel): - prompt_tokens: int = Field(default=0, description="Number of tokens in the prompt.") - completion_tokens: int = Field( - default=0, description="Number of tokens in the generated completion." - ) - total_tokens: int = Field( - default=0, description="Total number of tokens used in the request (prompt + completion)." - ) - - -class ChatCompletionChoice(BaseModel): - message: ChatEntry | ChatCompletionChoiceOutput = Field( - description="A chat completion message generated by the model." - ) - index: int = Field(description="The index of the choice in the list of choices.") - finish_reason: str | None = Field( - default=None, - 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." - ), - ) - - @property - def text(self) -> str: - """The text of the most recent chat completion.""" - return self.message.content - - -class ChatCompletionChoiceDelta(ChatCompletionChoice): - @computed_field - @property - def delta(self) -> ChatEntry | ChatCompletionChoiceOutput: - return self.message - - -class References(BaseModel): - object: str = Field( - default="chat.references", - description="Type of API response object.", - examples=["chat.references"], - ) - chunks: list[Chunk] = Field( - default=[], - description="A list of `Chunk`.", - examples=[ - [ - Chunk( - text="The Name of the Title is Hope\n\n...", - title="The Name of the Title is Hope", - page=0, - file_name="sample_tables.pdf", - file_path="amagpt/sample_tables.pdf", - metadata={ - "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)", - "Trapped": "False", - }, - ) - ] - ], - ) - search_query: str = Field(description="Query used to retrieve items from the KB database.") - finish_reason: Literal["stop", "context_overflow"] | None = Field( - default=None, - description=""" -In streaming mode, reference chunk will be streamed first. -However, if the model's context length is exceeded, then there will be no further completion chunks. -In this case, "finish_reason" will be set to "context_overflow". -Otherwise, it will be None or null. -""", - ) - - def remove_contents(self): - copy = self.model_copy(deep=True) - for d in copy.documents: - d.page_content = "" - return copy - - -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="Type of API response object.", - examples=["chat.completion.chunk"], - ) - created: int = Field( - description="The Unix timestamp (in seconds) of when the chat completion was created." - ) - model: str = Field(description="The model used for the chat completion.") - usage: CompletionUsage | None = Field( - description="Number of tokens consumed for the completion request.", - examples=[CompletionUsage(), None], - ) - choices: list[ChatCompletionChoice | ChatCompletionChoiceDelta] = Field( - description="A list of chat completion choices. Can be more than one if `n` is greater than 1." - ) - references: References | None = Field( - default=None, - description="Contains the references retrieved from database when performing chat completion with RAG.", - ) - - @property - def message(self) -> ChatEntry | ChatCompletionChoiceOutput | None: - return self.choices[0].message if len(self.choices) > 0 else None - - @property - def prompt_tokens(self) -> int: - return self.usage.prompt_tokens - - @property - def completion_tokens(self) -> int: - return self.usage.completion_tokens - - @property - def text(self) -> str: - """The text of the most recent chat completion.""" - 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="Type of API response object.", - examples=["gen_table.completion.chunk"], - ) - output_column_name: str - row_id: str - - -class FunctionParameter(BaseModel): - type: str = Field( - default="", description="The type of the parameter, e.g., 'string', 'number'." - ) - description: str = Field(default="", description="A description of the parameter.") - enum: list[str] = Field( - default=[], description="An optional list of allowed values for the parameter." - ) - - -class FunctionParameters(BaseModel): - type: str = Field( - default="object", description="The type of the parameters object, usually 'object'." - ) - properties: dict[str, FunctionParameter] = Field( - description="The properties of the parameters object." - ) - required: list[str] = Field(description="A list of required parameter names.") - additionalProperties: bool = Field( - default=False, description="Whether additional properties are allowed." - ) - - -class Function(BaseModel): - name: str = Field(default="", description="The name of the function.") - description: str = Field(default="", description="A description of what the function does.") - parameters: FunctionParameters = Field(description="The parameters for the function.") - - -class Tool(BaseModel): - type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") - function: Function = Field(description="The function details of the tool.") - - -class ToolChoiceFunction(BaseModel): - name: str = Field(default="", description="The name of the function.") - - -class ToolChoice(BaseModel): - type: str = Field(default="function", description="The type of the tool, e.g., 'function'.") - function: ToolChoiceFunction = Field(description="Select a tool for the chat model to use.") - - -class ChatRequest(BaseModel): - id: str = Field( - default="", - description="Chat ID. Must be unique against document ID for it to be embeddable. Defaults to ''.", - ) - 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.", - ) - messages: list[ChatEntry] = Field( - description="A list of messages comprising the conversation so far.", - min_length=1, - ) - 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], - ) - n: int = Field( - default=1, - description="How many chat completion choices to generate for each input message.", - examples=[1], - ) - stream: bool = Field( - default=True, - description=""" -If set, partial message deltas will be sent, like in ChatGPT. -Tokens will be sent as server-sent events as they become available, -with the stream terminated by a 'data: [DONE]' message. -""", - examples=[True], - ) - 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=[{}], - ) - user: str = Field( - default="", - description="A unique identifier representing your end-user. For monitoring and debugging purposes.", - examples=[""], - ) - - @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 ChatRequestWithTools(ChatRequest): - tools: list[Tool] = Field( - description="A list of tools available for the chat model to use.", - min_length=1, - examples=[ - # --- [Tool Function] --- - # def get_delivery_date(order_id: str) -> datetime: - # # Connect to the database - # conn = sqlite3.connect('ecommerce.db') - # cursor = conn.cursor() - # # ... - [ - Tool( - type="function", - function=Function( - name="get_delivery_date", - description="Get the delivery date for a customer's order.", - parameters=FunctionParameters( - type="object", - properties={ - "order_id": FunctionParameter( - type="string", description="The customer's order ID." - ) - }, - required=["order_id"], - additionalProperties=False, - ), - ), - ) - ], - ], - ) - tool_choice: str | ToolChoice = Field( - default="auto", - description="Set `auto` to let chat model pick a tool or select a tool for the chat model to use.", - examples=[ - "auto", - ToolChoice(type="function", function=ToolChoiceFunction(name="get_delivery_date")), - ], - ) - - -class EmbeddingRequest(BaseModel): - input: str | list[str] = Field( - description=( - "Input text to embed, encoded as a string or array of strings " - "(to embed multiple inputs in a single request). " - "The input must not exceed the max input tokens for the model, and cannot contain empty string." - ), - examples=["What is a llama?", ["What is a llama?", "What is an alpaca?"]], - ) - model: str = Field( - description=( - "The ID of the model to use. " - "You can use the List models API to see all of your available models." - ), - examples=EXAMPLE_EMBEDDING_MODEL_IDS, - ) - type: Literal["query", "document"] = Field( - default="document", - description=( - 'Whether the input text is a "query" (used to retrieve) or a "document" (to be retrieved).' - ), - examples=["query", "document"], - ) - encoding_format: Literal["float", "base64"] = Field( - default="float", - description=( - '_Optional_. The format to return the embeddings in. Can be either "float" or "base64". ' - "`base64` string should be decoded as a `float32` array. " - "Example: `np.frombuffer(base64.b64decode(response), dtype=np.float32)`" - ), - examples=["float", "base64"], - ) - - -class EmbeddingResponseData(BaseModel): - object: str = Field( - default="embedding", - description="Type of API response object.", - examples=["embedding"], - ) - embedding: list[float] | str = Field( - description=( - "The embedding vector, which is a list of floats or a base64-encoded string. " - "The length of vector depends on the model." - ), - examples=[[0.0, 1.0, 2.0], []], - ) - index: int = Field( - default=0, - description="The index of the embedding in the list of embeddings.", - examples=[0, 1], - ) - - -class EmbeddingResponse(BaseModel): - object: str = Field( - default="list", - description="Type of API response object.", - examples=["list"], - ) - data: list[EmbeddingResponseData] = Field( - description="List of `EmbeddingResponseData`.", - examples=[[EmbeddingResponseData(embedding=[0.0, 1.0, 2.0])]], - ) - model: str = Field( - description="The ID of the model used.", - examples=["openai/text-embedding-3-small-512"], - ) - usage: CompletionUsage = Field( - default=CompletionUsage(), - description="The number of tokens consumed.", - examples=[CompletionUsage()], - ) - - -class ClipInputData(BaseModel): - """Data model for Clip input data, assume if image_filename is None then it have to be text, otherwise, the input is an image with bytes content""" - - content: str | bytes - """content of this input data, either be str of text or an """ - image_filename: str | None - """image filename of the content, None if the content is text""" - - -T = TypeVar("T") - - -class Page(BaseModel, Generic[T]): - items: Annotated[ - Sequence[T], Field(description="List of items paginated items.", examples=[[]]) - ] = [] - 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): - return np.array(x) if isinstance(x, list) else x - - -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"(? 0: - return list[float] if json_safe else NdArray - return _str_to_py_type[py_type] - - -class MetaEnum(EnumMeta): - def __contains__(cls, x): - try: - cls[x] - except KeyError: - return False - return True - - -class CSVDelimiter(Enum, metaclass=MetaEnum): - COMMA = "," - """Comma-separated""" - TAB = "\t" - """Tab-separated""" - - def __str__(self) -> str: - return self.value - - -class ColumnDtype(str, Enum, metaclass=MetaEnum): - INT = "int" - INT8 = "int8" - FLOAT = "float" - FLOAT32 = "float32" - FLOAT16 = "float16" - BOOL = "bool" - STR = "str" - DATE_TIME = "date-time" - IMAGE = "image" - AUDIO = "audio" - - def __str__(self) -> str: - return self.value - - -class ColumnDtypeCreate(str, Enum, metaclass=MetaEnum): - INT = "int" - FLOAT = "float" - BOOL = "bool" - STR = "str" - IMAGE = "image" - AUDIO = "audio" - - def __str__(self) -> str: - return self.value - - -class TableType(str, Enum, metaclass=MetaEnum): - ACTION = "action" - """Action table.""" - KNOWLEDGE = "knowledge" - """Knowledge table.""" - 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_IDS, - ) - source_column: str = Field( - description="The source column for embedding.", - examples=["text_column"], - ) - - -class CodeGenConfig(p.CodeGenConfig): - pass - - -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 | CodeGenConfig -DiscriminatedGenConfig = Annotated[ - Union[ - Annotated[CodeGenConfig, Tag("gen_config.code")], - Annotated[LLMGenConfig, Tag("gen_config.llm")], - Annotated[LLMGenConfig, Tag("gen_config.chat")], - Annotated[EmbedGenConfig, Tag("gen_config.embed")], - ], - Discriminator(_gen_config_discriminator), -] - - -class ColumnSchema(BaseModel): - id: str = Field(description="Column name.") - dtype: ColumnDtype = Field( - default=ColumnDtype.STR, - description='Column data type, one of ["int", "int8", "float", "float32", "float16", "bool", "str", "date-time", "image"]', - ) - vlen: PositiveInt = Field( # type: ignore - default=0, - description=( - "_Optional_. Vector length. " - "If this is larger than zero, then `dtype` must be one of the floating data types. Defaults to zero." - ), - ) - index: bool = Field( - default=True, - description=( - "_Optional_. Whether to build full-text-search (FTS) or vector index for this column. " - "Only applies to string and vector columns. Defaults to True." - ), - ) - gen_config: DiscriminatedGenConfig | None = Field( - default=None, - description=( - '_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 (ColumnDtype.FLOAT32, ColumnDtype.FLOAT16): - raise ValueError("Vector columns must contain float32 or float16 only.") - return self - - -class ColumnSchemaCreate(ColumnSchema): - id: ColName = Field(description="Column name.") - dtype: ColumnDtypeCreate = Field( - default=ColumnDtypeCreate.STR, - description='Column data type, one of ["int", "float", "bool", "str", "image", "audio"]', - ) - - @model_validator(mode="before") - def match_column_dtype_file_to_image(self) -> Self: - if self.get("dtype", "") == "file": - self["dtype"] = ColumnDtype.IMAGE - return self - - @model_validator(mode="after") - def check_output_column_dtype(self) -> Self: - if self.gen_config is not None and self.vlen == 0: - if isinstance(self.gen_config, CodeGenConfig): - if self.dtype not in (ColumnDtype.STR, ColumnDtype.IMAGE): - raise ValueError( - "Output column must be either string or image column when gen_config is CodeGenConfig." - ) - elif self.dtype != ColumnDtype.STR: - raise ValueError("Output column must be string column.") - return self - - -class TableSQLModel(SQLModel): - metadata = MetaData() - - -class TableBase(TableSQLModel): - id: str = sql_Field(primary_key=True, description="Table name.") - 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): - cols: list[ColumnSchema] = sql_Field(description="List of column schema.") - - def get_col(self, id: str): - return [c for c in self.cols if c.id.lower() == id.lower()][0] - - @staticmethod - def _get_col_dtype(py_type: str, vlen: int = 0): - if vlen > 0: - return pa.list_(_str_to_arrow[py_type], vlen) - return _str_to_arrow[py_type] - - @property - def pyarrow(self) -> pa.Schema: - return pa.schema( - [pa.field(c.id, self._get_col_dtype(c.dtype.value, c.vlen)) for c in self.cols] - ) - - @property - def pyarrow_vec(self) -> pa.Schema: - return pa.schema( - [ - pa.field(c.id, self._get_col_dtype(c.dtype.value, c.vlen)) - for c in self.cols - if c.vlen > 0 - ] - ) - - def add_state_cols(self) -> Self: - """ - Adds state columns. - - Returns: - self (TableSchemaCreate): TableSchemaCreate - """ - cols = [] - 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=ColumnDtype.STR)) - self.cols = cols - return self - - def add_info_cols(self) -> Self: - """ - Adds "ID", "Updated at" columns. - - Returns: - self (TableSchemaCreate): TableSchemaCreate - """ - self.cols = [ - 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 - 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: {col_ids}." - ) - ) - elif isinstance(gen_config, CodeGenConfig): - source_col = next( - (c for c in available_cols if c.id == gen_config.source_column), None - ) - if source_col is None: - raise ValueError( - ( - f"Table '{self.id}': " - f"Code Execution config of column '{col.id}' referenced " - f"an invalid source column '{gen_config.source_column}'. " - "Make sure you only reference columns on its left. " - f"Available columns: {col_ids}." - ) - ) - if source_col.dtype != ColumnDtype.STR: - raise ValueError( - ( - f"Table '{self.id}': " - f"Code Execution config of column '{col.id}' referenced " - f"a source column '{gen_config.source_column}' with an invalid datatype of '{source_col.dtype}'. " - "Make sure the source column is Str typed." - ) - ) - elif isinstance(gen_config, LLMGenConfig): - # Insert default prompts if needed - system_prompt, user_prompt = self.get_default_prompts( - 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: {col_ids}." - ) - ) - return self - - -class TableSchemaCreate(TableSchema): - id: TableName = Field(description="Table name.") - cols: list[ColumnSchemaCreate] = Field(description="List of column schema.") - - @model_validator(mode="after") - def check_cols(self) -> Self: - if len(set(c.id.lower() for c in self.cols)) != len(self.cols): - raise ValueError("There are repeated column names (case-insensitive) in the schema.") - if sum(c.id.lower() in ("id", "updated at") for c in self.cols) > 0: - raise ValueError("Schema cannot contain column names: 'ID' or 'Updated at'.") - if sum(c.vlen > 0 for c in self.cols) > 0: - raise ValueError("Schema cannot contain columns with `vlen` > 0.") - 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 - - -class KnowledgeTableSchemaCreate(TableSchemaCreate): - embedding_model: str - - @model_validator(mode="after") - def check_cols(self) -> Self: - super().check_cols() - num_text_cols = sum( - c.id.lower() in ("text", "title", "file id", "page") for c in self.cols - ) - if num_text_cols != 0: - raise ValueError( - "Schema cannot contain column names: 'Text', 'Title', 'File ID', 'Page'." - ) - 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") - def check_cols(self) -> Self: - super().check_cols() - num_text_cols = sum( - c.id.lower() in ("text", "title", "file id", "page") for c in self.cols - ) - if num_text_cols != 0: - raise ValueError( - "Schema cannot contain column names: 'Text', 'Title', 'File ID', 'Page'." - ) - return self - - @model_validator(mode="after") - def check_gen_configs(self) -> Self: - # Check gen config using TableSchema - return self - - -class ChatTableSchemaCreate(TableSchemaCreate): - pass - - -class AddChatColumnSchema(TableSchemaCreate): - @model_validator(mode="after") - 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, table=True): - cols: list[dict[str, Any]] = sql_Field( - sa_column=Column(JSON), description="List of column schema." - ) - parent_id: str | None = sql_Field( - default=None, - description="The parent table ID. If None (default), it means this is a template table.", - ) - title: str = sql_Field( - default="", - description="Chat title. Defaults to ''.", - ) - updated_at: str = sql_Field( - 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( - default=None, description="Table last FTS index timestamp (ISO 8601 UTC)." - ) - indexed_at_vec: str | None = sql_Field( - default=None, description="Table last vector index timestamp (ISO 8601 UTC)." - ) - indexed_at_sca: str | None = sql_Field( - default=None, description="Table last scalar index timestamp (ISO 8601 UTC)." - ) - - @property - def cols_schema(self) -> list[ColumnSchema]: - return [ColumnSchema.model_validate(c) for c in deepcopy(self.cols)] - - @property - def regular_cols(self) -> list[ColumnSchema]: - return [c for c in self.cols_schema if not c.id.endswith("_")] - - -class TableMetaResponse(TableSchema): - parent_id: TableName | None = Field( - description="The parent table ID. If None (default), it means this is a template table.", - ) - title: str = Field(description="Chat title. Defaults to ''.") - updated_at: str = Field( - description="Table last update timestamp (ISO 8601 UTC).", - ) # SQLite does not support TZ - indexed_at_fts: str | None = Field( - description="Table last FTS index timestamp (ISO 8601 UTC)." - ) - indexed_at_vec: str | None = Field( - description="Table last vector index timestamp (ISO 8601 UTC)." - ) - indexed_at_sca: str | None = Field( - description="Table last scalar index timestamp (ISO 8601 UTC)." - ) - 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: - 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 RowAddData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - table_meta: TableMeta = Field(description="Table metadata.") - data: list[dict[ColName, Any]] = Field( - description="List of row data to add or update. Each list item is a mapping of column ID to its value." - ) - errors: list[list[str]] | None = Field( - default=None, - description=( - "List of row columns that encountered errors (perhaps LLM generation failed mid-stream). " - "Each list item is a list of column IDs." - ), - ) - - @model_validator(mode="after") - def check_errors(self) -> Self: - if self.errors is None: - return self - if len(self.errors) != len(self.data): - raise ValueError( - ( - "`errors` must contain same number of items as `data`, " - f"received: len(errors)={len(self.errors)} len(data)={len(self.data)}" - ) - ) - return self - - @model_validator(mode="after") - def check_data(self) -> Self: - if "updated at" in self.data: - raise ValueError("`data` cannot contain keys: 'Updated at'.") - return self - - @model_validator(mode="after") - def handle_nulls_and_validate(self) -> Self: - return self._handle_nulls_and_validate() - - def _handle_nulls_and_validate(self, check_missing_cols: bool = True) -> Self: - cols = { - c.id: c - for c in self.table_meta.cols_schema - if not (c.id.lower() in ("id", "updated at") or c.id.endswith("_")) - } - # Create the row schema for validation - PydanticSchema: Type[BaseModel] = create_model( - f"{self.__class__.__name__}Schema", - __config__=ConfigDict(arbitrary_types_allowed=True), - **{c.id: (str_to_py_type(c.dtype.value, c.vlen) | None, None) for c in cols.values()}, - ) - self.errors = [[] for _ in self.data] - - # Validate - for d, err in zip(self.data, self.errors, strict=True): - # Fill in missing cols - if check_missing_cols: - for k in cols: - if k not in d: - d[k] = None - try: - PydanticSchema.model_validate(d) - except ValidationError as e: - failed_cols = set(reduce(lambda a, b: a + b, (err["loc"] for err in e.errors()))) - logger.info( - f"Table {self.table_meta.id}: These columns failed validation: {failed_cols}" - ) - else: - failed_cols = {} - for k in list(d.keys()): - if k not in cols: - continue - col = cols[k] - state = {} - if k in failed_cols: - d[k], state["original"] = None, d[k] - if k in err: - d[k] = None - # state["error"] = True - if d[k] is None: - if col.dtype == ColumnDtype.INT: - d[k] = 0 - elif col.dtype == ColumnDtype.FLOAT: - d[k] = 0.0 - elif col.dtype == ColumnDtype.BOOL: - d[k] = False - elif col.dtype in (ColumnDtype.STR, ColumnDtype.IMAGE): - # Store null string as "" - # https://github.com/lancedb/lancedb/issues/1160 - d[k] = "" - elif col.vlen > 0: - # TODO: Investigate setting null vectors to np.nan - # Pros: nan vectors won't show up in vector search - # Cons: May cause error during vector indexing - d[k] = np.zeros([col.vlen], dtype=_str_to_py_type[col.dtype.value]) - state["is_null"] = True - else: - if col.vlen > 0: - d[k] = np.asarray(d[k], dtype=_str_to_py_type[col.dtype.value]) - state["is_null"] = False - d[f"{k}_"] = json_dumps(state) - d["Updated at"] = datetime.now(timezone.utc) - return self - - def set_id(self) -> Self: - """ - Sets ID, - - Returns: - self (RowAddData): RowAddData - """ - for d in self.data: - if "ID" not in d: - 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 == ColumnDtype.STR: - d[k] = re.sub(ODD_SINGLE_QUOTE, "''", d[k]) - return self - - -class RowUpdateData(RowAddData): - @model_validator(mode="after") - def check_data(self) -> Self: - if sum(n.lower() in ("id", "updated at") for d in self.data for n in d) > 0: - raise ValueError("`data` cannot contain keys: 'ID' or 'Updated at'.") - return self - - @model_validator(mode="after") - def handle_nulls_and_validate(self) -> Self: - return self._handle_nulls_and_validate(check_missing_cols=False) - - -class GenConfigUpdateRequest(BaseModel): - table_id: TableName = Field(description="Table name or ID.") - column_map: dict[ColName, DiscriminatedGenConfig | None] = Field( - description=( - "Mapping of column ID to generation config JSON in the form of `GenConfig`. " - "Table columns on its left can be referenced by `${column-name}`." - ) - ) - - @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'.") - return self - - -class ColumnRenameRequest(BaseModel): - table_id: TableName = Field(description="Table name or ID.") - column_map: dict[ColName, ColName] = Field( - description="Mapping of old column names to new column names." - ) - - @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'.") - return self - - -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.") - column_names: list[ColName] = Field(description="List of column ID to drop.") - - @model_validator(mode="after") - def check_column_names(self) -> Self: - if sum(n.lower() in ("id", "updated at") for n in self.column_names) > 0: - raise ValueError("`column_names` cannot contain keys: 'ID' or 'Updated at'.") - return self - - -class Task(BaseModel): - output_column_name: str - body: LLMGenConfig - - -class RowAdd(BaseModel): - table_id: TableName = Field( - description="Table name or ID.", - ) - data: dict[ColName, Any] = Field( - description="Mapping of column names to its value.", - ) - stream: bool = Field( - default=True, - description="Whether or not to stream the LLM generation.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - description="_Optional_. Whether or not to concurrently generate the output columns.", - ) - - -class RowAddRequest(BaseModel): - table_id: TableName = Field( - description="Table name or ID.", - ) - data: list[dict[ColName, Any]] = Field( - min_length=1, - description=( - "List of mapping of column names to its value. " - "In other words, each item in the list is a row, and each item is a mapping. " - "Minimum 1 row, maximum 100 rows." - ), - ) - stream: bool = Field( - default=True, - description="Whether or not to stream the LLM generation.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - description="_Optional_. Whether or not to concurrently generate the output rows and columns.", - ) - - def __repr__(self): - _data = [ - { - k: ( - {"type": type(v), "shape": v.shape, "dtype": v.dtype} - if isinstance(v, np.ndarray) - else v - ) - } - for row in self.data - for k, v in row.items() - ] - return ( - f"{self.__class__.__name__}(" - 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 + AUDIO_FILE_EXTENSIONS: - raise ValueError( - "Unsupported file type. Make sure the file belongs to " - "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" - f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS}" - ) - return self - - -class RowAddRequestWithLimit(RowAddRequest): - data: list[dict[ColName, Any]] = Field( - min_length=1, - max_length=100, - description=( - "List of mapping of column names to its value. " - "In other words, each item in the list is a row, and each item is a mapping. " - "Minimum 1 row, maximum 100 rows." - ), - ) - - -class RowUpdateRequest(BaseModel): - table_id: TableName = Field( - description="Table name or ID.", - ) - row_id: str = Field( - description="ID of the row to update.", - ) - data: dict[ColName, Any] = Field( - description="Mapping of column names to its value.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - - @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 + AUDIO_FILE_EXTENSIONS: - raise ValueError( - "Unsupported file type. Make sure the file belongs to " - "one of the following formats: \n" - f"[Image File Types]: \n{IMAGE_FILE_EXTENSIONS} \n" - f"[Audio File Types]: \n{AUDIO_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( - description="Table name or ID.", - ) - 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.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - description="_Optional_. Whether or not to concurrently generate the output columns.", - ) - - -class RowRegenRequest(BaseModel): - table_id: TableName = Field( - description="Table name or ID.", - ) - row_ids: list[str] = Field( - min_length=1, - 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.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - concurrent: bool = Field( - default=True, - 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.") - row_ids: list[str] | None = Field( - min_length=1, - max_length=100, - default=None, - description="List of ID of the row to delete. Minimum 1 row, maximum 100 rows.", - ) - where: str | None = Field( - default=None, - description="_Optional_. SQL where clause. If not provided, will match all rows and thus deleting all table content.", - ) - reindex: bool | None = Field( - default=None, - description=( - "_Optional_. If True, reindex immediately. If False, wait until next periodic reindex. " - "If None (default), reindex immediately for smaller tables." - ), - ) - - -class EmbedFileRequest(BaseModel): - table_id: TableName = Field(description="Table name or ID.") - file_id: str = Field(description="ID of the file.") - chunk_size: Annotated[ - int, Field(description="Maximum chunk size (number of characters). Must be > 0.", gt=0) - ] = 1000 - chunk_overlap: Annotated[ - int, Field(description="Overlap in characters between chunks. Must be >= 0.", ge=0) - ] = 200 - # stream: Annotated[bool, Field(description="Whether or not to stream the LLM generation.")] = ( - # True - # ) - - -class SearchRequest(BaseModel): - table_id: TableName = Field(description="Table name or ID.") - query: str = Field( - min_length=1, - description="Query for full-text-search (FTS) and vector search. Must not be empty.", - ) - where: str | None = Field( - default=None, - description="_Optional_. SQL where clause. If not provided, will match all rows.", - ) - limit: Annotated[int, Field(gt=0, le=1_000)] = Field( - default=100, description="_Optional_. Min 1, max 1000. Number of rows to return." - ) - metric: str = Field( - default="cosine", - description='_Optional_. Vector search similarity metric. Defaults to "cosine".', - ) - nprobes: Annotated[int, Field(gt=0, le=1000)] = Field( - default=50, - description=( - "_Optional_. Set the number of partitions to search (probe)." - "This argument is only used when the vector column has an IVF PQ index. If there is no index then this value is ignored. " - "The IVF stage of IVF PQ divides the input into partitions (clusters) of related values. " - "The partition whose centroids are closest to the query vector will be exhaustively searched to find matches. " - "This parameter controls how many partitions should be searched. " - "Increasing this value will increase the recall of your query but will also increase the latency of your query. Defaults to 50." - ), - ) - refine_factor: Annotated[int, Field(gt=0, le=1000)] = Field( - default=20, - description=( - "_Optional_. A multiplier to control how many additional rows are taken during the refine step. " - "This argument is only used when the vector column has an IVF PQ index. " - "If there is no index then this value is ignored. " - "An IVF PQ index stores compressed (quantized) values. " - "They query vector is compared against these values and, since they are compressed, the comparison is inaccurate. " - "This parameter can be used to refine the results. " - "It can improve both improve recall and correct the ordering of the nearest results. " - "To refine results LanceDb will first perform an ANN search to find the nearest limit * refine_factor results. " - "In other words, if refine_factor is 3 and limit is the default (10) then the first 30 results will be selected. " - "LanceDb then fetches the full, uncompressed, values for these 30 results. " - "The results are then reordered by the true distance and only the nearest 10 are kept. Defaults to 50." - ), - ) - float_decimals: int = Field( - default=0, - description="_Optional_. Number of decimals for float values. Defaults to 0 (no rounding).", - ) - vec_decimals: int = Field( - default=0, - 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.") - ] = None - - -class FileUploadRequest(BaseModel): - file_path: Annotated[str, Field(description="File path of the document to be uploaded.")] - table_id: Annotated[str, Field(description="Knowledge Table name / ID.")] - chunk_size: Annotated[ - int, Field(description="Maximum chunk size (number of characters). Must be > 0.", gt=0) - ] = 1000 - chunk_overlap: Annotated[ - int, Field(description="Overlap in characters between chunks. Must be >= 0.", ge=0) - ] = 200 - # overwrite: Annotated[ - # bool, - # Field( - # description="Whether to overwrite the file.", - # examples=[True, False], - # ), - # ] = False - - -class TableDataImportRequest(BaseModel): - file_path: Annotated[str, Field(description="CSV or TSV file path.")] - table_id: Annotated[str, Field(description="Table name / ID.")] - stream: Annotated[bool, Field(description="Whether or not to stream the LLM generation.")] = ( - True - ) - # column_names: Annotated[ - # list[str] | None, - # Field( - # description="A list of columns names if the CSV does not have header row. Defaults to None (read from CSV)." - # ), - # ] = None - # columns: Annotated[ - # list[str] | None, - # Field( - # description="A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at')." - # ), - # ] = None - delimiter: Annotated[ - 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/auth.py b/services/api/src/owl/routers/auth.py new file mode 100644 index 0000000..a3e99bf --- /dev/null +++ b/services/api/src/owl/routers/auth.py @@ -0,0 +1,90 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from pwdlib import PasswordHash +from sqlmodel import select + +from owl.db import AsyncSession, yield_async_session +from owl.db.models import User +from owl.types import ( + PasswordChangeRequest, + PasswordLoginRequest, + UserAuth, + UserCreate, + UserReadObscured, +) +from owl.utils.auth import auth_user_service_key +from owl.utils.exceptions import ( + AuthorizationError, + ForbiddenError, + ResourceNotFoundError, + handle_exception, +) + +router = APIRouter() + + +@router.post("/v2/auth/register/password", summary="Register with email and password.") +@handle_exception +async def register_password( + request: Request, + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: UserCreate, +) -> UserReadObscured: + from owl.routers.users.oss import create_user + + return await create_user(request=request, token="", session=session, body=body) + + +@router.post("/v2/auth/login/password", summary="Login with email and password.") +@handle_exception +async def login_password( + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: PasswordLoginRequest, +) -> UserReadObscured: + user = (await session.exec(select(User).where(User.email == body.email))).one_or_none() + if user: + password_hash, updated_hash = user.password_hash, None + if password_hash is None: + raise AuthorizationError("Invalid password.") + hasher = PasswordHash.recommended() + password_match, updated_hash = hasher.verify_and_update(body.password, user.password_hash) + if password_match: + if updated_hash is not None: + user.password_hash = updated_hash + session.add(user) + await session.commit() + await session.refresh(user) + else: + raise AuthorizationError("Invalid password.") + else: + raise AuthorizationError("User not found.") + user = await User.get(session, user.id, populate_existing=True) + return user + + +@router.patch("/v2/auth/login/password", summary="Change password.") +@handle_exception +async def change_password( + session: Annotated[AsyncSession, Depends(yield_async_session)], + _user: Annotated[UserAuth, Depends(auth_user_service_key)], + body: PasswordChangeRequest, +) -> UserReadObscured: + if _user.email != body.email: + raise ForbiddenError("You can only update your own account.") + # Re-fetch user to set `password_hash` + user = await User.get(session, _user.id) + if user is None: + raise ResourceNotFoundError(f'User "{_user.id}" is not found.') + password_hash, updated_hash = user.password_hash, None + hasher = PasswordHash.recommended() + password_match = hasher.verify(body.password, password_hash) + if password_match: + updated_hash = hasher.hash(body.new_password) + user.password_hash = updated_hash + session.add(user) + await session.commit() + await session.refresh(user) + else: + raise AuthorizationError("Invalid existing password.") + return user diff --git a/services/api/src/owl/routers/conversation.py b/services/api/src/owl/routers/conversation.py new file mode 100644 index 0000000..6ad7aaa --- /dev/null +++ b/services/api/src/owl/routers/conversation.py @@ -0,0 +1,574 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse +from loguru import logger + +from owl.db.gen_executor import MultiRowGenExecutor +from owl.db.gen_table import ChatTable +from owl.types import ( + AgentMetaResponse, + ConversationCreateRequest, + ConversationMetaResponse, + ConversationThreadsResponse, + GetConversationThreadsQuery, + ListMessageQuery, + ListQuery, + LLMGenConfig, + MessageAddRequest, + MessagesRegenRequest, + MessageUpdateRequest, + MultiRowAddRequest, + MultiRowRegenRequest, + OkResponse, + OrganizationRead, + Page, + ProjectRead, + SanitisedStr, + TableMetaResponse, + UserAuth, +) +from owl.utils.auth import auth_user_project, has_permissions +from owl.utils.billing import BillingManager +from owl.utils.exceptions import ResourceNotFoundError, handle_exception +from owl.utils.lm import LMEngine +from owl.utils.mcp import MCP_TOOL_TAG + +router = APIRouter() + + +def _table_meta_to_conv(metas: Page[TableMetaResponse]) -> Page[ConversationMetaResponse]: + """Converts Page[TableMetaResponse] to Page[ConversationMetaResponse].""" + return Page[ConversationMetaResponse]( + items=[ConversationMetaResponse.from_table_meta(m) for m in metas.items], + limit=metas.limit, + offset=metas.offset, + total=metas.total, + ) + + +async def _generate_and_save_title( + request: Request, + project: ProjectRead, + organization: OrganizationRead, + conversation_id: str, + table: ChatTable, +): + first_multiturn_column_meta = next( + ( + c + for c in table.column_metadata + if isinstance(c.gen_config, LLMGenConfig) and c.gen_config.multi_turn + ), + None, + ) + if first_multiturn_column_meta is None: + raise ResourceNotFoundError( + f'Conversation "{conversation_id}" has no multi-turn LLM column configured.' + ) + + first_multiturn_column_id = first_multiturn_column_meta.column_id + title_model_id = first_multiturn_column_meta.gen_config.model + + # Generate title after the first user message is saved and streamed + rows_page = await table.list_rows(limit=1, order_ascending=True) + first_user_content = rows_page.items[0].get("User", "") + first_assistant_content = rows_page.items[0].get(first_multiturn_column_id, "") + + llm = LMEngine(organization=organization, project=project, request=request) + generated_title = await llm.generate_chat_title( + user_content=first_user_content, + assistant_content=first_assistant_content, + model=title_model_id, + ) + await table.update_table_title(generated_title) + + +### --- Conversations CRUD --- ### + + +@router.post( + "/v2/conversations", + summary="Creates a new conversation and sends the first message. " + "Title will be generated automatically if not provided.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def create_conversation( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: ConversationCreateRequest, +) -> StreamingResponse: + user, project, org = auth_info + has_permissions(user, ["project"], project_id=project.id) + table_id = body.agent_id + # Validate data early + row_data = MultiRowAddRequest(table_id=table_id, data=[body.data], stream=True) + table = await ChatTable.open_table(project_id=project.id, table_id=table_id) + if table.table_metadata.parent_id is not None: + raise ResourceNotFoundError(f'Agent "{table_id}" is not found.') + + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + + table = await table.duplicate_table( + project_id=project.id, + table_id_src=table_id, + table_id_dst=None, + include_data=False, + create_as_child=True, + created_by=user.id, + ) + if body.title is not None: + table = await table.update_table_title(body.title) + conversation_id = table.table_metadata.table_id + row_data.table_id = conversation_id + executor = MultiRowGenExecutor( + request=request, + table=table, + organization=org, + project=project, + body=row_data, + ) + + async def stream_generator(): + meta = ConversationMetaResponse.from_table_meta(table.v1_meta_response) + yield f"event: metadata\ndata: {meta.model_dump_json()}\n\n" + + generator = await executor.generate() + async for chunk in generator: + if body.title is None and chunk == "data: [DONE]\n\n": + try: + await _generate_and_save_title( + request=request, + project=project, + organization=org, + conversation_id=conversation_id, + table=table, + ) + except Exception as e: + logger.error(f"Error generating title: {repr(e)}") + finally: + meta = ConversationMetaResponse.from_table_meta(table.v1_meta_response) + yield f"event: metadata\ndata: {meta.model_dump_json()}\n\n" + yield chunk + + return StreamingResponse( + content=stream_generator(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + + +@router.get( + "/v2/conversations/list", + summary="Lists all conversations.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def list_conversations( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + params: Annotated[ListQuery, Query()], +) -> Page[ConversationMetaResponse]: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + metas = await ChatTable.list_tables( + project_id=project.id, + limit=params.limit, + offset=params.offset, + order_by=params.order_by, + order_ascending=params.order_ascending, + created_by=user.id, + parent_id="_chat_", + search_query=params.search_query, + search_columns=["title"], + ) + return _table_meta_to_conv(metas) + + +@router.get( + "/v2/conversations/agents/list", + summary="Lists all available agents.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def list_agents( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + params: Annotated[ListQuery, Query()], +) -> Page[ConversationMetaResponse]: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + metas = await ChatTable.list_tables( + project_id=project.id, + limit=params.limit, + offset=params.offset, + order_by=params.order_by, + order_ascending=params.order_ascending, + parent_id="_agent_", + search_query=params.search_query, + search_columns=["table_id"], + ) + return _table_meta_to_conv(metas) + + +@router.get( + "/v2/conversations", + summary="Fetches a single conversation (table) metadata.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def get_conversation( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + conversation_id: Annotated[str, Query(description="The ID of the conversation to fetch.")], +) -> ConversationMetaResponse: + """Fetches a single conversation metadata.""" + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + return ConversationMetaResponse.from_table_meta(table.v1_meta_response) + + +@router.get( + "/v2/conversations/agents", + summary="Fetches a single agent (table) metadata.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def get_agent( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + agent_id: Annotated[str, Query(description="The ID of the agent to fetch.")], +) -> AgentMetaResponse: + """Fetches a single agent metadata.""" + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + try: + table = await ChatTable.open_table(project_id=project.id, table_id=agent_id) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Agent "{agent_id}" not found.') from e + return AgentMetaResponse.from_table_meta(table.v1_meta_response) + + +@router.post( + "/v2/conversations/title", + summary="Generates a title for a conversation based on the first user message and assistant response. " + "If the conversation already has a title, it will be overwritten.", + description="Permissions: `project`.", +) +@handle_exception +async def generate_conversation_title( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + conversation_id: Annotated[ + str, Query(description="The ID of the conversation to generate a title for.") + ], +) -> ConversationMetaResponse: + user, project, org = auth_info + has_permissions(user, ["project"], project_id=project.id) + + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + + await _generate_and_save_title( + request=request, + project=project, + organization=org, + conversation_id=conversation_id, + table=table, + ) + return ConversationMetaResponse.from_table_meta(table.v1_meta_response) + + +@router.patch( + "/v2/conversations/title", + summary="Renames conversation title.", + description="Permissions: `project`.", +) +@handle_exception +async def rename_conversation_title( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + conversation_id: Annotated[str, Query(description="The ID of the conversation to rename.")], + title: Annotated[SanitisedStr, Query(description="The new title for the conversation.")], +) -> ConversationMetaResponse: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + + table = await table.update_table_title(title) + return ConversationMetaResponse.from_table_meta(table.v1_meta_response) + + +@router.delete( + "/v2/conversations", + summary="Deletes a conversation permanently.", + description="Permissions: `project`.", +) +@handle_exception +async def delete_conversation( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + conversation_id: Annotated[str, Query(description="The ID of the conversation to delete.")], +) -> OkResponse: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + + await table.drop_table() + return OkResponse() + + +### --- Messages CRUD --- ### + + +@router.post( + "/v2/conversations/messages", + summary="Sends a message to a conversation and streams the response.", + description="Permissions: `project`.", +) +@handle_exception +async def send_message( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: MessageAddRequest, +) -> StreamingResponse: + user, project, org = auth_info + has_permissions(user, ["project"], project_id=project.id) + conversation_id = body.conversation_id + # Validate data early + row_data = MultiRowAddRequest(table_id=conversation_id, data=[body.data], stream=True) + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + + executor = MultiRowGenExecutor( + request=request, + table=table, + organization=org, + project=project, + body=row_data, + ) + + return StreamingResponse( + content=await executor.generate(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + + +@router.get( + "/v2/conversations/messages/list", + summary="Lists messages in a conversation.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def list_messages( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + params: Annotated[ListMessageQuery, Query()], +) -> Page[dict[str, Any]]: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=params.conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{params.conversation_id}" not found.') from e + + return await table.list_rows( + limit=params.limit, + offset=params.offset, + order_by=[params.order_by], + order_ascending=params.order_ascending, + columns=params.columns, + where=params.where, + search_query=params.search_query, + search_columns=params.search_columns, + remove_state_cols=False, + ) + + +@router.post( + "/v2/conversations/messages/regen", + summary="Regenerates a specific message in a conversation and streams back the response.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def regen_conversation_message( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: MessagesRegenRequest, +) -> StreamingResponse: + user, project, org = auth_info + has_permissions(user, ["project"], project_id=project.id) + + conversation_id = body.conversation_id + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{conversation_id}" not found.') from e + + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_egress_quota() + + # Construct the full request for the executor + regen_rows = await table.list_rows( + where=f"\"ID\" >= '{body.row_id}'", columns=["ID"], order_by=["ID"], order_ascending=True + ) + regen_row_ids = [str(r["ID"]) for r in regen_rows.items] + + executor = MultiRowGenExecutor( + request=request, + table=table, + organization=org, + project=project, + body=MultiRowRegenRequest( + table_id=table.table_metadata.table_id, row_ids=regen_row_ids, stream=True + ), + ) + + return StreamingResponse( + content=await executor.generate(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + + +@router.patch( + "/v2/conversations/messages", + summary="Updates a specific message in a conversation.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def update_conversation_message( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: MessageUpdateRequest, +) -> OkResponse: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + + try: + table = await ChatTable.open_table( + project_id=project.id, table_id=body.conversation_id, created_by=user.id + ) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{body.conversation_id}" not found.') from e + + # Check quota for DB write + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + + await table.update_rows({body.row_id: body.data}) + + return OkResponse() + + +### --- Threads CRUD --- ### + + +@router.get( + "/v2/conversations/threads", + summary="Get all threads from a conversation or an agent.", + description="Permissions: `project`.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def get_threads( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + params: Annotated[GetConversationThreadsQuery, Query()], +) -> ConversationThreadsResponse: + user, project, _ = auth_info + has_permissions(user, ["project"], project_id=project.id) + table_id = params.conversation_id + try: + table = await ChatTable.open_table(project_id=project.id, table_id=table_id) + except ResourceNotFoundError as e: + raise ResourceNotFoundError(f'Conversation "{table_id}" not found.') from e + if table.table_metadata.parent_id is None: + pass + elif table.table_metadata.created_by != user.id: + raise ResourceNotFoundError(f'Conversation "{table_id}" not found.') + if params.column_ids: + for column_id in params.column_ids: + table.check_multiturn_column(column_id) + cols = params.column_ids + else: + cols = [c.column_id for c in table.column_metadata if c.is_chat_column] + return ConversationThreadsResponse( + threads={c: await table.get_conversation_thread(column_id=c) for c in cols}, + conversation_id=table_id, + ) diff --git a/services/api/src/owl/routers/file.py b/services/api/src/owl/routers/file.py index fed6675..1b88072 100644 --- a/services/api/src/owl/routers/file.py +++ b/services/api/src/owl/routers/file.py @@ -1,4 +1,3 @@ -import mimetypes import os from os.path import splitext from typing import Annotated @@ -6,24 +5,35 @@ import httpx from fastapi import APIRouter, Depends, Request, Response, UploadFile -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import ORJSONResponse 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.configs import ENV_CONFIG +from owl.types import ( + FileUploadResponse, + GetURLRequest, + GetURLResponse, + OrganizationRead, + ProjectRead, + UserAuth, +) +from owl.utils.auth import auth_user_project, has_permissions +from owl.utils.billing import BillingManager from owl.utils.exceptions import handle_exception from owl.utils.io import ( AUDIO_WHITE_LIST_EXT, - LOCAL_FILE_DIR, - S3_CLIENT, + NON_PDF_DOC_WHITE_LIST_EXT, UPLOAD_WHITE_LIST_MIME, + get_global_thumbnail_path, get_s3_aclient, - upload_file_to_s3, + guess_mime, + s3_upload, ) -HTTP_ACLIENT = httpx.AsyncClient() if S3_CLIENT else None +HTTP_ACLIENT = httpx.AsyncClient( + timeout=10.0, + transport=httpx.AsyncHTTPTransport(retries=3), +) router = APIRouter() @@ -40,8 +50,8 @@ async def _generate_presigned_url(s3_client, bucket_name: str, key: str) -> str: return urlunparse( ( parsed_url.scheme, - ENV_CONFIG.owl_file_proxy_url, - "/api/v1/files" + parsed_url.path, + ENV_CONFIG.file_proxy_url, + "/api/v2/files" + parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment, @@ -49,52 +59,30 @@ async def _generate_presigned_url(s3_client, bucket_name: str, key: str) -> str: ) -@router.get("/v1/files/{path:path}") +@router.get("/v2/files/{path:path}") +@router.get("/v1/files/{path:path}", deprecated=True) @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") + encoded_path = quote(path) + original_url = f"{ENV_CONFIG.s3_endpoint}/{encoded_path}?{request.query_params}" + response = await HTTP_ACLIENT.get(original_url) + # Set the Content-Disposition header + response.headers["Content-Disposition"] = "inline" + # Usually we can get the MIME type from S3 metadata + if "Content-Type" not in response.headers: + response.headers["Content-Type"] = guess_mime(path) + return Response( + content=response.content, + status_code=response.status_code, + headers=response.headers, + ) -@router.options("/v1/files/upload") -@router.options("/v1/files/upload/", deprecated=True) +@router.options( + "/v2/files/upload", + summary="Get CORS preflight options for file upload endpoint.", +) +@router.options("/v1/files/upload", deprecated=True) @handle_exception async def upload_file_options(): headers = { @@ -103,53 +91,63 @@ async def upload_file_options(): "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "POST, OPTIONS", } - return JSONResponse(content={"accepted_types": list(UPLOAD_WHITE_LIST_MIME)}, headers=headers) + return ORJSONResponse( + content={"accepted_types": list(UPLOAD_WHITE_LIST_MIME)}, + headers=headers, + ) -@router.post("/v1/files/upload") -@router.post("/v1/files/upload/", deprecated=True) +@router.post( + "/v2/files/upload", + summary="Upload a file to the server.", + description="Permissions: `organization` OR `project`.", +) +@router.post("/v1/files/upload", deprecated=True) @handle_exception async def upload_file( - project: Annotated[ProjectRead, Depends(auth_user_project)], + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], file: UploadFile, ) -> FileUploadResponse: + user, project, org = auth_info + has_permissions( + user, + ["organization", "project"], + organization_id=org.id, + project_id=project.id, + ) + # Check quota + billing: BillingManager = request.state.billing + billing.has_file_storage_quota() content = await file.read() - uri = await upload_file_to_s3( - project.organization.id, project.id, content, file.content_type, file.filename + uri = await s3_upload( + project.organization.id, + project.id, + content, + content_type=file.content_type, + filename=file.filename, ) return FileUploadResponse(uri=uri) -@router.post("/v1/files/url/raw", response_model=GetURLResponse) +@router.post("/v2/files/url/raw") +@router.post("/v1/files/url/raw", deprecated=True) @handle_exception -async def get_raw_file_urls(body: GetURLRequest, request: Request) -> GetURLResponse: +async def get_raw_file_urls(body: GetURLRequest) -> 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 + async with get_s3_aclient() as aclient: 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: + try: + bucket_name, key = uri[5:].split("/", 1) + file_url = await _generate_presigned_url(aclient, bucket_name, key) + except Exception as e: + err_mssg = str(e) + if "NoSuchBucket" in err_mssg: + pass + else: logger.exception( f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' ) @@ -157,43 +155,32 @@ async def get_raw_file_urls(body: GetURLRequest, request: Request) -> GetURLResp return GetURLResponse(urls=results) -@router.post("/v1/files/url/thumb", response_model=GetURLResponse) +@router.post("/v2/files/url/thumb") +@router.post("/v1/files/url/thumb", deprecated=True) @handle_exception -async def get_thumbnail_urls(body: GetURLRequest, request: Request) -> GetURLResponse: +async def get_thumbnail_urls(body: GetURLRequest) -> 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: - ext = splitext(uri)[1].lower() - bucket_name, key = uri[5:].split("/", 1) - thumb_ext = "mp3" if ext in AUDIO_WHITE_LIST_EXT else "webp" - thumb_key = key.replace("raw", "thumb") - thumb_key = f"{os.path.splitext(thumb_key)[0]}.{thumb_ext}" - 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 + async with get_s3_aclient() as aclient: for uri in body.uris: file_url = "" - if uri.startswith("file://"): - try: - ext = splitext(uri)[1].lower() - local_path = os.path.abspath(uri[7:]) - thumb_ext = "mp3" if ext in AUDIO_WHITE_LIST_EXT else "webp" - thumb_path = local_path.replace("raw", "thumb") - thumb_path = f"{os.path.splitext(thumb_path)[0]}.{thumb_ext}" - if os.path.exists(thumb_path): - relative_path = os.path.relpath(thumb_path, LOCAL_FILE_DIR) - file_url = str(request.url_for("proxy_file", path=relative_path)) - except Exception as e: + try: + ext = splitext(uri)[1].lower() + bucket_name, key = uri[5:].split("/", 1) + thumb_ext = "mp3" if ext in AUDIO_WHITE_LIST_EXT else "webp" + if ext in NON_PDF_DOC_WHITE_LIST_EXT: + thumb_key = os.path.join( + key[: key.index("raw/")], + get_global_thumbnail_path(ext), + ) + else: + thumb_key = key.replace("raw", "thumb") + thumb_key = f"{os.path.splitext(thumb_key)[0]}.{thumb_ext}" + file_url = await _generate_presigned_url(aclient, bucket_name, thumb_key) + except Exception as e: + err_mssg = str(e) + if "NoSuchBucket" in err_mssg: + pass + else: logger.exception( f'Error generating URL for "{uri}" due to {e.__class__.__name__}: {e}' ) diff --git a/services/api/src/owl/routers/gen_table.py b/services/api/src/owl/routers/gen_table.py index 27b717b..1b3cb5c 100644 --- a/services/api/src/owl/routers/gen_table.py +++ b/services/api/src/owl/routers/gen_table.py @@ -1,1399 +1,1091 @@ import re +from asyncio import sleep from io import BytesIO -from os import listdir, makedirs -from os.path import isdir, join, splitext -from shutil import copy2, copytree +from os.path import join, splitext from tempfile import TemporaryDirectory +from time import perf_counter from typing import Annotated, Any -import numpy as np -import pandas as pd -import tiktoken +from celery.result import AsyncResult from fastapi import ( APIRouter, BackgroundTasks, Depends, - File, Form, Path, Query, Request, Response, - UploadFile, ) from fastapi.responses import FileResponse, StreamingResponse from loguru import logger - -from jamaibase.exceptions import ( - ResourceNotFoundError, - TableSchemaFixedError, - UnsupportedMediaTypeError, - make_validation_error, +from pydantic import Field + +from owl.configs import CACHE +from owl.db.gen_executor import MultiRowGenExecutor +from owl.db.gen_table import ( + ActionTable, + ChatTable, + ColumnMetadata, + KnowledgeTable, + TableMetadata, ) -from jamaibase.utils.io import csv_to_df, json_loads -from owl.configs.manager import ENV_CONFIG -from owl.db.gen_executor import MultiRowsGenExecutor -from owl.db.gen_table import GenerativeTable -from owl.llm import LLMEngine -from owl.loaders import load_file -from owl.models import CloudEmbedder, CloudReranker -from owl.protocol import ( - GEN_CONFIG_VAR_PATTERN, - TABLE_NAME_PATTERN, +from owl.docparse import GeneralDocLoader +from owl.tasks.gen_table import import_gen_table +from owl.types import ( ActionTableSchemaCreate, - AddActionColumnSchema, - AddChatColumnSchema, - AddKnowledgeColumnSchema, - ChatEntry, ChatTableSchemaCreate, - ChatThread, - CodeGenConfig, - ColName, + ChatThreadsResponse, ColumnDropRequest, - ColumnDtype, ColumnRenameRequest, ColumnReorderRequest, CSVDelimiter, - EmbedGenConfig, - GenConfig, + DuplicateTableQuery, + ExportTableDataQuery, + FileEmbedFormData, GenConfigUpdateRequest, - GenTableOrderBy, + GetTableRowQuery, + GetTableThreadsQuery, KnowledgeTableSchemaCreate, - LLMGenConfig, + ListTableQuery, + ListTableRowQuery, + MultiRowAddRequest, + MultiRowAddRequestWithLimit, + MultiRowDeleteRequest, + MultiRowRegenRequest, + MultiRowUpdateRequestWithLimit, OkResponse, + OrganizationRead, Page, - RowAddRequest, - RowAddRequestWithLimit, - RowDeleteRequest, - RowRegenRequest, - RowUpdateRequest, + ProjectRead, + RenameTableQuery, SearchRequest, + TableDataImportFormData, + TableImportFormData, + TableImportProgress, TableMetaResponse, - TableSchema, TableSchemaCreate, TableType, + UserAuth, +) +from owl.utils.auth import auth_user_project, has_permissions +from owl.utils.billing import BillingManager +from owl.utils.exceptions import ( + ServerBusyError, + UnexpectedError, + UnsupportedMediaTypeError, + handle_exception, ) -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 +from owl.utils.io import EMBED_WHITE_LIST_MIME, guess_mime, s3_temporary_file, s3_upload +from owl.utils.lm import LMEngine +from owl.utils.mcp import MCP_TOOL_TAG router = APIRouter() -def _validate_gen_config( - llm: LLMEngine, - gen_config: GenConfig | None, - table_type: TableType, - column_id: str, - image_column_ids: list[str], - audio_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 image_column_ids: - capabilities = ["image"] - if col_id in audio_column_ids: - capabilities = ["audio"] - break - gen_config.model = llm.validate_model_id( - model=gen_config.model, - capabilities=capabilities, - ) - 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." - ) - # 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, CodeGenConfig): - pass - elif isinstance(gen_config, EmbedGenConfig): - pass - return gen_config - - -def _create_table( +TABLE_CLS: dict[TableType, ActionTable | KnowledgeTable | ChatTable] = { + TableType.ACTION: ActionTable, + TableType.KNOWLEDGE: KnowledgeTable, + TableType.CHAT: ChatTable, +} + + +async def _create_table( + *, request: Request, - organization_id: str, - project_id: str, + user: UserAuth, + project: ProjectRead, + org: OrganizationRead, table_type: TableType, schema: TableSchemaCreate, ) -> TableMetaResponse: - # Validate - llm = LLMEngine(request=request) - image_column_ids = [ - col.id - for col in schema.cols - if col.dtype == ColumnDtype.IMAGE and not col.id.endswith("_") - ] - audio_column_ids = [ - col.id - for col in schema.cols - if col.dtype == ColumnDtype.AUDIO and not col.id.endswith("_") - ] - for col in schema.cols: - col.gen_config = _validate_gen_config( - llm=llm, - gen_config=col.gen_config, - table_type=table_type, - column_id=col.id, - image_column_ids=image_column_ids, - audio_column_ids=audio_column_ids, - ) - if table_type == TableType.KNOWLEDGE: - try: - embedding_model = schema.embedding_model - schema.embedding_model = llm.validate_model_id( - model=embedding_model, - capabilities=["embed"], + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + # Check quota + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + billing.has_egress_quota() + kwargs = dict( + project_id=project.id, + table_metadata=TableMetadata( + table_id=schema.id, + created_by=user.id, + ), + column_metadata_list=[ + ColumnMetadata( + table_id=schema.id, + column_id=col.id, + dtype=col.dtype.to_column_type(), + vlen=col.vlen, + gen_config=col.gen_config, ) - except ValueError as e: - raise ResourceNotFoundError("There is no embedding model available.") from e - except ResourceNotFoundError as e: - raise ResourceNotFoundError( - 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) - ) - meta = TableMetaResponse(**meta.model_dump(), num_rows=0) - return meta + for col in schema.cols + ], + ) + if table_type == TableType.KNOWLEDGE: + table = await KnowledgeTable.create_table(embedding_model=schema.embedding_model, **kwargs) + else: + table = await TABLE_CLS[table_type].create_table(**kwargs) + return table.v1_meta_response -@router.post("/v1/gen_tables/action") +@router.post( + "/v2/gen_tables/action", + summary="Create an action table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def create_action_table( +async def create_action_table( request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], body: ActionTableSchemaCreate, ) -> TableMetaResponse: - return _create_table(request, project.organization.id, project.id, TableType.ACTION, body) + user, project, org = auth_info + return await _create_table( + request=request, + user=user, + project=project, + org=org, + table_type=TableType.ACTION, + schema=body, + ) -@router.post("/v1/gen_tables/knowledge") +@router.post( + "/v2/gen_tables/knowledge", + summary="Create a knowledge table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def create_knowledge_table( +async def create_knowledge_table( request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], body: KnowledgeTableSchemaCreate, ) -> TableMetaResponse: - return _create_table(request, project.organization.id, project.id, TableType.KNOWLEDGE, body) + user, project, org = auth_info + return await _create_table( + request=request, + user=user, + project=project, + org=org, + table_type=TableType.KNOWLEDGE, + schema=body, + ) -@router.post("/v1/gen_tables/chat") +@router.post( + "/v2/gen_tables/chat", + summary="Create a chat table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def create_chat_table( +async def create_chat_table( request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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 + user, project, org = auth_info + return await _create_table( + request=request, + user=user, + project=project, + org=org, + table_type=TableType.CHAT, + schema=body, + ) -@router.post("/v1/gen_tables/{table_type}/duplicate/{table_id_src}") +@router.post( + "/v2/gen_tables/{table_type}/duplicate", + summary="Duplicate a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def duplicate_table( +async 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." - ), - ), + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[DuplicateTableQuery, Query()], ) -> 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, + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.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}") -@handle_exception -def duplicate_table_deprecated( - *, - 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=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`.", - ), - deploy: bool = Query( - default=False, - description="_Optional_. Whether to deploy the duplicated table. Defaults to `False`.", - ), -) -> 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." - '"' + # Check quota + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + billing.has_egress_quota() + table = await TABLE_CLS[table_type].open_table( + project_id=project.id, table_id=params.table_id_src ) - return _duplicate_table( - organization_id=project.organization.id, + table = await table.duplicate_table( 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, + table_id_src=params.table_id_src, + table_id_dst=params.table_id_dst, + include_data=params.include_data, + create_as_child=params.create_as_child, + created_by=user.id, ) + return table.v1_meta_response -@router.post("/v1/gen_tables/{table_type}/rename/{table_id_src}/{table_id_dst}") +@router.get( + "/v2/gen_tables/{table_type}", + summary="Get a specific table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def rename_table( - 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=TABLE_NAME_PATTERN, - description="Destination table name or ID.", - ), +async def get_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Query(description="Name of the table to fetch.")], ) -> 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 + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=table_id) + return table.v1_meta_response -@router.delete("/v1/gen_tables/{table_type}/{table_id}") -@handle_exception -def delete_table( - 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 -) -> 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() +class _ListTableQuery(ListTableQuery): + created_by: Annotated[ + str | None, + Field( + min_length=1, + description="Return tables created by this user. Defaults to None (return all tables).", + ), + ] = None -@router.get("/v1/gen_tables/{table_type}") +@router.get( + "/v2/gen_tables/{table_type}/list", + summary="List tables of a specific type.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def list_tables( - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def list_tables( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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) all chat agents by passing in "_agent_"; or ' - '(2) all chats by passing in "_chat_".' - ), - ), - ] = None, - 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, + params: Annotated[_ListTableQuery, Query()], ) -> 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, - ) - return Page[TableMetaResponse]( - items=metas, - offset=offset, - limit=limit, - total=total, - ) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + metas = await TABLE_CLS[table_type].list_tables( + project_id=project.id, + limit=params.limit, + offset=params.offset, + order_by=params.order_by, + order_ascending=params.order_ascending, + created_by=getattr(params, "created_by", None), + parent_id=params.parent_id, + search_query=params.search_query, + count_rows=params.count_rows, + ) + return metas -@router.get("/v1/gen_tables/{table_type}/{table_id}") +@router.post( + "/v2/gen_tables/{table_type}/rename", + summary="Rename a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def get_table( - request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def rename_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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."), + params: Annotated[RenameTableQuery, Query()], ) -> TableMetaResponse: - organization_id = project.organization.id - project_id = project.id - try: - 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 = 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 !!!" - ) - 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 + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table( + project_id=project.id, table_id=params.table_id_src + ) + table = await table.rename_table(params.table_id_dst) + return table.v1_meta_response -@router.post("/v1/gen_tables/{table_type}/gen_config/update") +@router.delete( + "/v2/gen_tables/{table_type}", + summary="Delete a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @handle_exception -def update_gen_config( - request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def delete_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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) - image_column_ids = [ - col["id"] - for col in meta.cols - if col["dtype"] == ColumnDtype.IMAGE and not col["id"].endswith("_") - ] - audio_column_ids = [ - col["id"] - for col in meta.cols - if col["dtype"] == ColumnDtype.AUDIO and not col["id"].endswith("_") - ] - - 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, - image_column_ids=image_column_ids, - audio_column_ids=audio_column_ids, - ) - 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 + table_id: Annotated[str, Query(description="Name of the table to be deleted.")], +) -> OkResponse: + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=table_id) + await table.drop_table() + return OkResponse() -def _add_columns( +@router.post( + "/v2/gen_tables/{table_type}/columns/add", + summary="Add columns to a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) +@handle_exception +async def add_columns( request: Request, - organization_id: str, - project_id: str, - table_type: TableType, - schema: TableSchemaCreate, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: TableSchemaCreate, ) -> TableMetaResponse: - # Validate - 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 - image_column_ids = [ - col.id for col in cols if col.dtype == ColumnDtype.IMAGE and not col.id.endswith("_") - ] - audio_column_ids = [ - col.id for col in cols if col.dtype == ColumnDtype.AUDIO and not col.id.endswith("_") - ] - schema.cols = [col for col in cols if col.id in set(c.id for c in schema.cols)] - for col in schema.cols: - col.gen_config = _validate_gen_config( - llm=llm, - gen_config=col.gen_config, - table_type=table_type, + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + for col in body.cols: + table = await table.add_column( + ColumnMetadata( + table_id=body.id, column_id=col.id, - image_column_ids=image_column_ids, - audio_column_ids=audio_column_ids, + dtype=col.dtype.to_column_type(), + vlen=col.vlen, + gen_config=col.gen_config, ) - # Create - _, meta = table.add_columns(session, schema) - meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) - return meta + ) + return table.v1_meta_response -@router.post("/v1/gen_tables/action/columns/add") +@router.post( + "/v2/gen_tables/{table_type}/columns/rename", + summary="Rename columns in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def add_action_columns( - request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], - body: AddActionColumnSchema, +async def rename_columns( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnRenameRequest, ) -> TableMetaResponse: - return _add_columns(request, project.organization.id, project.id, TableType.ACTION, body) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + table = await table.rename_columns(body.column_map) + return table.v1_meta_response -@router.post("/v1/gen_tables/knowledge/columns/add") +@router.patch( + "/v2/gen_tables/{table_type}/gen_config", + summary="Update generation configuration for table columns.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def add_knowledge_columns( - request: Request, - project: Annotated[ProjectRead, Depends(auth_user_project)], - body: AddKnowledgeColumnSchema, +async def update_gen_config( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + updates: GenConfigUpdateRequest, ) -> TableMetaResponse: - return _add_columns(request, project.organization.id, project.id, TableType.KNOWLEDGE, body) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table( + project_id=project.id, table_id=updates.table_id + ) + table = await table.update_gen_config(update_mapping=updates.column_map) + return table.v1_meta_response -@router.post("/v1/gen_tables/chat/columns/add") +@router.post( + "/v2/gen_tables/{table_type}/columns/reorder", + summary="Reorder columns in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def add_chat_columns( - request: Request, - 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, +async def reorder_columns( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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: - table.create_indexes(session, table_id) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + table = await table.reorder_columns(body.column_names) + return table.v1_meta_response -@router.post("/v1/gen_tables/{table_type}/columns/drop") +@router.post( + "/v2/gen_tables/{table_type}/columns/drop", + summary="Drop columns from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def drop_columns( - bg_tasks: BackgroundTasks, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def drop_columns( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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") + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + billing.has_egress_quota() + table = await table.drop_columns(body.column_names) + return table.v1_meta_response + + +@router.post( + "/v2/gen_tables/{table_type}/rows/add", + summary="Add rows to a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def rename_columns( - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def add_rows( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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 + body: MultiRowAddRequestWithLimit, +): + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + executor = MultiRowGenExecutor( + request=request, + table=table, + organization=org, + project=project, + body=body, + ) + if body.stream: + return StreamingResponse( + content=await executor.generate(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + else: + return await executor.generate() -@router.post("/v1/gen_tables/{table_type}/columns/reorder") +@router.get( + "/v2/gen_tables/{table_type}/rows/list", + summary="List rows in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def reorder_columns( - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def list_rows( + *, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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 + params: Annotated[ListTableRowQuery, Query()], +) -> Page[dict[str, Any]]: + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=params.table_id) + rows = await table.list_rows( + limit=params.limit, + offset=params.offset, + order_by=[params.order_by], + order_ascending=params.order_ascending, + columns=params.columns, + where=params.where, + search_query=params.search_query, + search_columns=params.search_columns, + remove_state_cols=False, + ) + return Page[dict[str, Any]]( + items=table.postprocess_rows( + rows.items, + float_decimals=params.float_decimals, + vec_decimals=params.vec_decimals, + ), + offset=params.offset, + limit=params.limit, + total=rows.total, + ) -@router.get("/v1/gen_tables/{table_type}/{table_id}/rows") +@router.get( + "/v2/gen_tables/{table_type}/rows", + summary="Get a specific row from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def list_rows( +async def get_row( *, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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, - description="_Optional_. Item offset for pagination. Defaults to 0.", - ), - limit: int = Query( - default=100, - gt=0, - le=100, - description="_Optional_. Number of rows to return (min 1, max 100). Defaults to 100.", - ), - search_query: str = Query( - default="", - max_length=10_000, - description='_Optional_. A string to search for within the rows as a filter. Defaults to "" (no filter).', - ), - 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. If its negative, exclude vector columns. Defaults to 0 (no rounding).", - ), - 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, - ) - else: - with table.create_session() as session: - rows = table.regex_search( - session=session, - table_id=table_id, - query=search_query, - 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, - ) - total = len(rows) - rows = rows[offset : offset + limit] - return Page[dict[ColName, Any]](items=rows, offset=offset, limit=limit, total=total) + params: Annotated[GetTableRowQuery, Query()], +) -> dict[str, Any]: + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=params.table_id) + row = await table.get_row( + row_id=params.row_id, + columns=params.columns, + remove_state_cols=False, + ) + row = table.postprocess_rows( + [row], + float_decimals=params.float_decimals, + vec_decimals=params.vec_decimals, + )[0] + return row -@router.get("/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}") +@router.get( + "/v2/gen_tables/{table_type}/threads", + summary="Get all multi-turn / conversation threads from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def get_row( - *, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def get_conversation_threads( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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[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. If its negative, exclude vector columns. Defaults to 0 (no rounding).", - ), -) -> 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, + params: Annotated[GetTableThreadsQuery, Query()], +) -> ChatThreadsResponse: + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table_id = params.table_id + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=table_id) + if params.column_ids: + for column_id in params.column_ids: + table.check_multiturn_column(column_id) + cols = params.column_ids + else: + cols = [c.column_id for c in table.column_metadata if c.is_chat_column] + return ChatThreadsResponse( + threads={ + c: await table.get_conversation_thread( + column_id=c, + row_id=params.row_id, + include_row=params.include_row, + ) + for c in cols + }, + table_id=table_id, ) - return row -@router.post("/v1/gen_tables/{table_type}/rows/add") +@router.post( + "/v2/gen_tables/{table_type}/hybrid_search", + summary="Perform hybrid search on a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -async def add_rows( +async def hybrid_search( request: Request, - bg_tasks: BackgroundTasks, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], table_type: Annotated[TableType, Path(description="Table type.")], - body: RowAddRequestWithLimit, -): - 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 + body: SearchRequest, +) -> list[dict[str, Any]]: + # TODO: Maybe this should return `Page` instead of `list` + def split_query_to_or_terms(query): + # Regular expression to match either quoted phrases or words + pattern = r'("[^"]*"|\S+)' + parts = re.findall(pattern, query) + return " OR ".join(parts) + + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, ) - # 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, + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + lm = LMEngine( + organization=org, + project=project, 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"}, - ) - else: - return await executor.gen_rows() + # Do a split and OR join for fts query + fts_query = split_query_to_or_terms(body.query) + + # As of 2025-04-17, this endpoint does not perform query rewrite + rows = await table.hybrid_search( + fts_query=fts_query, + vs_query=body.query, + embedding_fn=lm.embed_query_as_vector, + vector_column_names=None, + limit=body.limit, + offset=0, + remove_state_cols=False, + ) + # Rerank + if len(rows) > 0 and body.reranking_model is not None: + order = ( + await lm.rerank_documents( + model=body.reranking_model, + query=body.query, + documents=table.rows_to_documents(rows), + ) + ).results + rows = [rows[i.index] for i in order] + rows = rows[: body.limit] + rows = table.postprocess_rows( + rows, + float_decimals=body.float_decimals, + vec_decimals=body.vec_decimals, + ) + return rows -@router.post("/v1/gen_tables/{table_type}/rows/regen") +@router.post( + "/v2/gen_tables/{table_type}/rows/regen", + summary="Regenerate rows in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception async def regen_rows( request: Request, - bg_tasks: BackgroundTasks, - project: Annotated[ProjectRead, Depends(auth_user_project)], + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], table_type: Annotated[TableType, Path(description="Table type.")], - body: RowRegenRequest, + body: MultiRowRegenRequest, ): - 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}" - ) - ) - has_chat_cols = ( - sum( - col["gen_config"] is not None and col["gen_config"].get("multi_turn", False) - for col in meta.cols - ) - > 0 + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, ) - # 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, + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + executor = MultiRowGenExecutor( request=request, + table=table, + organization=org, + project=project, 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(), + content=await executor.generate(), status_code=200, media_type="text/event-stream", headers={"X-Accel-Buffering": "no"}, ) else: - return await executor.gen_rows() - - -@router.post("/v1/gen_tables/{table_type}/rows/update") -@handle_exception -def update_row( - bg_tasks: BackgroundTasks, - 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, - ) - 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() + return await executor.generate() -@router.post("/v1/gen_tables/{table_type}/rows/delete") +@router.patch( + "/v2/gen_tables/{table_type}/rows", + summary="Update rows in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def delete_rows( - bg_tasks: BackgroundTasks, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def update_rows( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], table_type: Annotated[TableType, Path(description="Table type.")], - body: RowDeleteRequest, + body: MultiRowUpdateRequestWithLimit, ) -> 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) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + await table.update_rows(body.data) return OkResponse() -@router.delete("/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}") +@router.post( + "/v2/gen_tables/{table_type}/rows/delete", + summary="Delete rows from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", + tags=[MCP_TOOL_TAG, "organization.MEMBER", "project.MEMBER"], +) @handle_exception -def delete_row( - bg_tasks: BackgroundTasks, - project: Annotated[ProjectRead, Depends(auth_user_project)], +async def delete_rows( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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, + body: MultiRowDeleteRequest, ) -> 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/{table_type}/{table_id}/thread") -@handle_exception -def get_conversation_thread( - 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_. ID / name of the last row in the thread. Defaults to "" (export all rows).' - ), - ] = "", - include: Annotated[ - bool, - Query( - description="_Optional_. Whether to include the row specified by `row_id`. Defaults to True." - ), - ] = True, -) -> 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") -@handle_exception -async def hybrid_search( - request: Request, - 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, - ) - return rows - - -def list_files(): - pass - - -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 _embed_file( - request: Request, - bg_tasks: BackgroundTasks, - project: ProjectRead, - table_id: str, - file_name: str, - file_content: bytes, - file_uri: str, - chunk_size: int, - chunk_overlap: int, -) -> 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])[:50000] - llm = LLMEngine(request=request) - model = llm.validate_model_id( - model="", - capabilities=["chat"], + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, ) - logger.debug(f"{request_id} - Performing title extraction using: {model}") - try: - response = await llm.generate( - id=request_id, - model=model, - messages=[ - 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. " - "Provide the title without explanation." - ) - ), - ], - max_tokens=200, - temperature=0.01, - top_p=0.01, - stream=False, - ) - title = response.text.strip() - if title.startswith('"') and title.endswith('"'): - title = title[1:-1] - except Exception: - logger.exception(f"{request_id} - Title extraction errored for excerpt: \n{excerpt}\n") - title = "" - - # --- Add into Knowledge Table --- # - organization_id = project.organization.id - project_id = project.id - table = GenerativeTable.from_ids(organization_id, project_id, TableType.KNOWLEDGE) - # Check 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 - text_embeds = [] - for col in meta.cols: - if col["vlen"] == 0: - continue - 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 = await _embed( - gen_config.embedding_model, embedder, [title], col["dtype"] - ) - title_embed = title_embed[0] - elif col["id"] == "Text Embed": - # 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: - raise RuntimeError( - "Sorry we encountered an issue during embedding. Please try again later." - ) - row_add_data = [ - { - "Text": chunk.text, - "Text Embed": text_embed, - "Title": title, - "Title Embed": title_embed, - "File ID": file_uri, - "Page": chunk.page, - } - 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, - 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) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=body.table_id) + await table.delete_rows(row_ids=body.row_ids, where=body.where) return OkResponse() -@router.options("/v1/gen_tables/knowledge/embed_file") -@router.options("/v1/gen_tables/knowledge/upload_file", deprecated=True) +@router.options( + "/v2/gen_tables/knowledge/embed_file", + summary="Get CORS preflight options for file embedding endpoint", + description="Permissions: None, publicly accessible.", +) @handle_exception -async def embed_file_options(request: Request, response: Response): +async def embed_file_options(): 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) +@router.post( + "/v2/gen_tables/knowledge/embed_file", + summary="Embed a file into a knowledge table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @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.", 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, - chunk_size: Annotated[ - int, Form(description="Maximum chunk size (number of characters). Must be > 0.", gt=0) - ] = 2000, - chunk_overlap: Annotated[ - int, Form(description="Overlap in characters between chunks. Must be >= 0.", ge=0) - ] = 200, - # stream: Annotated[ - # bool, Form(description="Whether or not to stream the LLM generation.") - # ] = True, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + data: Annotated[FileEmbedFormData, Form()], ) -> 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." - '"' - ) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) # 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" - elif splitext(file_name)[1].lower() == ".md": - file_content_type = "text/markdown" - elif splitext(file_name)[1].lower() == ".tsv": - file_content_type = "text/tab-separated-values" - else: - file_content_type = file.content_type - if file_content_type not in EMBED_WHITE_LIST_MIME: + file_name = data.file.filename or data.file_name + mime = guess_mime(file_name) + if mime == "application/octet-stream": + mime = data.file.content_type + if mime not in EMBED_WHITE_LIST_MIME: raise UnsupportedMediaTypeError( - f"File type '{file_content_type}' is unsupported. Accepted types are: {', '.join(EMBED_WHITE_LIST_MIME)}" + f'File type "{mime}" is unsupported. Accepted types are: {", ".join(EMBED_WHITE_LIST_MIME)}' ) - # --- Add into File Table --- # - content = await file.read() - uri = await upload_file_to_s3( + table = await KnowledgeTable.open_table( + project_id=project.id, + table_id=data.table_id, + ) + # Check quota + request_id: str = request.state.id + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + # --- Store original file into S3 --- # + file_content = await data.file.read() + file_uri = await s3_upload( project.organization.id, project.id, - content, - file_content_type, - file_name, + file_content, + content_type=mime, + filename=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, + logger.info(f'{request_id} - Parsing file "{file_name}".') + doc_parser = GeneralDocLoader(request_id=request_id) + chunks = await doc_parser.load_document_chunks( + file_name, file_content, data.chunk_size, data.chunk_overlap + ) + logger.info(f'{request_id} - Embedding file "{file_name}" with {len(chunks):,d} chunks.') + + # --- Extract title --- # + lm = LMEngine( + organization=org, project=project, - table_id=table_id, - file_name=file_name, - file_content=content, - file_uri=uri, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, + request=request, ) + ext = splitext(file_name)[1].lower() + if ext in [".pdf", ".pptx", ".xlsx"]: + first_page_chunks = [d.text for d in chunks if d.page == 1] + # If the first page content is too short, use the first 8 chunks instead + if len(first_page_chunks) < 3: + first_page_chunks = [d.text for d in chunks[:8]] + excerpt = "".join(first_page_chunks)[:50000] + else: + excerpt = "".join(d.text for d in chunks[:8])[:50000] + logger.debug(f"{request_id} - Performing title extraction.") + title = await lm.generate_title(excerpt=excerpt, model="") + + # --- Embed --- # + title_embed = text_embeds = None + for col in table.column_metadata: + if col.column_id.lower() == "title embed": + title_embed = await lm.embed_documents( + model=col.gen_config.embedding_model, + texts=[title], + encoding_format="float", + ) + title_embed = title_embed.data[0].embedding + elif col.column_id.lower() == "text embed": + text_embeds = await lm.embed_documents( + model=col.gen_config.embedding_model, + texts=[chunk.text for chunk in chunks], + encoding_format="float", + ) + text_embeds = [data.embedding for data in text_embeds.data] + + if title_embed is None or text_embeds is None or len(text_embeds) == 0: + raise UnexpectedError( + "Sorry we encountered an issue during embedding. If this issue persists, please contact support." + ) + # --- Store into Knowledge Table --- # + row_add_data = [ + { + "Title": title, + "Title Embed": title_embed, + "Text": chunk.text, + "Text Embed": text_embed, + "File ID": file_uri, + "Page": chunk.page, + } + for chunk, text_embed in zip(chunks, text_embeds, strict=True) + ] + await table.add_rows(row_add_data) + return OkResponse() -@router.post("/v1/gen_tables/{table_type}/import_data") +@router.post( + "/v2/gen_tables/{table_type}/import_data", + summary="Import data into a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @handle_exception async def import_table_data( request: Request, - bg_tasks: BackgroundTasks, - 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=TABLE_NAME_PATTERN, - description="ID or name of the table that the data should be imported into.", - ), + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) ], - 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[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[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[ - CSVDelimiter, - Form(description='The delimiter, can be "," or "\\t". Defaults to ",".'), - ] = CSVDelimiter.COMMA, + table_type: Annotated[TableType, Path(description="Table type.")], + data: Annotated[TableDataImportFormData, Form()], ): - # 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: - 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: - if dtype == "str": - df[col_id] = df[col_id].apply(lambda x: str(x) if not pd.isna(x) else x) - else: - if dtype in [ColumnDtype.IMAGE, ColumnDtype.AUDIO]: - 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") + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=data.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + # Import data + rows = await table.read_csv( + input_path=BytesIO(await data.file.read()), + column_id_mapping=None, + delimiter=data.delimiter, + ignore_info_columns=True, # Ignore "ID" and "Updated at" columns + ) return await add_rows( request=request, - bg_tasks=bg_tasks, - project=project, + auth_info=auth_info, table_type=table_type, - body=RowAddRequest(table_id=table_id, data=row_add_data, stream=stream), + body=MultiRowAddRequest(table_id=data.table_id, data=rows, stream=data.stream), ) -@router.get("/v1/gen_tables/{table_type}/{table_id}/export_data") +@router.get( + "/v2/gen_tables/{table_type}/export_data", + summary="Export data from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @handle_exception -def export_table_data( - *, +async def export_table_data( + request: Request, 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."), + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) ], - delimiter: Annotated[ - CSVDelimiter, - Query(description='The delimiter, can be "," or "\\t". Defaults to ",".'), - ] = CSVDelimiter.COMMA, - columns: Annotated[ - list[ColName] | None, - Query( - min_length=1, - description="_Optional_. A list of columns to be exported. Defaults to None (export all columns).", - ), - ] = None, + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[ExportTableDataQuery, Query()], ) -> FileResponse: - # Export data - table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) - ext = ".csv" if delimiter == CSVDelimiter.COMMA else ".tsv" + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=params.table_id) + # Check quota + billing: BillingManager = request.state.billing + billing.has_gen_table_quota(table) + billing.has_db_storage_quota() + billing.has_egress_quota() + # Temporary file + ext = ".csv" if params.delimiter == CSVDelimiter.COMMA else ".tsv" tmp_dir = TemporaryDirectory() - filename = f"{table_id}{ext}" + filename = f"{params.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) - # Get column ordering - with table.create_session() as session: - meta = table.open_meta(session, table_id, remove_state_cols=True) - columns_order = [c.id for c in meta.cols_schema] - if columns is None: - columns_to_export = columns_order - else: - columns_to_export = [ - col for col in columns_order if col in columns or col.lower() in ("id", "updated at") - ] - table.export_csv( - table_id=table_id, - columns=columns_to_export, - file_path=filepath, - delimiter=delimiter, + # Export + await table.export_data( + output_path=filepath, + columns=params.columns, + where="", + limit=None, + offset=0, + delimiter=params.delimiter, ) return FileResponse( path=filepath, @@ -1402,53 +1094,97 @@ def export_table_data( ) -@router.post("/v1/gen_tables/{table_type}/import") +@router.post( + "/v2/gen_tables/{table_type}/import", + summary="Import a table including its metadata.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @handle_exception async def import_table( - project: Annotated[ProjectRead, Depends(auth_user_project)], + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], 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, + data: Annotated[TableImportFormData, Form()], +) -> TableMetaResponse | OkResponse: + user, project, org = auth_info + if not data.migrate: + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + # Check quota + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + billing.has_egress_quota() + # Import + async with s3_temporary_file(await data.file.read(), "application/vnd.apache.parquet") as uri: + result: AsyncResult = import_gen_table.delay( + source=uri, + project_id=project.id, + table_type=table_type, + table_id_dst=data.table_id_dst, + reupload_files=not data.migrate, + progress_key=data.progress_key, + verbose=data.migrate, + ) + # Poll progress + initial_wait: float = 0.5 + max_wait: float = 30 * 60 # 30 minutes + t0 = perf_counter() + i = 1 + while (not result.ready()) and ((perf_counter() - t0) < max_wait): + await sleep(min(initial_wait * i, 5.0)) + if not data.blocking: + prog = await CACHE.get_progress(data.progress_key, TableImportProgress) + if prog.load_data.progress == 100: + return OkResponse(progress_key=data.progress_key) + i += 1 + if (perf_counter() - t0) >= max_wait: + raise ServerBusyError( + "Table import took too long to complete. Please try again later." ) - meta = TableMetaResponse(**meta.model_dump(), num_rows=table.count_rows(meta.id)) - return meta + return TableMetaResponse.model_validate_json(result.get(propagate=True)) -@router.get("/v1/gen_tables/{table_type}/{table_id}/export") +@router.get( + "/v2/gen_tables/{table_type}/export", + summary="Export a table including its metadata.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) @handle_exception -def export_table( - *, +async def export_table( + request: Request, 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."), + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Query(description="Table name.")], ) -> FileResponse: - table = GenerativeTable.from_ids(project.organization.id, project.id, table_type) + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + # Check quota + billing: BillingManager = request.state.billing + billing.has_db_storage_quota() + billing.has_egress_quota() + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=table_id) + # Temporary file 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, - dest=filepath, - ) + # Export + await table.export_table(filepath) return FileResponse( path=filepath, filename=filename, diff --git a/services/api/src/owl/routers/gen_table_v1.py b/services/api/src/owl/routers/gen_table_v1.py new file mode 100644 index 0000000..3d2d953 --- /dev/null +++ b/services/api/src/owl/routers/gen_table_v1.py @@ -0,0 +1,916 @@ +from typing import Annotated, Any, Literal + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + Form, + Path, + Query, + Request, + Response, + UploadFile, +) +from fastapi.responses import FileResponse +from pydantic import BaseModel, BeforeValidator, Field + +from owl.routers import gen_table as v2 +from owl.types import ( + TABLE_NAME_PATTERN, + ActionTableSchemaCreate, + ChatTableSchemaCreate, + ChatThreadResponse, + ColumnDropRequest, + ColumnRenameRequest, + ColumnReorderRequest, + CSVDelimiter, + GenConfigUpdateRequest, + KnowledgeTableSchemaCreate, + MultiRowAddRequestWithLimit, + MultiRowDeleteRequest, + MultiRowRegenRequest, + MultiRowUpdateRequest, + OkResponse, + OrganizationRead, + Page, + ProjectRead, + RowUpdateRequest, + SanitisedNonEmptyStr, + SearchRequest, + TableMetaResponse, + TableSchemaCreate, + TableType, + UserAuth, + empty_string_to_none, +) +from owl.utils import uuid7_str +from owl.utils.auth import auth_user_project, has_permissions +from owl.utils.exceptions import handle_exception + +router = APIRouter() + + +@router.post( + "/v1/gen_tables/action", + summary="Create an action table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def create_action_table( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: ActionTableSchemaCreate, +) -> TableMetaResponse: + return await v2.create_action_table(request=request, auth_info=auth_info, body=body) + + +@router.post( + "/v1/gen_tables/knowledge", + summary="Create a knowledge table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def create_knowledge_table( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: KnowledgeTableSchemaCreate, +) -> TableMetaResponse: + user, project, org = auth_info + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org.id, + project_id=project.id, + ) + return await v2.create_knowledge_table(request=request, auth_info=auth_info, body=body) + + +@router.post( + "/v1/gen_tables/chat", + summary="Create a chat table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def create_chat_table( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: ChatTableSchemaCreate, +) -> TableMetaResponse: + return await v2.create_chat_table(request=request, auth_info=auth_info, body=body) + + +@router.post( + "/v1/gen_tables/{table_type}/duplicate/{table_id_src}", + summary="Duplicate a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def duplicate_table( + *, + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: TableType, + table_id_src: str = Path(description="Name of the table to be duplicated."), + table_id_dst: str | None = Query( + None, + pattern=TABLE_NAME_PATTERN, + max_length=100, + description=( + "_Optional_. Name for the new table." + "Defaults to None (automatically find the next available table name)." + ), + ), + include_data: bool = Query( + True, + description="_Optional_. Whether to include data from the source table. Defaults to `True`.", + ), + create_as_child: bool = Query( + 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: + return await v2.duplicate_table( + request=request, + auth_info=auth_info, + table_type=table_type, + params=v2.DuplicateTableQuery( + 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}", + deprecated=True, + summary="Duplicate a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def duplicate_table_deprecated( + *, + request: Request, + response: Response, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: TableType, + table_id_src: str = Path(description="Source table name or ID."), + table_id_dst: str = Path( + pattern=TABLE_NAME_PATTERN, + max_length=100, + description="Destination table name or ID.", + ), + include_data: bool = Query( + True, + description="_Optional_. Whether to include the data from the source table in the duplicated table. Defaults to `True`.", + ), + deploy: bool = Query( + False, + description="_Optional_. Whether to deploy the duplicated table. Defaults to `False`.", + ), +) -> TableMetaResponse: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.5. ' + "Use '/v1/gen_tables/{table_type}/duplicate/{table_id_src}' instead." + '"' + ) + return await v2.duplicate_table( + request=request, + auth_info=auth_info, + table_type=table_type, + params=v2.DuplicateTableQuery( + table_id_src=table_id_src, + table_id_dst=table_id_dst, + include_data=include_data, + create_as_child=deploy, + ), + ) + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}", + summary="Get a specific table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def get_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(description="The ID of the table to fetch."), +) -> TableMetaResponse: + return await v2.get_table(auth_info=auth_info, table_type=table_type, table_id=table_id) + + +class _ListTableQueryLegacy(BaseModel): + offset: Annotated[ + int, + Field(ge=0, description="Item offset for pagination. Defaults to 0."), + ] = 0 + limit: Annotated[ + int, + Field( + gt=0, + le=100, + description="Number of tables to return (min 1, max 100). Defaults to 100.", + ), + ] = 100 + parent_id: Annotated[ + str | None, + Field( + min_length=1, + description=( + "Parent ID of tables to return. Defaults to None (return all tables). " + "Additionally for Chat Table, you can list: " + '(1) all chat agents by passing in "_agent_"; or ' + '(2) all chats by passing in "_chat_".' + ), + ), + ] = None + search_query: Annotated[ + str, + Field( + max_length=255, + description='A string to search for within table IDs as a filter. Defaults to "" (no filter).', + ), + ] = "" + order_by: Annotated[ + Literal["id", "updated_at"], + Field(description='Sort tables by this attribute. Defaults to "updated_at".'), + ] = "updated_at" + order_descending: Annotated[ + bool, + Field( + description="Whether to sort by descending order. Defaults to True.", + ), + ] = True + count_rows: Annotated[ + bool, + Field(description="Whether to count the rows of the tables. Defaults to False."), + ] = False + + +@router.get( + "/v1/gen_tables/{table_type}", + summary="List tables of a specific type.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def list_tables( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[_ListTableQueryLegacy, Query()], +) -> Page[TableMetaResponse]: + kwargs = params.model_dump() + order_ascending = not kwargs.pop("order_descending", True) + return await v2.list_tables( + auth_info=auth_info, + table_type=table_type, + params=v2.ListTableQuery(order_ascending=order_ascending, **kwargs), + ) + + +@router.post( + "/v1/gen_tables/{table_type}/rename/{table_id_src}/{table_id_dst}", + summary="Rename a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def rename_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id_src: Annotated[str, Path(description="Source table name.")], # Don't validate + table_id_dst: Annotated[ + str, + Path(pattern=TABLE_NAME_PATTERN, max_length=100, description="New name for the table."), + ], +) -> TableMetaResponse: + return await v2.rename_table( + auth_info=auth_info, + table_type=table_type, + params=v2.RenameTableQuery(table_id_src=table_id_src, table_id_dst=table_id_dst), + ) + + +@router.delete( + "/v1/gen_tables/{table_type}/{table_id}", + summary="Delete a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def delete_table( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Path(description="Name of the table to be deleted.")], +) -> OkResponse: + return await v2.delete_table(auth_info=auth_info, table_type=table_type, table_id=table_id) + + +@router.post( + "/v1/gen_tables/{table_type}/columns/add", + summary="Add columns to a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def add_columns( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: TableSchemaCreate, +) -> TableMetaResponse: + return await v2.add_columns( + request=request, auth_info=auth_info, table_type=table_type, body=body + ) + + +@router.post( + "/v1/gen_tables/{table_type}/columns/rename", + summary="Rename columns in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def rename_columns( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnRenameRequest, +) -> TableMetaResponse: + return await v2.rename_columns(auth_info=auth_info, table_type=table_type, body=body) + + +@router.post( + "/v1/gen_tables/{table_type}/gen_config/update", + summary="Update generation configuration for table columns.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def update_gen_config( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + updates: GenConfigUpdateRequest, +) -> TableMetaResponse: + return await v2.update_gen_config(auth_info=auth_info, table_type=table_type, updates=updates) + + +@router.post( + "/v1/gen_tables/{table_type}/columns/reorder", + summary="Reorder columns in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def reorder_columns( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnReorderRequest, +) -> TableMetaResponse: + return await v2.reorder_columns(auth_info=auth_info, table_type=table_type, body=body) + + +@router.post( + "/v1/gen_tables/{table_type}/columns/drop", + summary="Drop columns from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def drop_columns( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: ColumnDropRequest, +) -> TableMetaResponse: + return await v2.drop_columns( + request=request, auth_info=auth_info, table_type=table_type, body=body + ) + + +@router.post( + "/v1/gen_tables/{table_type}/rows/add", + summary="Add rows to a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def add_rows( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: MultiRowAddRequestWithLimit, +): + return await v2.add_rows( + request=request, auth_info=auth_info, table_type=table_type, body=body + ) + + +class _ListTableRowQueryLegacy(BaseModel): + offset: Annotated[ + int, + Field(ge=0, description="Item offset for pagination. Defaults to 0."), + ] = 0 + limit: Annotated[ + int, + Field( + gt=0, + le=100, + description="Number of rows to return (min 1, max 100). Defaults to 100.", + ), + ] = 100 + order_descending: Annotated[ + bool, + Field( + description="Whether to sort by descending order. Defaults to True.", + ), + ] = True + columns: Annotated[ + list[str] | None, + Field( + description="A list of column names to include in the response. Default is to return all columns.", + ), + ] = None + search_query: Annotated[ + str, + Field( + max_length=10_000, + description=( + "A string to search for within row data as a filter. " + 'The string is interpreted as both POSIX regular expression and literal string. Defaults to "" (no filter). ' + "It will be combined other filters using `AND`." + ), + ), + ] = "" + float_decimals: Annotated[ + int, + Field( + ge=0, + description="Number of decimals for float values. Defaults to 0 (no rounding).", + ), + ] = 0 + vec_decimals: Annotated[ + int, + Field( + description="Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", + ), + ] = 0 + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}/rows", + summary="List rows in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def list_rows( + *, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(description="Table ID or name."), + params: Annotated[_ListTableRowQueryLegacy, Query()], +) -> Page[dict[str, Any]]: + kwargs = params.model_dump() + order_ascending = not kwargs.pop("order_descending", True) + response = await v2.list_rows( + auth_info=auth_info, + table_type=table_type, + params=v2.ListTableRowQuery(table_id=table_id, order_ascending=order_ascending, **kwargs), + ) + # Reproduce V1 "value" bug for backwards compatibility + if params.columns: + for col in params.columns: + for row in response.items: + if col in row and isinstance(row[col], dict): + row[col] = row[col].get("value", row[col]) + return response + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}", + summary="Get a specific row from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def get_row( + *, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(description="Table ID or name."), + row_id: Annotated[str, Path(description="The ID of the specific row to fetch.")], + columns: list[str] | 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. If its negative, exclude vector columns. Defaults to 0 (no rounding).", + ), +) -> dict[str, Any]: + return await v2.get_row( + auth_info=auth_info, + table_type=table_type, + params=v2.GetTableRowQuery( + table_id=table_id, + row_id=row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + ), + ) + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}/thread", + summary="Get a conversation thread from a multi-turn LLM column.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def get_conversation_thread( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Path(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_. ID / name of the last row in the thread. Defaults to "" (export all rows).' + ), + ] = "", + include: Annotated[ + bool, + Query( + description="_Optional_. Whether to include the row specified by `row_id`. Defaults to True." + ), + ] = True, +) -> ChatThreadResponse: + response = await v2.get_conversation_threads( + auth_info=auth_info, + table_type=table_type, + params=v2.GetTableThreadsQuery( + table_id=table_id, column_ids=[column_id], row_id=row_id, include_row=include + ), + ) + return response.threads[column_id] + + +@router.post( + "/v1/gen_tables/{table_type}/hybrid_search", + summary="Perform hybrid search on a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def hybrid_search( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: SearchRequest, +) -> list[dict[str, Any]]: + return await v2.hybrid_search( + request=request, auth_info=auth_info, table_type=table_type, body=body + ) + + +@router.post( + "/v1/gen_tables/{table_type}/rows/regen", + summary="Regenerate rows in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def regen_rows( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: MultiRowRegenRequest, +): + return await v2.regen_rows( + request=request, auth_info=auth_info, table_type=table_type, body=body + ) + + +@router.post( + "/v1/gen_tables/{table_type}/rows/update", + summary="Update a row in a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def update_row( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: RowUpdateRequest, +) -> OkResponse: + return await v2.update_rows( + request=request, + auth_info=auth_info, + table_type=table_type, + body=MultiRowUpdateRequest(table_id=body.table_id, data={body.row_id: body.data}), + ) + + +@router.post( + "/v1/gen_tables/{table_type}/rows/delete", + summary="Delete rows from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def delete_rows( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + body: MultiRowDeleteRequest, +) -> OkResponse: + return await v2.delete_rows(auth_info=auth_info, table_type=table_type, body=body) + + +@router.delete( + "/v1/gen_tables/{table_type}/{table_id}/rows/{row_id}", + summary="Delete a row from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def delete_row( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: str = Path(description="Table ID or name."), + row_id: str = Path(description="The ID of the specific row to delete."), +) -> OkResponse: + return await v2.delete_rows( + auth_info=auth_info, + table_type=table_type, + body=MultiRowDeleteRequest(table_id=table_id, row_ids=[row_id]), + ) + + +@router.options( + "/v1/gen_tables/knowledge/embed_file", + summary="Get CORS preflight options for file embedding endpoint", + description="Permissions: None, publicly accessible.", +) +@router.options( + "/v1/gen_tables/knowledge/upload_file", + deprecated=True, + summary="Get CORS preflight options for file embedding endpoint", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def embed_file_options(request: Request, response: Response): + if "upload_file" in request.url.path: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.5. ' + "Use '/v1/gen_tables/{table_type}/embed_file' instead." + '"' + ) + return await v2.embed_file_options() + + +class FileEmbedFormData(BaseModel): + file: Annotated[UploadFile, File(description="The file.")] + file_name: Annotated[str, Field(description="File name.", deprecated=True)] = "" + table_id: Annotated[SanitisedNonEmptyStr, Field(description="Knowledge Table ID.")] + # overwrite: Annotated[ + # bool, Field(description="Whether to overwrite old file with the same name.") + # ] = False, + chunk_size: Annotated[ + int, Field(gt=0, description="Maximum chunk size (number of characters). Must be > 0.") + ] = 2000 + chunk_overlap: Annotated[ + int, Field(ge=0, description="Overlap in characters between chunks. Must be >= 0.") + ] = 200 + + +@router.post( + "/v1/gen_tables/knowledge/embed_file", + summary="Embed a file into a knowledge table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@router.post( + "/v1/gen_tables/knowledge/upload_file", + deprecated=True, + summary="Embed a file into a knowledge table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def embed_file( + *, + request: Request, + response: Response, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + data: Annotated[FileEmbedFormData, Form()], +) -> OkResponse: + if "upload_file" in request.url.path: + response.headers["Warning"] = ( + '299 - "This endpoint is deprecated and will be removed in v0.5. ' + "Use '/v1/gen_tables/{table_type}/embed_file' instead." + '"' + ) + return await v2.embed_file( + request=request, + auth_info=auth_info, + data=data, + ) + + +class TableDataImportFormData(BaseModel): + file: Annotated[UploadFile, File(description="The CSV or TSV file.")] + file_name: Annotated[str, Field(description="File name.", deprecated=True)] = "" + table_id: Annotated[ + SanitisedNonEmptyStr, + 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 + ) + # List of inputs is bugged as of 2024-07-14: https://github.com/tiangolo/fastapi/pull/9928/files + # TODO: Maybe we can re-enable these since the bug is for direct `Form` declaration and not Form Model + # column_names: Annotated[ + # list[ColName] | None, + # Field( + # 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[ColName] | None, + # Field( + # description="_Optional_. A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at').", + # ), + # ] = None + delimiter: Annotated[ + CSVDelimiter, + Field(description='The delimiter, can be "," or "\\t". Defaults to ",".'), + ] = CSVDelimiter.COMMA + + +@router.post( + "/v1/gen_tables/{table_type}/import_data", + summary="Import data into a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def import_table_data( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + data: Annotated[TableDataImportFormData, Form()], +): + return await v2.import_table_data( + request=request, auth_info=auth_info, table_type=table_type, data=data + ) + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}/export_data", + summary="Export data from a table.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def export_table_data( + request: Request, + bg_tasks: BackgroundTasks, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Path(description="ID or name of the table to be exported.")], + delimiter: Annotated[ + CSVDelimiter, + Query(description='The delimiter, can be "," or "\\t". Defaults to ",".'), + ] = CSVDelimiter.COMMA, + columns: Annotated[ + list[str] | None, + Query( + min_length=1, + description="_Optional_. A list of columns to be exported. Defaults to None (export all columns).", + ), + ] = None, +) -> FileResponse: + return await v2.export_table_data( + request=request, + bg_tasks=bg_tasks, + auth_info=auth_info, + table_type=table_type, + params=v2.ExportTableDataQuery(table_id=table_id, delimiter=delimiter, columns=columns), + ) + + +class TableImportFormData(BaseModel): + file: Annotated[UploadFile, File(description="The Parquet file.")] + table_id_dst: Annotated[ + SanitisedNonEmptyStr | None, + BeforeValidator(empty_string_to_none), + Field(description="The ID or name of the new table."), + ] = None + blocking: Annotated[ + bool, + Field( + description=( + "If True, waits until import finishes. " + "If False, the task is submitted to a task queue and returns immediately." + ), + ), + ] = True + progress_key: Annotated[ + str, + Field( + default_factory=uuid7_str, + description="The key to use to query progress. Defaults to a random string.", + ), + ] + migrate: Annotated[bool, Field(description="Whether to import in migration mode.")] = False + + +@router.post( + "/v1/gen_tables/{table_type}/import", + summary="Import a table including its metadata.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def import_table( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + data: Annotated[TableImportFormData, Form()], +) -> TableMetaResponse: + return await v2.import_table( + request=request, + auth_info=auth_info, + table_type=table_type, + data=data, + ) + + +@router.get( + "/v1/gen_tables/{table_type}/{table_id}/export", + summary="Export a table including its metadata.", + description="Permissions: `organization.MEMBER` OR `project.MEMBER`.", +) +@handle_exception +async def export_table( + request: Request, + bg_tasks: BackgroundTasks, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + table_type: Annotated[TableType, Path(description="Table type.")], + table_id: Annotated[str, Path(description="ID or name of the table to be exported.")], +) -> FileResponse: + return await v2.export_table( + request=request, + bg_tasks=bg_tasks, + auth_info=auth_info, + table_type=table_type, + table_id=table_id, + ) diff --git a/services/api/src/owl/routers/llm.py b/services/api/src/owl/routers/llm.py deleted file mode 100644 index 35d754a..0000000 --- a/services/api/src/owl/routers/llm.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -LLM operations. -""" - -import base64 -from typing import Annotated - -import numpy as np -from fastapi import APIRouter, Depends, Query, Request -from fastapi.responses import StreamingResponse - -from jamaibase.exceptions import ResourceNotFoundError -from owl.llm import LLMEngine -from owl.models import CloudEmbedder -from owl.protocol import ( - EXAMPLE_CHAT_MODEL_IDS, - ChatRequest, - ChatRequestWithTools, - EmbeddingRequest, - EmbeddingResponse, - EmbeddingResponseData, - ModelCapability, - ModelInfoResponse, -) -from owl.utils.auth import auth_user_project -from owl.utils.exceptions import handle_exception - -router = APIRouter(dependencies=[Depends(auth_user_project)]) - - -@router.get( - "/v1/models", - 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=EXAMPLE_CHAT_MODEL_IDS, - ), - ] = "", - capabilities: Annotated[ - list[ModelCapability] | None, - Query( - description=( - "Filter the model info by model's capabilities. " - "Leave it blank to disable filter." - ), - examples=[[ModelCapability.CHAT]], - ), - ] = None, -) -> ModelInfoResponse: - try: - if capabilities is not None: - capabilities = [c.value for c in capabilities] - return LLMEngine(request=request).model_info( - model=model, - capabilities=capabilities, - ) - except ResourceNotFoundError: - return ModelInfoResponse(data=[]) - - -@router.get( - "/v1/model_names", - summary="List the ID of models available.", - description=( - "List the ID of models available with the specified capabilities with an optional preferred model. " - "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=EXAMPLE_CHAT_MODEL_IDS, - ), - ] = "", - capabilities: Annotated[ - list[ModelCapability] | None, - Query( - description=( - "Filter the model info by model's capabilities. " - "Leave it blank to disable filter." - ), - examples=[[ModelCapability.CHAT]], - ), - ] = None, -) -> list[str]: - try: - if capabilities is not None: - capabilities = [c.value for c in capabilities] - return LLMEngine(request=request).model_names( - prefer=prefer, - capabilities=capabilities, - ) - except ResourceNotFoundError: - return [] - - -@router.post( - "/v1/chat/completions", - description="Given a list of messages comprising a conversation, the model will return a response.", -) -@handle_exception -async def generate_completions(request: Request, body: ChatRequest | ChatRequestWithTools): - # 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(**hyperparams): - sse = f"data: {chunk.model_dump_json()}\n\n" - content_length += len(sse.encode("utf-8")) - yield sse - 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"}, - ) - - else: - response = await llm.rag(**hyperparams) - request.state.billing.create_egress_events( - len(response.model_dump_json().encode("utf-8")) / (1024**3) - ) - return response - - -@router.post( - "/v1/embeddings", - description=( - "Get a vector representation of a given input that can be " - "easily consumed by machine learning models and algorithms. " - "Note that the vectors are NOT normalized." - ), -) -@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 - ) - for i, e in enumerate(embeddings.data) - ] - return embeddings diff --git a/services/api/src/owl/routers/meters.py b/services/api/src/owl/routers/meters.py new file mode 100644 index 0000000..0ef6e2e --- /dev/null +++ b/services/api/src/owl/routers/meters.py @@ -0,0 +1,631 @@ +from datetime import datetime +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query + +from owl.db import SCHEMA, async_session, cached_text +from owl.types import UsageResponse, UserAuth +from owl.utils.auth import ( + auth_user_service_key, + has_permissions, +) +from owl.utils.billing import CLICKHOUSE_CLIENT +from owl.utils.billing_metrics import BillingMetrics +from owl.utils.exceptions import ( + BadInputError, + handle_exception, +) +from owl.utils.metrics import Telemetry + +router = APIRouter() +telemetry = Telemetry() + +billing_metrics = BillingMetrics(clickhouse_client=CLICKHOUSE_CLIENT) + + +async def _check_permissions( + user: UserAuth, + org_ids: list[str] | None, + proj_ids: list[str] | None, +) -> None: + if org_ids is None and proj_ids is None: + # This will return usages across ALL orgs and ALL projects + has_permissions(user, ["system.MEMBER"]) + else: + if org_ids: + for org_id in org_ids: + has_permissions(user, ["organization.MEMBER"], organization_id=org_id) + if proj_ids: + for proj_id in proj_ids: + async with async_session() as session: + stmt = f"""SELECT organization_id FROM {SCHEMA}."Project" WHERE id = '{proj_id}';""" + org_id = (await session.exec(cached_text(stmt))).one() + has_permissions( + user, + ["organization.MEMBER", "project.MEMBER"], + organization_id=org_id, + project_id=proj_id, + ) + + +@router.get( + "/v2/meters/usages", + summary="Get the usage metrics of the specified type (llm, embedding, reranking).", + description=( + "Permissions: `system.MEMBER` to retrieve metrics for all organizations or all projects; " + "`organization.MEMBER` to retrieve metrics for a specific organization; " + "`project.MEMBER` to retrieve metrics for a specific project." + ), + response_model=UsageResponse, +) +@handle_exception +async def get_usage_metrics( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + type: Annotated[ + Literal["llm", "embedding", "reranking"], + Query( + min_length=1, + description="Type of usage data to query. Must be one of: 'llm', 'embedding', or 'reranking'.", + ), + ], + from_: Annotated[ + datetime, Query(alias="from", description="Start datetime for the usage data query.") + ], + window_size: Annotated[ + str, + Query( + min_length=1, + description="The aggregation window size (e.g., '1d' for daily, '1w' for weekly).", + alias="windowSize", + ), + ], + org_ids: Annotated[ + list[str] | None, + Query( + description="List of organization IDs to filter the query. If not provided, data for all organizations is returned.", + alias="orgIds", + ), + ] = None, + proj_ids: Annotated[ + list[str] | None, + Query( + description="List of project IDs to filter the query. If not provided, data for all projects is returned.", + alias="projIds", + ), + ] = None, + to: Annotated[ + datetime | None, + Query( + description="End datetime for the usage data query. If not provided, data up to the current datetime is returned." + ), + ] = None, + group_by: Annotated[ + list[str] | None, + Query( + min_length=1, + description="List of fields to group the usage data by. If not provided, no grouping is applied.", + alias="groupBy", + ), + ] = None, + data_source: Annotated[ + Literal["clickhouse", "victoriametrics"], + Query(description="Data source to query. Defaults to 'clickhouse'.", alias="dataSource"), + ] = "clickhouse", +) -> UsageResponse: + """ + Retrieves usages metrics based on the provided filters. + This endpoint requires `system.MEMBER` permission. + + This endpoint allows querying usage data for specific organizations within a given time range. + The results can be grouped by specified fields and aggregated using a window size. + + Args: + user (UserAuth): The authenticated user making the request. + type (str): Type of usage data to query. One of: llm, embedding, reranking. + from_ (datetime): The start of the time range for the usage data. + window_size (str): The size of the time window for aggregating usage data + (e.g., "1d" for daily, "1w" for weekly). + org_ids (list[str] | None): A list of organization IDs to filter the usage data. + If not provided, data for all organizations will be returned. + proj_ids (list[str] | None): A list of project IDs to filter the usage data. + If not provided, data for all projects will be returned. + to (datetime | None): The end of the time range for the usage data. + If not provided, data up to the current date will be returned. + group_by (list[str] | None): A list of fields to group the usage data by. + If not provided, the data will not be grouped. + data_source (str): The data source to query. Defaults to "clickhouse". + + Returns: + UsageResponse: A response containing window_size and a list of the usage metrics. + + Raises: + BadInputError: If the 'type' parameter is invalid (not one of 'llm', + 'embedding', or 'reranking'). + """ + # RBAC + await _check_permissions(user, org_ids, proj_ids) + # Fetch + if group_by is None: + group_by = [] + if data_source == "clickhouse": + metrics_client = billing_metrics + elif data_source == "victoriametrics": + metrics_client = telemetry + if type == "llm": + return await metrics_client.query_llm_usage( + org_ids, proj_ids, from_, to, group_by, window_size + ) + elif type == "embedding": + return await metrics_client.query_embedding_usage( + org_ids, proj_ids, from_, to, group_by, window_size + ) + elif type == "reranking": + return await metrics_client.query_reranking_usage( + org_ids, proj_ids, from_, to, group_by, window_size + ) + raise BadInputError(f"type: {type} invalid. Must be one of: llm, embedding, reranking") + + +@router.get( + "/v2/meters/billings", + summary="Get billing metrics.", + description="Permissions: `system.MEMBER`.", + response_model=UsageResponse, +) +@handle_exception +async def get_billing_metrics( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + from_: Annotated[ + datetime, + Query( + alias="from", + description="Start datetime for the billing data query.", + ), + ], + window_size: Annotated[ + str, + Query( + min_length=1, + description="The aggregation window size (e.g., '1d' for daily, '1w' for weekly).", + alias="windowSize", + ), + ], + org_ids: Annotated[ + list[str] | None, + Query( + description="List of organization IDs to filter the query. If not provided, data for all organizations is returned.", + alias="orgIds", + ), + ] = None, + proj_ids: Annotated[ + list[str] | None, + Query( + description="List of project IDs to filter the query. If not provided, data for all projects is returned.", + alias="projIds", + ), + ] = None, + to: Annotated[ + datetime | None, + Query( + description="End datetime for the billing data query. If not provided, data up to the current datetime is returned.", + ), + ] = None, + group_by: Annotated[ + list[str] | None, + Query( + min_length=1, + description="List of fields to group the billing data by. If not provided, no grouping is applied.", + alias="groupBy", + ), + ] = None, + data_source: Annotated[ + Literal["clickhouse", "victoriametrics"], + Query(description="Data source to query. Defaults to 'clickhouse'.", alias="dataSource"), + ] = "clickhouse", +) -> UsageResponse: + """ + Retrieves billing metrics based on the provided filters. + This endpoint requires `system.MEMBER` permission. + + This endpoint allows querying billing data for specific organizations within a given time range. + The results can be grouped by specified fields and aggregated using a window size. + + Args: + user (str): The authenticated user making the request. + from_ (datetime): The start of the time range for the billing data. + window_size (str): The size of the time window for aggregating billing data + (e.g., "1d" for daily, "1w" for weekly). + org_ids (list[str] | None): A list of organization IDs to filter the billing data. + If not provided, data for all organizations will be returned. + proj_ids (list[str] | None): A list of project IDs to filter the billing data. + If not provided, data for all projects will be returned. + to (datetime | None): The end of the time range for the billing data. + If not provided, data up to the current date will be returned. + group_by (list[str] | None): A list of fields to group the billing data by. + If not provided, the data will not be grouped. + data_source (str): The data source to query. Defaults to "clickhouse". + + Returns: + UsageResponse: A response containing window_size and a list of the billing metrics. + """ + # RBAC + await _check_permissions(user, org_ids, proj_ids) + # Fetch + if group_by is None: + group_by = [] + if data_source == "clickhouse": + metrics_client = billing_metrics + elif data_source == "victoriametrics": + metrics_client = telemetry + return await metrics_client.query_billing(org_ids, proj_ids, from_, to, group_by, window_size) + + +@router.get( + "/v2/meters/bandwidths", + summary="Get bandwidth usage metrics.", + description="Permissions: `system.MEMBER`.", + response_model=UsageResponse, +) +@handle_exception +async def get_bandwidth_metrics( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + from_: Annotated[ + datetime, + Query( + alias="from", + description="Start datetime for the bandwidth data query.", + ), + ], + window_size: Annotated[ + str, + Query( + min_length=1, + description="The aggregation window size (e.g., '1d' for daily, '1w' for weekly).", + alias="windowSize", + ), + ], + org_ids: Annotated[ + list[str] | None, + Query( + description="List of organization IDs to filter the query. If not provided, data for all organizations is returned.", + alias="orgIds", + ), + ] = None, + proj_ids: Annotated[ + list[str] | None, + Query( + description="List of project IDs to filter the query. If not provided, data for all projects is returned.", + alias="projIds", + ), + ] = None, + to: Annotated[ + datetime | None, + Query( + description="End datetime for the bandwidth data query. If not provided, data up to the current datetime is returned.", + ), + ] = None, + group_by: Annotated[ + list[str] | None, + Query( + min_length=1, + description="List of fields to group the bandwidth data by. If not provided, no grouping is applied.", + alias="groupBy", + ), + ] = None, + data_source: Annotated[ + Literal["clickhouse", "victoriametrics"], + Query(description="Data source to query. Defaults to 'clickhouse'.", alias="dataSource"), + ] = "clickhouse", +) -> UsageResponse: + """ + Retrieves bandwidth metrics based on the provided filters. + This endpoint requires `system.MEMBER` permission. + + This endpoint allows querying bandwidth data for specific organizations within a given time range. + The results can be grouped by specified fields and aggregated using a window size. + + Args: + user (str): The authenticated user making the request. + from_ (datetime): The start of the time range for the bandwidth data. + window_size (str): The size of the time window for aggregating bandwidth data + (e.g., "1d" for daily, "1w" for weekly). + org_ids (list[str] | None): A list of organization IDs to filter the bandwidth data. + If not provided, data for all organizations will be returned. + proj_ids (list[str] | None): A list of project IDs to filter the bandwidth data. + If not provided, data for all projects will be returned. + to (datetime | None): The end of the time range for the bandwidth data. + If not provided, data up to the current date will be returned. + group_by (list[str] | None): A list of fields to group the bandwidth data by. + If not provided, the data will not be grouped. + data_source (str): The data source to query. Defaults to "clickhouse". + + Returns: + UsageResponse: A response containing window_size and a list of the bandwidth metrics. + """ + # RBAC + await _check_permissions(user, org_ids, proj_ids) + # Fetch + if group_by is None: + group_by = [] + if data_source == "clickhouse": + metrics_client = billing_metrics + elif data_source == "victoriametrics": + metrics_client = telemetry + return await metrics_client.query_bandwidth( + org_ids, proj_ids, from_, to, group_by, window_size + ) + + +@router.get( + "/v2/meters/storages", + summary="Get storage usage metrics.", + description="Permissions: `system.MEMBER`.", + response_model=UsageResponse, +) +@handle_exception +async def get_storage_metrics( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + from_: Annotated[ + datetime, + Query( + alias="from", + description="Start datetime for the storage data query.", + ), + ], + window_size: Annotated[ + str, + Query( + min_length=1, + description="The aggregation window size (e.g., '1d' for daily, '1w' for weekly).", + alias="windowSize", + ), + ], + org_ids: Annotated[ + list[str] | None, + Query( + description="List of organization IDs to filter the query. If not provided, data for all organizations is returned.", + alias="orgIds", + ), + ] = None, + proj_ids: Annotated[ + list[str] | None, + Query( + description="List of project IDs to filter the query. If not provided, data for all projects is returned.", + alias="projIds", + ), + ] = None, + to: Annotated[ + datetime | None, + Query( + description="End datetime for the storage data query. If not provided, data up to the current datetime is returned.", + ), + ] = None, + group_by: Annotated[ + list[str] | None, + Query( + min_length=1, + description="List of fields to group the storage data by. If not provided, no grouping is applied.", + alias="groupBy", + ), + ] = None, + data_source: Annotated[ + Literal["clickhouse", "victoriametrics"], + Query(description="Data source to query. Defaults to 'clickhouse'.", alias="dataSource"), + ] = "clickhouse", +) -> UsageResponse: + """ + Retrieves storage metrics based on the provided filters. + This endpoint requires `system.MEMBER` permission. + + This endpoint allows querying storage data for specific organizations within a given time range. + The results can be grouped by specified fields and aggregated using a window size. + + Args: + user (str): The authenticated user making the request. + from_ (datetime): The start of the time range for the storage data. + window_size (str): The size of the time window for aggregating storage data + (e.g., "1d" for daily, "1w" for weekly). + org_ids (list[str] | None): A list of organization IDs to filter the storage data. + If not provided, data for all organizations will be returned. + proj_ids (list[str] | None): A list of project IDs to filter the storage data. + If not provided, data for all projects will be returned. + to (datetime | None): The end of the time range for the storage data. + If not provided, data up to the current date will be returned. + group_by (list[str] | None): A list of fields to group the storage data by. + If not provided, the data will not be grouped. + + Returns: + UsageResponse: A response containing window_size and a list of the storage metrics. + """ + # RBAC + await _check_permissions(user, org_ids, proj_ids) + # Fetch + if group_by is None: + group_by = [] + if data_source == "clickhouse": + metrics_client = billing_metrics + elif data_source == "victoriametrics": + metrics_client = telemetry + return await metrics_client.query_storage(org_ids, proj_ids, from_, to, group_by, window_size) + + +# @router.get( +# "/v2/meters/models/throughput", +# summary="Get the model throughput statistics of the specified model type (llm, embedding, reranking), and metric type.", +# description="Permissions: `system.models` OR `system.metrics`.", +# response_model=UsageResponse, +# ) +# @handle_exception +# async def get_model_throughput_metrics( +# user: Annotated[UserAuth, Depends(auth_user_service_key)], +# type: Annotated[ +# Literal["llm", "embedding", "reranking"], +# Query( +# min_length=1, +# description="Type of usage data to query. Must be one of: 'llm', 'embedding', or 'reranking'.", +# ), +# ], +# metric_type: Annotated[ +# Literal["tpm", "rpm", "spm"], +# Query( +# min_length=1, +# description=( +# "Type of metric to query, " +# "Here is the list of possible metric type: " +# "llm: tpm, rpm" +# "embedding: tpm, rpm" +# "reranking: spm, rpm" +# "tpm (tokens per minute), rpm (requests per minute), spm (searches per minute) " +# ), +# ), +# ], +# from_: Annotated[ +# datetime, Query(alias="from", description="Start datetime for the usage data query.") +# ], +# to: Annotated[ +# datetime | None, +# Query( +# description="End datetime for the usage data query. If not provided, data up to the current datetime is returned." +# ), +# ] = None, +# ) -> UsageResponse: +# """ +# Retrieves model throughput statistics based on the provided filters. +# This endpoint requires `system.metrics` permission. + +# This endpoint allows querying model throughput statistics data for specific metric type within a given time range. + +# Args: +# user (UserAuth): The authenticated user making the request. +# type (str): Type of usage data to query. One of: llm, embedding, reranking. +# metric_type (str): Type of metric to query. One of tpm, rpm, spm. +# Valid metric_type depends on model type: +# llm: tpm, rpm +# embedding: tpm, rpm +# reranking: spm, rpm +# from_ (datetime): The start of the time range for the usage data. +# to (datetime | None): The end of the time range for the usage data. +# If not provided, data up to the current date will be returned. + +# Returns: +# UsageResponse: A response containing window_size and a list of the usage metrics. + +# Raises: +# BadInputError: If the 'type' parameter is invalid (not one of 'llm', +# 'embedding', or 'reranking'). Or if the 'model_type' parameter is invalid. +# """ +# has_permissions(user, ["system.models", "system.metrics"]) +# if type == "llm": +# if metric_type == "tpm": +# return await telemetry.query_llm_tpm(from_, to) +# elif metric_type == "rpm": +# return await telemetry.query_llm_rpm(from_, to) +# elif type == "embedding": +# if metric_type == "tpm": +# return await telemetry.query_embed_tpm(from_, to) +# elif metric_type == "rpm": +# return await telemetry.query_embed_rpm(from_, to) +# elif type == "reranking": +# if metric_type == "spm": +# return await telemetry.query_rerank_spm(from_, to) +# elif metric_type == "rpm": +# return await telemetry.query_rerank_rpm(from_, to) +# raise BadInputError( +# f"type: {type} with metric type: {metric_type} invalid. Must be one of: llm (tpm, rpm), embedding (tpm, rpm), reranking (tpm, rpm)" +# ) + + +# @router.get( +# "/v2/meters/models/latency", +# summary="Get the model latency past hour statistics of the specified model type (llm, embedding, reranking), and metric type.", +# description="Permissions: `system.models` OR `system.metrics`.", +# response_model=UsageResponse, +# ) +# @handle_exception +# async def get_model_latency_metrics( +# user: Annotated[UserAuth, Depends(auth_user_service_key)], +# type: Annotated[ +# Literal["llm", "embedding", "reranking"], +# Query( +# min_length=1, +# description="Type of usage data to query. Must be one of: 'llm', 'embedding', or 'reranking'.", +# ), +# ], +# metric_type: Annotated[ +# Literal["itl", "ttft", "tpot", "rt"], +# Query( +# min_length=1, +# description=( +# "Type of metric to query, " +# "Here is the list of possible metric type: " +# "llm: itl, ttft, tpot " +# "embedding: rt " +# "reranking: rt " +# "itl (inter-token latency), ttft (time to first token), tpot (time per output token), rt (response time)" +# ), +# ), +# ], +# quantile: Annotated[ +# float, +# Query( +# ge=0, +# le=1, +# description=("Quantile of latency to query, ex: 0.95 means 95th percentile latency."), +# ), +# ], +# from_: Annotated[ +# datetime, Query(alias="from", description="Start datetime for the usage data query.") +# ], +# to: Annotated[ +# datetime | None, +# Query( +# description="End datetime for the usage data query. If not provided, data up to the current datetime is returned." +# ), +# ] = None, +# ) -> UsageResponse: +# """ +# Retrieves model latency statistics based on the provided filters. +# This endpoint requires `system.metrics` permission. + +# This endpoint allows querying model latency statistics data for specific metric type within a given time range. + +# Args: +# user (UserAuth): The authenticated user making the request. +# type (str): Type of usage data to query. One of: llm, embedding, reranking. +# metric_type (str): Type of metric to query. One of ttft, tpot, rt. +# Valid metric_type depends on model type: +# llm: itl, ttft, tpot +# embedding: rt +# reranking: rt +# quantile (float): Quantile of latency to query, ex: 0.95 means 95th percentile latency. +# from_ (datetime): The start of the time range for the usage data. +# to (datetime | None): The end of the time range for the usage data. +# If not provided, data up to the current date will be returned. + +# Returns: +# Each data point is the quantile latency based on past 1 hour data, with 1 minute resolution. +# UsageResponse: A response containing window_size and a list of the usage metrics. + +# Raises: +# BadInputError: If the 'type' parameter is invalid (not one of 'llm', +# 'embedding', or 'reranking'). Or if the 'model_type' parameter is invalid. +# """ +# has_permissions(user, ["system.models", "system.metrics"]) +# if type == "llm": +# if metric_type == "ttft": +# return await telemetry.query_hourly_llm_ttft_quantile(from_, to, quantile) +# elif metric_type == "tpot": +# return await telemetry.query_hourly_llm_tpot_quantile(from_, to, quantile) +# elif metric_type == "itl": +# return await telemetry.query_hourly_llm_itl_quantile(from_, to, quantile) +# elif type == "embedding": +# if metric_type == "rt": +# return await telemetry.query_hourly_embed_completion_time_quantile(from_, to, quantile) +# elif type == "reranking": +# if metric_type == "rt": +# return await telemetry.query_hourly_rerank_completion_time_quantile( +# from_, to, quantile +# ) +# raise BadInputError( +# f"type: {type} with metric type: {metric_type} invalid. Must be one of: llm (itl, ttft, tpot), embedding (rt), reranking (rt)" +# ) diff --git a/services/api/src/owl/routers/models.py b/services/api/src/owl/routers/models.py new file mode 100644 index 0000000..4418270 --- /dev/null +++ b/services/api/src/owl/routers/models.py @@ -0,0 +1,318 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Request +from loguru import logger +from sqlmodel import func, select + +from owl.db import AsyncSession, yield_async_session +from owl.db.models import Deployment, ModelConfig +from owl.types import ( + CloudProvider, + DeploymentCreate, + DeploymentRead, + DeploymentUpdate, + ListQuery, + ModelConfigCreate, + ModelConfigRead, + ModelConfigUpdate, + OkResponse, + Page, + UserAuth, +) +from owl.utils.auth import auth_user_service_key, has_permissions +from owl.utils.dates import now +from owl.utils.exceptions import ( + BadInputError, + ResourceExistsError, + ResourceNotFoundError, + handle_exception, +) + +router = APIRouter() + + +@router.post( + "/v2/models/configs", + summary="Create a model config.", + description="Permissions: `system.MEMBER`. Prerequisite for creating a deployment.", +) +@handle_exception +async def create_model_config( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: ModelConfigCreate, +) -> ModelConfigRead: + has_permissions(user, ["system.MEMBER"]) + if (await session.get(ModelConfig, body.id)) is not None: + raise ResourceExistsError(f'ModelConfig "{body.id}" already exists.') + model = ModelConfig.model_validate(body) + session.add(model) + await session.commit() + await session.refresh(model) + logger.bind(user_id=user.id).success( + f'{user.name} ({user.email}) created a model config for "{model.name}" ({model.id}).' + ) + logger.bind(user_id=user.id).info(f"{request.state.id} - Created model config: {model}") + return model + + +@router.get( + "/v2/models/configs/list", + summary="List system-wide model configs.", + description="Permissions: `system`.", +) +@handle_exception +async def list_model_configs( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQuery, Query()], +) -> Page[ModelConfigRead]: + has_permissions(user, ["system"]) + return await ModelConfig.list_( + session=session, + return_type=ModelConfigRead, + organization_id=None, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + after=params.after, + ) + + +@router.get( + "/v2/models/configs", + summary="Get a model config.", + description="Permissions: `system`.", +) +@handle_exception +async def get_model_config( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + model_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], +) -> ModelConfigRead: + has_permissions(user, ["system"]) + return await ModelConfig.get(session, model_id, name="Model config") + + +@router.patch( + "/v2/models/configs", + summary="Update a model config.", + description="Permissions: `system.MEMBER`.", +) +@handle_exception +async def update_model_config( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + model_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], + body: ModelConfigUpdate, +) -> ModelConfigRead: + has_permissions(user, ["system.MEMBER"]) + model = await ModelConfig.get(session, model_id, name="Model config") + updates = body.model_dump(exclude_unset=True) + ModelConfigCreate.validate_updates(base=model, updates=updates) + for key, value in updates.items(): + setattr(model, key, value) + model.updated_at = now() + session.add(model) + await session.commit() + await session.refresh(model) + logger.bind(user_id=user.id).success( + ( + f"{user.name} ({user.email}) updated the attributes " + f'{list(updates.keys())} of the model config for "{model.name}" ({model.id}).' + ) + ) + logger.bind(user_id=user.id).info(f"{request.state.id} - Updated model config: {model}") + return model + + +@router.delete( + "/v2/models/configs", + summary="Delete a model config.", + description="Permissions: `system.MEMBER`.", +) +@handle_exception +async def delete_model_config( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + model_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], +) -> OkResponse: + has_permissions(user, ["system.MEMBER"]) + model = await session.get(ModelConfig, model_id) + if model is None: + raise ResourceNotFoundError(f'ModelConfig "{model_id}" is not found.') + # Check deployments + num_deployments = ( + await session.exec( + select(func.count(Deployment.id)).where(Deployment.model_id == model_id) + ) + ).one() + if num_deployments > 0: + raise BadInputError( + ( + f'Cannot delete model "{model_id}" because it still has {num_deployments:,d} deployments. ' + "Please delete the deployments first." + ) + ) + await session.delete(model) + await session.commit() + return OkResponse() + + +@router.get( + "/v2/models/deployments/providers/cloud", + summary="List available cloud providers.", + description="Permissions: `system`.", +) +@handle_exception +async def list_available_providers( + user: Annotated[UserAuth, Depends(auth_user_service_key)], +) -> list[str]: + has_permissions(user, ["system"]) + return list(CloudProvider) + + +@router.post( + "/v2/models/deployments/cloud", + summary="Create an external cloud deployment.", + description=( + "Permissions: `system.MEMBER`. " + "Note that a model config must be created before creating a deployment. " + "Request body format: " + "`provider` must be a valid Provider enum. " + "`routing_id` must be a string. " + "`api_base` is an OPTIONAL string. " + ), +) +@handle_exception +async def create_deployment( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: DeploymentCreate, +) -> DeploymentRead: + logger.info(f"{request.state.id} - Creating deployment: {body}") + has_permissions(user, ["system.MEMBER"]) + # Check if the associated model exists + model = await session.get(ModelConfig, body.model_id) + if model is None: + raise ResourceNotFoundError(f'Model "{body.model_id}" does not exist.') + deployment = Deployment.model_validate(body) + session.add(deployment) + await session.commit() + await session.refresh(deployment) + logger.bind(user_id=user.id).success( + ( + f"{user.name} ({user.email}) created a cloud deployment " + f'"{deployment.name}" ({deployment.id}) for model "{model.name}" with ' + f'provider "{deployment.provider}".' + ) + ) + logger.info(f"{request.state.id} - Created cloud deployment: {deployment}") + return deployment + + +@router.get( + "/v2/models/deployments/list", + summary="List deployments.", + description="Permissions: `system`.", +) +@handle_exception +async def list_deployments( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQuery, Query()], +) -> Page[DeploymentRead]: + has_permissions(user, ["system"]) + return await Deployment.list_( + session=session, + return_type=DeploymentRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + after=params.after, + ) + + +@router.get( + "/v2/models/deployments", + summary="Get a deployment.", + description="Permissions: `system`.", +) +@handle_exception +async def get_deployment( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + deployment_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], +) -> DeploymentRead: + has_permissions(user, ["system"]) + deployment = await session.get(Deployment, deployment_id) + if deployment is None: + raise ResourceNotFoundError(f'Deployment "{deployment_id}" is not found.') + return deployment + + +@router.patch( + "/v2/models/deployments", + summary="Update a deployment.", + description="Permissions: `system.MEMBER`.", +) +@handle_exception +async def update_deployment( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + deployment_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], + body: DeploymentUpdate, +) -> DeploymentRead: + has_permissions(user, ["system.MEMBER"]) + deployment = await session.get(Deployment, deployment_id) + if deployment is None: + raise ResourceNotFoundError(f'Deployment "{deployment_id}" is not found.') + logger.info(f"Current deployment: {deployment}") + # Perform update + updates = body.model_dump(exclude=["id"], exclude_unset=True) + for key, value in updates.items(): + setattr(deployment, key, value) + deployment.updated_at = now() + session.add(deployment) + await session.commit() + await session.refresh(deployment) + logger.bind(user_id=user.id).success( + ( + f"{user.name} ({user.email}) updated the attributes " + f'{list(updates.keys())} of a deployment "{deployment.name}" ({deployment.id}).' + ) + ) + logger.info(f"{request.state.id} - Updated deployment: {deployment}") + return deployment + + +@router.delete( + "/v2/models/deployments", + summary="Delete a deployment.", + description="Permissions: `system.MEMBER`.", +) +@handle_exception +async def delete_deployment( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + deployment_id: Annotated[str, Query(min_length=1, description="Deployment ID.")], +) -> OkResponse: + logger.info(f"{request.state.id} - Deleting deployment: {deployment_id}") + has_permissions(user, ["system.MEMBER"]) + deployment = await session.get(Deployment, deployment_id) + if deployment is None: + raise ResourceNotFoundError(f'Deployment "{deployment_id}" is not found.') + await session.delete(deployment) + await session.commit() + return OkResponse() diff --git a/services/api/src/owl/routers/org_admin.py b/services/api/src/owl/routers/org_admin.py deleted file mode 100644 index e1f66e4..0000000 --- a/services/api/src/owl/routers/org_admin.py +++ /dev/null @@ -1,703 +0,0 @@ -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/organizations/__init__.py b/services/api/src/owl/routers/organizations/__init__.py new file mode 100644 index 0000000..8c5dbec --- /dev/null +++ b/services/api/src/owl/routers/organizations/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from owl.configs import ENV_CONFIG +from owl.routers.organizations.oss import router as oss_router + +router = APIRouter() +router.include_router(oss_router) + +if ENV_CONFIG.is_cloud: + from owl.routers.organizations.cloud import router as cloud_router + + router.include_router(cloud_router) diff --git a/services/api/src/owl/routers/organizations/oss.py b/services/api/src/owl/routers/organizations/oss.py new file mode 100644 index 0000000..55d223a --- /dev/null +++ b/services/api/src/owl/routers/organizations/oss.py @@ -0,0 +1,732 @@ +from datetime import datetime, timezone +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query, Request +from loguru import logger +from sqlmodel import delete, func, select + +from owl.configs import CACHE, ENV_CONFIG +from owl.db import TEMPLATE_ORG_ID, AsyncSession, async_session, yield_async_session +from owl.db.gen_table import GenerativeTableCore +from owl.db.models import ( + BASE_PLAN_ID, + Deployment, + ModelConfig, + Organization, + OrgMember, + PricePlan, + Project, + ProjectMember, + User, +) +from owl.types import ( + ListQuery, + ListQueryByOrg, + ModelConfigRead, + OkResponse, + OrganizationCreate, + OrganizationRead, + OrganizationReadDecrypt, + OrganizationUpdate, + OrgMemberRead, + OrgModelCatalogueQuery, + Page, + PricePlanCreate, + Role, + UsageResponse, + UserAuth, +) +from owl.utils import mask_dict +from owl.utils.auth import auth_user_service_key, has_permissions +from owl.utils.billing import CLICKHOUSE_CLIENT, STRIPE_CLIENT, BillingManager +from owl.utils.billing_metrics import BillingMetrics +from owl.utils.crypt import decrypt, encrypt_random, generate_key +from owl.utils.dates import now +from owl.utils.exceptions import ( + BadInputError, + BaseTierCountError, + ForbiddenError, + NoTierError, + ResourceExistsError, + ResourceNotFoundError, + UnexpectedError, + UpgradeTierError, + handle_exception, +) +from owl.utils.mcp import MCP_TOOL_TAG +from owl.utils.metrics import Telemetry + +router = APIRouter() +telemetry = Telemetry() + +billing_metrics = BillingMetrics(clickhouse_client=CLICKHOUSE_CLIENT) + + +def _encrypt_dict(value: dict[str, str]) -> dict[str, str]: + return {k: encrypt_random(v, ENV_CONFIG.encryption_key_plain) for k, v in value.items()} + + +def _decrypt_dict(value: dict[str, str]) -> dict[str, str]: + return {k: decrypt(v, ENV_CONFIG.encryption_key_plain) for k, v in value.items()} + + +@router.post( + "/v2/organizations", + summary="Create an organization.", + description="Permissions: None.", +) +@handle_exception +async def create_organization( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + body: OrganizationCreate, + organization_id: str = "", +) -> OrganizationReadDecrypt: + # There must always be a free plan + async with async_session() as session: + base_plan = await session.get(PricePlan, BASE_PLAN_ID) + if base_plan is None: + session.add( + PricePlan( + id=BASE_PLAN_ID, + **PricePlanCreate.free().model_dump(mode="json", exclude={"id"}), + ) + ) + await session.commit() + # This is mainly for migration and not exposed as REST API + if organization_id: + if await session.get(Organization, organization_id) is not None: + raise ResourceExistsError(f'Organization "{organization_id}" already exists.') + else: + # There must always be a system admin org + if await session.get(Organization, "0") is None: + organization_id = "0" + else: + organization_id = generate_key(24, "org_") + num_base_tier_orgs = len(await Organization.list_base_tier_orgs(session, user.id)) + if organization_id != "0" and ENV_CONFIG.is_cloud: + # A user can only have one free organization + if num_base_tier_orgs > 1: + raise BaseTierCountError + # Create Stripe customer + if STRIPE_CLIENT is None: + stripe_id = None + else: + customer = await STRIPE_CLIENT.customers.create_async( + dict( + name=f"{user.name} | {body.name}", + email=user.email, + metadata=dict(organization_id=organization_id), + ) + ) + logger.bind(user_id=user.id, org_id=organization_id).info( + f"Stripe customer created: {customer}" + ) + stripe_id = customer.id + async with async_session() as session: + org = Organization( + **body.model_dump(exclude={"external_keys"}), + id=organization_id, + created_by=user.id, + owner=user.id, + stripe_id=stripe_id, + external_keys=_encrypt_dict(body.external_keys), + ) + session.add(org) + await session.commit() + await session.refresh(org) + logger.bind(user_id=user.id, org_id=org.id).success( + f'{user.name} ({user.email}) created an organization "{org.name}".' + ) + logger.bind(user_id=user.id, org_id=org.id).info( + f"{request.state.id} - Created organization: {org}" + ) + # Add user as admin + org_member = OrgMember(user_id=user.id, organization_id=org.id, role=Role.ADMIN) + session.add(org_member) + await session.commit() + await session.refresh(org_member) + logger.bind(user_id=user.id, org_id=org.id).success( + f'{user.name} ({user.email}) joined organization "{org.name}" as as admin.' + ) + logger.info(f"{request.state.id} - Created organization member: {org_member}") + # Create template org + if await session.get(Organization, TEMPLATE_ORG_ID) is None: + session.add( + Organization( + id=TEMPLATE_ORG_ID, + name="Template", + created_by=user.id, + owner=user.id, + ) + ) + await session.commit() + logger.bind(user_id=user.id, org_id=org.id).success( + f"{user.name} ({user.email}) created template organization." + ) + session.add( + OrgMember(user_id=user.id, organization_id=TEMPLATE_ORG_ID, role=Role.ADMIN) + ) + await session.commit() + logger.bind(user_id=user.id, org_id=org.id).success( + f"{user.name} ({user.email}) joined template organization as as admin." + ) + # Subscribe to base plan if the user has no base tier org + if ENV_CONFIG.is_cloud and num_base_tier_orgs == 0: + from owl.routers.organizations.cloud import subscribe_plan + + async with async_session() as session: + user = UserAuth.model_validate( + await session.get(User, user.id, populate_existing=True) + ) + await subscribe_plan(user, org.id, BASE_PLAN_ID) + async with async_session() as session: + org = await session.get(Organization, org.id, populate_existing=True) + return org + + +@router.get( + "/v2/organizations/list", + summary="List organizations.", + description="Permissions: `system`.", + tags=[MCP_TOOL_TAG, "system"], +) +@handle_exception +async def list_organizations( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQuery, Query()], +) -> Page[OrganizationRead]: + has_permissions(user, ["system"]) + return await Organization.list_( + session=session, + return_type=OrganizationRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + after=params.after, + ) + + +@router.get( + "/v2/organizations", + summary="Get an organization.", + description="Permissions: `system` OR `organization`. Only `organization.ADMIN` can view API keys.", + tags=[MCP_TOOL_TAG, "system", "organization"], +) +@handle_exception +async def get_organization( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], +) -> OrganizationRead: + has_permissions(user, ["system", "organization"], organization_id=organization_id) + org = await session.get(Organization, organization_id) + if org is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + org = OrganizationReadDecrypt.model_validate(org) + # Whether we need to mask external API keys + if not has_permissions( + user, ["organization.ADMIN"], organization_id=org.id, raise_error=False + ): + org.external_keys = mask_dict(org.external_keys) + # Update billing data if needed + request.state.billing = BillingManager( + organization=org, + project_id="", + user_id=user.id, + request=request, + models=None, + ) + return org + + +@router.patch( + "/v2/organizations", + summary="Update an organization.", + description="Permissions: `organization.ADMIN`.", +) +@handle_exception +async def update_organization( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], + body: OrganizationUpdate, +) -> OrganizationRead: + has_permissions(user, ["organization.ADMIN"], organization_id=organization_id) + org = await session.get(Organization, organization_id) + if org is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + # Perform update + updates = body.model_dump(exclude=["id"], exclude_unset=True) + for key, value in updates.items(): + if key == "external_keys": + value = _encrypt_dict(value) + setattr(org, key, value) + org.updated_at = now() + session.add(org) + await session.commit() + await session.refresh(org) + logger.bind(user_id=user.id, org_id=org.id).success( + ( + f"{user.name} ({user.email}) updated the attributes " + f'{list(updates.keys())} of organization "{org.name}".' + ) + ) + org = OrganizationReadDecrypt.model_validate(org) + if not has_permissions( + user, ["organization.ADMIN"], organization_id=org.id, raise_error=False + ): + org.external_keys = mask_dict(org.external_keys) + # Clear cache + await CACHE.clear_organization_async(organization_id) + return org + + +@router.delete( + "/v2/organizations", + summary="Delete an organization.", + description=( + "Permissions: Only the owner can delete an organization. " + 'WARNING: Deleting system organization "0" will also delete ALL data.' + ), +) +@handle_exception +async def delete_organization( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], +) -> OkResponse: + organization = await session.get(Organization, organization_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + # TODO: Create an endpoint to transfer ownership + if organization.owner != user.id: + raise ForbiddenError("Only the owner can delete an organization.") + logger.info(f'{request.state.id} - Deleting organization: "{organization_id}"') + # Delete Generative Tables + await session.refresh(organization, ["projects"]) + for project in organization.projects: + await GenerativeTableCore.drop_schemas(project_id=project.id) + # Delete related resources + await session.exec(delete(Organization).where(Organization.id == organization_id)) + await session.exec(delete(Project).where(Project.organization_id == organization_id)) + if ENV_CONFIG.is_cloud: + from owl.db.models.cloud import VerificationCode + + await session.exec( + delete(VerificationCode).where(VerificationCode.organization_id == organization_id) + ) + if organization_id == "0": + await session.exec(delete(Deployment)) + await session.exec(delete(ModelConfig)) + await session.exec(delete(Organization).where(Organization.id == TEMPLATE_ORG_ID)) + # Delete Stripe customer + if STRIPE_CLIENT is not None and organization.stripe_id is not None: + customer = await STRIPE_CLIENT.customers.delete_async(organization.stripe_id) + logger.info( + f'Stripe customer "{customer.id}" deleted for organization "{organization_id}".' + ) + await session.commit() + if organization_id == "0": + logger.bind(user_id=user.id, org_id=organization_id).success( + f"{user.name} ({user.email}) deleted all templates, models and deployments." + ) + logger.bind(user_id=user.id, org_id=organization_id).success( + f'{user.name} ({user.email}) deleted organization "{organization.name}".' + ) + logger.info(f"{request.state.id} - Deleted organization: {organization_id}") + # Clear cache + await CACHE.clear_organization_async(organization_id) + return OkResponse() + + +@router.post( + "/v2/organizations/members", + summary="Join an organization.", + description=( + "Permissions: `organization.ADMIN`. " + "Permissions are only needed if adding another user or invite code is not provided." + ), +) +@handle_exception +async def join_organization( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="ID of the user joining the org.")], + invite_code: Annotated[ + str | None, + Query(min_length=1, description="(Optional) Invite code for validation."), + ] = None, + organization_id: Annotated[ + str | None, + Query( + min_length=1, + description="(Optional) Organization ID. Ignored if invite code is provided.", + ), + ] = None, + role: Annotated[ + Role | None, + Query(min_length=1, description="(Optional) Role. Ignored if invite code is provided."), + ] = None, +) -> OrgMemberRead: + joining_user = await session.get(User, user_id) + if joining_user is None: + raise ResourceNotFoundError(f'User "{user_id}" is not found.') + if invite_code is None: + if organization_id is None or role is None: + raise BadInputError("Missing organization ID or role.") + invite = None + else: + if ENV_CONFIG.is_oss: + raise BadInputError("Invite code is not supported in OSS.") + else: + from owl.db.models.cloud import VerificationCode + + # Fetch code + invite = await session.get(VerificationCode, invite_code) + if ( + invite is None + or invite.organization_id is None + or invite.purpose not in ("organization_invite", None) + or now() > invite.expiry + or invite.revoked_at is not None + or invite.used_at is not None + or invite.user_email != joining_user.preferred_email + ): + raise ResourceNotFoundError(f'Invite code "{invite_code}" is invalid.') + organization_id = invite.organization_id + role = invite.role + # RBAC + if user.id != user_id or invite_code is None: + has_permissions(user, ["organization.ADMIN"], organization_id=organization_id) + # Check for existing membership + organization = await session.get(Organization, organization_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + if await session.get(OrgMember, (user_id, organization_id)) is not None: + raise ResourceExistsError("You are already in the organization.") + # Enforce member count limit (cloud only) + if ENV_CONFIG.is_cloud and organization.id not in ["0", TEMPLATE_ORG_ID]: + if (plan := organization.price_plan) is None: + raise NoTierError + else: + if plan.max_users is not None: + member_count = ( + await session.exec( + select(func.count(OrgMember.user_id)).where( + OrgMember.organization_id == organization_id + ) + ) + ).one() + if member_count >= plan.max_users: + raise UpgradeTierError( + ( + f"Your subscribed plan only supports {plan.max_users:,d} members. " + "Consider upgrading your plan or remove existing member before adding more." + ) + ) + # Add member + org_member = OrgMember(user_id=user_id, organization_id=organization_id, role=role) + session.add(org_member) + await session.commit() + await session.refresh(org_member) + # Consume invite code + if invite is not None: + invite.used_at = now() + session.add(invite) + await session.commit() + logger.bind(user_id=joining_user.id, org_id=organization.id).success( + ( + f"{joining_user.preferred_name} ({joining_user.preferred_email}) joined " + f'organization "{organization.name}" as "{role.name}".' + ) + ) + return org_member + + +@router.get( + "/v2/organizations/members/list", + summary="List organization members.", + description="Permissions: `system` OR `organization`.", +) +@handle_exception +async def list_organization_members( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQueryByOrg[Literal["id", "created_at", "updated_at"]], Query()], +) -> Page[OrgMemberRead]: + has_permissions(user, ["system", "organization"], organization_id=params.organization_id) + return await OrgMember.list_( + session=session, + return_type=OrgMemberRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + filters=dict(organization_id=params.organization_id), + after=params.after, + ) + + +@router.get( + "/v2/organizations/members", + summary="Get an organization member.", + description="Permissions: `system` OR `organization`.", +) +@handle_exception +async def get_organization_member( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], +) -> OrgMemberRead: + has_permissions(user, ["system", "organization"], organization_id=organization_id) + member_id = (user_id, organization_id) + member = await session.get(OrgMember, member_id) + if member is None: + raise ResourceNotFoundError(f'Organization member "{member_id}" is not found.') + return member + + +@router.patch( + "/v2/organizations/members/role", + summary="Update a organization member's role.", + description="Permissions: `organization.ADMIN`.", +) +@handle_exception +async def update_member_role( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], + role: Annotated[Role, Query(description="New role.")], +) -> OrgMemberRead: + # Check permissions + has_permissions(user, ["organization.ADMIN"], organization_id=organization_id) + # Fetch the member + member = await session.get(OrgMember, (user_id, organization_id)) + if member is None: + raise ResourceNotFoundError( + f'User "{user_id}" is not a member of organization "{organization_id}".' + ) + # Update + member.role = role + await session.commit() + return member + + +@router.delete( + "/v2/organizations/members", + summary="Leave an organization.", + description=( + "Permissions: `organization.ADMIN`. " + "Permissions are only needed if deleting another user's membership." + ), +) +@handle_exception +async def leave_organization( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + organization_id: Annotated[str, Query(min_length=1, description="Organization ID.")], +) -> OkResponse: + if user.id != user_id: + has_permissions(user, ["organization.ADMIN"], organization_id=organization_id) + leaving_user = await session.get(User, user_id) + if leaving_user is None: + raise ResourceNotFoundError(f'User "{user_id}" is not found.') + organization = await session.get(Organization, organization_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + if user_id == organization.created_by: + raise ForbiddenError("Owner cannot leave the organization.") + org_member = await session.get(OrgMember, (user_id, organization_id)) + if org_member is None: + raise ResourceNotFoundError( + f"Organization membership {(user_id, organization_id)} is not found." + ) + await session.delete(org_member) + await session.commit() + # If the user has no remaining membership with the org, remove them from all projects + num_memberships = ( + await session.exec( + select(func.count(OrgMember.user_id)).where( + OrgMember.user_id == user_id, + OrgMember.organization_id == organization_id, + ) + ) + ).one() + if num_memberships == 0: + projects = ( + await session.exec(select(Project).where(Project.organization_id == organization_id)) + ).all() + for p in projects: + try: + await session.exec( + delete(ProjectMember).where( + ProjectMember.user_id == user_id, + ProjectMember.project_id == p.id, + ) + ) + await session.commit() + except Exception as e: + logger.warning( + f'Failed to remove "{user_id}" from project "{p.id}" due to {repr(e)}' + ) + logger.bind(user_id=leaving_user.id, org_id=organization.id).success( + ( + f"{leaving_user.preferred_name} ({leaving_user.preferred_email}) left " + f'organization "{organization.name}".' + ) + ) + return OkResponse() + + +@router.get( + "/v2/organizations/models/catalogue", + summary="List models AVAILABLE to an organization.", + description="Permissions: `system` OR `organization`.", +) +@handle_exception +async def organization_model_catalogue( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[OrgModelCatalogueQuery, Query()], +) -> Page[ModelConfigRead]: + has_permissions(user, ["system", "organization"], organization_id=params.organization_id) + return await ModelConfig.list_( + session=session, + return_type=ModelConfigRead, + organization_id=params.organization_id, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + after=params.after, + capabilities=params.capabilities, + exclude_inactive=True, + ) + + +@router.get("/v2/organizations/meters/query") +async def get_organization_metrics( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + metric_id: Annotated[ + Literal["llm", "embedding", "reranking", "spent", "bandwidth", "storage"], + Query(alias="metricId", description="Type of usage data to query."), + ], + from_: Annotated[ + datetime, + Query(alias="from", description="Start datetime for the usage data query."), + ], + window_size: Annotated[ + str | None, + Query( + min_length=1, + alias="windowSize", + description="The aggregation window size (e.g., '1d' for daily, '1w' for weekly).", + ), + ], + org_id: Annotated[ + str, + Query( + min_length=1, + alias="orgId", + description="Organization ID to filter the usage data.", + ), + ], + proj_ids: Annotated[ + list[str] | None, + Query( + min_length=1, + alias="projIds", + description="List of project IDs to filter the usage data. If not provided, data for all projects is returned.", + ), + ] = None, + to: Annotated[ + datetime | None, + Query( + description="End datetime for the usage data query. If not provided, data up to the current datetime is returned." + ), + ] = None, + group_by: Annotated[ + list[str] | None, + Query( + min_length=1, + alias="groupBy", + description="List of fields to group the usage data by. If not provided, no grouping is applied.", + ), + ] = None, + data_source: Annotated[ + Literal["clickhouse", "victoriametrics"], + Query(description="Data source to query. Defaults to 'clickhouse'.", alias="dataSource"), + ] = "clickhouse", +) -> UsageResponse: + has_permissions(user, ["organization.MEMBER"], organization_id=org_id) + try: + # always add org_id to group_by + if to is None: + to = datetime.now(tz=timezone.utc).replace(minute=0, second=0, microsecond=0) + # set to default [] + if group_by is None: + group_by = [] + + if data_source == "clickhouse": + metrics_client = billing_metrics + elif data_source == "victoriametrics": + metrics_client = telemetry + + if metric_id == "llm": + results = await metrics_client.query_llm_usage( + [org_id], + proj_ids, + from_, + to, + group_by, + window_size, + ) + elif metric_id == "embedding": + results = await metrics_client.query_embedding_usage( + [org_id], + proj_ids, + from_, + to, + group_by, + window_size, + ) + elif metric_id == "reranking": + results = await metrics_client.query_reranking_usage( + [org_id], + proj_ids, + from_, + to, + group_by, + window_size, + ) + elif metric_id == "spent": + results = await metrics_client.query_billing( + [org_id], proj_ids, from_, to, group_by, window_size + ) + elif metric_id == "bandwidth": + results = await metrics_client.query_bandwidth( + [org_id], proj_ids, from_, to, group_by, window_size + ) + elif metric_id == "storage": + results = await metrics_client.query_storage( + [org_id], proj_ids, from_, to, group_by, window_size + ) + return results + except Exception as e: + err = f"Failed to fetch Metrics Data events: {e}" + logger.error(err) + raise UnexpectedError(err) from e diff --git a/services/api/src/owl/routers/oss_admin.py b/services/api/src/owl/routers/oss_admin.py deleted file mode 100644 index 29a0cca..0000000 --- a/services/api/src/owl/routers/oss_admin.py +++ /dev/null @@ -1,94 +0,0 @@ -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/projects/__init__.py b/services/api/src/owl/routers/projects/__init__.py new file mode 100644 index 0000000..40533ab --- /dev/null +++ b/services/api/src/owl/routers/projects/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from owl.configs import ENV_CONFIG +from owl.routers.projects.oss import router as oss_router + +router = APIRouter() +router.include_router(oss_router) + +if ENV_CONFIG.is_cloud: + from owl.routers.projects.cloud import router as cloud_router + + router.include_router(cloud_router) diff --git a/services/api/src/owl/routers/projects/oss.py b/services/api/src/owl/routers/projects/oss.py new file mode 100644 index 0000000..8518e47 --- /dev/null +++ b/services/api/src/owl/routers/projects/oss.py @@ -0,0 +1,927 @@ +import base64 +from io import BytesIO +from os.path import join +from tempfile import TemporaryDirectory +from typing import Annotated, Literal + +import pyarrow as pa +import pyarrow.parquet as pq +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Query, Request, UploadFile +from fastapi.responses import FileResponse +from loguru import logger +from pydantic import BaseModel, Field +from sqlmodel import delete, func, select + +from owl.configs import ENV_CONFIG +from owl.db import AsyncSession, async_session, cached_text, yield_async_session +from owl.db.gen_table import ( + ActionTable, + ChatTable, + ColumnMetadata, + KnowledgeTable, + TableMetadata, +) +from owl.db.models import ( + Organization, + Project, + ProjectMember, + User, +) +from owl.types import ( + ListQueryByOrg, + ListQueryByProject, + OkResponse, + OrganizationRead, + Page, + ProjectCreate, + ProjectMemberRead, + ProjectRead, + ProjectUpdate, + Role, + TableMetaResponse, + TableType, + UserAuth, +) +from owl.utils.auth import auth_user, has_permissions +from owl.utils.billing import BillingManager +from owl.utils.dates import now +from owl.utils.exceptions import ( + BadInputError, + ForbiddenError, + ResourceExistsError, + ResourceNotFoundError, + UnexpectedError, + handle_exception, +) +from owl.utils.io import json_dumps, json_loads, open_uri_async, s3_upload +from owl.utils.mcp import MCP_TOOL_TAG + +router = APIRouter() + + +async def _count_project_name( + session: AsyncSession, + organization_id: str, + name: str, +) -> int: + return ( + await session.exec( + select( + func.count(Project.id).filter( + Project.organization_id == organization_id, Project.name == name + ) + ) + ) + ).one() + + +@router.post( + "/v2/projects", + summary="Create a new project under an organization.", + description="Permissions: `organization.ADMIN`.", + tags=[MCP_TOOL_TAG, "organization.ADMIN"], +) +@handle_exception +async def create_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: ProjectCreate, + project_id: str = "", +) -> ProjectRead: + has_permissions(user, ["organization.ADMIN"], organization_id=body.organization_id) + # Check for duplicate project ID + if project_id and await session.get(Project, project_id) is not None: + raise ResourceExistsError(f'Project "{project_id}" already exists.') + # Ensure the organization exists + organization = await session.get(Organization, body.organization_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{body.organization_id}" is not found.') + # Try assigning a unique name + name_count = await _count_project_name(session, body.organization_id, body.name) + if name_count > 0: + idx = name_count + while ( + await _count_project_name(session, body.organization_id, f"{body.name} ({idx})") + ) > 0: + idx += 1 + body.name = f"{body.name} ({idx})" + + # Create project + project = Project( + **body.model_dump(), + created_by=user.id, + owner=user.id, + ) + if project_id: + project.id = project_id + else: + project_id = project.id + session.add(project) + await session.commit() + await session.refresh(project) + logger.bind(user_id=user.id, org_id=organization.id, proj_id=project_id).info( + f"{request.state.id} - Created project: {project}" + ) + logger.bind(user_id=user.id, org_id=organization.id, proj_id=project_id).success( + f'{user.name} ({user.email}) created a project "{project.name}"' + ) + # Create membership + project_member = ProjectMember( + user_id=user.id, + project_id=project_id, + role=Role.ADMIN, + ) + session.add(project_member) + await session.commit() + await session.refresh(project_member) + logger.bind(user_id=user.id, org_id=organization.id, proj_id=project_id).info( + f"{request.state.id} - Created project member: {project_member}" + ) + logger.bind(user_id=user.id, org_id=organization.id, proj_id=project_id).success( + f'{user.name} ({user.email}) joined project "{project.name}" as as admin.' + ) + # Create Generative Table schemas + for table_type in TableType: + schema_id = f"{project_id}_{table_type}" + await session.exec(cached_text(f'CREATE SCHEMA IF NOT EXISTS "{schema_id}"')) + await session.exec(cached_text(TableMetadata.sql_create(schema_id))) + await session.exec(cached_text(ColumnMetadata.sql_create(schema_id))) + return project + + +class ListProjectQuery(ListQueryByOrg): + search_query: Annotated[ + str, + Field( + max_length=255, + description=( + "_Optional_. A string to search for within project names as a filter. " + 'Defaults to "" (no filter).' + ), + ), + ] = "" + list_chat_agents: Annotated[ + bool, Field(description="_Optional_. List chat agents. Defaults to False.") + ] = False + + +@router.get( + "/v2/projects/list", + summary="List all projects within an organization.", + description="Permissions: `system` OR `organization`.", + tags=[MCP_TOOL_TAG, "system", "organization"], +) +@handle_exception +async def list_projects( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListProjectQuery, Query()], +) -> Page[ProjectRead]: + org_id = params.organization_id + has_permissions(user, ["system", "organization"], organization_id=org_id) + # Ensure the organization exists + org_role = next((r.role for r in user.org_memberships if r.organization_id == org_id), None) + if org_role is None: + raise ResourceNotFoundError(f'Organization "{org_id}" is not found.') + # List + response = await Project.list_( + session=session, + return_type=ProjectRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + filters=dict(organization_id=org_id), + after=params.after, + filter_by_user=user.id if org_role == Role.GUEST else "", + ) + if params.list_chat_agents: + for p in response.items: + metas = await ChatTable.list_tables( + project_id=p.id, + limit=None, + offset=0, + order_by="id", + order_ascending=True, + parent_id="_agent_", + ) + p.chat_agents = metas.items + return response + + +@router.get( + "/v2/projects", + summary="Get a project.", + description="Permissions: `system` OR `organization`.", + tags=[MCP_TOOL_TAG, "system", "organization"], +) +@handle_exception +async def get_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], +) -> ProjectRead: + # Fetch the project + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + has_permissions(user, ["system", "organization"], organization_id=project.organization_id) + # Update billing data if needed + request.state.billing = BillingManager( + organization=OrganizationRead.model_validate(project.organization), + project_id="", # Skip egress charge + user_id=user.id, + request=request, + models=None, + ) + return project + + +@router.patch( + "/v2/projects", + summary="Update a project.", + description="Permissions: `organization.ADMIN` OR `project.ADMIN`.", +) +@handle_exception +async def update_project( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], + body: ProjectUpdate, +) -> ProjectRead: + # Fetch + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + # Check permissions + has_permissions( + user, + ["organization.ADMIN", "project.ADMIN"], + organization_id=project.organization_id, + project_id=project_id, + ) + # Update + updates = body.model_dump(exclude={"id"}, exclude_unset=True) + for key, value in updates.items(): + setattr(project, key, value) + project.updated_at = now() + session.add(project) + await session.commit() + await session.refresh(project) + logger.bind(user_id=user.id, proj_id=project.id).success( + ( + f"{user.name} ({user.email}) updated the attributes " + f'{list(updates.keys())} of project "{project.name}".' + ) + ) + return project + + +@router.delete( + "/v2/projects", + summary="Delete a project.", + description="Permissions: `organization.ADMIN`, OR None for the project owner.", +) +@handle_exception +async def delete_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], +) -> OkResponse: + # Fetch + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + # Check permissions + has_permissions(user, ["organization.ADMIN"], organization_id=project.organization_id) + # Delete Generative Tables + for table_type in TableType: + schema_id = f"{project_id}_{table_type}" + await session.exec(cached_text(f'DROP SCHEMA IF EXISTS "{schema_id}" CASCADE')) + # Delete related resources + await session.exec(delete(ProjectMember).where(ProjectMember.project_id == project_id)) + if ENV_CONFIG.is_cloud: + from owl.db.models.cloud import ProjectKey, VerificationCode + + await session.exec( + delete(VerificationCode).where(VerificationCode.project_id == project_id) + ) + await session.exec(delete(ProjectKey).where(ProjectKey.project_id == project_id)) + await session.delete(project) + await session.commit() + logger.bind(user_id=user.id, org_id=project.id).success( + f'{user.name} ({user.email}) deleted project "{project.name}".' + ) + logger.info(f"{request.state.id} - Deleted project: {project.id}") + return OkResponse() + + +@router.post( + "/v2/projects/members", + summary="Join a project.", + description=( + "Permissions: `organization.ADMIN` OR `project.ADMIN`. " + "Permissions are only needed if adding another user or invite code is not provided." + ), +) +@handle_exception +async def join_project( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[ + str, Query(min_length=1, description="ID of the user joining the project.") + ], + invite_code: Annotated[ + str | None, + Query(min_length=1, description="(Optional) Invite code for validation."), + ] = None, + project_id: Annotated[ + str | None, + Query( + min_length=1, + description="(Optional) Project ID. Ignored if invite code is provided.", + ), + ] = None, + role: Annotated[ + Role | None, + Query( + min_length=1, + description="(Optional) Project role. Ignored if invite code is provided.", + ), + ] = None, +) -> ProjectMemberRead: + joining_user = await session.get(User, user_id) + if joining_user is None: + raise ResourceNotFoundError(f'User "{user_id}" is not found.') + if invite_code is None: + if project_id is None or role is None: + raise BadInputError("Missing project ID or role.") + invite = None + else: + if ENV_CONFIG.is_oss: + raise BadInputError("Invite code is not supported in OSS.") + else: + from owl.db.models.cloud import VerificationCode + + # Fetch code + invite = await session.get(VerificationCode, invite_code) + if ( + invite is None + or invite.project_id is None + or invite.purpose not in ("project_invite", None) + or now() > invite.expiry + or invite.revoked_at is not None + or invite.used_at is not None + or invite.user_email != joining_user.preferred_email + ): + raise ResourceNotFoundError(f'Invite code "{invite_code}" is invalid.') + project_id = invite.project_id + role = invite.role + # Fetch + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + if project.organization_id not in [r.organization_id for r in joining_user.org_memberships]: + raise ForbiddenError("You are not a member of this project's organization.") + if await session.get(ProjectMember, (user_id, project_id)) is not None: + raise ResourceExistsError("You are already in the project.") + # RBAC + if user.id != user_id or invite_code is None: + has_permissions( + user, + ["organization.ADMIN", "project.ADMIN"], + organization_id=project.organization_id, + project_id=project_id, + ) + project_member = ProjectMember(user_id=user_id, project_id=project_id, role=role) + session.add(project_member) + await session.commit() + await session.refresh(project_member) + # Consume invite code + if invite is not None: + invite.used_at = now() + session.add(invite) + await session.commit() + logger.bind(user_id=joining_user.id, proj_id=project.id).success( + ( + f"{joining_user.preferred_name} ({joining_user.preferred_email}) joined " + f'project "{project.name}" as "{role.name}".' + ) + ) + return project_member + + +@router.get( + "/v2/projects/members/list", + summary="List project members.", + description="Permissions: `system` OR `organization` OR `project`.", +) +@handle_exception +async def list_project_members( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQueryByProject[Literal["id", "created_at", "updated_at"]], Query()], +) -> Page[ProjectMemberRead]: + project_id = params.project_id + # Fetch the project + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + has_permissions( + user, + ["system", "organization", "project"], + organization_id=project.organization_id, + project_id=project_id, + ) + return await ProjectMember.list_( + session=session, + return_type=ProjectMemberRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + filters=dict(project_id=project_id), + after=params.after, + ) + + +@router.get( + "/v2/projects/members", + summary="Get a project member.", + description="Permissions: `system` OR `organization` OR `project`.", +) +@handle_exception +async def get_project_member( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], +) -> ProjectMemberRead: + # Fetch the project + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + has_permissions( + user, + ["system", "organization", "project"], + organization_id=project.organization_id, + project_id=project_id, + ) + member = await session.get(ProjectMember, (user_id, project_id)) + if member is None: + raise ResourceNotFoundError(f'User "{user_id}" is not a member of project "{project_id}".') + return member + + +@router.patch( + "/v2/projects/members/role", + summary="Update a project member's role.", + description="Permissions: `organization.ADMIN` OR `project.ADMIN`.", +) +@handle_exception +async def update_member_role( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], + role: Annotated[Role, Query(description="New role.")], +) -> ProjectMemberRead: + # Fetch the project + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + # Check permissions + has_permissions( + user, + ["organization.ADMIN", "project.ADMIN"], + organization_id=project.organization_id, + project_id=project.id, + ) + # Fetch the member + member = await session.get(ProjectMember, (user_id, project_id)) + if member is None: + raise ResourceNotFoundError(f'User "{user_id}" is not a member of project "{project_id}".') + # Update + member.role = role + await session.commit() + return member + + +@router.delete( + "/v2/projects/members", + summary="Leave a project.", + description=( + "Permissions: `organization.ADMIN` OR `project.ADMIN`. " + "Permissions are only needed if deleting other user's membership." + ), +) +@handle_exception +async def leave_project( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[str, Query(min_length=1, description="User ID.")], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], +) -> OkResponse: + leaving_user = await session.get(User, user_id) + if leaving_user is None: + raise ResourceNotFoundError(f'User "{user_id}" is not found.') + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + if user.id != user_id: + has_permissions( + user, + ["organization.ADMIN", "project.ADMIN"], + organization_id=project.organization_id, + project_id=project_id, + ) + project_member = await session.get(ProjectMember, (user_id, project_id)) + if project_member is None: + raise ResourceNotFoundError(f'User "{user_id}" is not a member of project "{project_id}".') + await session.delete(project_member) + await session.commit() + logger.bind(user_id=leaving_user.id, proj_id=project.id).success( + ( + f"{leaving_user.preferred_name} ({leaving_user.preferred_email}) left " + f'project "{project.name}".' + ) + ) + return OkResponse() + + +TABLE_CLS: dict[TableType, ActionTable | KnowledgeTable | ChatTable] = { + TableType.ACTION: ActionTable, + TableType.KNOWLEDGE: KnowledgeTable, + TableType.CHAT: ChatTable, +} + + +async def _export_project_as_pa_table( + request: Request, + user: UserAuth, + project: Project, +) -> pa.Table: + organization = OrganizationRead.model_validate(project.organization) + # Check quota + billing = BillingManager( + organization=organization, + project_id=project.id, + user_id=user.id, + request=request, + models=None, + ) + billing.has_egress_quota() + # Dump all tables as parquet files + data = [] + table_types = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + for table_type in table_types: + metas = ( + await TABLE_CLS[table_type].list_tables( + project_id=project.id, + limit=None, + offset=0, + parent_id=None, + count_rows=False, + ) + ).items + for meta in metas: + table = await TABLE_CLS[table_type].open_table(project_id=project.id, table_id=meta.id) + with BytesIO() as f: + await table.export_table(f) + data.append((table_type, meta, f.getvalue())) + if len(data) == 0: + raise BadInputError(f'Project "{project.id}" is empty with no tables.') + # Download project pictures + project_meta = project.model_dump() + for pic_type in ["profile_picture", "cover_picture"]: + uri: str | None = project_meta.get(f"{pic_type}_url", None) + if uri is None: + continue + async with open_uri_async(uri) as (f, mime): + project_meta[pic_type] = ( + f"data:{mime};base64,{base64.b64encode(await f.read()).decode('utf-8')}" + ) + # Bundle everything into a single PyArrow Table + table_metas = [ + {"table_type": table_type, "table_meta": meta.model_dump(mode="json")} + for table_type, meta, _ in data + ] + data = list(zip(*data, strict=True)) + pa_table = pa.table( + {"table_type": pa.array(data[0], pa.utf8()), "data": pa.array(data[2], pa.binary())}, + metadata={ + "project_meta": json_dumps(project_meta), + "table_metas": json_dumps(table_metas), + }, + ) + return pa_table + + +@router.get("/v2/projects/export") +@handle_exception +async def export_project( + request: Request, + bg_tasks: BackgroundTasks, + user: Annotated[UserAuth, Depends(auth_user)], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], +) -> FileResponse: + # Fetch the project + async with async_session() as session: + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + pa_table = await _export_project_as_pa_table( + request=request, + user=user, + project=project, + ) + # Temporary file + tmp_dir = TemporaryDirectory() + filename = f"{project.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) + pq.write_table(pa_table, filepath, compression="ZSTD") + logger.bind(user_id=user.id).success( + f'{user.name} ({user.email}) exported project "{project.name}" ({project.id}).' + ) + return FileResponse( + path=filepath, + filename=filename, + media_type="application/octet-stream", + ) + + +async def _import_project_from_pa_table( + request: Request, + user: UserAuth, + *, + organization_id: str, + project_id: str, + pa_table: pa.Table, + keep_original_ids: bool = False, + check_quota: bool = True, + raise_error: bool = True, + verbose: bool = False, +) -> ProjectRead: + has_permissions(user, ["system", "organization"], organization_id=organization_id) + async with async_session() as session: + if project_id: + # Fetch the project + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + organization = project.organization + else: + if not organization_id: + raise BadInputError("Organization ID is required when project ID is not provided.") + project = None + # Fetch the organization + organization = await session.get(Organization, organization_id) + if organization is None: + raise ResourceNotFoundError(f'Organization "{organization_id}" is not found.') + organization = OrganizationRead.model_validate(organization) + # Check quota + if check_quota: + billing = BillingManager( + organization=organization, + project_id="", # Not needed to check storage quotas + user_id=user.id, + request=request, + models=None, + ) + billing.has_db_storage_quota() + billing.has_file_storage_quota() + # Create the project if needed + if project is None: + try: + project_meta = json_loads(pa_table.schema.metadata[b"project_meta"]) + except KeyError as e: + raise BadInputError("Missing project metadata in the Parquet file.") from e + except Exception as e: + raise BadInputError("Invalid project metadata in the Parquet file.") from e + body = {k: v for k, v in project_meta.items() if k not in ["id", "organization_id"]} + body["organization_id"] = organization_id + async with async_session() as session: + project = await create_project( + request=request, + user=user, + session=session, + body=ProjectCreate.model_validate(body), + project_id=project_meta.get("id", "") if keep_original_ids else "", + ) + # Upload and update project picture URL + project = await session.get(Project, project.id) + if project is None: + raise UnexpectedError(f'Project "{project.id}" is not found.') + for pic_type in ["profile_picture", "cover_picture"]: + data: str | None = project_meta.get(pic_type, None) + uri_ori: str | None = project_meta.get(f"{pic_type}_url", None) + if data is None or uri_ori is None: + uri = None + else: + # f"data:{mime};base64,{base64.b64encode(await f.read()).decode('utf-8')}" + mime_type, b64 = data.replace("data:", "", 1).split(";base64,") + uri = await s3_upload( + organization.id, + project.id, + base64.b64decode(b64.encode("utf-8")), + content_type=mime_type, + filename=uri_ori.split("/")[-1], + ) + setattr(project, f"{pic_type}_url", uri) + await session.commit() + await session.refresh(project) + if verbose: + logger.info( + f'Importing project "{project.name}" ({project.id}): Project metadata imported.' + ) + + # Import Knowledge Tables first + async def _import_table(_data: bytes, _type: str): + with BytesIO(_data) as source: + await TABLE_CLS[_type].import_table( + project_id=project.id, + source=source, + table_id_dst=None, + reupload_files=not keep_original_ids, + verbose=verbose, + ) + + table_metas = json_loads(pa_table.schema.metadata[b"table_metas"]) + rows = pa_table.to_pylist() + i = 1 + for row, meta in zip(rows, table_metas, strict=True): + if row["table_type"] != TableType.KNOWLEDGE: + continue + meta = TableMetaResponse.model_validate(meta["table_meta"]) + if verbose: + logger.info( + ( + f'Importing project "{project.name}" ({project.id}): ' + f'Importing table "{meta.id}" ({i} of {len(rows)}) ...' + ) + ) + try: + await _import_table(row["data"], row["table_type"]) + except ResourceExistsError as e: + logger.info(f'Importing project "{project.name}" ({project.id}): {e}') + if raise_error: + raise + except Exception as e: + logger.exception( + f'Importing project "{project.name}" ({project.id}): Failed to import table "{meta.id}": {e}' + ) + if raise_error: + raise + i += 1 + # Import the rest + for row, meta in zip(rows, table_metas, strict=True): + if row["table_type"] == TableType.KNOWLEDGE: + continue + meta = TableMetaResponse.model_validate(meta["table_meta"]) + if verbose: + logger.info( + ( + f'Importing project "{project.name}" ({project.id}): ' + f'Importing table "{meta.id}" ({i} of {len(rows)}) ...' + ) + ) + try: + await _import_table(row["data"], row["table_type"]) + except ResourceExistsError as e: + logger.info(f'Importing project "{project.name}" ({project.id}): {e}') + if raise_error: + raise + except Exception as e: + logger.exception( + f'Importing project "{project.name}" ({project.id}): Failed to import table "{meta.id}": {e}' + ) + if raise_error: + raise + i += 1 + return project + + +class ProjectImportFormData(BaseModel): + file: Annotated[UploadFile, File(description="The project or template Parquet file.")] + project_id: Annotated[ + str, + Field( + description='If given, import tables into this project. Defaults to "" (create new project).' + ), + ] = "" + organization_id: Annotated[ + str, + Field( + description="Organization ID of the new project. Only required if creating a new project." + ), + ] = "" + + +@router.post("/v2/projects/import/parquet") +@handle_exception +async def import_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + data: Annotated[ProjectImportFormData, Form()], +) -> ProjectRead: + # Load Parquet file + try: + with BytesIO(await data.file.read()) as source: + # TODO: Perhaps check the metadata with `columns=[]` first and avoid parsing the whole file + pa_table: pa.Table = pq.read_table( + source, columns=None, use_threads=False, memory_map=True + ) + except Exception as e: + raise BadInputError("Failed to parse Parquet file.") from e + return await _import_project_from_pa_table( + request, + user, + organization_id=data.organization_id, + project_id=data.project_id, + pa_table=pa_table, + ) + + +@router.post("/v2/projects/import/parquet/migration") +@handle_exception +async def import_project_migration( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + data: Annotated[ProjectImportFormData, Form()], +) -> ProjectRead: + # Load Parquet file + try: + with BytesIO(await data.file.read()) as source: + pa_table: pa.Table = pq.read_table( + source, columns=None, use_threads=False, memory_map=True + ) + except Exception as e: + raise BadInputError("Failed to parse Parquet file.") from e + try: + return await _import_project_from_pa_table( + request, + user, + organization_id=data.organization_id, + project_id=data.project_id, + pa_table=pa_table, + keep_original_ids=True, + check_quota=False, + raise_error=False, + verbose=True, + ) + except Exception as e: + logger.exception(e) + raise + + +class TemplateImportQuery(BaseModel): + template_id: Annotated[str, Field(description="Template ID.")] + project_id: Annotated[ + str, + Field( + description='If given, import tables into this project. Defaults to "" (create new project).' + ), + ] = "" + organization_id: Annotated[ + str, + Field( + description="Organization ID of the new project. Only required if creating a new project." + ), + ] = "" + + +@router.post("/v2/projects/import/template") +@handle_exception +async def import_template( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + params: Annotated[TemplateImportQuery, Query()], +) -> ProjectRead: + # Fetch the project + async with async_session() as session: + template = await session.get(Project, params.template_id) + if template is None: + raise ResourceNotFoundError(f'Template "{params.template_id}" is not found.') + # Export template + pa_table = await _export_project_as_pa_table( + request=request, + user=user, + project=template, + ) + # Import + return await _import_project_from_pa_table( + request, + user, + organization_id=params.organization_id, + project_id=params.project_id, + pa_table=pa_table, + ) diff --git a/services/api/src/owl/routers/projects/v1.py b/services/api/src/owl/routers/projects/v1.py new file mode 100644 index 0000000..0bbe0a4 --- /dev/null +++ b/services/api/src/owl/routers/projects/v1.py @@ -0,0 +1,156 @@ +from enum import StrEnum +from typing import Annotated + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + Form, + Path, + Query, + Request, + UploadFile, +) +from fastapi.responses import FileResponse + +from owl.db import AsyncSession, yield_async_session +from owl.routers.projects import oss as v2 +from owl.types import ( + OkResponse, + Page, + ProjectCreate, + ProjectRead, + ProjectUpdate, + UserAuth, +) +from owl.utils.auth import auth_user +from owl.utils.exceptions import handle_exception + +router = APIRouter() + + +@router.post("/v1/projects") +@handle_exception +async def create_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: ProjectCreate, + project_id: str = "", +) -> ProjectRead: + return await v2.create_project(request, user, session, body, project_id=project_id) + + +class AdminOrderBy(StrEnum): + ID = "id" + NAME = "name" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + + +@router.get("/v1/projects") +@handle_exception +async def list_projects( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + organization_id: Annotated[str, Query(min_length=1, description='Organization ID "org_xxx".')], + 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]: + params = v2.ListProjectQuery( + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=not order_descending, + organization_id=organization_id, + search_query=search_query, + ) + return await v2.list_projects(user, session, params) + + +@router.get("/v1/projects/{project_id}") +@handle_exception +async def get_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Path(min_length=1, description='Project ID "proj_xxx".')], +) -> ProjectRead: + return await v2.get_project(request, user, session, project_id) + + +@router.patch("/v1/projects") +@handle_exception +async def update_project( + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Query(min_length=1, description="Project ID.")], + body: ProjectUpdate, +) -> ProjectRead: + return await v2.update_project(user, session, project_id, body) + + +@router.delete("/v1/projects/{project_id}") +@handle_exception +async def delete_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + project_id: Annotated[str, Path(min_length=1, description='Project ID "proj_xxx".')], +) -> OkResponse: + return await v2.delete_project(request, user, session, project_id) + + +@router.get("/v1/projects/{project_id}/export") +@handle_exception +async def export_project( + request: Request, + bg_tasks: BackgroundTasks, + user: Annotated[UserAuth, Depends(auth_user)], + project_id: Annotated[str, Path(min_length=1, description='Project ID "proj_xxx".')], +) -> FileResponse: + return await v2.export_project(request, bg_tasks, user, project_id) + + +@router.post("/v1/projects/import/{organization_id}") +@handle_exception +async def import_project( + request: Request, + user: Annotated[UserAuth, Depends(auth_user)], + organization_id: Annotated[str, Path(min_length=1, description='Organization ID "org_xxx".')], + 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: + data = v2.ProjectImportFormData( + file=file, + project_id=project_id_dst, + organization_id=organization_id, + ) + return await v2.import_project(request, user, data) diff --git a/services/api/src/owl/routers/serving.py b/services/api/src/owl/routers/serving.py new file mode 100644 index 0000000..37305a0 --- /dev/null +++ b/services/api/src/owl/routers/serving.py @@ -0,0 +1,270 @@ +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from owl.db import AsyncSession, yield_async_session +from owl.db.gen_executor import GenExecutor +from owl.db.models import ModelConfig +from owl.types import ( + EXAMPLE_CHAT_MODEL_IDS, + ChatRequest, + EmbeddingRequest, + EmbeddingResponse, + ModelCapability, + ModelInfoListResponse, + ModelInfoRead, + OrganizationRead, + ProjectRead, + RerankingRequest, + RerankingResponse, + UserAuth, +) +from owl.utils.auth import auth_user_project +from owl.utils.billing import BillingManager +from owl.utils.exceptions import ResourceNotFoundError, handle_exception +from owl.utils.lm import LMEngine +from owl.utils.mcp import MCP_TOOL_TAG + +router = APIRouter() + + +class _ListQuery(BaseModel): + order_by: Literal["id", "name", "created_at", "updated_at"] = Field( + "id", + description='Sort by this attribute. Defaults to "id".', + ) + order_ascending: bool = Field( + True, + description="Whether to sort in ascending order. Defaults to True.", + ) + capabilities: list[ModelCapability] | None = Field( + None, + description=( + "Filter the model info by model's capabilities. Defaults to None (no filter)." + ), + examples=[[ModelCapability.CHAT]], + ) + + +class ModelInfoListQuery(_ListQuery): + model: str = Field( + "", + description="ID of the requested model.", + examples=EXAMPLE_CHAT_MODEL_IDS, + ) + + +@router.get( + "/v1/models", + summary="List the info of models available.", + description="List the info of models available with the specified name and capabilities.", + tags=[MCP_TOOL_TAG, "project"], +) +@handle_exception +async def model_info( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ModelInfoListQuery, Query()], +) -> ModelInfoListResponse: + _, _, org = auth_info + try: + models = ( + await ModelConfig.list_( + session=session, + return_type=ModelInfoRead, + organization_id=org.id, + order_by=params.order_by, + order_ascending=params.order_ascending, + capabilities=params.capabilities, + exclude_inactive=True, + ) + ).items + # Filter by name + if params.model != "": + models = [m for m in models if m.id == params.model] + return ModelInfoListResponse(data=models) + except ResourceNotFoundError: + return ModelInfoListResponse(data=[]) + + +class ModelIdListQuery(_ListQuery): + prefer: str = Field( + "", + description="ID of the preferred model.", + examples=EXAMPLE_CHAT_MODEL_IDS, + ) + + +@router.get( + "/v1/models/ids", + summary="List the ID of models available.", + description=( + "List the ID of models available with the specified capabilities with an optional preferred model. " + "If the preferred model is not available, then return the first available model." + ), +) +@router.get( + "/v1/model_names", + deprecated=True, + summary="List the ID of models available.", + description="Deprecated, use `/v1/models/ids` instead. List the ID of models available.", +) +@handle_exception +async def model_ids( + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ModelIdListQuery, Query()], +) -> list[str]: + models = await model_info( + auth_info, + session, + ModelInfoListQuery( + order_by=params.order_by, + order_ascending=params.order_ascending, + capabilities=params.capabilities, + model="", + ), + ) + names = [m.id for m in models.data] + if params.prefer in names: + names.remove(params.prefer) + names.insert(0, params.prefer) + return names + + +async def _empty_async_generator(): + """Returns an empty asynchronous generator.""" + return + # This line is never reached, but makes it an async generator + yield + + +@router.post( + "/v1/chat/completions", + summary="Chat completion.", + description="Given a list of messages comprising a conversation, returns a response from the model.", +) +@handle_exception +async def chat_completion( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: ChatRequest, +) -> Response: + # Check quota + billing: BillingManager = request.state.billing + billing.has_llm_quota(body.model) + billing.has_egress_quota() + _, project, org = auth_info + body.id = request.state.id + llm = LMEngine(organization=org, project=project, request=request) + body, references = await GenExecutor.setup_rag( + project=project, lm=llm, body=body, request_id=body.id + ) + if body.stream: + agen = llm.chat_completion_stream(messages=body.messages, **body.hyperparams) + try: + chunk = await anext(agen) + except StopAsyncIteration: + return StreamingResponse( + content=_empty_async_generator(), + status_code=200, + media_type="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + + async def _generate(): + content_length = 1 + if references is not None: + sse = f"data: {references.model_dump_json()}\n\n" + content_length += len(sse.encode("utf-8")) + yield sse + nonlocal chunk + yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + async for chunk in agen: + sse = f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n" + content_length += len(sse.encode("utf-8")) + yield sse + sse = "data: [DONE]\n\n" + content_length += len(sse.encode("utf-8")) + yield sse + # NOTE: We must create egress events here as SSE cannot be handled in the middleware + 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"}, + ) + else: + response = await llm.chat_completion(messages=body.messages, **body.hyperparams) + if references is not None: + response.references = references + # NOTE: Do not create egress events here as it is handled in the middleware + return response + + +@router.post( + "/v1/embeddings", + summary="Embeds texts as vectors.", + description=( + "Get a vector representation of a given input that can be easily consumed by machine learning models and algorithms. " + "Note that the vectors are NOT normalized." + ), +) +@handle_exception +async def generate_embeddings( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: EmbeddingRequest, +) -> EmbeddingResponse: + # Check quota + billing: BillingManager = request.state.billing + billing.has_embedding_quota(body.model) + billing.has_egress_quota() + _, project, org = auth_info + embedder = LMEngine(organization=org, project=project, request=request) + if isinstance(body.input, str): + body.input = [body.input] + kwargs = dict( + model=body.model, + texts=body.input, + encoding_format=body.encoding_format, + ) + if body.type == "document": + embeddings = await embedder.embed_documents(**kwargs) + else: + embeddings = await embedder.embed_queries(**kwargs) + return embeddings + + +@router.post( + "/v1/rerank", + summary="Ranks each text input to the query text.", + description="Get the similarity score of each text input to query by giving a query and list of text inputs.", +) +@handle_exception +async def generate_rankings( + request: Request, + auth_info: Annotated[ + tuple[UserAuth, ProjectRead, OrganizationRead], Depends(auth_user_project) + ], + body: RerankingRequest, +) -> RerankingResponse: + # Check quota + billing: BillingManager = request.state.billing + billing.has_reranker_quota(body.model) + billing.has_egress_quota() + _, project, org = auth_info + reranker = LMEngine(organization=org, project=project, request=request) + return await reranker.rerank_documents(**body.model_dump()) diff --git a/services/api/src/owl/routers/tasks.py b/services/api/src/owl/routers/tasks.py new file mode 100644 index 0000000..9587498 --- /dev/null +++ b/services/api/src/owl/routers/tasks.py @@ -0,0 +1,24 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query + +from owl.configs import CACHE +from owl.types import UserAuth +from owl.utils.auth import auth_user_service_key +from owl.utils.exceptions import handle_exception + +router = APIRouter() + + +@router.get( + "/v2/progress", + summary="Get progress data.", + description="Permissions: None as long as signed-in.", +) +@handle_exception +async def get_progress( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + key: Annotated[str, Query(min_length=1, description="Progress key.")], +) -> dict[str, Any]: + del user + return (await CACHE.get_progress(key, None)) or {} diff --git a/services/api/src/owl/routers/template.py b/services/api/src/owl/routers/template.py deleted file mode 100644 index 866a7d2..0000000 --- a/services/api/src/owl/routers/template.py +++ /dev/null @@ -1,417 +0,0 @@ -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/routers/templates.py b/services/api/src/owl/routers/templates.py new file mode 100644 index 0000000..55eb71f --- /dev/null +++ b/services/api/src/owl/routers/templates.py @@ -0,0 +1,216 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Path, Query +from loguru import logger +from pydantic import BaseModel, Field + +from owl.db import TEMPLATE_ORG_ID, AsyncSession, yield_async_session +from owl.db.gen_table import ( + ActionTable, + ChatTable, + KnowledgeTable, +) +from owl.db.models import Organization, Project +from owl.types import ( + GetTableRowQuery, + ListQuery, + ListTableQuery, + ListTableRowQuery, + Page, + ProjectRead, + SanitisedNonEmptyStr, + TableMetaResponse, + TableType, +) +from owl.utils.exceptions import ( + ResourceNotFoundError, + handle_exception, +) + +router = APIRouter() + + +class ListTemplateQuery(ListQuery): + search_query: Annotated[ + str, + Field( + max_length=255, + description='_Optional_. A string to search for within project names as a filter. Defaults to "" (no filter).', + ), + ] = "" + + +@router.get( + "/v2/templates/list", + summary="List templates.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def list_templates( + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListTemplateQuery, Query()], +) -> Page[ProjectRead]: + # Ensure the organization exists + if (await session.get(Organization, TEMPLATE_ORG_ID)) is None: + logger.warning(f'Template organization "{TEMPLATE_ORG_ID}" does not exist.') + return Page[ProjectRead]( + items=[], + offset=params.offset, + limit=params.limit, + total=0, + ) + # List + return await Project.list_( + session=session, + return_type=ProjectRead, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + filters=dict(organization_id=TEMPLATE_ORG_ID), + after=params.after, + ) + + +@router.get( + "/v2/templates", + summary="Get a specific template.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def get_template( + session: Annotated[AsyncSession, Depends(yield_async_session)], + template_id: Annotated[str, Query(min_length=1, description="Template ID.")], +) -> ProjectRead: + # Fetch the template + template = await session.get(Project, template_id) + if template is None: + raise ResourceNotFoundError(f'Template "{template_id}" is not found.') + return template + + +TABLE_CLS: dict[TableType, ActionTable | KnowledgeTable | ChatTable] = { + TableType.ACTION: ActionTable, + TableType.KNOWLEDGE: KnowledgeTable, + TableType.CHAT: ChatTable, +} + + +class _ListTableQuery(ListTableQuery): + template_id: Annotated[str, Field(min_length=1, description="Template ID.")] + + +@router.get( + "/v2/templates/gen_tables/{table_type}/list", + summary="List tables in a template.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def list_tables( + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[_ListTableQuery, Query()], +) -> Page[TableMetaResponse]: + metas = await TABLE_CLS[table_type].list_tables( + project_id=params.template_id, + limit=params.limit, + offset=params.offset, + parent_id=params.parent_id, + search_query=params.search_query, + order_by=params.order_by, + order_ascending=params.order_ascending, + count_rows=params.count_rows, + ) + return metas + + +class GetTableQuery(BaseModel): + template_id: Annotated[str, Field(min_length=1, description="Template ID.")] + table_id: Annotated[SanitisedNonEmptyStr, Field(description="The ID of the table to fetch.")] + + +@router.get( + "/v2/templates/gen_tables/{table_type}", + summary="Get a specific table from a template.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def get_table( + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[GetTableQuery, Query()], +) -> TableMetaResponse: + table = await TABLE_CLS[table_type].open_table( + project_id=params.template_id, table_id=params.table_id + ) + return table.v1_meta_response + + +class _ListTableRowQuery(ListTableRowQuery): + template_id: Annotated[str, Field(min_length=1, description="Template ID.")] + + +@router.get( + "/v2/templates/gen_tables/{table_type}/rows/list", + summary="List rows in a template table.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def list_table_rows( + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[_ListTableRowQuery, Query()], +) -> Page[dict[str, Any]]: + table = await TABLE_CLS[table_type].open_table( + project_id=params.template_id, table_id=params.table_id + ) + rows = await table.list_rows( + limit=params.limit, + offset=params.offset, + order_by=[params.order_by], + order_ascending=params.order_ascending, + columns=params.columns, + where=params.where, + search_query=params.search_query, + search_columns=params.search_columns, + remove_state_cols=False, + ) + return Page[dict[str, Any]]( + items=table.postprocess_rows( + rows.items, + float_decimals=params.float_decimals, + vec_decimals=params.vec_decimals, + ), + offset=params.offset, + limit=params.limit, + total=rows.total, + ) + + +class _GetTableRowQuery(GetTableRowQuery): + template_id: Annotated[str, Field(min_length=1, description="Template ID.")] + + +@router.get( + "/v2/templates/gen_tables/{table_type}/rows", + summary="Get a specific row from a template table.", + description="Permissions: None, publicly accessible.", +) +@handle_exception +async def get_table_row( + table_type: Annotated[TableType, Path(description="Table type.")], + params: Annotated[_GetTableRowQuery, Query()], +) -> dict[str, Any]: + table = await TABLE_CLS[table_type].open_table( + project_id=params.template_id, table_id=params.table_id + ) + row = await table.get_row( + row_id=params.row_id, + columns=params.columns, + remove_state_cols=False, + ) + row = table.postprocess_rows( + [row], + float_decimals=params.float_decimals, + vec_decimals=params.vec_decimals, + )[0] + return row diff --git a/services/api/src/owl/routers/users/__init__.py b/services/api/src/owl/routers/users/__init__.py new file mode 100644 index 0000000..e9dc821 --- /dev/null +++ b/services/api/src/owl/routers/users/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from owl.configs import ENV_CONFIG +from owl.routers.users.oss import router as oss_router + +router = APIRouter() +router.include_router(oss_router) + +if ENV_CONFIG.is_cloud: + from owl.routers.users.cloud import router as cloud_router + + router.include_router(cloud_router) diff --git a/services/api/src/owl/routers/users/oss.py b/services/api/src/owl/routers/users/oss.py new file mode 100644 index 0000000..8a03ba3 --- /dev/null +++ b/services/api/src/owl/routers/users/oss.py @@ -0,0 +1,207 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Request +from loguru import logger +from sqlmodel import delete, func, select + +from owl.configs import ENV_CONFIG +from owl.db import AsyncSession, yield_async_session +from owl.db.models import ( + Organization, + OrgMember, + ProjectMember, + User, +) +from owl.types import ( + ListQuery, + OkResponse, + Page, + UserAuth, + UserCreate, + UserReadObscured, + UserUpdate, +) +from owl.utils.auth import auth_service_key, auth_user_service_key, has_permissions +from owl.utils.dates import now +from owl.utils.exceptions import ( + ResourceExistsError, + ResourceNotFoundError, + handle_exception, +) + +router = APIRouter() + + +async def _count_email(session: AsyncSession, email: str) -> int: + return (await session.exec(select(func.count(User.id)).where(User.email == email))).one() + + +@router.post( + "/v2/users", + summary="Create a user.", + description="Permissions: None.", +) +@handle_exception +async def create_user( + request: Request, + token: Annotated[str, Depends(auth_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: UserCreate, +) -> UserReadObscured: + del token + # Unless explicitly specified, create the first user with ID 0 + if ( + "id" not in body.model_dump(exclude_unset=True) + and (await session.exec(select(func.count(User.id)))).one() == 0 + ): + body.id = "0" + # Check if user already exists + if (await session.get(User, body.id)) is not None: + raise ResourceExistsError(f'User "{body.id}" already exists.') + if await _count_email(session, body.email) > 0: + raise ResourceExistsError(f'User with email "{body.email}" already exists.') + user = User.model_validate(body) + # Auth0 handles email verification + if ENV_CONFIG.auth0_api_key_plain: + user.email_verified = True + session.add(user) + await session.commit() + await session.refresh(user) + logger.info( + f"{request.state.id} - Created user: {user.model_dump(exclude={'password', 'password_hash'})}" + ) + logger.bind(user_id=user.id).success(f"{user.name} ({user.email}) created their account.") + user = await User.get(session, user.id, populate_existing=True) + return user + + +@router.get( + "/v2/users/list", + summary="List users.", + description="Permissions: `system.ADMIN`.", +) +@handle_exception +async def list_users( + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + params: Annotated[ListQuery, Query()], +) -> Page[UserReadObscured]: + has_permissions(user, ["system.ADMIN"]) + return await User.list_( + session=session, + return_type=UserReadObscured, + offset=params.offset, + limit=params.limit, + order_by=params.order_by, + order_ascending=params.order_ascending, + search_query=params.search_query, + search_columns=params.search_columns, + after=params.after, + ) + + +@router.get( + "/v2/users", + summary="Get current user or a specific user.", + description=( + "Permissions: `system.ADMIN`. " + "Permissions are only needed if the queried user is not the current logged-in user." + ), +) +@handle_exception +async def get_user( + _user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + user_id: Annotated[ + str | None, Query(description="User ID. If not provided, the logged-in user is returned.") + ] = None, +) -> UserReadObscured: + if (not user_id) or (user_id == _user.id): + return await User.get(session, _user.id, populate_existing=True) + if _user.id != user_id: + has_permissions(_user, ["system.ADMIN"]) + return await User.get(session, user_id) + + +@router.patch( + "/v2/users", + summary="Update the current logged-in user.", + description="Permissions: None.", +) +@handle_exception +async def update_user( + *, + _user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], + body: UserUpdate, +) -> UserReadObscured: + user = await User.get(session, _user.id) + if user is None: + raise ResourceNotFoundError(f'User "{_user.id}" is not found.') + # Perform update + updates = body.model_dump(exclude={"id"}, exclude_unset=True) + for key, value in updates.items(): + if key == "email" and body.email != user.email: + if await _count_email(session, body.email) > 0: + raise ResourceExistsError(f'User with email "{body.email}" already exists.') + user.email_verified = False + setattr(user, key, value) + user.updated_at = now() + session.add(user) + await session.commit() + await session.refresh(user) + logger.bind(user_id=user.id).success( + ( + f"{user.name} ({user.email}) updated the attributes " + f"{list(updates.keys())} of their user account." + ) + ) + return user + + +@router.delete( + "/v2/users", + summary="Delete a user.", + description="Permissions: None.", +) +@handle_exception +async def delete_user( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + session: Annotated[AsyncSession, Depends(yield_async_session)], +) -> OkResponse: + user = await session.get(User, user.id) + if user is None: + raise ResourceNotFoundError(f'User "{user.id}" is not found.') + org_ids = [m.organization_id for m in user.org_memberships] + # Delete all related resources + logger.info(f'{request.state.id} - Deleting user: "{user.id}"') + await session.exec(delete(OrgMember).where(OrgMember.user_id == user.id)) + await session.exec(delete(ProjectMember).where(ProjectMember.user_id == user.id)) + if ENV_CONFIG.is_cloud: + from owl.db.models.cloud import ProjectKey, VerificationCode + + await session.exec(delete(ProjectKey).where(ProjectKey.user_id == user.id)) + await session.exec( + delete(VerificationCode).where(VerificationCode.user_email == user.email) + ) + await session.delete(user) + await session.commit() + # Delete organizations if the user was the last member + logger.info(f"{request.state.id} - Inspecting organizations: {org_ids}") + for org_id in org_ids: + member_count = ( + await session.exec( + select(func.count(OrgMember.user_id)).where(OrgMember.organization_id == org_id) + ) + ).one() + if member_count > 0: + continue + try: + await session.exec(delete(Organization).where(Organization.id == org_id)) + await session.commit() + logger.info(f'{request.state.id} - Deleting empty organization "{org_id}"') + except Exception as e: + logger.warning(f'Failed to delete organization "{org_id}" due to {repr(e)}') + logger.bind(user_id=user.id).success(f"{user.name} ({user.email}) deleted their account.") + return OkResponse() diff --git a/services/api/src/owl/scripts/backup_db.py b/services/api/src/owl/scripts/backup_db.py index 27fbaaf..d6e73e4 100644 --- a/services/api/src/owl/scripts/backup_db.py +++ b/services/api/src/owl/scripts/backup_db.py @@ -47,7 +47,7 @@ def restore(db_dir: str): ) ) src_path = join(proj_dir, bak_files[0]) - dst_path = join(proj_dir, f'{bak_files[0].split("_")[0]}.db') + dst_path = join(proj_dir, f"{bak_files[0].split('_')[0]}.db") os.remove(dst_path) copy2(src_path, dst_path) @@ -74,5 +74,5 @@ def find_sqlite_files(directory): 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}") + 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/update_db.py b/services/api/src/owl/scripts/update_db.py deleted file mode 100644 index 188d030..0000000 --- a/services/api/src/owl/scripts/update_db.py +++ /dev/null @@ -1,95 +0,0 @@ -import os -import sqlite3 -from datetime import datetime, timezone -from os.path import join -from pprint import pprint - -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/checks.py b/services/api/src/owl/tasks/checks.py new file mode 100644 index 0000000..de64814 --- /dev/null +++ b/services/api/src/owl/tasks/checks.py @@ -0,0 +1,88 @@ +from loguru import logger + +from jamaibase import JamAI +from jamaibase.types import ChatRequest +from owl.configs import ENV_CONFIG, celery_app + + +@celery_app.task +def test_models(): + client = JamAI( + api_base=f"http://localhost:{ENV_CONFIG.port}/api", + user_id="0", + token=ENV_CONFIG.service_key_plain, + ) + projects = client.projects.list_projects("0", limit=1).items + if len(projects) == 0: + logger.error("No projects found.") + return + project = projects[0] + client = JamAI( + api_base=f"http://localhost:{ENV_CONFIG.port}/api", + user_id="0", + project_id=project.id, + token=ENV_CONFIG.service_key_plain, + ) + + # Test chat completion + models = client.model_info(capabilities=["chat"]).data + status = {model.id: False for model in models} + for model in models: + logger.debug(f"------ {model.id} {model.name} ------") + for stream in [True, False]: + try: + response = client.generate_chat_completions( + ChatRequest( + model=model.id, + messages=[{"role": "user", "content": "Hello"}], + max_tokens=2, + stream=stream, + ), + ) + if stream: + for chunk in response: + logger.debug(chunk) + else: + logger.debug(response) + except Exception as e: + logger.error(f'Model "{model.name}" ({model.id}) failed: {repr(e)}') + status[model.id] = True + logger.info( + f"Chat model test: {sum(status.values()):,d} out of {len(status):,d} models passed." + ) + + # Test embedding + models = client.model_info(capabilities=["embed"]).data + status = {model.id: False for model in models} + for model in models: + logger.debug(f"------ {model.id} {model.name} ------") + for text in ["What is a llama?", ["What is a llama?", "What is an alpaca?"]]: + for encoding in ["float", "base64"]: + try: + response = client.generate_embeddings( + dict(model=model.id, input=text, encoding=encoding), + ) + logger.debug(response) + except Exception as e: + logger.error(f'Model "{model.name}" ({model.id}) failed: {repr(e)}') + status[model.id] = True + logger.info( + f"Embedding model test: {sum(status.values()):,d} out of {len(status):,d} models passed." + ) + + # Test rerank + models = client.model_info(capabilities=["rerank"]).data + status = {model.id: False for model in models} + for model in models: + logger.debug(f"------ {model.id} {model.name} ------") + try: + response = client.rerank( + dict(model=model.id, documents=["Norway", "Sweden"], query="Stockholm"), + ) + logger.debug(response) + except Exception as e: + logger.error(f'Model "{model.name}" ({model.id}) failed: {repr(e)}') + status[model.id] = True + logger.info( + f"Reranking model test: {sum(status.values()):,d} out of {len(status):,d} models passed." + ) diff --git a/services/api/src/owl/tasks/database.py b/services/api/src/owl/tasks/database.py new file mode 100644 index 0000000..0c785db --- /dev/null +++ b/services/api/src/owl/tasks/database.py @@ -0,0 +1,12 @@ +import asyncio + +from owl.configs import celery_app +from owl.utils.billing import CLICKHOUSE_CLIENT + + +@celery_app.task +def run_periodic_flush_buffer(): + """ + Flush redis buffer to clickhouse. + """ + asyncio.get_event_loop().run_until_complete(CLICKHOUSE_CLIENT.flush_buffer()) diff --git a/services/api/src/owl/tasks/gen_table.py b/services/api/src/owl/tasks/gen_table.py new file mode 100644 index 0000000..4ff9738 --- /dev/null +++ b/services/api/src/owl/tasks/gen_table.py @@ -0,0 +1,62 @@ +import asyncio +from io import BytesIO + +from loguru import logger + +from owl.configs import celery_app +from owl.db.gen_table import ActionTable, ChatTable, KnowledgeTable +from owl.types import TableType +from owl.utils.exceptions import JamaiException, ResourceExistsError +from owl.utils.io import open_uri_async + +TABLE_CLS: dict[TableType, ActionTable | KnowledgeTable | ChatTable] = { + TableType.ACTION: ActionTable, + TableType.KNOWLEDGE: KnowledgeTable, + TableType.CHAT: ChatTable, +} + + +@celery_app.task +def import_gen_table( + source: str | bytes, + *, + project_id: str, + table_type: str, + table_id_dst: str | None, + reupload_files: bool = True, + progress_key: str = "", + verbose: bool = False, +) -> str: + async def _task(): + if isinstance(source, str): + async with open_uri_async(source) as (f, _): + data = await f.read() + else: + data = source + with BytesIO(data) as f: + try: + return await TABLE_CLS[table_type].import_table( + project_id=project_id, + source=f, + table_id_dst=table_id_dst, + reupload_files=reupload_files, + progress_key=progress_key, + verbose=verbose, + ) + except ResourceExistsError: + raise + except JamaiException as e: + logger.error( + f'Failed to import table "{table_id_dst}" into project "{project_id}": {repr(e)}' + ) + raise + except Exception as e: + logger.exception( + f'Failed to import table "{table_id_dst}" into project "{project_id}": {repr(e)}' + ) + raise + + logger.info("Generative Table import task started.") + table = asyncio.get_event_loop().run_until_complete(_task()) + logger.info("Generative Table import task completed.") + return table.v1_meta_response.model_dump_json() diff --git a/services/api/src/owl/tasks/genitor.py b/services/api/src/owl/tasks/genitor.py index 880d8b9..20757a1 100644 --- a/services/api/src/owl/tasks/genitor.py +++ b/services/api/src/owl/tasks/genitor.py @@ -1,30 +1,10 @@ -# 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, -) +from owl.configs import ENV_CONFIG, celery_app AWS_DELETE_API_MAX_OBJECT_LIMIT = 1000 @@ -44,7 +24,7 @@ def _get_s3_client(): ) -@app.task +@celery_app.task def s3_cleanup(): s3_client = _get_s3_client() current_date = datetime.utcnow().date() @@ -144,35 +124,7 @@ def s3_cleanup(): 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 +@celery_app.task def backup_project_results(results): failed_project = [] status_dict = {} @@ -189,42 +141,3 @@ def backup_project_results(results): logger.info( f"Total number of successful project backup: {true_count} out of {len(results)}. \n Failed projects: {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 index 8b50a35..94a7688 100644 --- a/services/api/src/owl/tasks/restore.py +++ b/services/api/src/owl/tasks/restore.py @@ -1,25 +1,17 @@ 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.configs import ENV_CONFIG from owl.utils.logging import setup_logger_sinks -setup_logger_sinks(f"{ENV_CONFIG.owl_log_dir}/restoration.log") +setup_logger_sinks(f"{ENV_CONFIG.log_dir}/restoration.log") logger.info(f"Using configuration: {ENV_CONFIG}") @@ -41,7 +33,7 @@ def _initialize_databases(table_info_list): 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) + lance_path = os.path.join(ENV_CONFIG.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: @@ -55,57 +47,57 @@ 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}") +# 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.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.get_event_loop().run_until_complete( +# 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() @@ -118,13 +110,13 @@ def main(): 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, - ) + # # 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: @@ -198,37 +190,37 @@ def main(): 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) + current_files = os.listdir(ENV_CONFIG.db_dir) if current_files: - click.echo(f"Current database path: {ENV_CONFIG.owl_db_dir}") + click.echo(f"Current database path: {ENV_CONFIG.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}") + click.echo(f"Current database path: {ENV_CONFIG.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") + # 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}") diff --git a/services/api/src/owl/tasks/storage.py b/services/api/src/owl/tasks/storage.py deleted file mode 100644 index 866db6c..0000000 --- a/services/api/src/owl/tasks/storage.py +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 2782799..0000000 --- a/services/api/src/owl/templates/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# 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 deleted file mode 100644 index 541d16b..0000000 --- a/services/api/src/owl/templates/f1_due_diligence/action/Due_Diligence_ARM.parquet +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 4062cdd..0000000 --- a/services/api/src/owl/templates/f1_due_diligence/knowledge/Form_F1_ARM.parquet +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 22973e4..0000000 --- a/services/api/src/owl/templates/f1_due_diligence/template_meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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/types/__init__.py b/services/api/src/owl/types/__init__.py new file mode 100644 index 0000000..10ed78a --- /dev/null +++ b/services/api/src/owl/types/__init__.py @@ -0,0 +1,1032 @@ +from datetime import datetime +from enum import StrEnum +from os.path import splitext +from typing import Annotated, Any, Generic, Literal, Self, Type, TypeVar + +import pandas as pd +import pyarrow as pa +from fastapi import File, UploadFile +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + Field, + model_validator, +) + +from jamaibase import types as t +from jamaibase.types import ( # noqa: F401 + CITATION_PATTERN, + DEFAULT_MUL_LANGUAGES, + EXAMPLE_CHAT_MODEL_IDS, + EXAMPLE_EMBEDDING_MODEL_IDS, + EXAMPLE_RERANKING_MODEL_IDS, + AgentMetaResponse, + AudioContent, + AudioContentData, + AudioResponse, + CellCompletionResponse, + CellReferencesResponse, + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionDelta, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, + ChatContent, + ChatContentS3, + ChatEntry, + ChatRequest, + ChatRole, + ChatThreadEntry, + ChatThreadResponse, + ChatThreadsResponse, + Chunk, + CloudProvider, + CodeGenConfig, + CodeInterpreterTool, + ColumnDropRequest, + ColumnReorderRequest, + CompletionUsageDetails, + ConversationCreateRequest, + ConversationMetaResponse, + ConversationThreadsResponse, + CSVDelimiter, + DatetimeUTC, + DBStorageUsageData, + Deployment_, + DeploymentCreate, + DeploymentRead, + DeploymentUpdate, + DiscriminatedGenConfig, + EgressUsageData, + EmbeddingModelPrice, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingResponseData, + EmbeddingUsage, + EmbedGenConfig, + EmbedUsageData, + EmptyIfNoneStr, + FilePath, + FileStorageUsageData, + FileUploadResponse, + Function, + FunctionCall, + FunctionParameters, + GenConfigUpdateRequest, + GetURLRequest, + GetURLResponse, + Host, + ImageContent, + ImageContentData, + JSONInput, + JSONInputBin, + JSONOutput, + JSONOutputBin, + LanguageCodeList, + LLMGenConfig, + LLMModelPrice, + LlmUsageData, + LogProbs, + LogProbToken, + LogQueryResponse, + MessageAddRequest, + MessagesRegenRequest, + MessageUpdateRequest, + Metric, + ModelCapability, + ModelConfig_, + ModelConfigCreate, + ModelConfigRead, + ModelConfigUpdate, + ModelInfo, + ModelInfoListResponse, + ModelInfoRead, + ModelPrice, + ModelProvider, + ModelType, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + NullableStr, + OkResponse, + OnPremProvider, + OrganizationCreate, + OrganizationUpdate, + OrgMember_, + OrgMemberCreate, + OrgMemberRead, + OrgMemberUpdate, + Page, + PasswordChangeRequest, + PasswordLoginRequest, + PaymentState, + PositiveInt, + PositiveNonZeroInt, + PricePlan_, + PricePlanCreate, + PricePlanRead, + PricePlanUpdate, + PriceTier, + Product, + Products, + ProductType, + Progress, + ProgressStage, + ProgressState, + Project_, + ProjectCreate, + ProjectKey_, + ProjectKeyCreate, + ProjectKeyRead, + ProjectKeyUpdate, + ProjectMember_, + ProjectMemberCreate, + ProjectMemberRead, + ProjectMemberUpdate, + ProjectRead, + ProjectUpdate, + PromptUsageDetails, + PythonGenConfig, + RAGParams, + RankedRole, + References, + RerankingApiVersion, + RerankingBilledUnits, + RerankingData, + RerankingMeta, + RerankingMetaUsage, + RerankingModelPrice, + RerankingRequest, + RerankingResponse, + RerankingUsage, + RerankUsageData, + Role, + RowCompletionResponse, + S3Content, + SanitisedMultilineStr, + SanitisedNonEmptyStr, + SanitisedStr, + SearchRequest, + SplitChunksParams, + SplitChunksRequest, + StripeEventData, + StripePaymentInfo, + TableDataImportRequest, + TableImportProgress, + TableImportRequest, + TableMeta, + TableMetaResponse, + TableType, + TextContent, + ToolCall, + ToolChoice, + ToolChoiceFunction, + ToolUsageDetails, + Usage, + UsageData, + UsageResponse, + User_, + UserAgent, + UserAuth, + UserRead, + UserReadObscured, + VerificationCode_, + VerificationCodeCreate, + VerificationCodeRead, + VerificationCodeUpdate, + WebSearchTool, + YAMLInput, + YAMLOutput, + empty_string_to_none, + none_to_empty_string, +) +from jamaibase.utils import uuid7_str +from owl.types.db import ( # noqa: F401 + Organization_, + OrganizationRead, + OrganizationReadDecrypt, + ProjectKeyReadDecrypt, + UserCreate, + UserUpdate, +) +from owl.version import __version__ + + +class StripeEventType(StrEnum): + INVOICE_PAID = "invoice.paid" + INVOICE_PAYMENT_FAILED = "invoice.payment_failed" + INVOICE_MARKED_UNCOLLECTIBLE = "invoice.marked_uncollectible" + INVOICE_VOIDED = "invoice.voided" + # PAYMENT_INTENT_PROCESSING = "payment_intent.processing" + # PAYMENT_INTENT_SUCCEEDED = "payment_intent.succeeded" + # CUSTOMER_SUBSCRIPTION_DELETED = "customer.subscription.deleted" + CHARGE_SUCCEEDED = "charge.succeeded" + CHARGE_REFUNDED = "charge.refunded" + + +TABLE_NAME_PATTERN = r"^[A-Za-z0-9]([A-Za-z0-9.?!@#$%^&*_()\- ]*[A-Za-z0-9.?!()\-])?$" +COLUMN_NAME_PATTERN = TABLE_NAME_PATTERN +GEN_CONFIG_VAR_PATTERN = r"(? str: + return value.replace("\0", "") + + +PostgresSafeStr = Annotated[ + str, + AfterValidator(_str_post_validator), +] + +_MAP_TO_POSTGRES_TYPE = { + "int": "INTEGER", + "int8": "INTEGER", + "float": "FLOAT", + "float32": "FLOAT", + "float16": "FLOAT", + "bool": "BOOL", + "str": "TEXT", + "image": "TEXT", + "audio": "TEXT", + "document": "TEXT", + "date-time": "TIMESTAMPTZ", + "json": "JSONB", +} +_MAP_TO_PYTHON_TYPE = { + "int": int, + "int8": int, + "float": float, + "float32": float, + "float16": float, + "bool": bool, + "str": PostgresSafeStr, + "image": PostgresSafeStr, + "audio": PostgresSafeStr, + "document": PostgresSafeStr, + "date-time": datetime, + "json": dict, +} +_MAP_TO_PANDAS_TYPE = { + "int": pd.Int64Dtype(), + "int8": pd.Int8Dtype(), + "float": pd.Float64Dtype(), + "float32": pd.Float32Dtype(), + "float16": pd.Float32Dtype(), + "bool": pd.BooleanDtype(), + "str": pd.StringDtype(), + "image": pd.StringDtype(), + "audio": pd.StringDtype(), + "document": pd.StringDtype(), + "date-time": pd.StringDtype(), # Convert to ISO format first + "json": pd.StringDtype(), # In general, we should not export JSON +} +_MAP_TO_PYARROW_TYPE = { + "int": pa.int64(), + "int8": pa.int8(), + "float": pa.float64(), + "float32": pa.float32(), + "float16": pa.float16(), + "bool": pa.bool_(), + "str": pa.utf8(), + "image": pa.utf8(), # Store URI + "audio": pa.utf8(), # Store URI + "document": pa.utf8(), # Store URI + "date-time": pa.timestamp("us", "UTC"), + "json": pa.utf8(), +} + + +class DBStorageUsage(BaseModel): + schema_name: str + table_names: list[str] + table_sizes: list[float] + + @property + def total_size(self) -> float: + return sum(self.table_sizes) + + +class ColumnDtype(StrEnum): + INT = "int" + FLOAT = "float" + BOOL = "bool" + STR = "str" + IMAGE = "image" + AUDIO = "audio" + DOCUMENT = "document" + # Internal types + # INT8 = "int8" + # FLOAT32 = "float32" + # FLOAT16 = "float16" + DATE_TIME = "date-time" + JSON = "json" + + def to_postgres_type(self) -> str: + """ + Returns the corresponding PostgreSQL type definition. + """ + return _MAP_TO_POSTGRES_TYPE[self] + + def to_python_type(self) -> Type[int | float | bool | str | datetime | dict]: + """ + Returns the corresponding Python type. + """ + return _MAP_TO_PYTHON_TYPE[self] + + def to_pandas_type( + self, + ) -> ( + pd.Int64Dtype + | pd.Int8Dtype + | pd.Float64Dtype + | pd.Float32Dtype + | pd.BooleanDtype + | pd.StringDtype + ): + """ + Returns the corresponding Python type. + """ + return _MAP_TO_PANDAS_TYPE[self] + + def to_pyarrow_type(self) -> pa.DataType: + """ + Returns the corresponding Python type. + """ + return _MAP_TO_PYARROW_TYPE[self] + + +class ColumnDtypeCreate(StrEnum): + INT = "int" + FLOAT = "float" + BOOL = "bool" + STR = "str" + IMAGE = "image" + AUDIO = "audio" + DOCUMENT = "document" + + def to_column_type(self) -> ColumnDtype: + """ + Returns the corresponding ColumnDtype. + """ + return ColumnDtype(self) + + +class ColumnSchema(t.ColumnSchema): + dtype: ColumnDtype = Field( + ColumnDtype.STR, + description=f"Column data type, one of {list(map(str, ColumnDtype))}.", + ) + + +class ColumnSchemaCreate(t.ColumnSchemaCreate): + id: ColName = Field( + description="Column name.", + ) + dtype: ColumnDtypeCreate = Field( + ColumnDtypeCreate.STR, + description=f"Column data type, one of {list(map(str, ColumnDtypeCreate))}.", + ) + + @model_validator(mode="before") + @classmethod + def map_file_dtype_to_image(cls, data: dict[str, Any]) -> dict[str, Any]: + if data.get("dtype", "") == "file": + data["dtype"] = ColumnDtype.IMAGE + return data + + +class TableSchemaCreate(t.TableSchemaCreate): + id: TableName = Field( + description="Table name.", + ) + version: str = Field( + __version__, + description="Table version, following jamaibase version.", + ) + cols: list[ColumnSchemaCreate] = Field( + description="List of column schema.", + ) + + +class ActionTableSchemaCreate(TableSchemaCreate): + pass + + +class AddActionColumnSchema(ActionTableSchemaCreate): + pass + + +class KnowledgeTableSchemaCreate(TableSchemaCreate): + embedding_model: str + + +class AddKnowledgeColumnSchema(TableSchemaCreate): + pass + + +class ChatTableSchemaCreate(TableSchemaCreate): + pass + + +class AddChatColumnSchema(TableSchemaCreate): + pass + + +class ColumnRenameRequest(t.ColumnRenameRequest): + column_map: dict[str, ColName] = Field( + description="Mapping of old column names to new column names.", + ) + + +IMAGE_FILE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".gif", ".webp"] +AUDIO_FILE_EXTENSIONS = [".mp3", ".wav"] +DOCUMENT_FILE_EXTENSIONS = [ + ".csv", + ".docx", + ".html", + ".json", + ".jsonl", + ".md", + ".pdf", + ".pptx", + ".tsv", + ".txt", + ".xlsx", + ".xml", +] +ALLOWED_FILE_EXTENSIONS = set( + IMAGE_FILE_EXTENSIONS + AUDIO_FILE_EXTENSIONS + DOCUMENT_FILE_EXTENSIONS +) + + +def check_data(value: Any) -> Any: + if isinstance(value, str) and (value.startswith("s3://") or value.startswith("file://")): + extension = splitext(value)[1].lower() + if extension not in ALLOWED_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} \n" + f"[Audio File Types]: \n{AUDIO_FILE_EXTENSIONS} \n" + f"[Document File Types]: \n{DOCUMENT_FILE_EXTENSIONS}" + ) + return value + + +CellValue = Annotated[Any, AfterValidator(check_data)] + + +class RowAdd(BaseModel): + table_id: str = Field( + description="Table name or ID.", + ) + data: dict[str, CellValue] = Field( + description="Mapping of column names to its value.", + ) + stream: bool = Field( + default=True, + description="Whether or not to stream the LLM generation.", + ) + concurrent: bool = Field( + default=True, + description="_Optional_. Whether or not to concurrently generate the output columns.", + ) + + +class MultiRowAddRequest(t.MultiRowAddRequest): + data: list[dict[str, CellValue]] = Field( + min_length=1, + description=( + "List of mapping of column names to its value. " + "In other words, each item in the list is a row, and each item is a mapping. " + "Minimum 1 row, maximum 100 rows." + ), + ) + + +class MultiRowAddRequestWithLimit(MultiRowAddRequest): + data: list[dict[str, CellValue]] = Field( + min_length=1, + max_length=100, + description=( + "List of mapping of column names to its value. " + "In other words, each item in the list is a row, and each item is a mapping. " + "Minimum 1 row, maximum 100 rows." + ), + ) + + +class MultiRowUpdateRequest(t.MultiRowUpdateRequest): + data: dict[str, dict[str, CellValue]] = Field( + min_length=1, + description="Mapping of row IDs to row data, where each row data is a mapping of column names to its value.", + ) + + +class MultiRowUpdateRequestWithLimit(MultiRowUpdateRequest): + data: dict[str, dict[str, CellValue]] = Field( + min_length=1, + max_length=100, + description="Mapping of row IDs to row data, where each row data is a mapping of column names to its value.", + ) + + +class RowUpdateRequest(t.RowUpdateRequest): + data: dict[str, CellValue] = Field( + description="Mapping of column names to its value.", + ) + + +class RegenStrategy(StrEnum): + """Strategies for selecting columns during row regeneration.""" + + RUN_ALL = "run_all" + RUN_BEFORE = "run_before" + RUN_SELECTED = "run_selected" + RUN_AFTER = "run_after" + + +class RowRegen(t.RowRegen): + 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; " + ), + ) + + +class MultiRowRegenRequest(t.MultiRowRegenRequest): + 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; " + ), + ) + + @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 FileEmbedQuery(BaseModel): + table_id: SanitisedNonEmptyStr = Field( + description="Table name or ID.", + ) + file_id: SanitisedNonEmptyStr = Field( + description="ID of the file.", + ) + chunk_size: int = Field( + 1000, + gt=0, + description="Maximum chunk size (number of characters). Must be > 0.", + ) + chunk_overlap: int = Field( + 200, + ge=0, + description="Overlap in characters between chunks. Must be >= 0.", + ) + # stream: Annotated[bool, Field(description="Whether or not to stream the LLM generation.")] = ( + # True + # ) + + +ORDERED_BY = TypeVar("ORDERED_BY", bound=Literal["id", "name", "created_at", "updated_at"]) + + +class ListQuery(BaseModel, Generic[ORDERED_BY]): + offset: Annotated[int, Field(ge=0, description="Items offset.")] = 0 + limit: Annotated[int, Field(gt=0, le=1000, description="Number of items.")] = 1000 + order_by: Annotated[ORDERED_BY, Field(description="Sort by this attribute.")] = "updated_at" + order_ascending: Annotated[bool, Field(description="Whether to sort in ascending order.")] = True # fmt: skip + search_query: Annotated[ + str, + Field( + max_length=10_000, + description=( + "A string to search for as a filter. " + 'The string is interpreted as both POSIX regular expression and literal string. Defaults to "" (no filter). ' + "It will be combined other filters using `AND`." + ), + ), + ] = "" + search_columns: Annotated[ + list[str], + Field( + min_length=1, + description='A list of attribute names to search for `search_query`. Defaults to `["name"]`.', + ), + ] = ["name"] + after: Annotated[ + str | None, + Field( + description=( + "Opaque cursor token to paginate results. " + "If provided, the query will return items after this cursor and `offset` will be ignored. " + "Defaults to `None` (no cursor)." + ), + ), + ] = None + + +class ListQueryByOrg(ListQuery): + organization_id: Annotated[SanitisedNonEmptyStr, Field(description="Organization ID.")] + + +class ListQueryByOrgOptional(ListQuery): + organization_id: Annotated[ + SanitisedNonEmptyStr | None, Field(None, description="Organization ID.") + ] + + +class ListQueryByProject(ListQuery): + project_id: Annotated[SanitisedNonEmptyStr, Field(description="Project ID.")] + + +class OrgModelCatalogueQuery(ListQueryByOrg): + capabilities: list[ModelCapability] | None = Field( + None, + min_length=1, + description="List of capabilities of model.", + ) + + +class DuplicateTableQuery(BaseModel): + table_id_src: Annotated[str, Field(description="Name of the table to be duplicated.")] + table_id_dst: Annotated[ + TableName | None, + Field( + description=( + "Name for the new table. " + "Defaults to None (automatically find the next available table name)." + ) + ), + ] = None + include_data: Annotated[ + bool, + Field(description=("Whether to include data from the source table. Defaults to `True`.")), + ] = True + create_as_child: Annotated[ + bool, + Field( + description=( + "Whether the new table is a child table. Defaults to `False`. " + "If this is `True`, then `include_data` will be set to `True`." + ) + ), + ] = False + + +class RenameTableQuery(BaseModel): + table_id_src: Annotated[str, Field(description="Source table name.")] + table_id_dst: Annotated[TableName, Field(description="Name for the new table.")] + + +class GetTableThreadQuery(BaseModel): + table_id: Annotated[str, Field(description="Table name.")] + column_id: Annotated[str, Field(description="Column to fetch as a conversation thread.")] + row_id: Annotated[ + str, + Field(description='ID of the last row in the thread. Defaults to "" (export all rows).'), + ] = "" + include: Annotated[ + bool, + Field(description="Whether to include the row specified by `row_id`. Defaults to True."), + ] = True + + +class GetTableThreadsQuery(BaseModel): + table_id: Annotated[str, Field(description="Table name.")] + column_ids: Annotated[ + list[str] | None, + Field( + description="Columns to fetch as conversation threads. Defaults to None (fetch all)." + ), + ] = None + row_id: Annotated[ + str, + Field(description='ID of the last row in the thread. Defaults to "" (export all rows).'), + ] = "" + include_row: Annotated[ + bool, + Field(description="Whether to include the row specified by `row_id`. Defaults to True."), + ] = True + + +class GetConversationThreadsQuery(BaseModel): + conversation_id: Annotated[str, Field(description="Conversation ID.")] + column_ids: Annotated[ + list[str] | None, + Field( + description="Columns to fetch as conversation threads. Defaults to None (fetch all)." + ), + ] = None + + +class ListTableQuery(BaseModel): + offset: Annotated[ + int, + Field(ge=0, description="Item offset for pagination. Defaults to 0."), + ] = 0 + limit: Annotated[ + int, + Field( + gt=0, + le=100, + description="Number of tables to return (min 1, max 100). Defaults to 100.", + ), + ] = 100 + order_by: Annotated[ + Literal["id", "table_id", "updated_at"], + Field(description='Sort tables by this attribute. Defaults to "updated_at".'), + ] = "updated_at" + order_ascending: Annotated[ + bool, + Field(description="Whether to sort by ascending order. Defaults to True."), + ] = True + parent_id: Annotated[ + str | None, + Field( + min_length=1, + description=( + "Parent ID of tables to return. Defaults to None (return all tables). " + "Additionally for Chat Table, you can list: " + '(1) all chat agents by passing in "_agent_"; or ' + '(2) all chats by passing in "_chat_".' + ), + ), + ] = None + search_query: Annotated[ + str, + Field( + max_length=255, + description='A string to search for within table IDs as a filter. Defaults to "" (no filter).', + ), + ] = "" + count_rows: Annotated[ + bool, + Field(description="Whether to count the rows of the tables. Defaults to False."), + ] = False + + +class ListRowQuery(BaseModel): + offset: Annotated[ + int, + Field(ge=0, description="Item offset for pagination. Defaults to 0."), + ] = 0 + limit: Annotated[ + int, + Field( + gt=0, + le=100, + description="Number of rows to return (min 1, max 100). Defaults to 100.", + ), + ] = 100 + order_by: Annotated[ + str, + Field(description='Sort rows by this column. Defaults to "ID".'), + ] = "ID" + order_ascending: Annotated[ + bool, + Field(description="Whether to sort by ascending order. Defaults to True."), + ] = True + columns: Annotated[ + list[str] | None, + Field( + description="A list of column names to include in the response. Default is to return all columns.", + ), + ] = None + where: Annotated[ + EmptyIfNoneStr, + Field( + description=( + "SQL where clause. " + "Can be nested ie `x = '1' AND (\"y (1)\" = 2 OR z = '3')`. " + "It will be combined with `row_ids` using `AND`. " + 'Defaults to "" (no filter).' + ), + ), + ] = "" + search_query: Annotated[ + str, + Field( + max_length=10_000, + description=( + "A string to search for within row data as a filter. " + 'The string is interpreted as both POSIX regular expression and literal string. Defaults to "" (no filter). ' + "It will be combined other filters using `AND`." + ), + ), + ] = "" + search_columns: Annotated[ + list[str] | None, + Field( + description="A list of column names to search for `search_query`. Defaults to None (search all columns).", + ), + ] = None + float_decimals: Annotated[ + int, + Field( + ge=0, + description="Number of decimals for float values. Defaults to 0 (no rounding).", + ), + ] = 0 + vec_decimals: Annotated[ + int, + Field( + description="Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", + ), + ] = 0 + + +class ListTableRowQuery(ListRowQuery): + table_id: Annotated[SanitisedNonEmptyStr, Field(description="Table ID or name.")] + + +class ListMessageQuery(ListRowQuery): + conversation_id: Annotated[ + SanitisedNonEmptyStr, Field(description="Conversation ID (Table ID) to fetch.") + ] + + +class GetTableRowQuery(BaseModel): + table_id: Annotated[SanitisedNonEmptyStr, Field(description="Table name.")] + row_id: Annotated[ + SanitisedNonEmptyStr, Field(description="The ID of the specific row to fetch.") + ] + columns: Annotated[ + list[SanitisedNonEmptyStr] | None, + Field( + description="A list of column names to include in the response. Default is to return all columns.", + ), + ] = None + float_decimals: Annotated[ + int, + Field( + ge=0, + description="Number of decimals for float values. Defaults to 0 (no rounding).", + ), + ] = 0 + vec_decimals: Annotated[ + int, + Field( + description="Number of decimals for vectors. If its negative, exclude vector columns. Defaults to 0 (no rounding).", + ), + ] = 0 + + +class FileEmbedFormData(BaseModel): + file: Annotated[UploadFile, File(description="The file.")] + file_name: Annotated[str, Field(description="File name.", deprecated=True)] = "" + table_id: Annotated[SanitisedNonEmptyStr, Field(description="Knowledge Table ID.")] + # overwrite: Annotated[ + # bool, Field(description="Whether to overwrite old file with the same name.") + # ] = False, + chunk_size: Annotated[ + int, Field(gt=0, description="Maximum chunk size (number of characters). Must be > 0.") + ] = 2000 + chunk_overlap: Annotated[ + int, Field(ge=0, description="Overlap in characters between chunks. Must be >= 0.") + ] = 200 + + +class TableDataImportFormData(BaseModel): + file: Annotated[UploadFile, File(description="The CSV or TSV file.")] + file_name: Annotated[str, Field(description="File name.", deprecated=True)] = "" + table_id: Annotated[ + SanitisedNonEmptyStr, + 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 + ) + # List of inputs is bugged as of 2024-07-14: https://github.com/tiangolo/fastapi/pull/9928/files + # TODO: Maybe we can re-enable these since the bug is for direct `Form` declaration and not Form Model + # column_names: Annotated[ + # list[ColName] | None, + # Field( + # 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[ColName] | None, + # Field( + # description="_Optional_. A list of columns to be imported. Defaults to None (import all columns except 'ID' and 'Updated at').", + # ), + # ] = None + delimiter: Annotated[ + CSVDelimiter, + Field(description='The delimiter, can be "," or "\\t". Defaults to ",".'), + ] = CSVDelimiter.COMMA + + +class ExportTableDataQuery(BaseModel): + table_id: Annotated[SanitisedNonEmptyStr, Field(description="Table name.")] + delimiter: Annotated[ + CSVDelimiter, + Field(description='The delimiter, can be "," or "\\t". Defaults to ",".'), + ] = CSVDelimiter.COMMA + columns: Annotated[ + list[SanitisedNonEmptyStr] | None, + Field( + min_length=1, + description="_Optional_. A list of columns to be exported. Defaults to None (export all columns).", + ), + ] = None + + +TableImportName = Annotated[ + str, + Field( + pattern=TABLE_NAME_PATTERN, + min_length=1, + max_length=100, # Since we will truncate table IDs that are too long anyway + description=( + "Table name or ID. " + "Must be unique with at least 1 character and up to 46 characters. " + "Must start with an alphabet or number. " + "Characters in the middle can include space and these symbols: `.?!@#$%^&*_()-`. " + "Must end with an alphabet or number or these symbols: `.?!()-`." + ), + ), +] + + +class TableImportFormData(BaseModel): + file: Annotated[UploadFile, File(description="The Parquet file.")] + table_id_dst: Annotated[ + TableImportName | None, + BeforeValidator(empty_string_to_none), + Field(description="The ID or name of the new table."), + ] = None + blocking: Annotated[ + bool, + Field( + description=( + "If True, waits until import finishes. " + "If False, the task is submitted to a task queue and returns immediately." + ), + ), + ] = True + progress_key: Annotated[ + str, + Field( + default_factory=uuid7_str, + description="The key to use to query progress. Defaults to a random string.", + ), + ] + migrate: Annotated[ + bool, + Field(description="Whether to import in migration mode (maybe removed without notice)."), + ] = False diff --git a/services/api/src/owl/types/db.py b/services/api/src/owl/types/db.py new file mode 100644 index 0000000..69f9b07 --- /dev/null +++ b/services/api/src/owl/types/db.py @@ -0,0 +1,69 @@ +from typing import Annotated + +from pwdlib import PasswordHash +from pydantic import BaseModel, BeforeValidator, Field +from sqlmodel import Field as SqlField + +from jamaibase import types as t +from jamaibase.types import PricePlan_, SanitisedNonEmptyStr +from owl.utils.crypt import decrypt + + +def _decrypt(value: str) -> str: + from owl.configs import ENV_CONFIG + + return decrypt(value, ENV_CONFIG.encryption_key_plain) + + +def _decrypt_external_keys(value: dict[str, str] | BaseModel) -> dict[str, str]: + if isinstance(value, BaseModel): + value = value.model_dump(exclude_unset=True) + return {k: _decrypt(v) for k, v in value.items()} + + +class UserUpdate(t.UserUpdate): + password: SanitisedNonEmptyStr = Field( + "", + max_length=72, + description="Password in plain text.", + ) + + @property + def password_hash(self) -> str | None: + if self.password: + hasher = PasswordHash.recommended() + return hasher.hash(self.password) + return None + + +class UserCreate(t.UserCreate): + @property + def password_hash(self) -> str | None: + if self.password: + hasher = PasswordHash.recommended() + return hasher.hash(self.password) + return None + + +class Organization_(t.Organization_): + def get_external_key(self, provider: str) -> str: + api_key = self.external_keys.get(provider.lower(), "").strip() + return _decrypt(api_key) if api_key else "" + + +class OrganizationRead(Organization_): + price_plan: PricePlan_ | None = Field( + description="Subscribed plan.", + ) + + +class OrganizationReadDecrypt(OrganizationRead): + external_keys: Annotated[dict[str, str], BeforeValidator(_decrypt_external_keys)] = SqlField( + description="Mapping of external service provider to its API key.", + ) + + +class ProjectKeyReadDecrypt(t.ProjectKeyRead): + id: Annotated[str, BeforeValidator(_decrypt)] = Field( + description="The token after decryption.", + ) diff --git a/services/api/src/owl/unstructuredio.py b/services/api/src/owl/unstructuredio.py deleted file mode 100644 index bc9951e..0000000 --- a/services/api/src/owl/unstructuredio.py +++ /dev/null @@ -1,206 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Callable, Iterator - -from langchain_community.document_loaders.base import BaseLoader -from langchain_core.documents import Document -from loguru import logger -from unstructured_client import UnstructuredClient -from unstructured_client.models import shared -from unstructured_client.models.errors import SDKError - - -class UnstructuredBaseLoader(BaseLoader, ABC): - """Base Loader that uses `Unstructured`.""" - - def __init__( - self, - mode: str = "single", - post_processors: list[Callable] | None = None, - **unstructured_kwargs: Any, - ): - """Initialize with file path.""" - # try: - # import unstructured # noqa:F401 - # except ImportError: - # raise ValueError( - # "unstructured package not found, please install it with " - # "`pip install unstructured`" - # ) - _valid_modes = {"single", "elements", "paged"} - if mode not in _valid_modes: - raise ValueError(f"Got {mode} for `mode`, but should be one of `{_valid_modes}`") - self.mode = mode - - # if not satisfies_min_unstructured_version("0.5.4"): - # if "strategy" in unstructured_kwargs: - # unstructured_kwargs.pop("strategy") - - self.unstructured_kwargs = unstructured_kwargs - self.post_processors = post_processors or [] - - @abstractmethod - def _get_elements(self) -> list: - """Get elements.""" - - @abstractmethod - def _get_metadata(self) -> dict: - """Get metadata.""" - - def _post_process_elements(self, elements: list) -> list: - """Applies post processing functions to extracted unstructured elements. - Post processing functions are str -> str callables are passed - in using the post_processors kwarg when the loader is instantiated.""" - for element in elements: - for post_processor in self.post_processors: - element.apply(post_processor) - return elements - - def lazy_load(self) -> Iterator[Document]: - """Load file.""" - elements = self._get_elements() - self._post_process_elements(elements) - if self.mode == "elements": - for element in elements: - metadata = element["metadata"] - metadata["page"] = metadata.get("page_number", 1) - # NOTE(MthwRobinson) - the attribute check is for backward compatibility - # with unstructured<0.4.9. The metadata attributed was added in 0.4.9. - if hasattr(element, "metadata"): - metadata.update(element["metadata"]) - if hasattr(element, "type"): - metadata["type"] = element["NarrativeText"] - yield Document(page_content=str(element["text"]), metadata=metadata) - elif self.mode == "paged": - text_dict: dict[int, str] = {} - meta_dict: dict[int, dict] = {} - - for element in elements: - metadata = element["metadata"] - if hasattr(element, "metadata"): - metadata.update(element["metadata"]) - page_number = metadata.get("page_number", 1) - metadata["page"] = page_number - - # Check if this page_number already exists in docs_dict - if page_number not in text_dict: - # If not, create new entry with initial text and metadata - text_dict[page_number] = element["text"] + "\n\n" - meta_dict[page_number] = metadata - else: - # If exists, append to text and update the metadata - text_dict[page_number] += element["text"] + "\n\n" - meta_dict[page_number].update(metadata) - - # Convert the dict to a list of Document objects - for key in text_dict.keys(): - yield Document(page_content=text_dict[key], metadata=meta_dict[key]) - elif self.mode == "single": - metadata = self._get_metadata() - text = "\n\n".join([el["text"] for el in elements]) - yield Document(page_content=text, metadata=metadata) - else: - raise ValueError(f"mode of {self.mode} not supported.") - - -def partition( - filename: str, - unstructuredio_client, - **unstructured_kwargs: Any, -): - languages = unstructured_kwargs.pop("languages", ["en", "cn"]) - - with open(filename, "rb") as f: - # Note that this currently only supports a single file - files = shared.Files( - content=f.read(), - file_name=filename, - ) - - req = shared.PartitionParameters( - files=files, - # Other partition params - languages=languages, - **unstructured_kwargs, - ) - - try: - resp = unstructuredio_client.general.partition(req) - return resp.elements - except SDKError as e: - logger.error(f"UnstructuredIO SDK Error: {str(e)}") - return [] - - -class UnstructuredAPIFileLoader(UnstructuredBaseLoader): - """Load files using `Unstructured`. - - Example: - - UnstructuredAPIFileLoader( - "helloworld.txt", - mode="single", - url="http://unstructuredio:6989/general/v0/general", - api_key="ellm", - languages=["en", "cn"] - ) - - """ - - def __init__( - self, - file_path: str | list[str], - mode: str = "single", - url="https://api.unstructured.io/general/v0/general", - api_key: str = "ellm", - **unstructured_kwargs: Any, - ): - """Initialize with file path.""" - self.file_path = file_path - self.url = url - self.api_key = api_key - super().__init__(mode=mode, **unstructured_kwargs) - - def _get_elements(self) -> list: - s = UnstructuredClient(server_url=self.url, api_key_auth=self.api_key) - - if isinstance(self.file_path, list): - elements = [] - for file in self.file_path: - elements.extend( - partition(filename=file, unstructuredio_client=s, **self.unstructured_kwargs) - ) - return elements - else: - return partition( - filename=self.file_path, unstructuredio_client=s, **self.unstructured_kwargs - ) - - def _get_metadata(self) -> dict: - return {"source": self.file_path} - - -if __name__ == "__main__": - filename = "clients/python/tests/files/docx/Recommendation Letter.docx" - doc_loader = UnstructuredAPIFileLoader( - filename, - mode="single", - url="http://localhost:6989/general/v0/general", - api_key="ellm", - languages=["en", "cn"], - ).load() - - doc_loader = UnstructuredAPIFileLoader( - filename, - mode="paged", - url="http://localhost:6989/general/v0/general", - api_key="ellm", - languages=["en", "cn"], - ).load() - - doc_loader = UnstructuredAPIFileLoader( - filename, - mode="elements", - url="http://localhost:6989/general/v0/general", - api_key="ellm", - languages=["en", "cn"], - ).load() diff --git a/services/api/src/owl/utils/__init__.py b/services/api/src/owl/utils/__init__.py index 7bde827..7caf0e8 100644 --- a/services/api/src/owl/utils/__init__.py +++ b/services/api/src/owl/utils/__init__.py @@ -1,77 +1,77 @@ -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" +import sqlparse +from loguru import logger +from sqlparse.sql import Comparison, Function, Identifier, Parenthesis, Where + +from jamaibase.utils import ( # noqa: F401 + get_non_empty, + get_ttl_hash, + mask_content, + mask_dict, + mask_string, + merge_dict, + run, + uuid7_draft2_str, + uuid7_str, +) + + +def validate_where_expr(expr: str, *, id_map: dict[str, str] = None) -> str: + sql = sqlparse.split(expr)[0] + sql = sql.replace("\r", " ").replace("\n", " ").replace("\t", " ").strip().rstrip(";") + if "shutdown" in sql.lower(): + raise ValueError("SQL expression contains shutdown.") + if not sql: + raise ValueError("SQL expression is empty.") + tokens = sqlparse.parse(sql)[0].tokens + if any(isinstance(t, Function) for t in tokens) > 0: + raise ValueError(f"SQL expression contains function: `{expr}`") + # Further breakdown Where + if isinstance(tokens[0], Where): + tokens = tokens[0].tokens[1:] + token_types = [] + + def _breakdown(_tokens): + for t in _tokens: + if t.ttype is None: + _breakdown(t) + else: + token_types.append((str(t), list(t.ttype))) + + _breakdown(tokens) + # logger.info(f"`{''.join(str(t) for t in tokens)}` {token_types=} {[type(t) for t in tokens]}") + dml_tokens = [t for t in token_types if t[1][-1] == "DML"] + ddl_tokens = [t for t in token_types if t[1][-1] == "DDL"] + keyword_tokens = [ + t + for t in token_types + if t[1][0] == "Keyword" and t[0].lower() not in ["and", "or", "null", "true", "false"] + ] + comment_tokens = [t for t in token_types if t[1][0] == "Comment"] + if len(dml_tokens) > 0: + raise ValueError(f"SQL expression contains DML: `{expr}`") + if len(ddl_tokens) > 0: + raise ValueError(f"SQL expression contains DDL: `{expr}`") + if len(keyword_tokens) > 0: + raise ValueError(f"SQL expression contains keyword: `{expr}`") + if len(comment_tokens) > 0 or "/*" in sql or "*/" in sql: + raise ValueError(f"SQL expression contains comment: `{expr}`") + if id_map: + mapped_tokens = [] + + def _map(_tokens): + for t in _tokens: + if isinstance(t, (Parenthesis, Comparison)): + _map(t) + elif isinstance(t, Identifier): + t = str(t).strip('"') + t = id_map.get(t, t).strip('"') + mapped_tokens.append(f'"{id_map.get(t, t)}"') + else: + mapped_tokens.append(t) + + _map(tokens) else: - 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: - if x is None: - return 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 + mapped_tokens = tokens + new_sql = "".join(str(t) for t in mapped_tokens).strip().rstrip(";") + logger.info(f"Validated SQL: `{expr}` -> `{new_sql}`") + return new_sql diff --git a/services/api/src/owl/utils/auth.py b/services/api/src/owl/utils/auth.py deleted file mode 100644 index 06f8e9e..0000000 --- a/services/api/src/owl/utils/auth.py +++ /dev/null @@ -1,471 +0,0 @@ -from functools import lru_cache -from secrets import compare_digest -from typing import Annotated, AsyncGenerator - -from fastapi import BackgroundTasks, Header, Request, Response -from httpx import RequestError -from loguru import logger -from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential - -from jamaibase import JamAIAsync -from jamaibase.exceptions import ( - AuthorizationError, - ForbiddenError, - ResourceNotFoundError, - ServerBusyError, - UnexpectedError, - UpgradeTierError, -) -from jamaibase.protocol import ( - EmbeddingModelConfig, - LLMModelConfig, - ModelDeploymentConfig, - OrganizationRead, - PATRead, - ProjectRead, - RerankingModelConfig, - 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), - deepseek=get_non_empty(ext_keys, "deepseek", ENV_CONFIG.deepseek_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 - - -def _get_valid_deployments( - model: LLMModelConfig | EmbeddingModelConfig | RerankingModelConfig, - valid_providers: list[str], -) -> list[ModelDeploymentConfig]: - valid_deployments = [] - for deployment in model.deployments: - if deployment.provider in valid_providers: - valid_deployments.append(deployment) - return valid_deployments - - -@lru_cache(maxsize=64) -def _get_valid_modellistconfig(all_models: str, external_keys: str) -> ModelListConfig: - all_models = ModelListConfig.model_validate_json(all_models) - external_keys = ExternalKeys.model_validate_json(external_keys) - # define all possible api providers - available_providers = [ - "openai", - "anthropic", - "together_ai", - "cohere", - "sambanova", - "cerebras", - "hyperbolic", - "deepseek", - ] - # remove providers without credentials - available_providers = [ - provider for provider in available_providers if getattr(external_keys, provider) != "" - ] - # add custom and ellm providers as allow no credentials - available_providers.extend( - [ - "custom", - "ellm", - ] - ) - - # Initialize lists to hold valid models - valid_llm_models = [] - valid_embed_models = [] - valid_rerank_models = [] - - # Iterate over the llm, embed, rerank list - for m in all_models.llm_models: - valid_deployments = _get_valid_deployments(m, available_providers) - if len(valid_deployments) > 0: - m.deployments = valid_deployments - valid_llm_models.append(m) - - for m in all_models.embed_models: - valid_deployments = _get_valid_deployments(m, available_providers) - if len(valid_deployments) > 0: - m.deployments = valid_deployments - valid_embed_models.append(m) - - for m in all_models.rerank_models: - valid_deployments = _get_valid_deployments(m, available_providers) - if len(valid_deployments) > 0: - m.deployments = valid_deployments - valid_rerank_models.append(m) - - # Create a new ModelListConfig with the valid models - valid_model_list_config = ModelListConfig( - llm_models=valid_llm_models, - embed_models=valid_embed_models, - rerank_models=valid_rerank_models, - ) - - return valid_model_list_config - - -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) - all_models = request.state.org_models + CONFIG.get_model_config() - request.state.all_models = _get_valid_modellistconfig( - all_models.model_dump_json(), request.state.external_keys.model_dump_json() - ) - request.state.billing = BillingManager(request=request) - - yield project - - -async def auth_user_project_cloud( - bg_tasks: BackgroundTasks, - 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 - 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) - all_models = request.state.org_models + CONFIG.get_model_config() - request.state.all_models = _get_valid_modellistconfig( - all_models.model_dump_json(), request.state.external_keys.model_dump_json() - ) - # 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 - - # NOTE that billing processing is done in middleware where response headers are available - - # Set project updated at datetime - async def _set_project_updated_at() -> None: - if "gen_tables" in route and request.method in WRITE_METHODS: - try: - await CLIENT.admin.organization.set_project_updated_at(project_id) - except Exception as e: - logger.warning( - f'{request.state.id} - Error setting project "{project_id}" last updated time: {e}' - ) - - # This will run AFTER streaming responses are sent - bg_tasks.add_task(_set_project_updated_at) - - -auth_user_project = auth_user_project_oss if ENV_CONFIG.is_oss else auth_user_project_cloud diff --git a/services/api/src/owl/utils/auth/__init__.py b/services/api/src/owl/utils/auth/__init__.py new file mode 100644 index 0000000..2faa534 --- /dev/null +++ b/services/api/src/owl/utils/auth/__init__.py @@ -0,0 +1,18 @@ +from owl.configs import ENV_CONFIG + +if ENV_CONFIG.is_oss: + from owl.utils.auth.oss import ( # noqa: F401 + auth_service_key, + auth_user, + auth_user_project, + auth_user_service_key, + has_permissions, + ) +else: + from owl.utils.auth.cloud import ( # noqa: F401 + auth_service_key, + auth_user, + auth_user_project, + auth_user_service_key, + has_permissions, + ) diff --git a/services/api/src/owl/utils/auth/oss.py b/services/api/src/owl/utils/auth/oss.py new file mode 100644 index 0000000..3043423 --- /dev/null +++ b/services/api/src/owl/utils/auth/oss.py @@ -0,0 +1,156 @@ +from secrets import compare_digest +from time import perf_counter +from typing import Annotated, AsyncGenerator + +from fastapi import BackgroundTasks, Depends, Header, Request +from loguru import logger + +from owl.configs import ENV_CONFIG +from owl.db import async_session +from owl.db.models.oss import ModelConfig, Project, User +from owl.types import ( + ModelConfigRead, + OrganizationRead, + ProjectRead, + UserAuth, +) +from owl.utils.billing import BillingManager +from owl.utils.dates import now +from owl.utils.exceptions import AuthorizationError, ResourceNotFoundError + +WRITE_METHODS = {"PUT", "PATCH", "POST", "DELETE", "PURGE"} +NO_USER_ID_MESSAGE = ( + 'You didn\'t provide a user ID. You need to provide the user ID in an "X-USER-ID" header.' +) +NO_PROJECT_ID_MESSAGE = ( + "You didn't provide a project ID. " + 'You need to provide the project ID in an "X-PROJECT-ID" header.' +) +NO_TOKEN_MESSAGE = ( + "You didn't provide an authorization token. " + 'You need to provide your PAT in an "Authorization" header using Bearer auth (i.e. "Authorization: Bearer TOKEN").' +) +INVALID_TOKEN_MESSAGE = "You provided an invalid authorization token." + + +def is_service_key(token: str) -> bool: + return compare_digest(token, ENV_CONFIG.service_key_plain) or compare_digest( + token, ENV_CONFIG.service_key_alt_plain + ) + + +async def auth_service_key( + bearer_token: Annotated[ + str, Header(alias="Authorization", description="Not needed for OSS.") + ] = "", +) -> str: + return bearer_token + + +async def _bearer_auth( + user_id: Annotated[str, Header(alias="X-USER-ID", description="User ID.")] = "", +) -> tuple[UserAuth, None]: + if user_id == "": + user_id = "0" + async with async_session() as session: + user = await session.get(User, user_id) + if user is None: + raise AuthorizationError(f'User "{user_id}" is not found.') + user = UserAuth.model_validate(user) + return user, None + + +async def auth_user_service_key( + request: Request, + user_project: Annotated[tuple[UserAuth, None], Depends(_bearer_auth)], +) -> AsyncGenerator[UserAuth, None]: + t0 = perf_counter() + user = user_project[0] + t1 = perf_counter() + request.state.timing["Auth"] = t1 - t0 + yield user + request.state.timing["Request"] = perf_counter() - t1 + + +auth_user = auth_user_service_key + + +async def _set_project_updated_at( + request: Request, + project_id: str, +) -> None: + if "gen_tables" in request.url.path and request.method in WRITE_METHODS: + try: + async with async_session() as session: + project = await session.get(Project, project_id) + if project is None: + raise ResourceNotFoundError(f'Project "{project_id}" is not found.') + project.updated_at = now() + session.add(project) + await session.commit() + except Exception as e: + logger.warning( + f'{request.state.id} - Error setting project "{project_id}" last updated time: {e}' + ) + + +async def auth_user_project( + request: Request, + bg_tasks: BackgroundTasks, + user_project: Annotated[tuple[UserAuth, None], Depends(_bearer_auth)], + project_id: Annotated[ + str, Header(alias="X-PROJECT-ID", description="Project ID.") + ] = "default", +) -> AsyncGenerator[tuple[UserAuth, ProjectRead, OrganizationRead], None]: + t0 = perf_counter() + user, project = user_project + ### --- Fetch project --- ### + async with async_session() as session: + proj = await session.get(Project, project_id) + if proj is None: + raise AuthorizationError(f'Project "{project_id}" is not found.') + project = ProjectRead.model_validate(proj) + organization = OrganizationRead.model_validate(proj.organization) + models = ( + await ModelConfig.list_( + session=session, + return_type=ModelConfigRead, + organization_id=organization.id, + ) + ).items + ### --- Billing --- ### + request.state.billing = BillingManager( + organization=organization, + project_id=project.id, + user_id=user.id, + request=request, + models=models, + ) + t1 = perf_counter() + request.state.timing["Auth"] = t1 - t0 + yield user, project, organization + request.state.timing["Request"] = perf_counter() - t1 + # This will run BEFORE any responses are sent + + # Background tasks will run AFTER streaming responses are sent + bg_tasks.add_task( + _set_project_updated_at, + request=request, + project_id=project_id, + ) + + +def has_permissions( + user: UserAuth, + requirements: list[str], + *, + organization_id: str | None = None, + project_id: str | None = None, + raise_error: bool = True, +) -> bool: + del user + del requirements + del organization_id + del project_id + del raise_error + return True diff --git a/services/api/src/owl/utils/billing/__init__.py b/services/api/src/owl/utils/billing/__init__.py new file mode 100644 index 0000000..e9f18f3 --- /dev/null +++ b/services/api/src/owl/utils/billing/__init__.py @@ -0,0 +1,18 @@ +from owl.configs import ENV_CONFIG + +if ENV_CONFIG.is_oss: + from owl.utils.billing.oss import ( # noqa: F401 + CLICKHOUSE_CLIENT, + OPENTELEMETRY_CLIENT, + STRIPE_CLIENT, + BillingManager, + ClickHouseAsyncClient, + ) +else: + from owl.utils.billing.cloud import ( # noqa: F401 + CLICKHOUSE_CLIENT, + OPENTELEMETRY_CLIENT, + STRIPE_CLIENT, + BillingManager, + ClickHouseAsyncClient, + ) diff --git a/services/api/src/owl/utils/billing/oss.py b/services/api/src/owl/utils/billing/oss.py new file mode 100644 index 0000000..22d4865 --- /dev/null +++ b/services/api/src/owl/utils/billing/oss.py @@ -0,0 +1,788 @@ +import asyncio +from collections import defaultdict +from time import perf_counter +from typing import Any, DefaultDict + +import clickhouse_connect +from cloudevents.conversion import to_dict +from cloudevents.http import CloudEvent +from fastapi import Request +from loguru import logger +from opentelemetry import metrics +from opentelemetry.metrics import Counter, Histogram, _Gauge +from tenacity import retry, stop_after_attempt, wait_exponential + +from owl.configs import CACHE, ENV_CONFIG +from owl.db.gen_table import GenerativeTableCore +from owl.types import ( + DBStorageUsageData, + EgressUsageData, + EmbedUsageData, + FileStorageUsageData, + LlmUsageData, + ModelConfigRead, + OrganizationRead, + ProductType, + RerankUsageData, + UsageData, + UserAgent, +) +from owl.utils.exceptions import ResourceNotFoundError, handle_exception + + +class OpenTelemetryClient: + def __init__(self) -> None: + # resource = Resource.create( + # { + # "service.name": "owl-service", + # "service.instance.id": uuid7_str(), + # } + # ) + # reader = PeriodicExportingMetricReader( + # OTLPMetricExporter(endpoint=endpoint), export_interval_millis=math.inf + # ) + # self.provider = MeterProvider(resource=resource, metric_readers=[reader]) + # metrics.set_meter_provider(self.provider) + self.meter = metrics.get_meter(__name__) + self.counters: DefaultDict[str, Counter] = defaultdict( + lambda: self.meter.create_counter(name="default") + ) + self.histograms: DefaultDict[str, Histogram] = defaultdict( + lambda: self.meter.create_histogram(name="default") + ) + self.gauges: DefaultDict[str, _Gauge] = defaultdict( + lambda: self.meter.create_gauge(name="default") + ) + + def get_counter(self, name) -> Counter: + if name not in self.counters: + self.counters[name] = self.meter.create_counter(name=name) + return self.counters[name] + + def get_histogram(self, name) -> Histogram: + if name not in self.histograms: + self.histograms[name] = self.meter.create_histogram(name=name) + return self.histograms[name] + + def get_gauge(self, name) -> _Gauge: + if name not in self.gauges: + self.gauges[name] = self.meter.create_gauge(name=name) + return self.gauges[name] + + def get_meter(self): + return self.meter + + def force_flush(self): + # self.provider.force_flush() + metrics.get_meter_provider().force_flush() + + +class ClickHouseAsyncClient: + def __init__( + self, + host: str, + username: str, + password: str, + database: str, + port: int, + ) -> None: + self.client = asyncio.run( + clickhouse_connect.get_async_client( + host=host, + username=username, + password=password, + database=database, + port=port, + ) + ) + + def _log_debug(self, message: str): + logger.debug(f"{self.__class__.__name__}: {message}") + + def _log_info(self, message: str): + logger.info(f"{self.__class__.__name__}: {message}") + + def _log_error(self, message: str): + logger.error(f"{self.__class__.__name__}: {message}") + + async def query(self, sql: str): + try: + result = await self.client.query(sql) + return result + except Exception as e: + self._log_error(f"Failed to execute query: {sql}. Error: {e}") + raise + + async def insert_llm_usage(self, usages: list[LlmUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="llm_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "model", + "input_token", + "output_token", + "input_cost", + "output_cost", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: llm_usage. Error: {e}") + raise + + async def insert_embed_usage(self, usages: list[EmbedUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="embed_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "model", + "num_token", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: embed_usage. Error: {e}") + raise + + async def insert_rerank_usage(self, usages: list[RerankUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="rerank_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "model", + "num_search", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: rerank_usage. Error: {e}") + raise + + async def insert_egress_usage(self, usages: list[EgressUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="egress_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "amount_gib", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: egress_usage. Error: {e}") + raise + + async def insert_file_storage_usage(self, usages: list[FileStorageUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="file_storage_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "amount_gib", + "snapshot_gib", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: file_storage_usage. Error: {e}") + raise + + async def insert_db_storage_usage(self, usages: list[DBStorageUsageData]): + try: + usages_list = [usage.as_list() for usage in usages] + result = await self.client.insert( + table="db_storage_usage", + data=usages_list, + column_names=[ + "id", + "org_id", + "proj_id", + "user_id", + "timestamp", + "cost", + "amount_gib", + "snapshot_gib", + ], + settings={ + "async_insert": 1, + "wait_for_async_insert": 1, + "async_insert_busy_timeout_ms": 1000, + "async_insert_use_adaptive_busy_timeout": 1, + }, + ) + return result + except Exception as e: + self._log_error(f"Failed to insert data into table: db_storage_usage. Error: {e}") + raise + + @retry( + wait=wait_exponential(multiplier=1, min=2, max=10), + stop=stop_after_attempt(4), + reraise=True, + ) + async def insert_usage(self, usage: UsageData): + llm_result = await self.insert_llm_usage(usage.llm_usage) + embed_result = await self.insert_embed_usage(usage.embed_usage) + rerank_result = await self.insert_rerank_usage(usage.rerank_usage) + egress_result = await self.insert_egress_usage(usage.egress_usage) + file_storage_result = await self.insert_file_storage_usage(usage.file_storage_usage) + db_storage_result = await self.insert_db_storage_usage(usage.db_storage_usage) + return ( + llm_result, + embed_result, + rerank_result, + egress_result, + file_storage_result, + db_storage_result, + ) + + async def bulk_insert_usage(self, usages: list[UsageData]): + all_usages = sum(usages, start=UsageData()) + results = await self.insert_usage(all_usages) + return results + + async def flush_buffer(self): + buffer_key = ENV_CONFIG.clickhouse_buffer_key + buffer_count_key = buffer_key + "_count" + temp_key = buffer_key + "_temp" + lock_key = buffer_key + ":lock" + + async with CACHE.alock(lock_key, blocking=False, expire=5) as lock_acquired: + if lock_acquired: + self._log_debug("Acquired lock to flush buffer.") + else: + self._log_debug("Could not acquire lock to flush buffer.") + return + + # Exit if buffer key not found + if not await CACHE.exists(buffer_key): + self._log_debug("Buffer key not found, skipping insert operation.") + return + + # Move data from buffer to temp key + # TODO: Maybe use async redis + with CACHE._redis.pipeline() as pipe: + pipe.multi() + pipe.rename(buffer_key, temp_key) + temp_count = pipe.get(buffer_count_key) + pipe.delete(buffer_count_key) + pipe.execute() + + buffer_data = CACHE._redis.lrange(temp_key, 0, -1) + if buffer_data: + _t = perf_counter() + usages = [UsageData.model_validate_json(data) for data in buffer_data] + try: + await self.bulk_insert_usage(usages) + # Delete temp key on success + del CACHE[temp_key] + self._log_info( + ( + f"{sum([usage.total_usage_events for usage in usages]):,d} buffered usage data inserted to DB, " + f"time taken: {perf_counter() - _t:,.3} seconds" + ) + ) + except Exception as e: + self._log_error(f"Failed to insert data. Error: {e}") + # Move data back to buffer on failure + # Append data back to buffer on failure + with CACHE._redis.pipeline() as pipe: + pipe.multi() + for data in buffer_data: + pipe.rpush(buffer_key, data) + pipe.incrby(buffer_count_key, int(temp_count or 0)) + pipe.execute() + # Delete temp key after appending data back to buffer + del CACHE[temp_key] + + +# OPENTELEMETRY_CLIENT = OpenTelemetryClient( +# endpoint=f"http://{ENV_CONFIG.opentelemetry_host}:{ENV_CONFIG.opentelemetry_port}" +# ) +OPENTELEMETRY_CLIENT = OpenTelemetryClient() +CLICKHOUSE_CLIENT = ClickHouseAsyncClient( + host=ENV_CONFIG.clickhouse_host, + username=ENV_CONFIG.clickhouse_user, + password=ENV_CONFIG.clickhouse_password.get_secret_value(), + database=ENV_CONFIG.clickhouse_db, + port=ENV_CONFIG.clickhouse_port, +) +STRIPE_CLIENT = None + + +def _log_exception(e: Exception, *_, **__): + logger.exception(f"Billing event processing encountered an error: {repr(e)}") + + +class BillingManager: + def __init__( + self, + *, + organization: OrganizationRead, + project_id: str = "", + user_id: str = "", + request: Request | None = None, + models: list[ModelConfigRead] | None = None, + ) -> None: + if not isinstance(organization, OrganizationRead): + raise TypeError( + f"`organization` must be an instance of `OrganizationRead`, received: {type(organization)}" + ) + self.org = organization + self.project_id = project_id + self.user_id = user_id + self.request = request + self.id: str = request.state.id if request else "" + if models and not all(isinstance(m, ModelConfigRead) for m in models): + raise TypeError( + f"`models` must be a list of `ModelConfigRead` instances, received: {models}" + ) + self.models = models + self.model_map = {m.id: m for m in models} if models else {} + if request is None: + self._user_agent = UserAgent(is_browser=False, agent="") + else: + self._user_agent: UserAgent = request.state.user_agent + self._price_plan = None + self._events = [] + self._deltas: dict[ProductType, float] = defaultdict(float) + self._values: dict[ProductType, float] = defaultdict(float) + self._llm_usage_events: list[LlmUsageData] = [] + self._embed_usage_events: list[EmbedUsageData] = [] + self._rerank_usage_events: list[RerankUsageData] = [] + self._egress_usage_events: list[EgressUsageData] = [] + self._file_storage_usage_events: list[FileStorageUsageData] = [] + self._db_storage_usage_events: list[DBStorageUsageData] = [] + self._cost = 0.0 + + @property + def cost(self) -> float: + return self._cost + + @property + def total_balance(self) -> float: + return self.org.credit + self.org.credit_grant + + def _log_info(self, message: str): + logger.info(f"{self.id} - {self.__class__.__name__}: {message}") + + def _log_warning(self, message: str): + logger.warning(f"{self.id} - {self.__class__.__name__}: {message}") + + def _model(self, model_id: str) -> ModelConfigRead: + model = self.model_map.get(model_id, None) + if model is None: + raise ResourceNotFoundError( + f'Model "{self._model_id_or_name(model_id)}" is not found.' + ) + return model + + def _check_project_id(self): + if self.project_id.strip() == "": + raise ValueError("Project ID must be provided.") + + def _cloud_event( + self, + attributes: dict[str, Any], + data: dict[str, Any], + ) -> CloudEvent: + if ( + len(data.get("proj_id", "dummy")) == 0 + ): # Update to proj_id to align with Clickhouse Column + raise ValueError('"proj_id" if provided must not be empty.') + # check if request_count + extra_labels = ( + self._user_agent.model_dump() if attributes.get("type", "") == "request_count" else {} + ) + return CloudEvent( + attributes={ + **attributes, + "source": "owl", + "subject": self.org.id, + }, + data={ + **data, + "org_id": self.org.id, + "user_id": self.user_id, + **extra_labels, + }, + ) + + # --- Generative Table Usage --- # + + def has_gen_table_quota(self, table: GenerativeTableCore) -> bool: + return True + + # --- LLM Usage --- # + + def has_llm_quota(self, model_id: str) -> bool: + return True + + def create_llm_events( + self, + model_id: str, + input_tokens: int, + output_tokens: int, + *, + create_usage: bool = True, + ) -> None: + input_tokens = int(input_tokens) + output_tokens = int(output_tokens) + if input_tokens <= 0 and output_tokens <= 0: + return + self._check_project_id() + # Analytics: Token usage + self._events += [ + self._cloud_event( + {"type": ProductType.LLM_TOKENS}, + { + "model": model_id, + "tokens": v, + "type": t, + "proj_id": self.project_id, # Update to proj_id to align with Clickhouse Column + }, + ) + for t, v in [("input", input_tokens), ("output", output_tokens)] + ] + if create_usage: + self._llm_usage_events.append( + LlmUsageData( + org_id=self.org.id, + proj_id=self.project_id, + user_id=self.user_id, + model=model_id, + input_token=input_tokens, + output_token=output_tokens, + input_cost=0.0, + output_cost=0.0, + cost=0.0, + ) + ) + + # --- Embedding Usage --- # + + def has_embedding_quota(self, model_id: str) -> bool: + return True + + def create_embedding_events( + self, + model_id: str, + token_usage: int, + *, + create_usage: bool = True, + ) -> None: + token_usage = int(token_usage) + if token_usage <= 0: + return + self._check_project_id() + # Analytics: Token usage + self._events += [ + self._cloud_event( + {"type": ProductType.EMBEDDING_TOKENS}, + { + "model": model_id, + "tokens": token_usage, + "proj_id": self.project_id, # Update to proj_id to align with Clickhouse Column + }, + ) + ] + if create_usage: + self._embed_usage_events.append( + EmbedUsageData( + org_id=self.org.id, + proj_id=self.project_id, + user_id=self.user_id, + model=model_id, + token=token_usage, + cost=0.0, + ) + ) + + # --- Reranker Usage --- # + + def has_reranker_quota(self, model_id: str) -> bool: + return True + + def create_reranker_events( + self, + model_id: str, + num_searches: int, + *, + create_usage: bool = True, + ) -> None: + num_searches = int(num_searches) + if num_searches <= 0: + return + self._check_project_id() + # Analytics: Rerank usage + self._events += [ + self._cloud_event( + {"type": ProductType.RERANKER_SEARCHES}, + { + "model": model_id, + "searches": num_searches, + "proj_id": self.project_id, # Update to proj_id to align with Clickhouse Column + }, + ) + ] + if create_usage: + self._rerank_usage_events.append( + RerankUsageData( + org_id=self.org.id, + proj_id=self.project_id, + user_id=self.user_id, + model=model_id, + number_of_search=num_searches, + cost=0.0, + ) + ) + + # --- Egress Usage --- # + + def has_egress_quota(self) -> bool: + return True + + def create_egress_events(self, amount_gib: float, *, create_usage: bool = True) -> None: + if amount_gib <= 0 or not self.project_id: + return + # Analytics: Egress usage + self._events += [ + self._cloud_event( + {"type": "bandwidth"}, + { + "amount_gib": amount_gib, + "type": ProductType.EGRESS, + "proj_id": self.project_id, # Update to proj_id to align with Clickhouse Column + }, + ) + ] + if create_usage: + self._egress_usage_events.append( + EgressUsageData( + org_id=self.org.id, + proj_id=self.project_id, + user_id=self.user_id, + amount_gib=amount_gib, + cost=0.0, + ) + ) + + # --- DB Storage Usage --- # + + def has_db_storage_quota(self) -> bool: + return True + + def create_db_storage_events(self, db_usage_gib: float, *, create_usage: bool = True) -> None: + if db_usage_gib <= 0: + return + # Analytics: DB storage usage + self._events += [ + self._cloud_event({"type": "storage"}, {"amount_gib": db_usage_gib, "type": "db"}), + ] + if create_usage: + self._db_storage_usage_events.append( + DBStorageUsageData( + org_id=self.org.id, + proj_id=self.project_id + or "not_applicable", # possible the request is not associated with a project + user_id=self.user_id, + amount_gib=0.0, + cost=0.0, + snapshot_gib=db_usage_gib, + ) + ) + + # --- File Storage Usage --- # + + def has_file_storage_quota(self) -> bool: + return True + + def create_file_storage_events( + self, file_usage_gib: float, *, create_usage: bool = True + ) -> None: + if file_usage_gib <= 0: + return + # Analytics: DB storage usage + self._events += [ + self._cloud_event({"type": "storage"}, {"amount_gib": file_usage_gib, "type": "file"}), + ] + if create_usage: + self._file_storage_usage_events.append( + FileStorageUsageData( + org_id=self.org.id, + proj_id=self.project_id + or "not_applicable", # possible the request is not associated with a project + user_id=self.user_id, + amount_gib=0.0, + cost=0.0, + snapshot_gib=file_usage_gib, + ) + ) + + # --- Process all events --- # + + @handle_exception(handler=_log_exception) + async def process_all(self) -> None: + """ + Process all events. In general, only call this as a BACKGROUND TASK after the response is sent. + """ + + # Push usage to redis for queue if buffer less than 10000 + usage_data = UsageData( + llm_usage=self._llm_usage_events, + embed_usage=self._embed_usage_events, + rerank_usage=self._rerank_usage_events, + egress_usage=self._egress_usage_events, + file_storage_usage=self._file_storage_usage_events, + db_storage_usage=self._db_storage_usage_events, + ) + usage_count = (await CACHE.get_usage_buffer_count()) + usage_data.total_usage_events + if usage_count >= ENV_CONFIG.clickhouse_max_buffer_queue_size: + await CLICKHOUSE_CLIENT.flush_buffer() + # We could use asyncio TaskGroup here if there are other async tasks downstream + # For now there isn't any, so we just simply await it + await CLICKHOUSE_CLIENT.insert_usage(usage_data) + elif usage_data.total_usage_events > 0: + await CACHE.add_usage_to_buffer(usage_data) + + # API request count + req_scope = getattr(self.request, "scope", {}) + req_method: str = getattr(self.request, "method", "") + if req_scope.get("route", None) and req_method and self.project_id: + # https://stackoverflow.com/a/72239186 + path = req_scope.get("root_path", "") + req_scope["route"].path + self._events += [ + self._cloud_event( + {"type": "request_count"}, + { + "method": req_method, + "path": path, + "proj_id": self.project_id, # Update to proj_id to align with Clickhouse Column + }, + ) + ] + # Send OpenTelemetry events + if len(self._events) > 0: + t0 = perf_counter() + for event in self._events: + attributes = to_dict(event) + event_type = attributes["type"] + if event_type == "request_count": + counter = OPENTELEMETRY_CLIENT.get_counter(name="request_count") + counter.add(1, attributes["data"]) + elif event_type == ProductType.LLM_TOKENS: + counter = OPENTELEMETRY_CLIENT.get_counter(name="llm_token_usage") + counter.add( + attributes["data"]["tokens"], + {k: v for k, v in attributes["data"].items() if k != "tokens"}, + ) + elif event_type == ProductType.EMBEDDING_TOKENS: + counter = OPENTELEMETRY_CLIENT.get_counter(name="embedding_token_usage") + counter.add( + attributes["data"]["tokens"], + {k: v for k, v in attributes["data"].items() if k != "tokens"}, + ) + elif event_type == ProductType.RERANKER_SEARCHES: + counter = OPENTELEMETRY_CLIENT.get_counter(name="reranker_search_usage") + counter.add( + attributes["data"]["searches"], + {k: v for k, v in attributes["data"].items() if k != "searches"}, + ) + elif event_type == "bandwidth": + counter = OPENTELEMETRY_CLIENT.get_counter(name="bandwidth_usage") + counter.add( + attributes["data"]["amount_gib"], + {k: v for k, v in attributes["data"].items() if k != "amount_gib"}, + ) + elif event_type == "storage": + gauge = OPENTELEMETRY_CLIENT.get_gauge(name="storage_usage") + gauge.set( + attributes["data"]["amount_gib"], + {k: v for k, v in attributes["data"].items() if k != "amount_gib"}, + ) + elif event_type == "spent": + counter = OPENTELEMETRY_CLIENT.get_counter(name="spent") + counter.add( + attributes["data"]["spent_usd"], + {k: v for k, v in attributes["data"].items() if k != "spent_usd"}, + ) + self._log_info( + ( + f"OpenTelemetry events ingestion: " + f"t={(perf_counter() - t0) * 1e3:,.2f} ms " + f"num_events={len(self._events):,d} " + f"event_types={set(str(e.get_attributes()['type']) for e in self._events)}" + ) + ) + # Force flush + # OPENTELEMETRY_CLIENT.force_flush() + # Clear events + self._events = [] diff --git a/services/api/src/owl/utils/billing_metrics.py b/services/api/src/owl/utils/billing_metrics.py new file mode 100644 index 0000000..4e0b611 --- /dev/null +++ b/services/api/src/owl/utils/billing_metrics.py @@ -0,0 +1,742 @@ +from __future__ import annotations + +import re +from collections import namedtuple +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +from loguru import logger + +from owl.types import ProductType, Usage, UsageResponse +from owl.utils.billing import ClickHouseAsyncClient +from owl.utils.exceptions import BadInputError + + +############################################################################### +# 1. Column-level registry +############################################################################### +@dataclass(frozen=True, slots=True) +class _BaseTable: + org_col: str = "org_id" + proj_col: str = "proj_id" + user_col: str = "user_id" + model_col: str = "model" + ts_col: str = "timestamp" + ts_interval: str = "timestamp_interval" + + def valid_group_by_cols(self) -> list[str]: + return [ + self.org_col, + self.proj_col, + self.user_col, + self.model_col, + ] + + +@dataclass(frozen=True, slots=True) +class LlmTable(_BaseTable): + table_id: str = "llm_usage" + input_col: str = "input_token" + output_col: str = "output_token" + input_cost_col: str = "input_cost" + output_cost_col: str = "output_cost" + result_input_token: str = "input" + result_output_token: str = "output" + result_total_token: str = "total_token" + category_total: str = "total_cost" + + def valid_group_by_cols(self) -> list[str]: + return _BaseTable().valid_group_by_cols() + ["type"] + + +@dataclass(frozen=True, slots=True) +class EmbedTable(_BaseTable): + table_id: str = "embed_usage" + value_col: str = "num_token" + cost_col: str = "cost" + + +@dataclass(frozen=True, slots=True) +class RerankTable(_BaseTable): + table_id: str = "rerank_usage" + value_col: str = "num_search" + cost_col: str = "cost" + + +@dataclass(frozen=True, slots=True) +class EgressTable(_BaseTable): + table_id: str = "egress_usage" + value_col: str = "amount_gib" + cost_col: str = "cost" + model_col: str = "bandwidth" # Egress actually does not have model + type: str = "egress" # to align with vm + + def valid_group_by_cols(self) -> list[str]: + _valid = _BaseTable().valid_group_by_cols() + ["type"] + _valid.remove(_BaseTable().model_col) + return _valid + + +@dataclass(frozen=True, slots=True) +class FileStorageTable(_BaseTable): + table_id: str = "file_storage_usage" + value_col: str = "amount_gib" + cost_col: str = "cost" + snapshot_col: str = "snapshot_gib" + model_col: str = "file_storage" # FileStorage actually does not have model + type: str = "file" # used for grouping + + def valid_group_by_cols(self) -> list[str]: + _valid = _BaseTable().valid_group_by_cols() + ["type"] + _valid.remove(_BaseTable().model_col) + return _valid + + +# For Storage usage type, = file/db +@dataclass(frozen=True, slots=True) +class DBStorageTable(_BaseTable): + table_id: str = "db_storage_usage" + value_col: str = "amount_gib" + cost_col: str = "cost" + snapshot_col: str = "snapshot_gib" + model_col: str = "db_storage" # DBStorage actually does not have model + type: str = "db" # used for grouping + + def valid_group_by_cols(self) -> list[str]: + _valid = _BaseTable().valid_group_by_cols() + ["type"] + _valid.remove(_BaseTable().model_col) + return _valid + + +# HACK: This is not an actual clickhouse table just to make it work with other parts +# For Storage spent, category = file/db (no type) +@dataclass(frozen=True, slots=True) +class CostTable(_BaseTable): + llm_table: LlmTable = LlmTable() + embed_table: EmbedTable = EmbedTable() + rerank_table: RerankTable = RerankTable() + egress_table: EgressTable = EgressTable() + file_storage_table: FileStorageTable = FileStorageTable() + db_storage_table: DBStorageTable = DBStorageTable() + category_total: str = "cost" + category_llm_input: str = "input_cost" + category_llm_output: str = "output_cost" + llm_input_type: str = "input" + llm_output_type: str = "output" + category_llm: str = ProductType.LLM_TOKENS.value + category_embed: str = ProductType.EMBEDDING_TOKENS.value + category_rerank: str = ProductType.RERANKER_SEARCHES.value + category_egress: str = ProductType.EGRESS.value + category_file_storage: str = ProductType.FILE_STORAGE.value + category_db_storage: str = ProductType.DB_STORAGE.value + + # HACK: so the table_id get from with_where_clause + table_id: str = "" + + # HACK: to make this compatible with victoriametrics query + def valid_group_by_cols(self) -> list[str]: + return _BaseTable().valid_group_by_cols() + ["type", "category"] + + def build_table_id(self, where_clause: str = "") -> str: + """Return the table_id with WHERE clause injected into each subquery""" + base_where = f"WHERE {where_clause}" if where_clause else "" + # HACK: the egress table does not have model_col, put model as 'bandwidth' + # HACK: the file_storage and db_storage table does not have model_col, put model as 'file_storage' and 'db_storage' + return f"""( + SELECT {self.llm_table.org_col}, {self.llm_table.proj_col}, {self.llm_table.model_col}, {self.llm_table.ts_col}, {self.llm_table.input_cost_col}, {self.llm_table.output_cost_col}, {self.llm_table.input_cost_col} + {self.llm_table.output_cost_col} as {self.category_llm}, 0 as {self.category_embed}, 0 as {self.category_rerank}, 0 as {self.category_egress}, 0 as {self.category_file_storage}, 0 as {self.category_db_storage} + FROM {self.llm_table.table_id} + {base_where} + UNION ALL + SELECT {self.embed_table.org_col}, {self.embed_table.proj_col}, {self.embed_table.model_col}, {self.embed_table.ts_col}, 0 as {self.llm_table.input_cost_col}, 0 as {self.llm_table.output_cost_col}, 0 as {self.category_llm}, {self.embed_table.cost_col} as {self.category_embed}, 0 as {self.category_rerank}, 0 as {self.category_egress}, 0 as {self.category_file_storage}, 0 as {self.category_db_storage} + FROM {self.embed_table.table_id} + {base_where} + UNION ALL + SELECT {self.rerank_table.org_col}, {self.rerank_table.proj_col}, {self.rerank_table.model_col}, {self.rerank_table.ts_col}, 0 as {self.llm_table.input_cost_col}, 0 as {self.llm_table.output_cost_col}, 0 as {self.category_llm}, 0 as {self.category_embed}, {self.rerank_table.cost_col} as {self.category_rerank}, 0 as {self.category_egress}, 0 as {self.category_file_storage}, 0 as {self.category_db_storage} + FROM {self.rerank_table.table_id} + {base_where} + UNION ALL + SELECT {self.egress_table.org_col}, {self.egress_table.proj_col}, '{self.egress_table.model_col}' as {_BaseTable().model_col}, {self.egress_table.ts_col}, 0 as {self.llm_table.input_cost_col}, 0 as {self.llm_table.output_cost_col}, 0 as {self.category_llm}, 0 as {self.category_embed}, {self.rerank_table.cost_col} as {self.category_rerank}, {self.egress_table.cost_col} as {self.category_egress}, 0 as {self.category_file_storage}, 0 as {self.category_db_storage} + FROM {self.egress_table.table_id} + {base_where} + UNION ALL + SELECT {self.file_storage_table.org_col}, {self.file_storage_table.proj_col}, '{self.file_storage_table.model_col}' as {_BaseTable().model_col}, {self.file_storage_table.ts_col}, 0 as {self.llm_table.input_cost_col}, 0 as {self.llm_table.output_cost_col}, 0 as {self.category_llm}, 0 as {self.category_embed}, {self.rerank_table.cost_col} as {self.category_rerank}, 0 as {self.category_egress}, {self.file_storage_table.cost_col} as {self.category_file_storage}, 0 as {self.category_db_storage} + FROM {self.file_storage_table.table_id} + {base_where} + UNION ALL + SELECT {self.db_storage_table.org_col}, {self.db_storage_table.proj_col}, '{self.db_storage_table.model_col}' as {_BaseTable().model_col}, {self.db_storage_table.ts_col}, 0 as {self.llm_table.input_cost_col}, 0 as {self.llm_table.output_cost_col}, 0 as {self.category_llm}, 0 as {self.category_embed}, {self.rerank_table.cost_col} as {self.category_rerank}, 0 as {self.category_egress}, 0 as {self.category_file_storage}, {self.db_storage_table.cost_col} as {self.category_db_storage} + FROM {self.db_storage_table.table_id} + {base_where} + )""" + + def row_is_llm(self, row: dict[str, Any]) -> bool: + # special handling to remove non llm type (when group by with 'model') + if row.get(self.model_col, "") in [ + self.egress_table.model_col, + self.file_storage_table.model_col, + self.db_storage_table.model_col, + ]: + return False + return True + + +############################################################################### +# 2. Helper utilities +############################################################################### +_duration_units = { + "ms": timedelta(milliseconds=1), + "s": timedelta(seconds=1), + "m": timedelta(minutes=1), + "h": timedelta(hours=1), + "d": timedelta(days=1), + "w": timedelta(weeks=1), + "y": timedelta(days=365), +} + +_interval_map = { + "s": "SECOND", + "m": "MINUTE", + "h": "HOUR", + "d": "DAY", + "w": "WEEK", + "y": "YEAR", +} + +MetricDef = namedtuple("MetricDef", ["name", "value_col", "extra_dims", "gb_mask"]) + +_METRICS: tuple[MetricDef, ...] = ( + MetricDef( + "embed", CostTable().category_embed, {"category": CostTable().category_embed}, "embed" + ), + MetricDef( + "rerank", CostTable().category_rerank, {"category": CostTable().category_rerank}, "rerank" + ), + MetricDef( + "egress", CostTable().category_egress, {"category": CostTable().category_egress}, "egress" + ), + MetricDef( + "file", + CostTable().category_file_storage, + {"category": CostTable().category_file_storage}, + "file", + ), + MetricDef( + "db", CostTable().category_db_storage, {"category": CostTable().category_db_storage}, "db" + ), + MetricDef( + "llm_input", + CostTable().category_llm_input, + {"category": CostTable().category_llm, "type": CostTable().llm_input_type}, + "common", + ), + MetricDef( + "llm_output", + CostTable().category_llm_output, + {"category": CostTable().category_llm, "type": CostTable().llm_output_type}, + "common", + ), + MetricDef("llm", CostTable().category_llm, {"category": CostTable().category_llm}, "common"), + MetricDef("total", CostTable().category_total, {}, "common"), +) + + +def _parse_duration(duration: str) -> timedelta: + delta = timedelta() + for value, unit in re.findall(r"(\d+)([smhdwy])", duration): + delta += int(value) * _duration_units[unit] + return delta + + +def _parse_interval(window_size: str) -> str: + m = re.fullmatch(r"(\d+)([smhdwy])", window_size) + if not m or m.group(2) not in _interval_map: + raise BadInputError(f"Bad window_size {window_size!r}, expected s/m/h/d/w/y") + + number = m.group(1) + unit = _interval_map[m.group(2)] + return f"{number} {unit}" + + +def _in_filter(col: str, values: list[str] | None) -> str: + if not values: + return "1=1" + quoted = ", ".join(f"'{v}'" for v in values) + return f"{col} IN ({quoted})" + + +def _filter_groupby(group_by: list[str], invalids: list[str] | None = None) -> list[str]: + if invalids is None: + invalids = [] + return [g for g in group_by if g not in invalids] + + +def _build_gb_filters(has_category: bool, has_type: bool, has_model: bool) -> dict[str, list[str]]: + base = [] if has_category else ["category"] + filters = {mask: base.copy() for mask in ("common", "embed", "rerank", "egress", "file", "db")} + if has_type: + for m in ("embed", "rerank", "egress", "file", "db"): + filters[m].append("type") + if has_model: + for m in ("file", "db", "egress"): + filters[m].append("model") + return filters + + +def _get_active_metrics(has_category: bool, has_type: bool) -> list[MetricDef]: + if not has_category: + if has_type: + # not has_category and has_type + return [m for m in _METRICS if m.name in {"llm_input", "llm_output", "total"}] + # has_category and has_type + return [m for m in _METRICS if m.name == "total"] + if has_type: + # has_category and has_type + return [ + m + for m in _METRICS + if m.name in {"embed", "rerank", "egress", "llm_input", "llm_output", "file", "db"} + ] + # has_category and not has_type + return [m for m in _METRICS if m.name in {"embed", "rerank", "egress", "file", "db", "llm"}] + + +############################################################################### +# 3. Generic query builder +############################################################################### +def _build_time_bucket_query( + spec: LlmTable + | EmbedTable + | RerankTable + | EgressTable + | FileStorageTable + | DBStorageTable + | CostTable, + org_ids: list[str] | None, + proj_ids: list[str] | None, + from_: datetime, + to: datetime, + group_by: list[str], + window_size: str, +) -> tuple[str, timedelta]: + for group in group_by: + if group not in spec.valid_group_by_cols(): + raise BadInputError( + f"Invalid group_by column: {group}, must be one of {spec.valid_group_by_cols()}" + ) + + org_c = _in_filter(spec.org_col, org_ids) + proj_c = _in_filter(spec.proj_col, proj_ids) + interval = _parse_interval(window_size) + ts_alias = f"toStartOfInterval({spec.ts_col}, INTERVAL {interval}) AS {spec.ts_interval}" + + has_type = "type" in group_by + has_category = "category" in group_by + if has_type: + group_by.remove("type") + if has_category: + group_by.remove("category") + + select_cols = [ts_alias, *group_by] + + # where clause + where_clause = f"""{spec.ts_col} >= '{from_:%Y-%m-%d %H:%M:%S}' + AND {spec.ts_col} < '{to:%Y-%m-%d %H:%M:%S}' + AND {org_c} + AND {proj_c} + """ + # Value expression + if isinstance(spec, LlmTable): + if has_type: + value_expr = f"SUM({spec.input_col}) as {spec.result_input_token}, SUM({spec.output_col}) as {spec.result_output_token}" + else: + value_expr = f"SUM({spec.input_col} + {spec.output_col}) AS {spec.result_total_token}" + elif isinstance(spec, FileStorageTable) or isinstance(spec, DBStorageTable): + value_expr = f"MAX({spec.snapshot_col}) AS {spec.snapshot_col}" + elif isinstance(spec, CostTable): + if has_category: + if has_type: + value_expr = f"SUM({spec.category_llm_input}) AS {spec.category_llm_input}, SUM({spec.category_llm_output}) AS {spec.category_llm_output}, SUM({spec.category_embed}) AS {spec.category_embed}, SUM({spec.category_rerank}) AS {spec.category_rerank}, SUM({spec.category_egress}) AS {spec.category_egress}, SUM({spec.category_file_storage}) AS {spec.category_file_storage}, SUM({spec.category_db_storage}) AS {spec.category_db_storage}" + else: + value_expr = f"SUM({spec.category_llm}) AS {spec.category_llm}, SUM({spec.category_embed}) AS {spec.category_embed}, SUM({spec.category_rerank}) AS {spec.category_rerank}, SUM({spec.category_egress}) AS {spec.category_egress}, SUM({spec.category_file_storage}) AS {spec.category_file_storage}, SUM({spec.category_db_storage}) AS {spec.category_db_storage}" + else: + if has_type: + value_expr = f"SUM({spec.category_llm_input}) as {spec.category_llm_input}, SUM({spec.category_llm_output}) as {spec.category_llm_output}, SUM({spec.category_embed} + {spec.category_rerank} + {spec.category_egress} + {spec.category_file_storage} + {spec.category_db_storage}) AS {spec.category_total}" + else: + value_expr = f"SUM({spec.category_llm} + {spec.category_embed} + {spec.category_rerank} + {spec.category_egress} + {spec.category_file_storage} + {spec.category_db_storage}) AS {spec.category_total}" + else: + value_expr = f"SUM({spec.value_col}) AS {spec.value_col}" + select_cols.append(value_expr) + + group_clause = ", ".join([spec.ts_interval, *group_by]) + sql = f""" + SELECT {", ".join(select_cols)} + FROM {spec.table_id or spec.build_table_id(where_clause)} + WHERE {where_clause} + GROUP BY {group_clause} + ORDER BY {spec.ts_interval} + """ + return sql, _parse_duration(window_size) + + +############################################################################### +# 4. Billing service +############################################################################### +class BillingMetrics: + def __init__(self, clickhouse_client: ClickHouseAsyncClient) -> None: + self.client = clickhouse_client + + async def _query(self, sql: str) -> list[dict[str, Any]]: + try: + res = await self.client.query(sql) + logger.info( + f"Query ID {res.summary.get('query_id')} " + f"rows={res.summary.get('result_rows')} " + f"elapsed={res.summary.get('elapsed_ns')}ns" + ) + if res.summary.get("result_rows") == "0": + return [] + return [ + dict(zip(res.column_names, row, strict=True)) + for row in zip(*res.result_columns, strict=True) + ] + except Exception as e: + logger.error(f"Query failed: {sql} – {e}") + raise + + @staticmethod + def _process_group_by(group_by: list[str]) -> list[str]: + # if "organization_id" in group_by: + # group_by.remove("organization_id") + # if "project_id" in group_by: + # group_by.remove("project_id") + # group_by.append("proj_id") + group_by = list(set([_BaseTable().org_col] + group_by)) + return group_by + + # ------------------------------------------------------------------ + # Public API – unchanged signatures + # ------------------------------------------------------------------ + async def query_llm_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + table = LlmTable() + to = to or datetime.now(timezone.utc) + group_by = self._process_group_by(group_by) + # group_by might be modified + sql, interval = _build_time_bucket_query( + table, filtered_by_org_id, filtered_by_proj_id, from_, to, group_by.copy(), window_size + ) + rows = await self._query(sql) + if "type" in group_by: + usages = [] + for r in rows: + usages.append( + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.result_input_token), + ], + {**r, "type": table.result_input_token}, + interval, + group_by, + ) + ) + usages.append( + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.result_output_token), + ], + {**r, "type": table.result_output_token}, + interval, + group_by, + ) + ) + else: + usages = [ + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.result_total_token), + ], + r, + interval, + group_by, + ) + for r in rows + ] + return UsageResponse( + windowSize=window_size, + data=usages, + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + async def query_embedding_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + table = EmbedTable() + to = to or datetime.now(timezone.utc) + group_by = self._process_group_by(group_by) + sql, interval = _build_time_bucket_query( + table, filtered_by_org_id, filtered_by_proj_id, from_, to, group_by.copy(), window_size + ) + rows = await self._query(sql) + return UsageResponse( + windowSize=window_size, + data=[ + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.value_col), + ], + r, + interval, + group_by, + ) + for r in rows + ], + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + async def query_reranking_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + table = RerankTable() + to = to or datetime.now(timezone.utc) + group_by = self._process_group_by(group_by) + sql, interval = _build_time_bucket_query( + table, + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by.copy(), + window_size, + ) + rows = await self._query(sql) + return UsageResponse( + windowSize=window_size, + data=[ + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.value_col), + ], + r, + interval, + group_by, + ) + for r in rows + ], + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + async def query_bandwidth( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + table = EgressTable() + to = to or datetime.now(timezone.utc) + group_by = self._process_group_by(group_by) + has_type = "type" in group_by + sql, interval = _build_time_bucket_query( + table, + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by.copy(), + window_size, + ) + rows = await self._query(sql) + return UsageResponse( + windowSize=window_size, + data=[ + Usage.from_result( + [ + int((r.get(table.ts_interval) + interval).timestamp()), + r.get(table.value_col), + ], + {**r, "type": table.type} if has_type else r, + interval, + group_by, + ) + for r in rows + ], + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + async def query_storage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + file_table = FileStorageTable() + db_table = DBStorageTable() + to = to or datetime.now(timezone.utc) + group_by = self._process_group_by(group_by) + # group_by might be modified + file_sql, _ = _build_time_bucket_query( + file_table, + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by.copy(), + window_size, + ) + file_rows = await self._query(file_sql) + db_sql, interval = _build_time_bucket_query( + db_table, + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by.copy(), + window_size, + ) + db_rows = await self._query(db_sql) + if "type" in group_by: # to be compatible with VM query + usages = [] + for r in file_rows: + usages.append( + Usage.from_result( + [ + int((r.get(file_table.ts_interval) + interval).timestamp()), + r.get(file_table.snapshot_col), + ], + {**r, "type": file_table.type}, + interval, + group_by, + ) + ) + for r in db_rows: + usages.append( + Usage.from_result( + [ + int((r.get(db_table.ts_interval) + interval).timestamp()), + r.get(db_table.snapshot_col), + ], + {**r, "type": db_table.type}, + interval, + group_by, + ) + ) + else: + usages = [ + Usage.from_result( + [ + int((r.get(file_table.ts_interval) + interval).timestamp()), + r.get(file_table.snapshot_col), + ], + r, + interval, + group_by, + ) + for r in file_rows + ] + [ + Usage.from_result( + [ + int((r.get(db_table.ts_interval) + interval).timestamp()), + r.get(db_table.snapshot_col), + ], + r, + interval, + group_by, + ) + for r in db_rows + ] + return UsageResponse( + windowSize=window_size, + data=usages, + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + async def query_billing( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + cost_table = CostTable() + to = to or datetime.now(timezone.utc) + group_by = list(set([cost_table.org_col] + group_by)) + has_category = "category" in group_by + has_type = "type" in group_by + has_model = "model" in group_by + sql, interval = _build_time_bucket_query( + cost_table, + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by.copy(), # group_by might be modified + window_size, + ) + rows = await self._query(sql) + usages = [] + + gb_filters = _build_gb_filters(has_category, has_type, has_model) + active_metrics = _get_active_metrics(has_category, has_type) + + usages: list[Usage] = [] + for row in rows: + ts = int((row.get(cost_table.ts_interval) + interval).timestamp()) + for metric in active_metrics: + value = row.get(metric.value_col) + if value <= 0: + continue + + metrics_dict = { + **row, + **metric.extra_dims, + } + usages.append( + Usage.from_result( + [ts, value], + metrics_dict, + interval, + _filter_groupby(group_by, gb_filters[metric.gb_mask]), + ) + ) + return UsageResponse( + windowSize=window_size, + data=usages, + start=from_.strftime("%Y-%m-%dT%H:%M:%SZ"), + end=to.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) diff --git a/services/api/src/owl/utils/cache.py b/services/api/src/owl/utils/cache.py new file mode 100644 index 0000000..4f028e4 --- /dev/null +++ b/services/api/src/owl/utils/cache.py @@ -0,0 +1,267 @@ +from contextlib import asynccontextmanager, suppress +from random import random +from typing import Any, AsyncGenerator, Type, TypeVar + +from pottery import AIORedlock, ReleaseUnlockedLock +from redis import Redis +from redis.asyncio import Redis as RedisAsync +from redis.backoff import EqualJitterBackoff +from redis.exceptions import ConnectionError, TimeoutError +from redis.retry import Retry +from sqlmodel.ext.asyncio.session import AsyncSession + +from owl.types import Organization_, Progress, UsageData + +ProgressType = TypeVar("ProgressType", bound=Progress) + + +class Cache: + def __init__( + self, + *, + redis_url: str, + clickhouse_buffer_key: str, + cache_expiration: int = 5 * 60, # 5 minutes + ): + self._redis_kwargs = dict( + # url=f"redis://[[username]:[password]]@{ENV_CONFIG.redis_host}:{ENV_CONFIG.redis_port}/1", + url=redis_url, + # https://redis.io/kb/doc/22wxq63j93/how-to-manage-client-reconnections-in-case-of-errors-with-redis-py + retry=Retry(EqualJitterBackoff(cap=10, base=1), 5), + retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError], + health_check_interval=15, + decode_responses=True, + ) + self._redis = Redis.from_url(**self._redis_kwargs) + self._redis_async = RedisAsync.from_url(**self._redis_kwargs) + self.clickhouse_buffer_key = clickhouse_buffer_key + self.cache_expiration = int(cache_expiration) + # try: + # self._redis.ping() + # except ConnectionError as e: + # logger.error(f"Failed to connect to Redis: {repr(e)}") + # raise + + def __getitem__(self, key: str) -> str | None: + """ + Getter method. + ``` + cache = Cache(...) + value = cache["key"] + ``` + + Args: + key (str): Key. + + Returns: + value (str | None): Value. + """ + return self._redis.get(key) + + def __setitem__(self, key: str, value: str) -> None: + """ + Setter method. + ``` + cache = Cache(...) + cache["key"] = value + ``` + + Args: + key (str): Key. + value (str): Value. + """ + if not isinstance(value, str): + raise TypeError(f"`value` must be a str, received: {type(value)}") + self._redis.set(key, value) + + def __delitem__(self, key) -> None: + """ + Delete method. + ``` + cache = Cache(...) + del cache["key"] + ``` + + Args: + key (str): Key. + """ + self._redis.delete(key) + + def __contains__(self, key) -> bool: + self._redis.exists(key) + + def purge(self): + self._redis.flushdb() + + async def aclose(self): + self._redis.close() + await self._redis_async.aclose() + + async def get(self, key: str) -> str | None: + return await self._redis_async.get(key) + + async def set(self, key: str, value: str, **kwargs) -> None: + if not isinstance(value, str): + raise TypeError(f"`value` must be a str, received: {type(value)}") + await self._redis_async.set(key, value, **kwargs) + + async def delete(self, key: str) -> None: + await self._redis_async.delete(key) + + async def exists(self, *keys: str) -> int: + return await self._redis_async.exists(*keys) + + @asynccontextmanager + async def alock( + self, + key: str, + blocking: bool = True, + expire: float = 60.0, + ) -> AsyncGenerator[bool, None]: + lock = AIORedlock( + key=key, + masters={self._redis_async}, + auto_release_time=max(1.0, expire), + ) + lock_acquired = await lock.acquire(blocking=blocking) + try: + yield lock_acquired + finally: + if lock_acquired: + with suppress(ReleaseUnlockedLock): + await lock.release() + + async def add_usage_to_buffer(self, usage: UsageData): + await self._redis_async.rpush(self.clickhouse_buffer_key, usage.model_dump_json()) + await self._redis_async.incrby( + self.clickhouse_buffer_key + "_count", usage.total_usage_events + ) + + # def retrieve_usage_buffer(self) -> list[UsageData]: + # return [ + # UsageData.model_validate_json(data) + # for data in self._redis.lrange(self.clickhouse_buffer_key, 0, -1) + # ] + + async def get_usage_buffer_count(self) -> int: + return int(await self._redis_async.get(self.clickhouse_buffer_key + "_count") or 0) + + # def reset_buffer_and_count(self): + # # Delete the buffer and count keys + # del self[self.clickhouse_buffer_key] + # del self[self.clickhouse_buffer_key + "_count"] + + @staticmethod + def get_capacity_search_keys(deployment_id: str) -> dict[str, str]: + queue_key = f"capacity_search_model_queue:{deployment_id}" + active_key = f"capacity_search_model_active:{deployment_id}" + queue_task_key = f"capacity_search_queue_task:{deployment_id}" + active_task_key = f"capacity_search_active_task:{deployment_id}" + return { + "queue_key": queue_key, + "active_key": active_key, + "queue_task_key": queue_task_key, + "active_task_key": active_task_key, + } + + @staticmethod + def get_capacity_search_cancellation_key(task_id: str) -> str: + return f"capacity_search_cancel:{task_id}" + + async def set_progress( + self, + prog: Progress, + ex: int = 240, + nx: bool = False, + **kwargs, + ) -> bool | None: + """ + Set progress data into Redis at key `prog.key`. + + Args: + prog (Progress): Progress instance. + ex (int, optional): Expiration time in seconds. Defaults to 240. + nx (bool, optional): Set this key only if it does not exist. Defaults to False. + + Returns: + response (bool | None): True if published or key is empty, otherwise None. + """ + if not prog.key: + return True + # Returns True if set, None if not + return await self._redis_async.set( + prog.key, + prog.model_dump_json(), + ex=ex, + nx=nx, + **kwargs, + ) + + async def get_progress( + self, + key: str, + response_model: Type[ProgressType] | None = Progress, + ) -> ProgressType | dict[str, Any] | None: + """ + Get progress data from Redis at key `key`. + + Args: + key (str): Progress key. + response_model (Type[ProgressType], optional): Response model. Defaults to `Progress`. + + Returns: + response (ProgressType | dict[str, Any] | None): The progress data. + """ + from owl.utils.io import json_loads + + prog = await self._redis_async.get(key) + if response_model is None: + return json_loads(prog) if prog else prog + if prog: + return response_model.model_validate_json(prog) + return response_model(key=key) + + def _ex_jitter(self) -> int: + # Jitter to prevent cache stampede + return int(self.cache_expiration * random() / 2) + + async def clear_all_async(self) -> None: + pipe = self._redis_async.pipeline() + for prefix in ["user", "organization", "project", "models"]: + async for key in self._redis_async.scan_iter(match=f"{prefix}:*"): + pipe.delete(key) + await pipe.execute() + + async def cache_organization_async(self, organization: Organization_) -> None: + await self.set( + f"organization:{organization.id}", + Organization_.model_validate(organization).model_dump_json(), + ex=self.cache_expiration + self._ex_jitter(), + ) + + async def get_organization_async( + self, + organization_id: str, + session: AsyncSession, + ) -> Organization_ | None: + from owl.db.models import Organization + + if data := await self.get(f"organization:{organization_id}"): + return Organization_.model_validate_json(data) + organization = await session.get(Organization, organization_id) + if organization is None: + return None + organization = Organization_.model_validate(organization) + await self.cache_organization_async(organization) + return organization + + async def clear_organization_async(self, organization_id: str) -> None: + await self.delete(f"organization:{organization_id}") + + async def refresh_organization_async( + self, + organization_id: str, + session: AsyncSession, + ) -> Organization_ | None: + await self.clear_organization_async(organization_id) + return await self.get_organization_async(organization_id, session) diff --git a/services/api/src/owl/utils/code.py b/services/api/src/owl/utils/code.py index 76a764e..2ca653d 100644 --- a/services/api/src/owl/utils/code.py +++ b/services/api/src/owl/utils/code.py @@ -1,58 +1,151 @@ import base64 +import pickle +import time import uuid +from contextlib import asynccontextmanager +from typing import Any import filetype import httpx from fastapi import Request from loguru import logger -from owl.configs.manager import ENV_CONFIG -from owl.utils.io import upload_file_to_s3 +from owl.configs import ENV_CONFIG +from owl.types import AUDIO_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, ColumnDtype +from owl.utils.billing import OPENTELEMETRY_CLIENT +from owl.utils.io import s3_upload +REQ_COUNTER = OPENTELEMETRY_CLIENT.get_counter("code_executor_requests_total") +REQ_SECONDS = OPENTELEMETRY_CLIENT.get_histogram("code_executor_duration_seconds") +RES_BYTES = OPENTELEMETRY_CLIENT.get_histogram("code_executor_result_bytes") -async def code_executor(source_code: str, dtype: str, request: Request) -> str | None: - response = None +def _status_class(code: int | None) -> str: + if code is None: + return "none" try: - if dtype == "image": - dtype = "file" # for code execution endpoint usage - async with httpx.AsyncClient() as client: - response = await client.post( - f"{ENV_CONFIG.code_executor_endpoint}/execute", - json={"code": source_code}, - ) - response.raise_for_status() - result = response.json() + c = int(code) + except (TypeError, ValueError): + return "none" + return f"{c // 100}xx" if 100 <= c <= 599 else "none" + + +@asynccontextmanager +async def observe_code_execution( + *, + organization_id: str, + project_id: str, + dtype: str, +): + start = time.monotonic() + outcome: str = "ok" + error_type: str | None = None + + rec: dict[str, Any] = { + "result_bytes": 0, + "status_code": None, + } + + class Recorder: + def set_result_bytes(self, n: int) -> None: + rec["result_bytes"] = max(0, int(n)) - if dtype == "file": - if result["type"].startswith("image"): - image_content = base64.b64decode(result["result"]) - content_type = filetype.guess(image_content) - if content_type is None: - raise ValueError("Unable to determine file type") - filename = f"{uuid.uuid4()}.{content_type.extension}" + def set_status_code(self, code: int) -> None: + rec["status_code"] = int(code) + try: + yield Recorder() + except Exception as exc: + outcome = "error" + error_type = exc.__class__.__name__ + raise + finally: + duration = time.monotonic() - start + labels = { + "outcome": outcome, + "error_type": error_type or "", + "status_class": _status_class(rec["status_code"]), + "status_code": rec["status_code"] or 0, + "org_id": organization_id, + "proj_id": project_id, + "dtype": dtype, + } + REQ_COUNTER.add(1, labels) + REQ_SECONDS.record(duration, labels) + RES_BYTES.record(rec["result_bytes"], labels) + + +async def code_executor( + *, + request: Request, + organization_id: str, + project_id: str, + source_code: str, + output_column: str, + row_data: dict | None, + dtype: str, +) -> str: + async with observe_code_execution( + organization_id=organization_id, + project_id=project_id, + dtype=dtype, + ) as rec: + try: + async with httpx.AsyncClient(timeout=ENV_CONFIG.code_timeout_sec) as client: + row_data = base64.b64encode(pickle.dumps(row_data)).decode("utf-8") + response = await client.post( + f"{ENV_CONFIG.code_executor_endpoint}/execute", + json={ + "source_code": source_code, + "output_column": output_column, + "row_data": row_data, + }, + ) + rec.set_status_code(response.status_code) + response.raise_for_status() + result = pickle.loads(base64.b64decode(response.text.strip('"'))) + + # Return early if output column is ColumnDtype.STR + if dtype == ColumnDtype.STR: + rec.set_result_bytes(len(str(result).encode("utf-8"))) + logger.info( + f"Code Executor: {request.state.id} - Python code execution completed for column {output_column}" + ) + return str(result) + + if not isinstance(result, bytes): + raise Exception( + f"Expected type bytes for {dtype}, got {type(result)}:\n\n{str(result)[:100]}" + ) + + rec.set_result_bytes(len(result)) + + content_type = filetype.guess(result) + if not content_type: + raise Exception("Result is bytes but could not determine content type") + + file_extension = f".{content_type.extension}" + + # Handle different data types + if (dtype == ColumnDtype.IMAGE and file_extension in IMAGE_FILE_EXTENSIONS) or ( + dtype == ColumnDtype.AUDIO and file_extension in AUDIO_FILE_EXTENSIONS + ): + filename = f"{uuid.uuid4()}{file_extension}" # Upload the file - uri = await upload_file_to_s3( - organization_id=request.state.org_id, - project_id=request.state.project_id, - content=image_content, + uri = await s3_upload( + organization_id=organization_id, + project_id=project_id, + content=result, content_type=content_type.mime, filename=filename, ) - response = uri - else: - logger.warning( - f"Code Executor: {request.state.id} - Unsupported file type: {result['type']}" + logger.info( + f"Code Executor: {request.state.id} - Python code execution completed for column {output_column}" ) - response = None - else: - response = str(result["result"]) - - logger.info(f"Code Executor: {request.state.id} - Python code execution completed") + return uri - except Exception as e: - logger.error(f"Code Executor: {request.state.id} - An unexpected error occurred: {e}") - response = None - - return response + except Exception as e: + logger.error( + f"Code Executor: {request.state.id} - Python code execution encountered error for column {output_column} : {e}" + ) + raise diff --git a/services/api/src/owl/utils/crypt.py b/services/api/src/owl/utils/crypt.py index bcc48b0..e0d43f6 100644 --- a/services/api/src/owl/utils/crypt.py +++ b/services/api/src/owl/utils/crypt.py @@ -7,7 +7,9 @@ import hashlib import secrets from base64 import b64decode, b64encode +from functools import lru_cache from hashlib import blake2b +from typing import Any # Import Union for type annotations from Cryptodome.Cipher import AES from Cryptodome.Random import get_random_bytes @@ -15,7 +17,11 @@ def _encrypt(message: str, password: str, aes_mode: int) -> str: """ - pass + Encrypts a message using AES encryption with the given password and mode. + :param message: The message to encrypt. + :param password: The password to use for encryption. + :param aes_mode: The AES mode to use (either AES.MODE_SIV or AES.MODE_GCM). + :return: The encrypted message as a string. """ if not (aes_mode == AES.MODE_SIV or aes_mode == AES.MODE_GCM): raise ValueError("`aes_mode` can only be `AES.MODE_SIV` or `AES.MODE_GCM`.") @@ -37,16 +43,20 @@ def _encrypt(message: str, password: str, aes_mode: int) -> str: ) # Create cipher config cipher_config = AES.new(private_key, aes_mode) + # Encrypt the message cipher_text, tag = cipher_config.encrypt_and_digest(message.encode("utf-8")) - cipher_text = b64encode(cipher_text).decode("utf-8") - tag = b64encode(tag).decode("utf-8") + # Encode the cipher_text and tag to base64 + cipher_text_b64 = b64encode(cipher_text).decode("utf-8") + tag_b64 = b64encode(tag).decode("utf-8") + # Create final encrypted text if aes_mode == AES.MODE_SIV: - encrypted = f"{cipher_text}*{tag}" + encrypted = f"{cipher_text_b64}*{tag_b64}" else: - salt = b64encode(salt).decode("utf-8") - nonce = b64encode(cipher_config.nonce).decode("utf-8") - encrypted = f"{cipher_text}*{salt}*{nonce}*{tag}" + salt_b64 = b64encode(salt).decode("utf-8") + nonce_b64 = b64encode(cipher_config.nonce).decode("utf-8") + encrypted = f"{cipher_text_b64}*{salt_b64}*{nonce_b64}*{tag_b64}" + return encrypted @@ -57,6 +67,7 @@ def encrypt_random(message: str, password: str) -> str: return _encrypt(message, password, AES.MODE_GCM) +@lru_cache(maxsize=100000) def encrypt_deterministic(message: str, password: str) -> str: """ Deterministic encryption using AES SIV mode with @@ -65,47 +76,60 @@ def encrypt_deterministic(message: str, password: str) -> str: return _encrypt(message, password, AES.MODE_SIV) +@lru_cache(maxsize=100000) def decrypt(encrypted: str, password: str) -> str: + """ + Decrypts an encrypted message using AES decryption with the given password. + + :param encrypted: The encrypted message as a string. + :param password: The password used for decryption. + :return: The decrypted message as a string. + """ parts = encrypted.split("*") n_parts = len(parts) # Decode the entries from base64 if n_parts == 4: - cipher_text, salt, nonce, tag = parts - salt = b64decode(salt) - nonce = b64decode(nonce) + cipher_text_b64, salt_b64, nonce_b64, tag_b64 = parts + salt = b64decode(salt_b64) # Decode salt to bytes + nonce = b64decode(nonce_b64) # Decode nonce to bytes elif n_parts == 2: - cipher_text, tag = parts - salt = b"" - nonce = None - # elif n_parts == 1: - # logger.warning(f"Attempting to decrypt string that looks unencrypted: {encrypted}") - # return encrypted + cipher_text_b64, tag_b64 = parts + salt = b"" # Use empty salt for AES.MODE_SIV + nonce = None # No nonce for AES.MODE_SIV else: raise ValueError(f"Encrypted string must have either 2 or 4 parts, received: {n_parts}") - cipher_text = b64decode(cipher_text) - tag = b64decode(tag) + + # Decode cipher_text and tag to bytes + cipher_text = b64decode(cipher_text_b64) + tag = b64decode(tag_b64) + # Generate the private key from the password and salt private_key = hashlib.scrypt( - password.encode(), - salt=salt, + password.encode(), # Encode password to bytes + salt=salt, # salt is already bytes n=2**14, r=8, p=1, dklen=32, ) + # Create the cipher config + cipher: Any # Use Any to avoid issues with inaccessible types if n_parts == 4: - cipher = AES.new(private_key, AES.MODE_GCM, nonce=nonce) + cipher = AES.new(private_key, AES.MODE_GCM, nonce=nonce) # Use GCM mode with nonce else: - cipher = AES.new(private_key, AES.MODE_SIV) + cipher = AES.new(private_key, AES.MODE_SIV) # Use SIV mode + # Decrypt the cipher text - decrypted = cipher.decrypt_and_verify(cipher_text, tag) - return decrypted.decode("UTF-8") + decrypted = cipher.decrypt_and_verify(cipher_text, tag) # Both inputs are bytes + return decrypted.decode("UTF-8") # Decode the decrypted bytes to a string -def hash_string_blake2b(string: str, digest_size: int = 8) -> str: - hasher = blake2b(digest_size=digest_size) +def hash_string_blake2b(string: str, key_length: int = 8) -> str: + if key_length % 2 != 0: + raise ValueError("Key length must be a multiple of 2.") + hasher = blake2b(digest_size=key_length // 2) # 2 characters per byte hasher.update(string.encode()) return hasher.hexdigest() @@ -132,13 +156,13 @@ def generate_key(key_length: int = 48, prefix: str = "") -> str: prefix (str, optional): Prefix of the key. Defaults to "". Raises: - ValueError: If `key_length` is < 16 or not a multiple of 2. + ValueError: If `key_length` is < 8 or not a multiple of 2. Returns: api_key (str): A random key. """ - if key_length < 16: - raise ValueError("Key length must be at least 16 characters.") + if key_length < 8: + raise ValueError("Key length must be at least 8 characters.") if key_length % 2 != 0: raise ValueError("Key length must be a multiple of 2.") api_key = blake2b(secrets.token_bytes(key_length), digest_size=key_length // 2).hexdigest() diff --git a/services/api/src/owl/utils/dates.py b/services/api/src/owl/utils/dates.py new file mode 100644 index 0000000..4996017 --- /dev/null +++ b/services/api/src/owl/utils/dates.py @@ -0,0 +1,14 @@ +from jamaibase.utils.dates import ( # noqa: F401 + date_to_utc, + date_to_utc_iso, + earliest, + ensure_utc_timezone, + now, + now_iso, + now_tz_naive, + utc_datetime_from_iso, + utc_iso_from_datetime, + utc_iso_from_string, + utc_iso_from_uuid7, + utc_iso_from_uuid7_draft2, +) diff --git a/services/api/src/owl/utils/exceptions.py b/services/api/src/owl/utils/exceptions.py index 991fedd..8844185 100644 --- a/services/api/src/owl/utils/exceptions.py +++ b/services/api/src/owl/utils/exceptions.py @@ -1,21 +1,37 @@ -from functools import wraps +from functools import partial, wraps from inspect import iscoroutinefunction -from typing import Any, Callable, Type, TypeVar, overload +from typing import Any, Callable, TypeVar, overload 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 -from jamaibase.exceptions import JamaiException, ResourceExistsError, UnexpectedError - - -def check_type(obj: Any, clss: tuple[Type] | Type, mssg: str) -> None: - if not isinstance(obj, clss): - raise TypeError(f"{mssg} Received: {type(obj)}") - +# Import from jamaibase for use within owl +from jamaibase.utils.exceptions import ( # noqa: F401 + AuthorizationError, + BadInputError, + BaseTierCountError, + ContextOverflowError, + ExternalAuthError, + ForbiddenError, + InsufficientCreditsError, + JamaiException, + MethodNotAllowedError, + ModelCapabilityError, + ModelOverloadError, + NoTierError, + RateLimitExceedError, + ResourceExistsError, + ResourceNotFoundError, + ServerBusyError, + UnavailableError, + UnexpectedError, + UnsupportedMediaTypeError, + UpgradeTierError, + UpStreamError, + docstring_message, +) F = TypeVar("F", bound=Callable[..., Any]) @@ -24,29 +40,27 @@ def check_type(obj: Any, clss: tuple[Type] | Type, mssg: str) -> None: def handle_exception( func: F, *, - failure_message: str = "", + handler: Callable[..., Any] | None = None, ) -> F: ... @overload def handle_exception( *, - failure_message: str = "", + handler: Callable[..., Any] | None = None, ) -> Callable[[F], F]: ... 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`. + - Produce shorter traceback (160 vs 500 lines) upon unexpected errors (such as `ValueError`). + - Transform certain error classes, for example `IntegrityError` -> `ResourceExistsError`. It also allows you to specify a custom exception handler function. The handler function should accept a single positional argument (the exception instance) @@ -57,84 +71,64 @@ def handle_exception( 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. + The handler function should accept a positional argument (the exception instance) + followed by all 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. + JamaiException: If `JamaiException` is raised. + RequestValidationError: If `fastapi.exceptions.RequestValidationError` is raised. + ResourceExistsError: If `sqlalchemy.exc.IntegrityError` indicates a unique constraint violation in the database. + UnexpectedError: For all other exception. """ - 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 + def _default_handler(e: Exception, *args, **kwargs): + if isinstance(e, JamaiException): + raise + elif isinstance(e, RequestValidationError): + raise + # elif isinstance(e, ValidationError): + # raise RequestValidationError(errors=e.errors()) from e + elif isinstance(e, IntegrityError): + err_mssg: str = e.args[0] + err_mssgs = err_mssg.split("UNIQUE constraint failed:") + if len(err_mssgs) > 1: + constraint = err_mssgs[1].strip() + raise ResourceExistsError(f'DB item "{constraint}" already exists.') from e 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 + else: + request: Request | None = kwargs.get("request", None) + mssg = f"Failed to run {func.__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): + if handler is None: + handler = _default_handler - @wraps(fn) - async def wrapper(**kwargs): - try: - return await fn(**kwargs) - except Exception as e: - return _handle_exception(e, kwargs) + if iscoroutinefunction(func): - else: + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + return handler(e, *args, **kwargs) - @wraps(fn) - def wrapper(**kwargs): - try: - return fn(**kwargs) - except Exception as e: - return _handle_exception(e, kwargs) + else: - return wrapper + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + return handler(e, *args, **kwargs) - return decorator if func is None else decorator(func) + return partial(handle_exception, handler=handler) if func is None else wrapper diff --git a/services/api/src/owl/utils/handlers.py b/services/api/src/owl/utils/handlers.py new file mode 100644 index 0000000..07cd952 --- /dev/null +++ b/services/api/src/owl/utils/handlers.py @@ -0,0 +1,391 @@ +from typing import Any, Mapping + +import orjson +from fastapi import Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import ORJSONResponse +from loguru import logger +from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError +from starlette.exceptions import HTTPException + +from owl.utils import mask_string +from owl.utils.exceptions import ( + AuthorizationError, + BadInputError, + ContextOverflowError, + ExternalAuthError, + ForbiddenError, + InsufficientCreditsError, + JamaiException, + MethodNotAllowedError, + ModelOverloadError, + RateLimitExceedError, + ResourceExistsError, + ResourceNotFoundError, + ServerBusyError, + UnavailableError, + UnsupportedMediaTypeError, + UpgradeTierError, +) + +INTERNAL_ERROR_MESSAGE = "Oops sorry we ran into an unexpected error. Please try again later." + + +def make_request_log_str(request: Request, status_code: int | None = None) -> 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 "" + msg = f'{request.state.id} - "{request.method} {request.url.path}{query}"' + if status_code is not None: + msg = f"{msg} {status_code}" + return msg + + +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)}" + if headers is None: + headers = {} + headers["x-request-id"] = request.headers.get("x-request-id", "") + request_headers = {k.lower(): v for k, v in request.headers.items()} + token = request_headers.get("authorization", "") + if token.startswith("Bearer "): + request_headers["authorization"] = f"Bearer {mask_string(token[7:], include_len=False)}" + else: + request_headers["authorization"] = mask_string(token, include_len=False) + 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, + "request_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 + + +class Wrapper(BaseModel): + body: Any + + +async def _request_validation_exc_handler(request: Request, exc: RequestValidationError): + content = None + try: + logger.info( + f"{make_request_log_str(request, 422)} - RequestValidationError: {exc.errors()}" + ) + errors, messages = [], [] + for i, e in enumerate(exc.errors()): + try: + msg = str(e["ctx"]["error"]).strip() + except Exception: + msg = e["msg"].strip() + if not msg.endswith("."): + msg = f"{msg}." + + 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}" + 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=content, + ) + except Exception: + 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": message, + "detail": message, + "request_id": request.state.id, + "exception": exc.__class__.__name__, + }, + ) + + +async def path_not_found_handler(request: Request, e: HTTPException): + return make_response( + request=request, + message=f"The path '{request.url.path}' was not found.", + error="http_error", + status_code=e.status_code, + exception=e, + log=False, + ) + + +async def exception_handler(request: Request, e: Exception): + if isinstance(e, RequestValidationError): + return await _request_validation_exc_handler(request, e) + # elif isinstance(e, ValidationError): + # raise RequestValidationError(errors=e.errors()) from e + elif isinstance(e, AuthorizationError): + return make_response( + request=request, + message=str(e), + error="unauthorized", + status_code=status.HTTP_401_UNAUTHORIZED, + exception=e, + ) + elif isinstance(e, ExternalAuthError): + return make_response( + request=request, + message=str(e), + error="external_authentication_failed", + status_code=status.HTTP_401_UNAUTHORIZED, + exception=e, + ) + elif isinstance(e, PermissionError): + return make_response( + request=request, + message=str(e), + error="resource_protected", + status_code=status.HTTP_403_FORBIDDEN, + exception=e, + ) + elif isinstance(e, ForbiddenError): + return make_response( + request=request, + message=str(e), + error="forbidden", + status_code=status.HTTP_403_FORBIDDEN, + exception=e, + ) + elif isinstance(e, UpgradeTierError): + return make_response( + request=request, + message=str(e), + error="upgrade_tier", + status_code=status.HTTP_403_FORBIDDEN, + exception=e, + ) + elif isinstance(e, InsufficientCreditsError): + return make_response( + request=request, + message=str(e), + error="insufficient_credits", + status_code=status.HTTP_403_FORBIDDEN, + exception=e, + ) + elif isinstance(e, (ResourceNotFoundError, FileNotFoundError)): + return make_response( + request=request, + message=str(e), + error="resource_not_found", + status_code=status.HTTP_404_NOT_FOUND, + exception=e, + ) + elif isinstance(e, (ResourceExistsError, FileExistsError)): + return make_response( + request=request, + message=str(e), + error="resource_exists", + status_code=status.HTTP_409_CONFLICT, + exception=e, + ) + elif isinstance(e, UnsupportedMediaTypeError): + return make_response( + request=request, + message=str(e), + error="unsupported_media_type", + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + exception=e, + ) + elif isinstance(e, BadInputError): + return make_response( + request=request, + message=str(e), + error="bad_input", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + exception=e, + ) + elif isinstance(e, ContextOverflowError): + return make_response( + request=request, + message=str(e), + error="context_overflow", + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + exception=e, + ) + elif isinstance(e, RateLimitExceedError): + retry_after = "30" if e.retry_after is None else str(e.retry_after) + used = str(e.limit) if e.used is None else str(e.used) + meta = "{}" if e.meta is None else orjson.dumps(e.meta).decode("utf-8") + return make_response( + request=request, + message=str(e), + error="rate_limit_exceeded", + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + exception=e, + headers={ + "X-RateLimit-Limit": str(e.limit), + "X-RateLimit-Remaining": str(e.remaining), + "X-RateLimit-Reset": str(e.reset_at), + "Retry-After": retry_after, + "X-RateLimit-Used": used, + "X-RateLimit-Meta": meta, + }, + ) + elif isinstance(e, UnavailableError): + return make_response( + request=request, + message=str(e), + error="not_implemented", + status_code=status.HTTP_501_NOT_IMPLEMENTED, + exception=e, + ) + elif isinstance(e, ServerBusyError): + return make_response( + request=request, + message="The server is currently busy. Please try again later.", + error="busy", + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + exception=e, + headers={"Retry-After": "30"}, + ) + elif isinstance(e, ModelOverloadError): + return make_response( + request=request, + message="The model is overloaded. Please try again later.", + error="busy", + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + exception=e, + headers={"Retry-After": "30"}, + ) + elif isinstance(e, HTTPException): + return make_response( + request=request, + message=e.detail, + error="http_error", + status_code=e.status_code, + exception=e, + log=e.status_code != 404, + ) + elif isinstance(e, IntegrityError): + err_mssg: str = e.args[0] + err_mssgs = err_mssg.split("UNIQUE constraint failed:") + if len(err_mssgs) > 1: + constraint = err_mssgs[1].strip() + return make_response( + request=request, + message=f'DB item "{constraint}" already exists.', + error="resource_exists", + status_code=status.HTTP_409_CONFLICT, + exception=e, + ) + else: + return make_response( + request=request, + message=INTERNAL_ERROR_MESSAGE, + error="unexpected_error", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + exception=e, + ) + elif isinstance(e, MethodNotAllowedError): + return make_response( + request=request, + message=str(e), + error="method_not_allowed", + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + exception=e, + ) + else: + return make_response( + request=request, + message=INTERNAL_ERROR_MESSAGE, + error="unexpected_error", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + exception=e, + ) diff --git a/services/api/src/owl/utils/io.py b/services/api/src/owl/utils/io.py index 91fef1e..8f5149b 100644 --- a/services/api/src/owl/utils/io.py +++ b/services/api/src/owl/utils/io.py @@ -1,78 +1,63 @@ -import asyncio -import contextlib import os -import pathlib -import zipfile +from contextlib import asynccontextmanager +from hashlib import blake2b 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 +from os.path import join, splitext +from pathlib import Path +from typing import AsyncGenerator, BinaryIO 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_audio_thumbnail, generate_image_thumbnail -from owl.configs.manager import ENV_CONFIG +from PIL import Image, ImageDraw, ImageFont +from sqlmodel import select + +from jamaibase.utils.io import ( # noqa: F401 + AUDIO_WHITE_LIST, + DOC_WHITE_LIST, + EMBED_WHITE_LIST, + IMAGE_WHITE_LIST, + csv_to_df, + df_to_csv, + dump_json, + dump_pickle, + dump_toml, + dump_yaml, + guess_mime, + json_dumps, + json_loads, + load_pickle, + read_image, + read_json, + read_toml, + read_yaml, +) +from owl.configs import ENV_CONFIG +from owl.types import DBStorageUsage, TableType from owl.utils import uuid7_str +from owl.utils.exceptions import BadInputError, ResourceNotFoundError -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}") +S3_BUCKET_NAME = ENV_CONFIG.file_dir.replace("s3://", "") +ASSET_DIRPATH = Path(__file__).resolve().parent.parent / "assets" +ICON_DIRPATH = ASSET_DIRPATH / "icons" +if ICON_DIRPATH.is_dir() and (ICON_DIRPATH / "csv.webp").is_file(): + logger.info(f'Documents icons will be loaded from "{ICON_DIRPATH}".') 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"], -} -AUDIO_WHITE_LIST = { - "audio/mpeg": [".mp3"], - "audio/vnd.wav": [".wav"], - "audio/x-wav": [".wav"], - "audio/x-pn-wav": [".wav"], - "audio/wave": [".wav"], - "audio/vnd.wave": [".wav"], -} + ICON_DIRPATH = None + logger.warning( + f'Documents icons not found in "{ICON_DIRPATH}". Falling back to generating text-based thumbnails.' + ) +GiB = 1024**3 + UPLOAD_WHITE_LIST = {**EMBED_WHITE_LIST, **IMAGE_WHITE_LIST, **AUDIO_WHITE_LIST} EMBED_WHITE_LIST_MIME = set(EMBED_WHITE_LIST.keys()) EMBED_WHITE_LIST_EXT = set(ext for exts in EMBED_WHITE_LIST.values() for ext in exts) +DOC_WHITE_LIST_MIME = set(DOC_WHITE_LIST.keys()) +DOC_WHITE_LIST_EXT = set(ext for exts in DOC_WHITE_LIST.values() for ext in exts) +NON_PDF_DOC_WHITE_LIST_EXT = set( + ext for exts in DOC_WHITE_LIST.values() for ext in exts if ext != ".pdf" +) IMAGE_WHITE_LIST_MIME = set(IMAGE_WHITE_LIST.keys()) IMAGE_WHITE_LIST_EXT = set(ext for exts in IMAGE_WHITE_LIST.values() for ext in exts) AUDIO_WHITE_LIST_MIME = set(AUDIO_WHITE_LIST.keys()) @@ -81,86 +66,7 @@ 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 +@asynccontextmanager async def get_s3_aclient(): async with aioboto3.Session().client( "s3", @@ -171,74 +77,224 @@ async def get_s3_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}') +@asynccontextmanager +async def open_uri_async(uri: str) -> AsyncGenerator[tuple[BinaryIO | BytesIO, str], None]: + if isinstance(uri, str) and 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"], str(response["ContentType"]) + except ClientError as e: + if "NoSuchKey" in str(e): raise ResourceNotFoundError(f'File "{uri}" is not found.') from e - else: - raise ResourceNotFoundError(f'File "{uri}" is not found.') + logger.warning(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" cannot be opened.') from e + except Exception as e: + logger.exception(f'Failed to open "{uri}" due to {e.__class__.__name__}: {e}') + raise ResourceNotFoundError(f'File "{uri}" cannot be opened.') from e else: - if uri.startswith("file://"): + raise ResourceNotFoundError(f'File "{uri}" cannot be opened.') + + +def get_bytes_size_mb(bytes_content: bytes, decimal_places: int = 3) -> float: + """ + Convert bytes to megabytes (MB). + + Args: + bytes_content (bytes): The content in bytes to be calculated. + decimal_places (int, optional): Number of decimal places to round to. Defaults to 3. + + Returns: + float: The converted value in megabytes (MB) + """ + mb_value = len(bytes_content) / (1024 * 1024) # 1 MB = 1024 KB = 1024 * 1024 bytes + return round(mb_value, decimal_places) + + +def _image_to_webp_bytes(image: Image.Image) -> bytes: + """ + Converts an image to bytes. + + Args: + image (Image.Image): The image. + + Returns: + bytes: The image as bytes (WebP format). + """ + with BytesIO() as f: + image.save( + f, + format="webp", + lossless=False, + quality=60, + alpha_quality=50, + method=6, + exact=False, + ) + return f.getvalue() + + +def generate_image_thumbnail( + file_content: bytes, + size: tuple[float, float] = (450.0, 450.0), +) -> bytes | None: + """ + Generates an image thumbnail. + + Args: + file_content (bytes): The image file content. + size (tuple[float, float]): The desired size of the thumbnail (width, height). + Defaults to (450.0, 450.0). + + Returns: + thumbnail (bytes | None): The thumbnail image as bytes, or None if generation fails. + """ + 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) + return _image_to_webp_bytes(img) + except Exception as e: + logger.exception(f"Failed to generate image thumbnail due to {e.__class__.__name__}: {e}") + return None + + +def generate_audio_thumbnail( + file_content: bytes, + duration_ms: int = 30000, +) -> bytes | None: + """ + Generates an audio thumbnail by extracting a segment from the original audio. + + Args: + file_content (bytes): The audio file content. + duration_ms (int): Duration of the thumbnail in milliseconds. + Defaults to 30000 (30 seconds). + + Returns: + thumbnail (bytes | None): The thumbnail audio as bytes, or None if generation fails. + """ + from pydub import AudioSegment + + try: + # Extract the first `duration_ms` milliseconds + audio = AudioSegment.from_file(BytesIO(file_content)) + thumbnail = audio[:duration_ms] + # Export the thumbnail to a bytes object + with BytesIO() as output: + thumbnail.export(output, format="mp3") + return output.getvalue() + except Exception as e: + logger.exception(f"Failed to generate audio thumbnail due to {e.__class__.__name__}: {e}") + return None + + +def generate_pdf_thumbnail( + file_content: bytes, + size: tuple[int, int] = (950, 950), +) -> bytes | None: + """ + Generates a PDF thumbnail image. + + Args: + file_content (bytes): The PDF file content. + size (tuple[int, int]): The desired size of the thumbnail (width, height). + Defaults to (950, 950). + + Returns: + thumbnail (bytes | None): The thumbnail image as bytes, or None if generation fails. + """ + from pdf2image import convert_from_bytes + + try: + images = convert_from_bytes( + file_content, + dpi=200, + first_page=1, + last_page=1, # process only the first page + ) + if not images: + return b"" + img = images[0] + img.thumbnail(size=size) + thumbnail_bytes = _image_to_webp_bytes(img) + for image in images: + image.close() # release resources + return thumbnail_bytes + + except Exception as e: + logger.exception(f"Failed to generate PDF thumbnail: {e.__class__.__name__}: {e}") + return None + + +def _generate_text_thumbnail(file_extension: str, size: tuple[int, int]) -> bytes: + """Generates a text-based thumbnail (as a fallback).""" + try: + img = Image.new("RGB", size, color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + + text = file_extension + font_size = min(size) // 2 + while font_size > 1: 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: + font_ttf = ASSET_DIRPATH / "Roboto-Regular.ttf" + font = ImageFont.truetype(font_ttf, font_size) + except OSError: + logger.warning("Roboto font not found. Using default fallback font.") + font = ImageFont.load_default() + break + + text_bbox = draw.textbbox((0, 0), text, font=font) + if ( + text_bbox[2] - text_bbox[0] < size[0] * 0.9 + and text_bbox[3] - text_bbox[1] < size[1] * 0.9 + ): + break + font_size -= 1 + + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + text_x = (size[0] - text_width) // 2 + text_y = (size[1] - text_height) // 2 + + draw.text((text_x, text_y), text, fill=(0, 0, 0), font=font) + + return _image_to_webp_bytes(img) + + except Exception as e: + logger.exception(f"Failed to generate text thumbnail: {e.__class__.__name__}: {e}") + return b"" + + +def generate_extension_name_thumbnail( + file_extension: str, + size: tuple[int, int] = (512, 512), +) -> bytes: + """ + Loads a pre-generated thumbnail based on the file extension. + If no icon is found, falls back to generating a text-based thumbnail. + """ + if ICON_DIRPATH: + icon_path = ICON_DIRPATH / f"{file_extension[1:]}.webp" + try: + with open(icon_path, "rb") as f: + img = Image.open(f) + if img.size != size: + img.thumbnail(size) + return _image_to_webp_bytes(img) + except Exception as e: + logger.exception(f"Error loading pre-generated icon: {repr(e)}") + # Fallback: Generate a text-based thumbnail if the icon is not found or there's an error. + return _generate_text_thumbnail(file_extension, size) + + +def _os_path_to_s3_key(path: Path | str) -> str: # Convert path to string if it's a PathLike object path_str = str(path) # Replace backslashes with forward slashes @@ -247,97 +303,384 @@ def os_path_to_s3_key(path: pathlib.Path | str) -> str: return s3_key.lstrip("/") -async def upload_file_to_s3( +async def s3_upload( organization_id: str, project_id: str, content: bytes, + *, content_type: str, filename: str, + generate_thumbnail: bool = True, + key: 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)}" + f'Unsupported MIME type "{content_type}" for file "{filename}". Allowed types are: {", ".join(UPLOAD_WHITE_LIST_MIME)}' ) - file_extension = os.path.splitext(filename)[1].lower() + file_extension = 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)}" + f'Unsupported extension "{file_extension}" for file "{filename}". Allowed types are: {", ".join(UPLOAD_WHITE_LIST_EXT)}' ) else: if ( file_extension in EMBED_WHITE_LIST_EXT - and len(content) > ENV_CONFIG.owl_embed_file_upload_max_bytes + and len(content) > ENV_CONFIG.embed_file_upload_max_bytes ): raise BadInputError( - f"File size exceeds {ENV_CONFIG.owl_embed_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + f"File size exceeds {ENV_CONFIG.embed_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" ) elif ( file_extension in AUDIO_WHITE_LIST_EXT - and len(content) > ENV_CONFIG.owl_audio_file_upload_max_bytes + and len(content) > ENV_CONFIG.audio_file_upload_max_bytes ): raise BadInputError( - f"File size exceeds {ENV_CONFIG.owl_audio_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + f"File size exceeds {ENV_CONFIG.audio_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" ) elif ( file_extension in IMAGE_WHITE_LIST_EXT - and len(content) > ENV_CONFIG.owl_image_file_upload_max_bytes + and len(content) > ENV_CONFIG.image_file_upload_max_bytes ): raise BadInputError( - f"File size exceeds {ENV_CONFIG.owl_image_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" + f"File size exceeds {ENV_CONFIG.image_file_upload_max_bytes / 1024**2} MB limit: {len(content) / 1024**2} MB" ) - - uuid = uuid7_str() - raw_path = os.path.join("raw", organization_id, project_id, uuid, filename) - raw_key = os_path_to_s3_key(raw_path) + # Process key + if key: + key = key.removeprefix(f"s3://{S3_BUCKET_NAME}/").lstrip("/") + if not key.startswith("raw/"): + raise BadInputError( + f'Invalid S3 key "{key}". Must start with one of ["raw/", "s3:///raw/"].' + ) + else: + key = join("raw", organization_id, project_id, uuid7_str(), filename) + raw_key = _os_path_to_s3_key(key) thumb_ext = "mp3" if file_extension in AUDIO_WHITE_LIST_EXT else "webp" - thumb_filename = f"{os.path.splitext(filename)[0]}.{thumb_ext}" - thumb_path = os.path.join("thumb", organization_id, project_id, uuid, thumb_filename) - thumb_key = os_path_to_s3_key(thumb_path) - if file_extension in AUDIO_WHITE_LIST_EXT: - thumbnail_task = asyncio.create_task(asyncio.to_thread(generate_audio_thumbnail, content)) + thumb_key = f"{splitext(raw_key.replace('raw/', 'thumb/', 1))[0]}.{thumb_ext}" + if generate_thumbnail: + if file_extension == ".pdf": + thumbnail = generate_pdf_thumbnail(content) + elif file_extension in NON_PDF_DOC_WHITE_LIST_EXT: + thumbnail = await generate_document_thumbnail(file_extension) + elif file_extension in AUDIO_WHITE_LIST_EXT: + thumbnail = generate_audio_thumbnail(content) + else: + thumbnail = generate_image_thumbnail(content) else: - thumbnail_task = asyncio.create_task(asyncio.to_thread(generate_image_thumbnail, content)) - thumbnail = await thumbnail_task - - if S3_CLIENT: + thumbnail = None + + 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 thumbnail is not None: + await aclient.put_object( + Body=thumbnail, + Bucket=S3_BUCKET_NAME, + Key=thumb_key, + ContentType=f"{content_type.split('/')[0]}/{'mpeg' if thumb_ext == 'mp3' else thumb_ext}", + ) + 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}" + + +# async def s3_cache_file( +# content: bytes, +# content_type: str, +# ) -> str: +# content_len = len(content) +# content_hash = blake2b(content).hexdigest() +# s3_key = f"temp/{content_hash}-{content_len}" +# uri = f"s3://{S3_BUCKET_NAME}/{s3_key}" +# # Upload file +# async with get_s3_aclient() as aclient: +# # If file already exists, skip +# try: +# await aclient.head_object(Bucket=S3_BUCKET_NAME, Key=s3_key) +# return uri +# except Exception: +# pass +# # Upload +# await aclient.put_object( +# Body=content, +# Bucket=S3_BUCKET_NAME, +# Key=s3_key, +# ContentType=content_type, +# ) +# logger.info(f"S3 file created: {uri}") +# return uri + + +# async def s3_delete( +# *, +# organization_id: str = "", +# project_id: str = "", +# filename: str = "", +# key: str = "", +# delete_thumbnail: bool = True, +# ) -> str: +# # Process key +# if key: +# key = key.removeprefix(f"s3://{S3_BUCKET_NAME}/").lstrip("/") +# if not key.startswith(("raw/", "temp/")): +# raise BadInputError( +# ( +# f'Invalid S3 key "{key}". Must start with one of ' +# '["raw/", "temp/", "s3:///raw/", "s3:///temp/"].' +# ) +# ) +# else: +# key = join("raw", organization_id, project_id, uuid7_str(), filename) +# raw_key = _os_path_to_s3_key(key) +# file_extension = splitext(filename)[1].lower() +# thumb_ext = "mp3" if file_extension in AUDIO_WHITE_LIST_EXT else "webp" +# thumb_key = f"{splitext(raw_key.replace('raw/', 'thumb/', 1))[0]}.{thumb_ext}" + +# async with get_s3_aclient() as aclient: +# # Delete raw file +# await aclient.delete_object(Bucket=S3_BUCKET_NAME, Key=raw_key) +# # Delete thumbnail +# if delete_thumbnail: +# try: +# await aclient.delete_object(Bucket=S3_BUCKET_NAME, Key=thumb_key) +# except Exception as e: +# logger.warning(f'Failed to delete thumbnail "{thumb_key}": {repr(e)}') +# logger.info(f"File deleted: s3://{S3_BUCKET_NAME}/{raw_key}") +# return raw_key + + +@asynccontextmanager +async def s3_temporary_file( + content: bytes, + content_type: str, +) -> AsyncGenerator[str, None]: + from owl.configs import CACHE + + content_len = len(content) + content_hash = blake2b(content).hexdigest() + cache_key = f"temp:{content_hash}-{content_len}" + s3_key = cache_key.replace(":", "/") + # This lock is so that we don't upload the same file twice + async with CACHE.alock(f"{cache_key}:lock", blocking=True, expire=180) as lock_acquired: + if not lock_acquired: + raise BadInputError("Another upload of this file is in progress.") + # Upload file async with get_s3_aclient() as aclient: - # Upload raw file await aclient.put_object( Body=content, Bucket=S3_BUCKET_NAME, - Key=raw_key, + Key=s3_key, ContentType=content_type, ) - if len(thumbnail) > 0: - await aclient.put_object( - Body=thumbnail, - Bucket=S3_BUCKET_NAME, - Key=thumb_key, - ContentType=f"{content_type.split('/')[0]}/{"mpeg" if thumb_ext == "mp3" else thumb_ext}", - ) - 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}. " + uri = f"s3://{S3_BUCKET_NAME}/{s3_key}" + logger.info(f"Temporary S3 file created: {uri}") + try: + yield uri + finally: + # Delete file + try: + async with get_s3_aclient() as aclient: + await aclient.delete_object(Bucket=S3_BUCKET_NAME, Key=s3_key) + logger.info(f"Temporary S3 file deleted: {uri}") + except Exception as e: + logger.warning(f'Failed to delete temporary S3 file "{uri}": {repr(e)}') + + +def get_global_thumbnail_path(extension: str) -> str: + """Returns the path for a global thumbnail based on file extension.""" + return join("thumb", "global", f"{extension[1:]}.webp") + + +async def get_global_thumbnail(extension: str) -> bytes | None: + """Retrieves a global thumbnail if it exists.""" + + try: + thumbnail_path = get_global_thumbnail_path(extension) + async with get_s3_aclient() as aclient: + try: + response = await aclient.get_object(Bucket=S3_BUCKET_NAME, Key=thumbnail_path) + return await response["Body"].read() + except ClientError: + return None + except Exception as e: + logger.warning(f"Failed to get global thumbnail: {e}") + return None + + +async def save_global_thumbnail(extension: str, thumbnail: bytes) -> None: + """Saves a global thumbnail for future use.""" + + try: + thumbnail_path = get_global_thumbnail_path(extension) + async with get_s3_aclient() as aclient: + await aclient.put_object( + Body=thumbnail, + Bucket=S3_BUCKET_NAME, + Key=thumbnail_path, + ContentType="image/webp", + ) + except Exception as e: + logger.warning(f"Failed to save global thumbnail: {e}") + + +async def generate_document_thumbnail( + file_extension: str, + size: tuple[int, int] = (512, 512), +) -> None: + """ + Generates a thumbnail based on the given file extension with global cache. + > if doc and non-pdf, generate global thumbnail, no local thumbnail + > when get thumbnail url, check raw url for extension, get global thumbnail url + + Args: + file_extension (str): The file extension (e.g., ".xlsx"). + size (tuple[int, int]): The desired size (width, height) of the thumbnail. + """ + file_extension = file_extension.lower() + if file_extension not in NON_PDF_DOC_WHITE_LIST_EXT: + raise ValueError(f"Unsupported file extension: {file_extension}") + try: + # Check global cache first + if (await get_global_thumbnail(file_extension)) is not None: + return + # Generate and cache new thumbnail + thumbnail_path = get_global_thumbnail_path(file_extension) + async with get_s3_aclient() as aclient: + await aclient.put_object( + Body=generate_extension_name_thumbnail(file_extension, size), + Bucket=S3_BUCKET_NAME, + Key=thumbnail_path, + ContentType="image/webp", + ) + return + except Exception as e: + logger.exception(f"Failed to generate file thumbnail due to {e.__class__.__name__}: {e}") + + +async def get_file_storage_usage(org_id: str) -> float | None: + """ + Calculates the total file storage used by an organization in the S3 bucket. + + This function iterates through the S3 objects under the standard 'raw/{org_id}/' + and 'thumb/{org_id}/' prefixes, summing their sizes. It includes error + handling to prevent task failure if S3 is unavailable. + + Args: + org_id (str): The ID of the organization to measure. + + Returns: + usage_gib (float | None): The total storage used in GiB. Returns None on error. + """ + try: + async with get_s3_aclient() as aclient: + paginator = aclient.get_paginator("list_objects_v2") + total_size = 0 + for prefix in [f"raw/{org_id}/", f"thumb/{org_id}/"]: + prefix_size = 0 + async for page in paginator.paginate(Bucket=S3_BUCKET_NAME, Prefix=prefix): + for obj in page.get("Contents", []): + prefix_size += obj["Size"] + total_size += prefix_size + return total_size / GiB + except Exception as e: + logger.exception( + f'Failed to compute file storage usage for organization "{org_id}": {repr(e)}' ) - 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) + return None + - os.makedirs(os.path.dirname(raw_file_path)) - os.makedirs(os.path.dirname(thumb_file_path)) +async def get_schema_storage_usage_postgres(schema_name: str) -> DBStorageUsage: + """ + Calculates detailed storage usage for a given schema in PostgreSQL. - async with aiofiles.open(raw_file_path, "wb") as out_file: - await out_file.write(content) + This function queries PostgreSQL system tables to get the total size of all + tables and their associated indexes within a specific schema. - if len(thumbnail) > 0: - async with aiofiles.open(thumb_file_path, "wb") as thumb_file: - await thumb_file.write(thumbnail) + Args: + session: The SQLAlchemy session to use for the query. + schema_name: The name of the database schema to measure. - logger.info( - f"File Uploaded: [{organization_id}/{project_id}] " - f"Location: file://{raw_file_path} " - f"File name: {filename}, MIME type: {content_type}. " + Returns: + The total size in GiB. + """ + from owl.db import async_session, cached_text + + usage = DBStorageUsage( + schema_name=schema_name, + table_names=[], + table_sizes=[], + ) + try: + query = cached_text( + """ + SELECT + nspname AS schema_name, + array_agg(c.relname) AS names, + array_agg(pg_total_relation_size(c.oid)::bigint) AS total_relation_sizes, + array_agg(pg_total_relation_size(c.reltoastrelid)::bigint) AS total_toast_sizes + FROM + pg_class c + LEFT JOIN + pg_namespace n ON (n.oid = c.relnamespace) + WHERE + n.nspname = :schema_name + AND c.relkind IN ('r', 'm') -- r = table, m = materialized view + GROUP BY nspname; + """ + ) + async with async_session() as session: + stats = (await session.exec(query, params={"schema_name": schema_name})).one_or_none() + if not stats: + return usage + return DBStorageUsage( + schema_name=stats.schema_name, + table_names=stats.names, + table_sizes=[ + float(rs or 0.0) + float(ts or 0.0) + for rs, ts in zip(stats.total_relation_sizes, stats.total_toast_sizes, strict=True) + ], + ) + except Exception as e: + logger.exception( + f'Failed to compute DB storage usage for schema "{schema_name}": {repr(e)}' + ) + return usage + + +async def get_db_storage_usage(org_id: str) -> float | None: + """ + Calculates the total DB storage used by an organization. + + Args: + org_id (str): The ID of the organization to measure. + + Returns: + usage_gib (float | None): The total storage used in GiB. Returns None on error. + """ + from owl.db import async_session + from owl.db.models import Project + + try: + db_usage = 0.0 + async with async_session() as session: + projects_in_org = ( + await session.exec(select(Project.id).where(Project.organization_id == org_id)) + ).all() + for project_id in projects_in_org: + for table_type in TableType: + schema_name = f"{project_id}_{table_type}" + usage = await get_schema_storage_usage_postgres(schema_name) + db_usage += usage.total_size + return db_usage / GiB + except Exception as e: + logger.exception( + f'Failed to compute DB storage usage for organization "{org_id}": {repr(e)}' ) - return f"file://{raw_file_path}" + return None diff --git a/services/api/src/owl/utils/ip_address.py b/services/api/src/owl/utils/ip_address.py deleted file mode 100644 index 5a34424..0000000 --- a/services/api/src/owl/utils/ip_address.py +++ /dev/null @@ -1,136 +0,0 @@ -import re - - -def is_valid_ipv4(ip): - pattern = re.compile( - r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" - ) - return pattern.match(ip) is not None - - -def is_valid_port(port): - return 0 <= int(port) <= 65535 - - -def expand_port_ranges(port_ranges): - ports = [] - for part in port_ranges.split(","): - if "-" in part: - start, end = part.split("-") - ports.extend(range(int(start), int(end) + 1)) - else: - ports.append(int(part)) - return ports - - -def validate_and_process_ip_address(input_string): - urls = input_string.split("|") - result = [] - - for url in urls: - if not url: - continue - match = re.match(r"http://(\d+\.\d+\.\d+\.\d+):(.+)", url) - if not match: - return "Input is invalid" - - ip, port_ranges = match.groups() - if not is_valid_ipv4(ip): - return "Input is invalid" - - try: - ports = expand_port_ranges(port_ranges) - for port in ports: - if not is_valid_port(port): - return "Input is invalid" - result.append(f"http://{ip}:{port}") - except ValueError: - return "Input is invalid" - - return result - - -if __name__ == "__main__": - - def test_input_vllm_url(): - test_case = [] - - test_case.append( - {"input": "http://192.168.1.1:1234", "expected": ["http://192.168.1.1:1234"]} - ) - - test_case.append( - { - "input": "http://192.168.171.1:1234,1235,1237", - "expected": [ - "http://192.168.171.1:1234", - "http://192.168.171.1:1235", - "http://192.168.171.1:1237", - ], - } - ) - - test_case.append( - { - "input": "http://192.6.171.1:1234-1237", - "expected": [ - "http://192.6.171.1:1234", - "http://192.6.171.1:1235", - "http://192.6.171.1:1236", - "http://192.6.171.1:1237", - ], - } - ) - - test_case.append( - { - "input": "http://10.168.171.1:1234|http://192.168.171.1:1256", - "expected": ["http://10.168.171.1:1234", "http://192.168.171.1:1256"], - } - ) - - test_case.append( - { - "input": "http://192.168.171.6:2345|http://192.168.171.1:1234,1235,1237|", - "expected": [ - "http://192.168.171.6:2345", - "http://192.168.171.1:1234", - "http://192.168.171.1:1235", - "http://192.168.171.1:1237", - ], - } - ) - - test_case.append( - { - "input": "http://192.168.171.1:1234-1237|http://192.168.171.6:2345", - "expected": [ - "http://192.168.171.1:1234", - "http://192.168.171.1:1235", - "http://192.168.171.1:1236", - "http://192.168.171.1:1237", - "http://192.168.171.6:2345", - ], - } - ) - - test_case.append( - { - "input": "http://192.168.171.1:1234-1237|https://192.168.171.6:2345", - "expected": [ - "http://192.168.171.1:1234", - "http://192.168.171.1:1235", - "http://192.168.171.1:1236", - "http://192.168.171.1:1237", - "http://192.168.171.6:2345", - ], - } - ) - - return test_case - - valid_test_cases = test_input_vllm_url() - - for test_case in valid_test_cases: - output = validate_and_process_ip_address(test_case["input"]) - assert output == test_case["expected"], f"{output} \n {test_case['expected']}" diff --git a/services/api/src/owl/utils/jwt.py b/services/api/src/owl/utils/jwt.py index b443e57..9551a8c 100644 --- a/services/api/src/owl/utils/jwt.py +++ b/services/api/src/owl/utils/jwt.py @@ -4,13 +4,13 @@ import jwt from loguru import logger -from jamaibase.exceptions import AuthorizationError -from owl.configs.manager import ENV_CONFIG +from owl.configs import ENV_CONFIG +from owl.utils.exceptions import AuthorizationError 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") + token = jwt.encode(data, f"{ENV_CONFIG.encryption_key_plain}_secret", algorithm="HS256") return token @@ -23,7 +23,7 @@ def decode_jwt( try: data = jwt.decode( token, - f"{ENV_CONFIG.owl_encryption_key_plain}_secret", + f"{ENV_CONFIG.encryption_key_plain}_secret", algorithms=["HS256"], ) return data diff --git a/services/api/src/owl/utils/kb.py b/services/api/src/owl/utils/kb.py index 8d342a1..8897325 100644 --- a/services/api/src/owl/utils/kb.py +++ b/services/api/src/owl/utils/kb.py @@ -2,7 +2,7 @@ from itertools import chain, pairwise from typing import Any -from owl.protocol import Chunk +from owl.types import Chunk def detect_consecutive_segments(lst: list[tuple[Any, Any]]) -> list[tuple[Any, Any]]: diff --git a/services/api/src/owl/utils/lm.py b/services/api/src/owl/utils/lm.py new file mode 100644 index 0000000..5aa1862 --- /dev/null +++ b/services/api/src/owl/utils/lm.py @@ -0,0 +1,1843 @@ +import asyncio +import itertools +import random +from base64 import b64encode +from contextlib import asynccontextmanager +from copy import deepcopy +from dataclasses import dataclass +from datetime import timedelta +from textwrap import dedent +from time import perf_counter, time +from typing import Any, AsyncGenerator + +import httpx +import litellm +import numpy as np +import openai +from fastapi import Request +from litellm import acompletion, aembedding, arerank +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.rerank import RerankResponse +from litellm.types.utils import ( + Choices, + Delta, + Message, + ModelResponse, + ModelResponseStream, + StreamingChoices, + Usage, +) +from litellm.types.utils import ( + EmbeddingResponse as LiteLLMEmbeddingResponse, +) +from loguru import logger +from natsort import natsorted +from openai import AsyncOpenAI +from openai.types.responses import ( + Response, + ResponseCodeInterpreterToolCall, + ResponseCompletedEvent, + ResponseFunctionWebSearch, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseReasoningItem, + ResponseReasoningSummaryTextDeltaEvent, + ResponseTextDeltaEvent, +) +from tenacity import ( + AsyncRetrying, + before_sleep_log, + retry_if_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) + +from jamaibase.types.common import SanitisedStr +from owl.configs import CACHE, ENV_CONFIG +from owl.db import SCHEMA, async_session, cached_text +from owl.db.models import Deployment, ModelConfig +from owl.types import ( + AudioContent, + ChatCompletionChunkResponse, + ChatCompletionResponse, + ChatCompletionUsage, + ChatEntry, + ChatRole, + CloudProvider, + CodeInterpreterTool, + CompletionUsageDetails, + Deployment_, + EmbeddingResponse, + EmbeddingResponseData, + EmbeddingUsage, + ImageContent, + ModelCapability, + ModelConfig_, + ModelConfigRead, + ModelProvider, + ModelType, + OnPremProvider, + OrganizationRead, + Project_, + PromptUsageDetails, + RAGParams, + References, + RerankingResponse, + RerankingUsage, + TextContent, + ToolUsageDetails, + WebSearchTool, +) +from owl.utils import mask_content, mask_string +from owl.utils.billing import BillingManager +from owl.utils.dates import now +from owl.utils.exceptions import ( + BadInputError, + ExternalAuthError, + JamaiException, + ModelCapabilityError, + ModelOverloadError, + RateLimitExceedError, + ResourceNotFoundError, + UnavailableError, + UnexpectedError, +) + +litellm.drop_params = True +litellm.set_verbose = False +litellm.suppress_debug_info = True + +WEB_SEARCH_TOOL = WebSearchTool() +CODE_INTERPRETER_TOOL = CodeInterpreterTool() +OPENAI_HOSTED_TOOLS = (WEB_SEARCH_TOOL.type, CODE_INTERPRETER_TOOL.type) + + +class _Logger: + @staticmethod + def log( + log_level: int, + message: str, + **kwargs, + ): + logger.bind(**kwargs).log(log_level, message) + + +@dataclass(slots=True) +class DeploymentContext: + deployment: Deployment + api_key: str + routing_id: str + inference_provider: str + is_reasoning_model: bool + use_openai_responses: bool = False + + +class DeploymentRouter: + def __init__( + self, + *, + request: Request, + config: ModelConfigRead, + organization: OrganizationRead, + cooldown: float = 0.0, # No cooldown by default + is_browser: bool = False, + ) -> None: + self.request = request + self.id: str = request.state.id + if not isinstance(config, ModelConfigRead): + raise TypeError(f"Expected ModelConfigRead, got {type(config)}.") + self.config = config + if not isinstance(organization, OrganizationRead): + raise TypeError(f"Expected OrganizationRead, got {type(organization)}.") + self.organization = organization + self.cooldown = cooldown + self.is_browser = is_browser + self.retry_policy = dict( + retry=retry_if_exception_type((RateLimitExceedError, ModelOverloadError)), + wait=wait_exponential_jitter(initial=0.5, exp_base=1.2, max=5, jitter=0.5), + stop=stop_after_attempt(3), + reraise=True, + before_sleep=before_sleep_log(_Logger(), "WARNING"), + ) + self._model_display_id = self.config.name if is_browser else self.config.id + + @staticmethod + def batch(seq, n): + if n < 1: + raise ValueError("`n` must be > 0") + for i in range(0, len(seq), n): + yield seq[i : i + n] + + def _inference_provider(self, provider: str) -> str: + if provider == CloudProvider.ELLM: + return CloudProvider.ELLM + if provider in ModelProvider: + return ModelProvider(provider) + if provider in OnPremProvider: + return OnPremProvider(provider) + owned_by = self.config.owned_by or "" + return next((p for p in ModelProvider if owned_by.lower() == p.value.lower()), "") + + def _litellm_model_id(self, deployment: Deployment_): + """ + Chat and embedding: + - Known cloud providers: provider/model + - Unknown cloud providers and on-prem: openai/model + + Reranking: + - Known cloud providers and on-prem: provider/model + - Unknown cloud providers: cohere/model + """ + provider = deployment.provider + routing_id = self.config.id if deployment.routing_id == "" else deployment.routing_id + if provider in CloudProvider and provider not in ( + CloudProvider.INFINITY_CLOUD, + CloudProvider.ELLM, + ): + # Standard cloud providers + prefix = "hosted_vllm" if provider == CloudProvider.VLLM_CLOUD else provider + return routing_id if routing_id.startswith(f"{prefix}/") else f"{prefix}/{routing_id}" + if self.config.type != ModelType.RERANK: + # Non-standard providers including ELLM + prefix = "openai" + else: + # Reranking + if provider in ( + CloudProvider.INFINITY_CLOUD, + CloudProvider.ELLM, + ): + prefix = "infinity" + elif provider in OnPremProvider: + prefix = provider.split("_")[0] # infinity_cpu -> infinity + else: + prefix = "cohere" + return f"{prefix}/{routing_id}" + + def _log_completion_masked( + self, + messages: list[dict], + **hyperparams, + ): + body = dict( + model=self.config.id, + messages=[mask_content(m) for m in messages], + **hyperparams, + ) + logger.info(f"{self.id} - Generating chat completions: {body}") + + def _map_and_log_exception( + self, + e: Exception, + api_key: str, + *, + messages: list[dict], + **hyperparams, + ) -> Exception: + messages = [mask_content(m) for m in messages] + err_mssg = getattr(e, "message", str(e)) + logger.warning( + f'{self.id} - LLM request to model "{self.config.id}" failed. Exception: {e.__class__}: {err_mssg}' + ) + if isinstance(e, JamaiException): + return e + elif isinstance(e, openai.BadRequestError): + logger.info( + ( + f'{self.id} - LLM request to model "{self.config.id}" failed due to bad request. ' + f"Hyperparameters: {hyperparams} Messages: {messages}" + ) + ) + return BadInputError(err_mssg) + elif isinstance(e, openai.AuthenticationError): + return ExternalAuthError(f"Invalid API key: {mask_string(api_key)}") + elif isinstance(e, openai.RateLimitError): + _header = e.response.headers + limit = int(_header.get("X-RateLimit-Limit", 0)) + remaining = int(_header.get("X-RateLimit-Remaining", 0)) + reset_at = int(_header.get("X-RateLimit-Reset", time() + 30)) + return RateLimitExceedError( + err_mssg, + limit=limit, + remaining=remaining, + reset_at=reset_at, + used=int(_header.get("X-RateLimit-Used", limit - remaining)), + retry_after=int(_header.get("Retry-After", int(reset_at - time()) + 1)), + meta=None, + ) + elif isinstance( + e, + ( + openai.APITimeoutError, + openai.APIError, + httpx.HTTPStatusError, + httpx.TimeoutException, # ReadTimeout, ConnectTimeout, etc + ), + ): + return ModelOverloadError( + f'Model provider for "{self._model_display_id}" is overloaded. Please try again later.' + ) + elif isinstance(e, (BaseLLMException, openai.OpenAIError)): + return BadInputError(err_mssg) + else: + body = dict( + model=self.config.id, + api_key=mask_string(api_key), + messages=messages, + **hyperparams, + ) + logger.exception( + f"{self.id} - {self.__class__.__name__} - Unexpected error !!! {body}" + ) + return UnexpectedError(err_mssg) + + async def _cooldown_deployment(self, deployment: Deployment_, cooldown_time: timedelta): + if cooldown_time.total_seconds() <= 0: + logger.warning( + f"{self.id} - Cooldown time is zero or negative for deployment {deployment.id}. Skipping cooldown." + ) + return + cooldown_until = now() + cooldown_time + logger.warning( + ( + f'{self.id} - Cooling down deployment "{deployment.id}" ' + f"until {cooldown_until} ({cooldown_time.total_seconds()} seconds)." + ) + ) + try: + async with async_session() as session: + await session.exec( + cached_text( + f'UPDATE {SCHEMA}."Deployment" SET cooldown_until = :cooldown_until WHERE id = :deployment_id;' + ), + params={ + "cooldown_until": cooldown_until, + "deployment_id": deployment.id, + }, + ) + await session.commit() + except Exception as exc: + logger.warning(f"{self.id} - Failed to cooldown deployment: {repr(exc)}") + + @asynccontextmanager + async def _get_deployment( + self, + **hyperparams, + ) -> AsyncGenerator[DeploymentContext, None]: + name = self.config.name + # Get deployment + if len(self.config.deployments) == 0: + logger.warning( + f"{self.id} - No deployments attached to model config. Fetching from database." + ) + async with async_session() as session: + deployments = ( + await Deployment.list_( + session=session, + return_type=Deployment_, + filters=dict(model_id=self.config.id), + ) + ).items + if len(deployments) == 0: + raise UnavailableError(f'No deployments found for model "{name}".') + else: + deployments = self.config.deployments + deployments = [d for d in deployments if d.cooldown_until <= now()] + if len(deployments) == 0: + raise UnavailableError(f'All deployments are on cooldown for model "{name}".') + deployment = random.choices(deployments, weights=[d.weight for d in deployments], k=1)[0] + # Get API key + provider = deployment.provider.lower() + api_key = "" + if self.organization.id == "0" or ( + ENV_CONFIG.enable_byok and provider not in OnPremProvider + ): + # Use Organization keys + api_key = self.organization.get_external_key(provider) + if (not api_key) and self.organization.id != "0": + # Use TSP keys + async with async_session() as session: + tsp_org = await CACHE.get_organization_async("0", session) + api_key = "" if tsp_org is None else tsp_org.get_external_key(provider) + if not api_key: + # Use System keys + api_key = ENV_CONFIG.get_api_key(provider) + if not api_key: + api_key = "DUMMY_KEY" + # Get model routing ID + routing_id = self._litellm_model_id(deployment) + # Check if its a reasoning model + can_reason = ModelCapability.REASONING in self.config.capabilities + is_reasoning_model = can_reason or litellm.supports_reasoning(routing_id) + if is_reasoning_model and not can_reason: + logger.warning( + f'Model "{self.config.id}" by provider "{provider}" seems to support reasoning, but it is not labelled as such.' + ) + try: + logger.info( + f'{self.id} - Request started for model "{self.config.id}" ({provider=}, {routing_id=}).' + ) + t0 = perf_counter() + self.request.state.model_start_time = t0 + yield DeploymentContext( + deployment=deployment, + api_key=api_key, + routing_id=routing_id, + inference_provider=self._inference_provider(provider), + is_reasoning_model=is_reasoning_model, + ) + self.request.state.timing["external_call"] = perf_counter() - t0 + logger.info(f'{self.id} - Request completed for model "{self.config.id}".') + except Exception as e: + mapped_e = self._map_and_log_exception(e, api_key, **hyperparams) + if isinstance(mapped_e, (ModelOverloadError, RateLimitExceedError)): + # Cooldown deployment + if len(deployments) > 1: + cooldown_time = timedelta( + seconds=getattr(mapped_e, "retry_after", self.cooldown) + ) + await self._cooldown_deployment(deployment, cooldown_time) + logger.warning( + f"{self.id} - LLM request failed. Mapped exception: {mapped_e.__class__}: {str(mapped_e)}" + ) + raise mapped_e from e + + ### --- Chat Completion --- ### + + async def _prepare_chat( + self, + *, + messages: list[ChatEntry], + hyperparams, + **kwargs, + ) -> tuple[list[dict[str, Any]], dict]: + # Prepare messages + if len(messages) == 0: + raise ValueError("`messages` is an empty list.") + elif len(messages) == 1: + # [user] + if messages[0].role == ChatRole.USER: + pass + # [system] + elif messages[0].role == ChatRole.SYSTEM: + messages.append(ChatEntry.user(content=".")) + # [assistant] + else: + messages = [ + ChatEntry.system(content="."), + ChatEntry.user(content="."), + ] + messages + else: + # [user, ...] + if messages[0].role == ChatRole.USER: + pass + # [system, ...] + elif messages[0].role == ChatRole.SYSTEM: + # [system, assistant, ...] + if messages[1].role == ChatRole.ASSISTANT: + messages.insert(1, ChatEntry.user(content=".")) + # [assistant, ...] + else: + messages = [ + ChatEntry.system(content="."), + ChatEntry.user(content="."), + ] + messages + if messages[0].role == ChatRole.SYSTEM and messages[0].content == "": + messages[0].content = "." + messages = [m.model_dump(mode="json", exclude_none=True) for m in messages] + # Prepare hyperparams + if isinstance(hyperparams.get("stop", None), list) and len(hyperparams["stop"]) == 0: + hyperparams["stop"] = None + hyperparams.update(kwargs) + # if self.config.id.startswith("anthropic"): + # hyperparams["extra_headers"] = {"anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15"} + # Log + # self._log_completion_masked(messages, **hyperparams) + return messages, hyperparams + + def _prepare_hyperparams( + self, + ctx: DeploymentContext, + hyperparams: dict[str, Any], + ): + # Handle max_tokens + max_tokens = hyperparams.pop("max_tokens", None) + max_completion_tokens = hyperparams.pop("max_completion_tokens", None) + hyperparams["max_tokens"] = max_completion_tokens or max_tokens + tools: list[dict] = hyperparams.pop("tools", None) or [] + # OpenAI specific + if ctx.inference_provider == CloudProvider.OPENAI: + if ctx.is_reasoning_model or any( + t.get("type", "") in OPENAI_HOSTED_TOOLS for t in tools + ): + ctx.use_openai_responses = True + if ctx.is_reasoning_model: + hyperparams.pop("temperature", None) + hyperparams.pop("top_p", None) + hyperparams["max_output_tokens"] = hyperparams.pop("max_tokens", None) + hyperparams.pop("id", None) + hyperparams.pop("n", None) + hyperparams.pop("presence_penalty", None) + hyperparams.pop("frequency_penalty", None) + hyperparams.pop("logit_bias", None) + hyperparams.pop("stop", None) + else: + hyperparams["max_completion_tokens"] = hyperparams.pop("max_tokens", None) + else: + tools = [t for t in tools if t.get("type", "") not in OPENAI_HOSTED_TOOLS] + + # Anthropic specific + if ctx.inference_provider == CloudProvider.ANTHROPIC: + # Sonnet 4.5 cannot specify both `temperature` and `top_p` + if "sonnet-4-5" in ctx.routing_id: + t = hyperparams.get("temperature", None) + p = hyperparams.get("top_p", None) + if t is not None and p is not None: + hyperparams.pop("top_p", None) # Prioritise temperature + + if tools: + hyperparams["tools"] = tools + + # Handle reasoning params + reasoning_effort: str | None = hyperparams.pop("reasoning_effort", None) + thinking_budget: int | None = hyperparams.pop("thinking_budget", None) + reasoning_summary: str = hyperparams.pop("reasoning_summary", "auto") + if thinking_budget is not None: + thinking_budget = max(thinking_budget, 0) + # Non-reasoning model does not require further processing + if not ctx.is_reasoning_model: + return + # Disable reasoning if requested + if ( + reasoning_effort in ("disable", "minimal") + or thinking_budget == 0 + or (reasoning_effort is None and thinking_budget is None) + ): + if ctx.inference_provider == CloudProvider.ELLM: + hyperparams["reasoning_effort"] = "disable" + return + elif ctx.inference_provider == CloudProvider.GEMINI: + # 2.5 Pro cannot disable thinking + if "2.5-pro" in ctx.routing_id: + hyperparams["thinking"] = {"type": "enabled", "budget_tokens": 128} + else: + hyperparams["reasoning_effort"] = "disable" + return + elif ctx.inference_provider == CloudProvider.ANTHROPIC: + hyperparams["thinking"] = {"type": "disabled"} + return + elif ctx.inference_provider == CloudProvider.OPENAI: + if "gpt-5" in ctx.routing_id: + hyperparams["reasoning"] = { + "effort": "minimal", + "summary": reasoning_summary, + } + return + elif "o1" in ctx.routing_id or "o3" in ctx.routing_id or "o4" in ctx.routing_id: + hyperparams["reasoning"] = { + "effort": "low", + "summary": reasoning_summary, + } + return + else: + hyperparams["reasoning"] = { + "effort": "low", + "summary": reasoning_summary, + } + return + elif ctx.inference_provider == OnPremProvider.VLLM: + hyperparams["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}} + return + logger.warning( + ( + f'Disabling reasoning is not supported for model "{self.config.id}" ' + f'by provider "{ctx.inference_provider}". ' + f"(owned_by={self.config.owned_by}, deployment.provider={ctx.deployment.provider})" + ) + ) + return + # Configure reasoning effort + if reasoning_effort not in ("disable", "minimal") or thinking_budget: + if reasoning_effort not in ("low", "medium", "high"): + if thinking_budget <= 1024: + reasoning_effort = "low" + elif thinking_budget <= 4096: + reasoning_effort = "medium" + else: + reasoning_effort = "high" + if ctx.inference_provider == CloudProvider.ELLM: + hyperparams["reasoning_effort"] = reasoning_effort + return + elif ctx.inference_provider in [CloudProvider.GEMINI, CloudProvider.ANTHROPIC]: + if not thinking_budget: + if reasoning_effort == "low": + thinking_budget = 1024 + elif reasoning_effort == "medium": + thinking_budget = 4096 + else: + thinking_budget = 8192 + if ctx.inference_provider == CloudProvider.ANTHROPIC: + hyperparams["temperature"] = 1 + hyperparams["top_p"] = min(max(0.95, hyperparams.pop("top_p", 1.0)), 1.0) + thinking_budget = max(thinking_budget, 1024) + hyperparams["thinking"] = { + "type": "enabled", + "budget_tokens": thinking_budget, + } + elif ctx.inference_provider == CloudProvider.OPENAI: + hyperparams["reasoning"] = { + "effort": reasoning_effort, + "summary": reasoning_summary, + } + else: + logger.warning( + ( + f'Thinking budget is not supported for model "{self.config.id}" ' + f'by provider "{ctx.inference_provider}". ' + f"(owned_by={self.config.owned_by}, deployment.provider={ctx.deployment.provider})" + ) + ) + + def _stream_delta(self, delta: Delta, finish_reason: Any | None = None) -> ModelResponseStream: + return ModelResponseStream( + id=self.id, + model=self.config.id, + choices=[StreamingChoices(index=0, delta=delta, finish_reason=finish_reason)], + ) + + def _prepare_responses_messages(self, messages: list[dict]) -> list[dict]: + for m in messages: + content: str | list[dict[str, str]] = m["content"] + if not isinstance(content, list): + continue + for c in content: + if c.get("type", None) == "text": + c["type"] = "input_text" + elif c.get("type", None) == "image_url": + c["type"] = "input_image" + c["image_url"] = c["image_url"]["url"] + elif c.get("type", None) == "input_audio": + pass + else: + pass + + async def _openai_responses_stream( + self, + ctx: DeploymentContext, + messages: list[dict], + **hyperparams, + ) -> AsyncGenerator[ModelResponseStream, None]: + self._prepare_responses_messages(messages) + openai_client = AsyncOpenAI(api_key=ctx.api_key) + response_stream = await openai_client.responses.create( + model=ctx.routing_id.split("openai/")[-1], + input=messages, + stream=True, + **hyperparams, + ) + usage_stats = {"web_search_calls": 0, "code_interpreter_calls": 0} + final_usage = None + async for chunk in response_stream: + if isinstance(chunk, ResponseReasoningSummaryTextDeltaEvent): + yield self._stream_delta(Delta(role="assistant", reasoning_content=chunk.delta)) + elif isinstance(chunk, ResponseOutputItemDoneEvent): + if isinstance(chunk.item, ResponseFunctionWebSearch): + usage_stats["web_search_calls"] += 1 + if ( + chunk.item.action + and hasattr(chunk.item.action, "query") + and chunk.item.action.query + ): + yield self._stream_delta( + Delta( + role="assistant", + reasoning_content=f'Searched the web for "{chunk.item.action.query}".', + ) + ) + yield self._stream_delta(Delta(role="assistant", reasoning_content="\n\n")) + elif isinstance(chunk.item, ResponseCodeInterpreterToolCall): + usage_stats["code_interpreter_calls"] += 1 + code_snippet = chunk.item.code + yield self._stream_delta( + Delta( + role="assistant", + reasoning_content=f"Ran Python code:\n\n```python\n{code_snippet}\n```", + ) + ) + yield self._stream_delta(Delta(role="assistant", reasoning_content="\n\n")) + elif isinstance(chunk, ResponseTextDeltaEvent): + yield self._stream_delta(Delta(role="assistant", content=chunk.delta)) + elif isinstance(chunk, ResponseCompletedEvent): + if chunk.response.usage: + final_usage = chunk.response.usage + + if final_usage: + usage = ChatCompletionUsage( + prompt_tokens=final_usage.input_tokens, + completion_tokens=final_usage.output_tokens, + total_tokens=final_usage.total_tokens, + prompt_tokens_details=PromptUsageDetails( + cached_tokens=final_usage.input_tokens_details.cached_tokens + if final_usage.input_tokens_details + else 0 + ), + completion_tokens_details=CompletionUsageDetails( + reasoning_tokens=final_usage.output_tokens_details.reasoning_tokens + if final_usage.output_tokens_details + else 0 + ), + tool_usage_details=ToolUsageDetails(**usage_stats), + ) + else: + # Fallback if usage is not in the final chunk for some reason + usage = ChatCompletionUsage(tool_usage_details=ToolUsageDetails(**usage_stats)) + + final_chunk = self._stream_delta(delta=Delta(), finish_reason="stop") + final_chunk.usage = Usage(**usage.model_dump()) + yield final_chunk + + async def _openai_responses( + self, + ctx: DeploymentContext, + messages: list[dict], + **hyperparams, + ) -> ModelResponse: + self._prepare_responses_messages(messages) + openai_client = AsyncOpenAI(api_key=ctx.api_key) + response: Response = await openai_client.responses.create( + model=ctx.routing_id.split("openai/")[-1], + input=messages, + stream=False, + **hyperparams, + ) + reasoning_parts = [] + result_parts = [] + usage_stats = {"web_search_calls": 0, "code_interpreter_calls": 0} + for item in response.output: + if isinstance(item, ResponseReasoningItem): + if item.summary: + summary_text = "\n".join( + part.text for part in item.summary if hasattr(part, "text") + ) + if summary_text: + reasoning_parts.append(summary_text) + elif isinstance(item, ResponseFunctionWebSearch) and item.status == "completed": + usage_stats["web_search_calls"] += 1 + if item.action and hasattr(item.action, "query") and item.action.query: + reasoning_parts.append(f'Searched the web for "{item.action.query}".') + elif isinstance(item, ResponseCodeInterpreterToolCall) and item.status == "completed": + usage_stats["code_interpreter_calls"] += 1 + code_snippet = item.code + reasoning_parts.append(f"Ran Python code:\n\n```python\n{code_snippet}\n```") + elif isinstance(item, ResponseOutputMessage) and item.status == "completed": + text_content = item.content[0].text if item.content else "" + result_parts.append(text_content) + + reasoning_result = "\n\n".join(part for part in reasoning_parts if part) + final_result = "\n\n".join(part for part in result_parts if part) + + if response.usage: + usage = ChatCompletionUsage( + prompt_tokens=response.usage.input_tokens, + completion_tokens=response.usage.output_tokens, + total_tokens=response.usage.total_tokens, + prompt_tokens_details=PromptUsageDetails( + cached_tokens=response.usage.input_tokens_details.cached_tokens + if response.usage.input_tokens_details + else 0 + ), + completion_tokens_details=CompletionUsageDetails( + reasoning_tokens=response.usage.output_tokens_details.reasoning_tokens + if response.usage.output_tokens_details + else 0 + ), + tool_usage_details=ToolUsageDetails(**usage_stats), + ) + else: + usage = ChatCompletionUsage(tool_usage_details=ToolUsageDetails(**usage_stats)) + + return ModelResponse( + id=self.id, + model=self.config.id, + choices=[ + Choices( + index=0, + message=Message( + role="assistant", + content=final_result, + reasoning_content=reasoning_result.strip(), + ), + finish_reason="stop", + ) + ], + usage=Usage(**usage.model_dump()), + created=int(time()), + ) + + async def _completion_stream( + self, + messages: list[dict], + **hyperparams, + ) -> AsyncGenerator[ModelResponseStream, None]: + async for attempt in AsyncRetrying(**self.retry_policy): + with attempt: + async with self._get_deployment(messages=messages, **hyperparams) as ctx: + self._prepare_hyperparams(ctx, hyperparams) + # logger.warning(f"{hyperparams=}") + if ctx.use_openai_responses: + async for chunk in self._openai_responses_stream( + ctx, messages, **hyperparams + ): + yield chunk + else: + response: AsyncGenerator[ModelResponseStream, None] = await acompletion( + timeout=self.config.timeout, + api_key=ctx.api_key, + base_url=ctx.deployment.api_base, + model=ctx.routing_id, + messages=messages, + stream=True, + stream_options={"include_usage": True}, + **hyperparams, + ) + if response is None: + raise ModelOverloadError( + f'Model provider for "{self._model_display_id}" is overloaded. Please try again later.' + ) + # TODO: Investigate why litellm yields role chunks at the end of the stream + # i = 0 + async for chunk in response: + chunk.model = self.config.id + yield chunk + # i += 1 + + async def _completion( + self, + messages: list[dict], + **hyperparams, + ) -> ModelResponse: + async for attempt in AsyncRetrying(**self.retry_policy): + with attempt: + async with self._get_deployment(messages=messages, **hyperparams) as ctx: + self._prepare_hyperparams(ctx, hyperparams) + if ctx.use_openai_responses: + return await self._openai_responses(ctx, messages, **hyperparams) + response = await acompletion( + timeout=self.config.timeout, + api_key=ctx.api_key, + base_url=ctx.deployment.api_base, + model=ctx.routing_id, + messages=messages, + stream=False, + **hyperparams, + ) + if response is None: + raise ModelOverloadError( + f'Model provider for "{self._model_display_id}" is overloaded. Please try again later.' + ) + response.model = self.config.id + return response + + async def chat_completion( + self, + *, + messages: list[ChatEntry], + stream: bool, + **hyperparams, + ) -> ModelResponse | AsyncGenerator[ModelResponse, None]: + if not (isinstance(messages, list) and all(isinstance(m, ChatEntry) for m in messages)): + # We raise TypeError here since this is a programming error + raise TypeError("`messages` must be a list of `ChatEntry`.") + hyperparams.pop("stream_options", None) + messages, hyperparams = await self._prepare_chat( + messages=messages, + hyperparams=hyperparams, + ) + if stream: + return self._completion_stream(messages, **hyperparams) + else: + return await self._completion(messages, **hyperparams) + + ### --- Embedding --- ### + + async def embedding( + self, + *, + texts: list[str], + is_query: bool = True, + encoding_format: str | None = None, + **hyperparams, + ) -> EmbeddingResponse: + async for attempt in AsyncRetrying(**self.retry_policy): + with attempt: + async with self._get_deployment( + texts=texts, encoding_format=encoding_format, **hyperparams + ) as ctx: + # Get output dimensions + dimensions = ( + hyperparams.get("dimensions", None) or self.config.embedding_dimensions + ) + # Maybe transform texts + if self.config.embedding_transform_query is not None: + texts = [self.config.embedding_transform_query + text for text in texts] + # Set batch size and hyperparams + batch_size = 2048 + if ctx.deployment.provider == CloudProvider.COHERE: + if is_query: + hyperparams["input_type"] = "search_query" + else: + hyperparams["input_type"] = "search_document" + batch_size = 96 # limit on cohere server + elif ctx.deployment.provider == CloudProvider.JINA_AI: + batch_size = 128 # don't know limit, but too large will timeout + elif ctx.deployment.provider == CloudProvider.VOYAGE: + batch_size = 128 # limit on voyage server + elif ctx.deployment.provider == CloudProvider.OPENAI: + batch_size = 256 # limited by token per min (10,000,000) + + # self._billing.has_embedding_quota(model_id=self.embedder_config["id"]) + # Call + responses: list[LiteLLMEmbeddingResponse] = await asyncio.gather( + *[ + aembedding( + timeout=self.config.timeout, + api_key=ctx.api_key, + api_base=ctx.deployment.api_base, + model=ctx.routing_id, + input=txt, + dimensions=dimensions, + encoding_format=encoding_format, + **hyperparams, + ) + for txt in self.batch(texts, batch_size) + ] + ) + # Compile from batches + vectors = [ + e["embedding"] for e in itertools.chain(*[r.data for r in responses]) + ] + usage = EmbeddingUsage( + prompt_tokens=sum(getattr(r.usage, "prompt_tokens", 1) for r in responses), + total_tokens=sum(getattr(r.usage, "total_tokens", 1) for r in responses), + ) + # Might need to encode into base64 + if encoding_format == "base64" and isinstance(vectors[0], list): + logger.warning( + "`encoding_format` is `base64` but vectors are not base64 encoded." + ) + vectors = [ + b64encode(np.asarray(v, dtype=np.float32).tobytes()).decode("ascii") + for v in vectors + ] + embeddings = EmbeddingResponse( + data=[ + EmbeddingResponseData(embedding=v, index=i) + for i, v in enumerate(vectors) + ], + model=self.config.id, + usage=usage, + ) + return embeddings + + ### --- Reranking --- ### + + async def reranking( + self, + *, + query: str, + documents: list[str], + top_n: int | None = None, + **hyperparams, + ) -> RerankingResponse: + if len(documents) == 0: + raise ValueError("There are no documents to rerank.") + async for attempt in AsyncRetrying(**self.retry_policy): + with attempt: + async with self._get_deployment( + query=query, documents=documents, **hyperparams + ) as ctx: + batch_size = 100 + # self._billing.has_embedding_quota(model_id=self.embedder_config["id"]) + # Call + batches = list(self.batch(documents, batch_size)) + responses: list[RerankResponse] = await asyncio.gather( + *[ + arerank( + timeout=self.config.timeout, + api_key=ctx.api_key, + api_base=ctx.deployment.api_base, + model=ctx.routing_id, + query=query, + documents=docs, + top_n=top_n, + return_documents=False, + **hyperparams, + ) + for docs in batches + ] + ) + responses = [r.model_dump(exclude_unset=True) for r in responses] + # Compile results from batches + results = [ + { + "index": res["index"] + if i == 0 + else res["index"] + i * len(batches[i - 1]), + "relevance_score": res["relevance_score"], + } + for i, response in enumerate(responses) + for res in response["results"] + ] + results = sorted(results, key=lambda x: x["relevance_score"], reverse=True) + # Compile usage from batches + metas = [r.get("meta", {}) for r in responses] + billed_units = [m.get("billed_units", {}) for m in metas] + tokens = [m.get("tokens", {}) for m in metas] + billed_units = { + k: sum(d.get(k, 0) or 0 for d in billed_units) + for k in set().union(*billed_units) + } + tokens = { + k: sum(d.get(k, 0) or 0 for d in tokens) for k in set().union(*tokens) + } + usage = deepcopy(tokens) + usage["documents"] = len(documents) + # Generate final response + try: + response = responses[0] + except IndexError: + logger.error( + f"No responses from reranking!!! {batches=} {documents=} {batch_size=}" + ) + raise + response["results"] = results + response["usage"] = usage + response["meta"]["model"] = self.config.id + if len(billed_units) > 0: + response["meta"]["billed_units"] = billed_units + if len(tokens) > 0: + response["meta"]["tokens"] = tokens + return RerankingResponse.model_validate(response) + + +class LMEngine: + def __init__( + self, + *, + organization: OrganizationRead, + project: Project_, + request: Request, + ) -> None: + self.organization = organization + self.project = project + self.request = request + self.id: str = request.state.id + self.is_browser: bool = request.state.user_agent.is_browser + self.billing: BillingManager | None = getattr(request.state, "billing", None) + self._models: list[ModelConfigRead] | None = getattr(self.billing, "models", None) + self._chat_usage = ChatCompletionUsage() + self._embed_usage = EmbeddingUsage() + self._rerank_usage = RerankingUsage(documents=0) + + async def _get_models(self, capabilities: list[str] | None = None) -> list[ModelConfigRead]: + if self._models is None: + logger.warning( + f"{self.id} - No models found in BillingManager. Fetching from database." + ) + async with async_session() as session: + models = ( + await ModelConfig.list_( + session=session, + return_type=ModelConfigRead, + organization_id=self.organization.id, + capabilities=capabilities, + exclude_inactive=True, + ) + ).items + self._models = models + else: + models = [m for m in self._models if m.is_active] + # Filter by capability + 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: {list(map(str, capabilities))}." + ) + return models + + async def _get_model(self, model: str) -> ModelConfigRead: + model = model.strip() + model_configs = await self._get_models() + model_config = next((m for m in model_configs if m.id == model), None) + if model_config is None: + raise ResourceNotFoundError(f'Model "{model}" is not found.') + return model_config + + @staticmethod + def pick_best_model( + model_configs: list[ModelConfig_], + capabilities: list[ModelCapability], + ) -> ModelConfig_: + def _sort_key_with_priority(m: ModelConfig_) -> tuple[int, int, str]: + return ( + int(not m.id.startswith("ellm")), + int(ModelCapability.AUDIO in m.capabilities), # De-prioritise audio models + len(m.capabilities_set - set(capabilities)), + -m.priority, + m.name, + ) + + model_configs = natsorted(model_configs, key=_sort_key_with_priority) + return model_configs[0] + + ### --- Chat Completion --- ### + + @staticmethod + def _check_messages_type(messages: list[ChatEntry]): + if not (isinstance(messages, list) and all(isinstance(m, ChatEntry) for m in messages)): + # We raise TypeError here since this is a programming error + raise TypeError("`messages` must be a list of `ChatEntry`.") + + async def _get_default_model( + self, + model: str, + capabilities: list[ModelCapability], + ) -> ModelConfigRead: + capabilities_set = set(capabilities) + # If model is empty string, we try to get a suitable model + if model == "": + # Error will be raised if no suitable model is found + model_configs = await self._get_models(capabilities) + model_config = self.pick_best_model(model_configs, capabilities) + else: + model_config = await self._get_model(model) + if len(lack := (capabilities_set - model_config.capabilities_set)) > 0: + raise ModelCapabilityError( + f'Model "{model_config.name if self.is_browser else model}" lack these capabilities: {", ".join(lack)}' + ) + return model_config + + @asynccontextmanager + async def _setup_chat( + self, + model: str, + messages: list[ChatEntry], + ): + # Validate model capability + self._check_messages_type(messages) + capabilities = [str(ModelCapability.CHAT)] + if any(m.has_image for m in messages): + capabilities.append(str(ModelCapability.IMAGE)) + if any(m.has_audio for m in messages): + capabilities.append(str(ModelCapability.AUDIO)) + # If model is empty string, we try to get a suitable model + model_config = await self._get_default_model(model, capabilities) + model = model_config.id + # Setup rate limiting + # rpm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.llm_requests_per_minute, + # proj_hpm=ENV_CONFIG.llm_requests_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:rpm", + # name="RPM", + # ) + # tpm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.llm_tokens_per_minute, + # proj_hpm=ENV_CONFIG.llm_tokens_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:tpm", + # name="TPM", + # ) + # # Test rate limits + # await asyncio.gather(rpm_limiter.test(), tpm_limiter.test(max_tokens)) + router = DeploymentRouter( + request=self.request, + config=model_config, + organization=self.organization, + is_browser=self.is_browser, + ) + try: + yield router + finally: + # # Consume rate limits + # await asyncio.gather(rpm_limiter.hit(), tpm_limiter.hit(self._chat_usage.total_tokens)) + if self.billing is not None: + try: + self.billing.create_llm_events( + model_id=model, + input_tokens=self._chat_usage.prompt_tokens, + output_tokens=self._chat_usage.completion_tokens, + ) + except Exception as e: + logger.warning(f"Failed to create LLM events due to error: {repr(e)}") + + async def chat_completion_stream( + self, + *, + model: str, + messages: list[ChatEntry], + **hyperparams, + ) -> AsyncGenerator[ChatCompletionChunkResponse, None]: + """ + Generate streaming chat completions. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model based on message content. + messages (list[ChatEntry]): List of messages. + **hyperparams (Any): Keyword arguments. + + Yields: + chunk (ChatCompletionChunkResponse): A chat chunk. + """ + hyperparams.pop("stream", None) + async with self._setup_chat(model, messages) as router: + completion: AsyncGenerator[ModelResponse, None] = await router.chat_completion( + messages=messages, + stream=True, + **hyperparams, + ) + async for chunk in completion: + if hasattr(chunk, "usage"): + self._chat_usage = ChatCompletionUsage.model_validate(chunk.usage.model_dump()) + yield ChatCompletionChunkResponse( + **chunk.model_dump(exclude_unset=True, exclude_none=True) + ) + + async def chat_completion( + self, + *, + model: str, + messages: list[ChatEntry], + **hyperparams, + ) -> ChatCompletionResponse: + """ + Generate chat completions. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model based on message content. + messages (list[ChatEntry]): List of messages. + **hyperparams (Any): Keyword arguments. + + Returns: + response (ChatCompletionResponse): The chat response. + """ + hyperparams.pop("stream", None) + async with self._setup_chat(model, messages) as router: + completion: ModelResponse = await router.chat_completion( + messages=messages, + stream=False, + **hyperparams, + ) + completion = ChatCompletionResponse.model_validate( + completion.model_dump(exclude_unset=True, exclude_none=True) + ) + self._chat_usage = completion.usage + return completion + + async def generate_title( + self, + *, + excerpt: str, + model: str = "", + **hyperparams, + ) -> str: + system_prompt = dedent("""\ + You are a professional document analyst. Your primary goal is to extract the most accurate and complete title from the document's first page. + + Analyze the page using the following prioritized steps: + + 1. **PRIORITY 1: EXTRACT VERBATIM TITLE:** + - First, attempt to identify and extract the main, verbatim title. This is typically the most prominent text block (e.g., largest font, bold, centered) + at the top of the page, common in academic papers, reports, or articles. This is the preferred method. + - Prominent text block may not always represent the title, so read the entire page to understand the context. Append the suitable subtitle if it exists. + - Append the purpose of the document based on the page content. + + 2. **PRIORITY 2: ASSEMBLE FROM COMPONENTS:** + If no single, clear verbatim title exists (common in forms or structured plans), + then construct a title by extracting and combining these components: + - Primary Entity: The main company/organization. + - Document Type: The official name of the document (e.g., Insurance Plan, Agreement). + - Key Identifiers: Extract ALL unique codes and levels. This includes a master identifier (like a Policy or Group Number) + AND specific sub-identifier (like a Plan Name, Plan Level, Tier, Date and/or Year). + + 3. **UNIVERSAL RULE: INCLUDE IDENTIFIERS:** + Regardless of whether the title is extracted verbatim (Priority 1) or assembled (Priority 2), + append both the master identifier and the specific plan level if both are present. + Append the date or year if it is part of the title or relevant to the document's context. + + 4. **OUTPUT:** Output only the final, single-line title. (Max 20 words). + """) + prompt = dedent(f"""\ + Analyze the Page content below and output the most representative title based on your core instructions. + - DO NOT THINK, OUTPUT ONLY THE FINAL TITLE + + **Page Context:** + {excerpt} + """) + # Override hyperparams + hyperparams.update( + temperature=0.01, + top_p=0.01, + max_tokens=500, + stream=False, + reasoning_effort="minimal", + ) + try: + completion = ( + await self.chat_completion( + model=model, + messages=[ChatEntry.system(system_prompt), ChatEntry.user(prompt)], + **hyperparams, + ) + ).content + title = completion.strip().strip('"') + except Exception as e: + logger.warning( + f"{hyperparams.get('id', '')} - Title extraction failed for excerpt: \n{excerpt}\n, error: {e}" + ) + title = "" + if not title: + title = "Document" + return title + + async def generate_chat_title( + self, + *, + user_content: str, + assistant_content: str, + model: str = "", + **hyperparams, + ) -> SanitisedStr: + system_prompt = "Generate a concise, descriptive title for a chat message." + prompt = dedent(f"""\ + + {user_content} + + + + {assistant_content} + + + Do not think. Generate a short, concise title of no more than 5 words for the conversation. + """) + # Override hyperparams + hyperparams.update( + temperature=0.01, + top_p=0.01, + max_tokens=500, + stream=False, + reasoning_effort="minimal", + ) + default_title = "New Chat" + try: + completion = ( + await self.chat_completion( + model=model, + messages=[ChatEntry.system(system_prompt), ChatEntry.user(prompt)], + **hyperparams, + ) + ).content + title = completion.strip().strip('"') + if not title: + title = default_title + except Exception as e: + logger.warning( + f"{hyperparams.get('id', '')} - Title generation failed for the chat message: {user_content}, error: {e}" + ) + title = default_title + + # Replace non-printable characters with space + return " ".join("".join(c if c.isprintable() else " " for c in title).split()) + + def _rewrite_prompts_for_fts_query(self, input_prompt: str) -> str: + system_prompt = dedent("""\ + You are an advanced search query generation system. Your purpose is to translate user questions and conversational context into precise query components optimized for an information retrieval system using both keyword-based Full-Text Search (FTS) with pgroonga. + + Your primary tasks are: + 1. **Analyze Intent:** Deeply understand the user's information need expressed in their query and any relevant conversation history (if provided). + 2. **Extract Key Information:** Identify critical keywords, named entities (people, places, organizations, dates, etc.), specific technical terms, and core concepts. + 3. **Disambiguate:** Resolve ambiguities based on context. + 4. **Generate Direct Query Output:** Produce a direct answer containing the distinct query strings: + *Optimized for keyword precision and recall in pgroonga. Focus on essential nouns, verbs, entities, and specific identifiers. Should be concise. + + Focus on generating queries that, when used together in their respective search engines, will yield the most relevant results. Accuracy, relevance, and appropriate optimization for each search type are paramount. + """) + prompt = dedent(f"""\ + "user_query": "{input_prompt}", + "current_datetime": "{now().isoformat()}" + + Instructions: + Analyze the user_query, considering the current_datetime for temporal references. Generate a direct query string containing the rewritten query optimized for pgroonga FTS, keeping in mind that **stemming is active (at least for English)**. Follow these steps precisely: + + 1. **Identify Core Concepts:** Extract the most important terms representing the subject, action/intent, and key context from the user_query. Include essential nouns, verbs, entities, codes, and specific identifiers. Since stemming is active, focus on the root concepts. + 2. **Handle Phrases:** Identify multi-word terms crucial to the meaning (e.g., "machine learning", "API key", "user acceptance testing"). Enclose these exact phrases in double quotes (`"`). Stemming does not preserve word order, making phrase matching critical. + 3. **Use Synonyms/Alternatives (OR - Strategically):** + * Use `OR` *only* for genuinely distinct synonyms or alternative concepts that **will likely not stem to the same root** (e.g., `bug OR defect`, `UI OR "user interface"`). + * **Do NOT** use `OR` for simple word variations handled by stemming (e.g., do not write `database OR databases`, `configure OR configuration`, `run OR running` - the stemmer handles these). + * Use OR sparingly, focusing on high-value alternatives to improve recall for distinct concepts. + 4. **Convert Dates:** Use the `current_datetime` to resolve relative temporal references (e.g., "last year", "yesterday") into absolute numeric formats (YYYY or YYYY-MM-DD). For ranges like "last 2 years", list the specific years space-separated (e.g., based on 2025-04-16, "last 2 years" -> `2023 2024`). + 5. **Combine Terms:** Join individual keywords (prefer base/stemmed forms where natural), quoted phrases, and `OR` groups primarily with spaces (implying an AND relationship between distinct concepts). + 6. **Filter Noise but Preserve Meaning:** Remove generic filler words (like "the", "a", "is", "how to") UNLESS they are part of an essential quoted phrase. Prioritize terms likely to appear verbatim (or their stems) in relevant documents, but do not discard terms crucial for understanding the query's specific intent (e.g., keep words like "compare", "impact", "migrate" if central). + 7. **Conciseness and Completeness:** Aim for a query that is concise yet captures the full essential meaning of the original user query, leveraging the stemmer's capabilities. + 8. **Multi-Word Terms:** Use double quotes for terms composed of multiple words that should be treated as a single unit. Example: United Kingdom -> "United Kingdom" + + **Examples:** + + * **User Query:** What's the meaning of USG? + **FTS Query:** USG meaning OR definition + + * **User Query:** In 2024 how many database outage happened? + **FTS Query:** database outage OR failure 2024 count + + * **User Query:** How can I configure the connection pool for the main transaction database? + **FTS Query:** configure OR setup "connection pool" "main transaction database" + + * **User Query:** Any issues reported for the payment gateway integration last month? (Given Datetime: 2025-04-16) + **FTS Query:** issue OR problem OR error "payment gateway integration" 2025-03 + + * **User Query:** Compare performance impact of Redis vs Memcached deployment in production last year. (Given Datetime: 2025-04-16) + **FTS Query:** compare performance impact Redis Memcached deployment production 2024 + + * **User Query:** What's the weather in Japan 3 months ago? (Given Datetime: 2025-04-16) + **FTS Query:** weather Japan 2025-01 + + * **User Query:** ãƒã‚«ãƒ¯ã¯ä½•å¹´ã«ç”Ÿã¾ã‚Œã¾ã™ã‹ï¼Ÿ + **FTS Query:** ãƒã‚«ãƒ¯ 生ã¾ã‚Œã‚‹ OR 誕生 + + Reply ONLY with the generated FTS query string. Do not think. Do not include explanations, reasoning, markdown formatting, no need to use quotes to encapsulate the entire results, or any text outside the final FTS Query in the original query language. + + Now generate the query: + """) + return system_prompt, prompt + + def _rewrite_prompts_for_vs_query(self, input_prompt: str) -> str: + system_prompt = dedent("""\ + You are an advanced search query generation system. Your purpose is to translate user questions and conversational context into precise query components optimized for an information retrieval system using semantic Vector Search (VS). + + Your primary tasks are: + 1. **Analyze Intent:** Deeply understand the user's information need expressed in their query and any relevant conversation history (if provided). + 2. **Extract Key Information:** Identify critical keywords, named entities (people, places, organizations, dates, etc.), specific technical terms, and core concepts. + 3. **Disambiguate:** Resolve ambiguities based on context. + 4. **Generate Direct Query Output:** Produce a direct answer containing the distinct query strings: + *Optimized for capturing semantic meaning and nuance for vector embedding similarity search. Should be a well-formed natural language sentence or question reflecting the user's core intent. + + Focus on generating queries that, when used together in their respective search engines, will yield the most relevant results. Accuracy, relevance, and appropriate optimization for each search type are paramount. + """) + prompt = dedent(f"""\ + "user_query": "{input_prompt}", + "current_datetime": "{now().isoformat()}" + + Instructions: + Analyze the user_query, considering the current_datetime for temporal references. Generate a direct query string containing vector query for vector search. + + 1. **vector_query**: + * Create a natural language sentence or question that captures the core semantic meaning and intent of the user_query. + * This query should be suitable for generating an embedding for vector similarity search. + * Retain natural language phrasing for concepts, including relative time expressions (e.g., "last year", "next quarter") if they better represent the user's intent semantically. + * Example style: How to fix database connection timeout errors when configuring pgroonga, especially issues seen recently? + + Reply ONLY with the generated VS query. Do not think. Do not include explanations, reasoning, markdown formatting, or any text outside the final VS Query. + + Now generate the query: + """) + return system_prompt, prompt + + @staticmethod + def _extract_text_prompt( + messages: list[ChatEntry], + ) -> tuple[list[ChatEntry], str, list[ImageContent | AudioContent] | None]: + # Make a deep copy to avoid side effects + messages = deepcopy(messages) + # The message list should end with user message + if messages[-1].role == ChatRole.USER: + pass + elif messages[-2].role == ChatRole.USER: + messages = messages[:-1] + else: + raise BadInputError("The message list should end with user or assistant message.") + content = messages[-1].content + if isinstance(content, str): + prompt = content + multimodal_contents = None + else: + prompt = messages[-1].text_content + multimodal_contents = [c for c in content if not isinstance(c, TextContent)] + return messages, prompt, multimodal_contents + + async def _generate_search_query( + self, + *, + model: str, + messages: list[ChatEntry], + type: str, + **hyperparams, + ) -> str: + messages, prompt, multimodal_contents = self._extract_text_prompt(messages) + # Retrieved system and user prompt, updated as of 2025-04-17 + if type == "fts": + system_prompt, new_prompt = self._rewrite_prompts_for_fts_query(prompt) + elif type == "vs": + system_prompt, new_prompt = self._rewrite_prompts_for_vs_query(prompt) + else: + raise BadInputError( + f"Rewrite prompt only works for type: FTS or VS. Invalid type: {type}" + ) + + if messages[0].role == ChatRole.SYSTEM: + # Suggest to just override system prompt, 2025-04-17 + messages[0].content = system_prompt + else: + messages.insert(0, ChatEntry.system(system_prompt)) + # Override hyperparams + hyperparams.update( + temperature=0.01, + top_p=0.01, + max_tokens=1000, + stream=False, + reasoning_effort="minimal", + ) + if multimodal_contents is not None: + new_prompt = multimodal_contents + [TextContent(text=new_prompt)] + messages[-1] = ChatEntry.user(new_prompt) + completion = ( + await self.chat_completion( + model=model, + messages=messages, + **hyperparams, + ) + ).content + if completion is None: + new_prompt = prompt + else: + new_prompt = completion.strip() + if new_prompt.startswith('"') and new_prompt.endswith('"'): + new_prompt = new_prompt[1:-1] + return new_prompt + + async def generate_search_query( + self, + *, + model: str, + messages: list[ChatEntry], + rag_params: RAGParams, + **hyperparams, + ) -> tuple[str, str]: + """ + Generate search query for RAG. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model based on message content. + messages (list[ChatEntry]): List of messages. + rag_params (RAGParams): RAG parameters. + **hyperparams (Any): Keyword arguments. + + Raises: + TypeError: If `rag_params` is not an instance of `RAGParams`. + BadInputError: If the message list does not end with user or assistant message. + + Returns: + fts_query (str): The fts search query. + vs_query (str): The vs search query. + """ + self._check_messages_type(messages) + if not isinstance(rag_params, RAGParams): + raise TypeError("`rag_params` must be an instance of `RAGParams`.") + # Generate missing queries in parallel + queries = { + "fts": rag_params.search_query.strip(), + "vs": rag_params.search_query.strip(), + } + to_generate = [q_type for q_type, query in queries.items() if not query] + if to_generate: + generated = await asyncio.gather( + *[ + self._generate_search_query( + model=model, + messages=messages, + type=q_type, + **hyperparams, + ) + for q_type in to_generate + ] + ) + # Update the queries dict with generated values + for q_type, generated_query in zip(to_generate, generated, strict=True): + queries[q_type] = generated_query + return queries["fts"], queries["vs"] + + async def generate_rag_prompt( + self, + *, + messages: list[ChatEntry], + references: References, + inline_citations: bool = False, + ) -> str | list[TextContent | ImageContent | AudioContent]: + _, prompt, multimodal_contents = self._extract_text_prompt(messages) + documents = "\n\n".join( + dedent(f"""\ + + + {chunk.title} + {i} + {chunk.page} + + {"\n".join(f"## {k}: {v}" for k, v in chunk.context.items())} + + ## Text:\n{chunk.text} + + + + + """) + for i, chunk in enumerate(references.chunks) + ) + context_prompt = f"\n\n{documents}\n\n\n\n" + if inline_citations: + prompt += ( + "\n" + "When any sentence in your answer is supported by or refers to one or more documents inside , " + "append inline citations using Pandoc-style `[@]` for each supporting document at the end of that sentence, " + "immediately before the sentence-ending punctuation. " + "Use the exact from each and never invent IDs. " + "Arrange the citations from most to least relevant. " + "If multiple documents support the sentence, include multiple citations delimited by semicolons `[@; @]`. " + "Always separate the text and citations with one space, ie ` [@]`. " + "Do not cite for general knowledge, your own reasoning, or content not found in the provided documents. " + "\n" + "For example:" + "\n" + '- "London is the capital of England."\n' + '- "The merger was completed in Q3 [@4]."\n' + '- "Revenue was $8.2 million [@7; @1]."\n' + ) + if multimodal_contents is None: + multimodal_contents = [] + prompt = ( + [TextContent(text=context_prompt)] + multimodal_contents + [TextContent(text=prompt)] + ) + return prompt + + ### --- Embedding --- ### + + @asynccontextmanager + async def _setup_embedding(self, model: str): + # Validate model capability + capabilities = [str(ModelCapability.EMBED)] + model_config = await self._get_default_model(model, capabilities) + model = model_config.id + # Setup rate limiting + # rpm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.embed_requests_per_minute, + # proj_hpm=ENV_CONFIG.embed_requests_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:rpm", + # name="RPM", + # ) + # tpm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.embed_tokens_per_minute, + # proj_hpm=ENV_CONFIG.embed_tokens_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:tpm", + # name="TPM", + # ) + # # Test rate limits + # await asyncio.gather(rpm_limiter.test(), tpm_limiter.test()) + router = DeploymentRouter( + request=self.request, + config=model_config, + organization=self.organization, + is_browser=self.is_browser, + ) + try: + yield router + finally: + # # Consume rate limits + # await asyncio.gather( + # rpm_limiter.hit(), tpm_limiter.hit(self._embed_usage.total_tokens) + # ) + if self.billing is not None: + try: + self.billing.create_embedding_events( + model_id=model, + token_usage=self._embed_usage.total_tokens, + ) + except Exception as e: + logger.warning(f"Failed to create embedding events due to error: {repr(e)}") + + async def embed_documents( + self, + *, + model: str, + texts: list[str], + encoding_format: str | None = None, + **hyperparams, + ) -> EmbeddingResponse: + """ + Embed documents. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model. + texts (list[str]): List of strings to embed as documents. + encoding_format (str | None, optional): Vector encoding format. Defaults to None. + + Returns: + response (EmbeddingResponse): The embedding response. + """ + if len(texts) == 0: + raise BadInputError("There is no text or content to embed.") + # TODO: Do we need to truncate based on 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) + async with self._setup_embedding(model) as router: + embeddings = await router.embedding( + texts=texts, + is_query=False, + encoding_format=encoding_format, + **hyperparams, + ) + self._embed_usage = embeddings.usage + return embeddings + + async def embed_queries( + self, + model: str, + texts: list[str], + encoding_format: str | None = None, + **hyperparams, + ) -> EmbeddingResponse: + """ + Embed documents. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model. + texts (list[str]): List of strings to embed as queries. + encoding_format (str | None, optional): Vector encoding format. Defaults to None. + + Returns: + response (EmbeddingResponse): The embedding response. + """ + # TODO: Do we need to truncate based on context length? + async with self._setup_embedding(model) as router: + embeddings = await router.embedding( + texts=texts, + is_query=True, + encoding_format=encoding_format, + **hyperparams, + ) + self._embed_usage = embeddings.usage + return embeddings + + async def embed_query_as_vector( + self, + model: str, + text: str, + **hyperparams, + ) -> list[float]: + """ + Embed documents. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model. + text (str): A string to embed as query. + + Returns: + vector (list[float]): The embedding vector. + """ + response = await self.embed_queries( + model=model, + texts=[text], + encoding_format="float", + **hyperparams, + ) + return response.data[0].embedding + + ### --- Reranking --- ### + + @asynccontextmanager + async def _setup_reranking(self, model: str): + # Validate model capability + capabilities = [str(ModelCapability.RERANK)] + model_config = await self._get_default_model(model, capabilities) + model = model_config.id + # Setup rate limiting + # rpm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.rerank_requests_per_minute, + # proj_hpm=ENV_CONFIG.rerank_requests_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:rpm", + # name="RPM", + # ) + # spm_limiter = CascadeRateLimiter( + # org_hpm=ENV_CONFIG.rerank_searches_per_minute, + # proj_hpm=ENV_CONFIG.rerank_searches_per_minute, + # organization_id=self.organization.id, + # project_id=self.project.id, + # key=f"{model}:spm", + # name="SPM", + # ) + # # Test rate limits + # await asyncio.gather(rpm_limiter.test(), spm_limiter.test()) + router = DeploymentRouter( + request=self.request, + config=model_config, + organization=self.organization, + is_browser=self.is_browser, + ) + try: + yield router + finally: + # # Consume rate limits + # await asyncio.gather(rpm_limiter.hit(), spm_limiter.hit(self._rerank_usage.documents)) + if self.billing is not None: + try: + self.billing.create_reranker_events( + model_id=model, + num_searches=self._rerank_usage.documents, + ) + except Exception as e: + logger.warning(f"Failed to create reranker events due to error: {repr(e)}") + + async def rerank_documents( + self, + *, + model: str, + query: str, + documents: list[str], + top_n: int | None = None, + **hyperparams, + ) -> RerankingResponse: + """ + Rerank documents. + + Args: + model (str): Model ID. Can be empty in which case we try to get a suitable model. + query (str): Query string. + documents (list[str]): List of strings to rerank. + top_n (int | None, optional): Only return `top_n` results. Defaults to None. + + Returns: + response (RerankingResponse): The rerank response. + """ + if len(query.strip()) == 0: + raise BadInputError("Query cannot be empty.") + if len(documents) == 0: + raise BadInputError("There are no documents to rerank.") + async with self._setup_reranking(model) as router: + rerankings = await router.reranking( + query=query, + documents=documents, + top_n=top_n, + **hyperparams, + ) + self._rerank_usage = rerankings.usage + return rerankings diff --git a/services/api/src/owl/utils/logging.py b/services/api/src/owl/utils/logging.py index 6812766..3d2a998 100644 --- a/services/api/src/owl/utils/logging.py +++ b/services/api/src/owl/utils/logging.py @@ -7,10 +7,15 @@ import inspect import logging import sys +from typing import Any +import httpx from loguru import logger -from owl.configs.manager import ENV_CONFIG +from owl.client import VictoriaMetricsAsync +from owl.configs import ENV_CONFIG +from owl.types import LogQueryResponse +from owl.utils.io import json_loads class InterceptHandler(logging.Handler): @@ -75,20 +80,22 @@ def suppress_logging_handlers(names: list[str], include_submodules: bool = True) lgg.setLevel("ERROR") -def setup_logger_sinks(log_filepath: str = f"{ENV_CONFIG.owl_log_dir}/owl.log"): +def setup_logger_sinks(log_filepath: str | None = f"{ENV_CONFIG.log_dir}/owl.log"): logger.remove() logger.level("INFO", color="") - logger.configure( - handlers=[ - { - "sink": sys.stderr, - "level": "INFO", - "serialize": False, - "backtrace": False, - "diagnose": True, - "enqueue": True, - "catch": True, - }, + handlers = [ + { + "sink": sys.stderr, + "level": "INFO", + "serialize": False, + "backtrace": False, + "diagnose": True, + "enqueue": True, + "catch": True, + }, + ] + if log_filepath is not None: + handlers.append( { "sink": log_filepath, "level": "INFO", @@ -101,5 +108,92 @@ def setup_logger_sinks(log_filepath: str = f"{ENV_CONFIG.owl_log_dir}/owl.log"): "delay": False, "watch": False, }, - ], - ) + ) + logger.configure(handlers=handlers) + + +class VictoriaLogClient(VictoriaMetricsAsync): + __QUERY_ENDPOINT = "/select/logsql/query" + + def _construct_query( + self, + time: str = None, + severity: str = None, + org_ids: list[str] = None, + proj_ids: list[str] = None, + user_ids: list[str] = None, + ) -> str: + """ + Constructs a query string for the VictoriaMetrics log query. + + Args: + time (str, optional): The time range for the query defaults to 5m. + severity (str, optional): The severity level of the logs. + org_ids (list[str], optional): organization IDs. + proj_ids (list[str], optional): project IDs. + user_ids (list[str], optional): user IDs. + + Returns: + str: A query string starting with '_time:5m' if no parameters are provided, + otherwise a string of key:value pairs joined by ' AND '. + """ + + query_params = { + "_time": time or "5m", + "severity": severity.upper() if severity else None, + } + + query_parts = [ + f"{key}:{value}" for key, value in query_params.items() if value is not None + ] + + if org_ids: + org_values = " OR ".join(org_ids) + query_parts.append(f"org_id:({org_values})") + + if proj_ids: + proj_values = " OR ".join(proj_ids) + query_parts.append(f"proj_id:({proj_values})") + + if user_ids: + user_values = " OR ".join(user_ids) + query_parts.append(f"user_id:({user_values})") + + return " AND ".join(query_parts) + + async def query_logs( + self, + time: str = None, + severity: str = None, + org_ids: list[str] = None, + proj_ids: list[str] = None, + user_ids: list[str] = None, + ) -> LogQueryResponse: + """ + Queries logs from VictoriaMetrics using the constructed query parameters. + + Args: + time (str, optional): The time range for the query. + severity (str, optional): The severity level of the logs. + org_ids (list[str], optional): organization IDs. + proj_ids (list[str], optional): project IDs. + user_ids (list[str], optional): user IDs. + + Returns: + LogQueryResponse: A list of JSON objects representing the logs. + """ + params = {"query": self._construct_query(time, severity, org_ids, proj_ids, user_ids)} + response = await self._fetch_victoria_metrics(self.__QUERY_ENDPOINT, params) + return LogQueryResponse(logs=self._process_logs(response)) + + def _process_logs(self, response: httpx.Response) -> list[dict[str, Any]]: + """ + Processes the HTTP response from VictoriaMetrics and extracts log entries. + + Args: + response (httpx.Response): The HTTP response object. + + Returns: + list: A list of JSON objects parsed from the response. + """ + return [json_loads(line) for line in response.iter_lines() if line] diff --git a/services/api/src/owl/utils/loguru_otlp_handler.py b/services/api/src/owl/utils/loguru_otlp_handler.py new file mode 100644 index 0000000..20bf75b --- /dev/null +++ b/services/api/src/owl/utils/loguru_otlp_handler.py @@ -0,0 +1,290 @@ +""" +Adapted from: https://github.com/s71m/opentelemetry-loguru-telegram/blob/master/utils/loguru_otlp_handler.py +""" + +import atexit +import queue +import signal +import sys +import threading +import time +import traceback +from time import time_ns +from typing import Any, ClassVar, Dict + +from loguru import logger +from opentelemetry import trace +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs._internal import LoggerProvider, LogRecord +from opentelemetry.sdk._logs._internal.export import BatchLogRecordProcessor, LogExporter +from opentelemetry.sdk.resources import Resource + +# Constants +MAX_QUEUE_SIZE = 10000 + +# Simplified severity mapping +SEVERITY_MAPPING = { + 10: SeverityNumber.DEBUG, + 20: SeverityNumber.INFO, + 30: SeverityNumber.WARN, + 40: SeverityNumber.ERROR, + 50: SeverityNumber.FATAL, +} + + +class OTLPHandler: + _instances: ClassVar[list["OTLPHandler"]] = [] # Changed from set to list for safe iteration + _shutdown_lock: ClassVar[threading.Lock] = threading.Lock() + _is_shutting_down: ClassVar[bool] = False + + def __init__( + self, + service_name: str, + exporter: LogExporter, + max_queue_size: int = MAX_QUEUE_SIZE, + batch_size: int = 100, + export_interval_ms: int = 1000, + ): + self._resource = Resource( + { + "service.name": service_name, + # "service.instance.id": uuid7_str(), + } + ) + self._queue: queue.Queue[Dict[str, Any]] = queue.Queue(maxsize=max_queue_size) + self._shutdown_event = threading.Event() + # self._flush_complete = threading.Event() + + # Initialize logger provider with resource + self._logger_provider = LoggerProvider(resource=self._resource) + self._logger_provider.add_log_record_processor( + BatchLogRecordProcessor( + exporter, + max_export_batch_size=batch_size, + schedule_delay_millis=export_interval_ms, + export_timeout_millis=5000, + ) + ) + self._logger = self._logger_provider.get_logger(service_name) + + # Start worker thread + self._worker = threading.Thread(target=self._process_queue, name="loguru_otlp_worker") + self._worker.daemon = True + self._worker.start() + + # Register this instance + with self._shutdown_lock: + self.__class__._instances.append(self) + + # Register shutdown handlers only once + if len(self._instances) == 1: + atexit.register(self._shutdown_all_handlers) + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def _get_trace_context(self) -> tuple: + """Get the current trace context.""" + span_context = trace.get_current_span().get_span_context() + return ( + span_context.trace_id if span_context.is_valid else 0, + span_context.span_id if span_context.is_valid else 0, + span_context.trace_flags if span_context.is_valid else 0, + ) + + def _get_severity(self, level_no: int) -> tuple: + """Map Loguru level to OpenTelemetry severity.""" + base_level = (level_no // 10) * 10 + return ( + SEVERITY_MAPPING.get(base_level, SeverityNumber.UNSPECIFIED), + "CRITICAL" + if level_no >= 50 + else "ERROR" + if level_no >= 40 + else "WARNING" + if level_no >= 30 + else "INFO" + if level_no >= 20 + else "DEBUG", + ) + + def _extract_attributes(self, record: Dict[str, Any]) -> Dict[str, Any]: + """Extract attributes from the record.""" + attributes = { + "code.filepath": record["file"].path, + "code.function": record["function"], + "code.lineno": record["line"], + "filename": record["file"].name, + } + + # Add extra attributes if present + extra = record.get("extra", {}) + if isinstance(extra, dict): + for k, v in extra.items(): + if isinstance(v, (str, int, float, bool)): + attributes[k] = v + else: + attributes[k] = repr(v) + else: + pass + + # Handle exception information + if "exception" in record and record["exception"]: + exc_type, exc_value, exc_tb = record["exception"] + if exc_type: + attributes.update( + { + "exception.type": exc_type.__name__, + "exception.message": str(exc_value) if exc_value else "No message", + "exception.stacktrace": "".join( + traceback.format_exception(exc_type, exc_value, exc_tb) + ) + if exc_tb + else "No stacktrace", + } + ) + + return attributes + + def _create_log_record(self, record: Dict[str, Any]) -> LogRecord: + """Create an OpenTelemetry LogRecord.""" + severity_number, severity_text = self._get_severity(record["level"].no) + trace_id, span_id, trace_flags = self._get_trace_context() + + if "exception" in record and record["exception"]: + severity_number = SeverityNumber.FATAL + severity_text = "CRITICAL" + + return LogRecord( + timestamp=int(record["time"].timestamp() * 1e9), + observed_timestamp=time_ns(), + trace_id=trace_id, + span_id=span_id, + trace_flags=trace_flags, + severity_text=severity_text, + severity_number=severity_number, + body=record["message"], + resource=self._logger.resource, + attributes=self._extract_attributes(record), + ) + + @classmethod + def _shutdown_all_handlers(cls): + """Shutdown all handler instances safely.""" + with cls._shutdown_lock: + if cls._is_shutting_down: + return + cls._is_shutting_down = True + + # Create a copy of instances for safe iteration + handlers = cls._instances.copy() + + # Shutdown each handler + for handler in handlers: + try: + handler.shutdown() + except Exception as e: + logger.warning(f"Error shutting down handler: {e}", file=sys.stderr) + + # Clear the instances list + with cls._shutdown_lock: + cls._instances.clear() + + @classmethod + def _signal_handler(cls, signum, frame): + """Handle termination signals.""" + logger.info("\nShutting down logger...", file=sys.stderr) + cls._shutdown_all_handlers() + sys.exit(0) + + def _process_queue(self) -> None: + """Process logs from the queue until shutdown.""" + while not self._shutdown_event.is_set() or not self._queue.empty(): + try: + try: + record = self._queue.get(timeout=0.1) + except queue.Empty: + continue + + if record is None: + self._queue.task_done() + continue + + log_record = self._create_log_record(record) + self._logger.emit(log_record) + self._queue.task_done() + + except Exception as e: + logger.warning(f"Error processing log record: {e}", file=sys.stderr) + + def sink(self, message) -> None: + """Add log message to queue.""" + if self._shutdown_event.is_set(): + return + + try: + self._queue.put_nowait(message.record) + except queue.Full: + logger.warning("Warning: Log queue full, dropping message", file=sys.stderr) + + def shutdown(self) -> None: + """Graceful shutdown of the handler.""" + if self._shutdown_event.is_set(): + return + + try: + # Signal shutdown + self._shutdown_event.set() + # Wait for queue to empty + try: + # Wait with timeout + if not self._queue.empty(): + # Give some time for the queue to process + timeout = 5.0 # 5 seconds timeout + start_time = time.time() + + while not self._queue.empty() and (time.time() - start_time) < timeout: + time.sleep(0.1) + + if not self._queue.empty(): + logger.warning("Warning: Queue not empty after timeout", file=sys.stderr) + + # Force flush remaining logs + # self._logger_provider.force_flush(timeout_millis=5000) + + # Wait for flush completion + # self._flush_complete.wait(timeout=1.0) + + except Exception as e: + logger.warning(f"Error during queue processing: {e}", file=sys.stderr) + + # Final shutdown of logger provider + self._logger_provider.shutdown() + + except Exception as e: + logger.warning(f"Error during shutdown: {e}", file=sys.stderr) + + @classmethod + def create( + cls, + service_name: str, + exporter: LogExporter, + development_mode: bool = False, + export_interval_ms: int = 1000, + ) -> "OTLPHandler": + """Factory method with environment-specific configurations.""" + if development_mode: + return cls( + service_name=service_name, + exporter=exporter, + max_queue_size=1000, + batch_size=50, + export_interval_ms=500, + ) + + return cls( + service_name=service_name, + exporter=exporter, + max_queue_size=MAX_QUEUE_SIZE, + batch_size=100, + export_interval_ms=export_interval_ms, + ) diff --git a/services/api/src/owl/utils/mcp/__init__.py b/services/api/src/owl/utils/mcp/__init__.py new file mode 100644 index 0000000..3ade0f3 --- /dev/null +++ b/services/api/src/owl/utils/mcp/__init__.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.responses import ORJSONResponse + +from owl.types import UserAuth +from owl.utils.auth import auth_user_service_key +from owl.utils.exceptions import handle_exception +from owl.utils.mcp.helpers import _get_mcp_server +from owl.utils.mcp.server import MCP_TOOL_TAG # noqa: F401 + +router = APIRouter() + + +def get_mcp_router(app: FastAPI) -> APIRouter: + """Get the MCP router.""" + mcp_server = _get_mcp_server(app) + import owl.utils.mcp.custom_tools # noqa: F401 + + @router.get("/v1/mcp/http", summary="MCP Streamable HTTP endpoint") + @router.post("/v1/mcp/http", summary="MCP Streamable HTTP endpoint") + @handle_exception + async def mcp_streamable( + request: Request, + user: Annotated[UserAuth, Depends(auth_user_service_key)], + ) -> ORJSONResponse: + if request.method == "GET": + return await mcp_server.get() + return await mcp_server.post( + user=user, + body=await request.json(), + headers=dict(request.headers), + ) + + return router diff --git a/services/api/src/owl/utils/mcp/custom_tools.py b/services/api/src/owl/utils/mcp/custom_tools.py new file mode 100644 index 0000000..44c6a33 --- /dev/null +++ b/services/api/src/owl/utils/mcp/custom_tools.py @@ -0,0 +1,6 @@ +from owl.utils.mcp.helpers import mcp_tool + + +@mcp_tool +async def sum(a: int, b: int) -> int: + return a + b diff --git a/services/api/src/owl/utils/mcp/helpers.py b/services/api/src/owl/utils/mcp/helpers.py new file mode 100644 index 0000000..34f161f --- /dev/null +++ b/services/api/src/owl/utils/mcp/helpers.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING, Callable + +from owl.utils.mcp.server import MCPServer + +if TYPE_CHECKING: + from fastapi import FastAPI + +_mcp_singleton: MCPServer | None = None + + +def _get_mcp_server(app: "FastAPI") -> MCPServer: + global _mcp_singleton + if _mcp_singleton is None: + _mcp_singleton = MCPServer(app) + return _mcp_singleton + + +def mcp_tool(fn: Callable[..., object]) -> Callable[..., object]: + """ + Module-level decorator that forwards to MCPServer.tool + Must be used *after* `get_mcp_router` has been called once. + """ + if _mcp_singleton is None: + raise RuntimeError( + "MCP server not initialized yet. " + "Make sure get_mcp_router(...) is called before decorating." + ) + return _mcp_singleton.tool(fn) diff --git a/services/api/src/owl/utils/mcp/server.py b/services/api/src/owl/utils/mcp/server.py new file mode 100644 index 0000000..cdbc621 --- /dev/null +++ b/services/api/src/owl/utils/mcp/server.py @@ -0,0 +1,483 @@ +import asyncio +import inspect +from collections import defaultdict +from functools import cached_property +from typing import Any, Callable, get_type_hints +from urllib.parse import quote + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from fastapi.responses import ORJSONResponse +from loguru import logger +from pydantic import BaseModel, ValidationError, create_model + +from jamaibase.types.db import RankedRole, UserAuth +from jamaibase.types.mcp import ( + CallToolRequest, + CallToolResult, + ErrorData, + Implementation, + InitializeResult, + JSONRPCEmptyResponse, + JSONRPCError, + JSONRPCErrorCode, + JSONRPCResponse, + ListToolsResult, + ServerCapabilities, + TextContent, + ToolAPI, + ToolAPIInfo, + ToolInputSchema, +) +from owl.client import JamaiASGIAsync +from owl.utils.auth import has_permissions +from owl.utils.exceptions import ( + BadInputError, + ForbiddenError, + JamaiException, + MethodNotAllowedError, + ResourceNotFoundError, +) +from owl.utils.handlers import INTERNAL_ERROR_MESSAGE + +MCP_TOOL_TAG = "mcp_tool" + + +class MCPServer: + _custom_tools: list[ToolAPI] = [] + _custom_callables: dict[str, Callable[..., Any]] = {} + _custom_models: dict[str, BaseModel] = {} + + def __init__( + self, + app: FastAPI, + *, + include_headers_in_input: bool = False, + ): + self.app = app + self.include_headers_in_input = include_headers_in_input + self.openapi_schema = get_openapi( + title=self.app.title, + version=self.app.version, + description=self.app.description, + routes=self.app.routes, + ) + self.init_result = InitializeResult( + capabilities=ServerCapabilities(), + serverInfo=Implementation( + name=self.app.title, + version=self.app.version, + ), + ) + self.client = JamaiASGIAsync(app=self.app) + _ = self.tools + + def tool(self, fn: Callable[..., Any]) -> Callable[..., Any]: + """ + Decorator that turns any sync/async function into an MCP tool. + Arguments are validated through a dynamically created Pydantic model. + """ + sig = inspect.signature(fn) + type_hints = get_type_hints(fn) + + # Build Pydantic model from signature + fields = {} + required = [] + for name, param in sig.parameters.items(): + annotation = type_hints.get(name, Any) + default = ... if param.default is inspect.Parameter.empty else param.default + fields[name] = (annotation, default) + if param.default is inspect.Parameter.empty: + required.append(name) + + Model = create_model(f"{fn.__name__}Schema", **fields) + + # Register + tool = ToolAPI( + name=fn.__name__, + description=fn.__doc__ or "", + inputSchema=ToolInputSchema( + properties=Model.model_json_schema()["properties"], + required=required, + ), + api_info=None, + ) + + self._custom_tools.append(tool) + self._custom_callables[fn.__name__] = fn + self._custom_models[fn.__name__] = Model + return fn + + @cached_property + def tools(self) -> list[ToolAPI]: + tools = [] + operation_ids = set() # Track operation IDs to detect duplicates + + # dump_json(openapi_schema, "openapi_schema.json") + paths: dict[str, Any] = self.openapi_schema.get("paths", {}) + if len(paths) == 0: + logger.warning("Failed to extract paths from OpenAPI schema.") + schemas: dict[str, Any] = self.openapi_schema.get("components", {}).get("schemas", {}) + if len(schemas) == 0: + logger.warning("Failed to extract schemas from OpenAPI schema.") + # Extract tools + for path, methods in paths.items(): + args_types: dict[str, str] = {} + for method, method_info in methods.items(): + tags = method_info.get("tags", []) + if MCP_TOOL_TAG not in tags: + continue + + # Check for duplicate operation IDs + operation_id = method_info.get("operationId", method_info.get("summary", "")) + assert operation_id not in operation_ids, ( + f"Duplicate operation ID found: '{operation_id}' in {method.upper()} {path}" + ) + operation_ids.add(operation_id) + + schema = { + "title": operation_id, + "type": "object", + "properties": {}, + "required": [], + } + # Process path and query parameters (optional headers) + parameters: dict[str, Any] = method_info.get("parameters", {}) + for param in parameters: + if param["in"] == "header" and not self.include_headers_in_input: + continue + param_name = param["name"] + schema["properties"][param_name] = param["schema"] + # self._add_schema_to_properties( + # properties=schema["properties"], + # schema=param["schema"], + # name=param_name, + # ) + if param.get("required", False): + schema["required"].append(param_name) + args_types[param_name] = param["in"] + # Process body + body_schema_ref: str = ( + method_info.get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema", {}) + .get("$ref", "") + ) + body = schemas.get(body_schema_ref.replace("#/components/schemas/", ""), {}) + for param_name, param_schema in body.get("properties", {}).items(): + # Maybe need to resolve reference + if "$ref" in param_schema: + param_schema = schemas.get( + body_schema_ref.replace("#/components/schemas/", ""), {} + ) + schema["properties"][param_name] = param_schema + # self._add_schema_to_properties( + # properties=schema["properties"], + # schema=param_schema, + # name=param_name, + # ) + args_types[param_name] = "body" + schema["required"] += body.get("required", []) + # Create the tool definition + summary = method_info.get("summary", schema.get("title", "")).strip() + if not summary.endswith("."): + summary += "." + description = method_info.get("description", None) + description = summary if description is None else f"{summary}\n{description}" + if method_info.get("deprecated", False): + description += " (Deprecated)" + tool = ToolAPI( + name=schema["title"], + description=description, + inputSchema=ToolInputSchema( + properties=schema["properties"], + required=schema["required"], + ), + api_info=ToolAPIInfo( + path=path, + method=method, + args_types=args_types, + method_info=method_info, + ), + ) + # logger.info(f"{tool=}") + tools.append(tool) + return tools + + @cached_property + def tools_map(self) -> dict[str, ToolAPI]: + return {tool.name: tool for tool in self.tools} + + @cached_property + def permission_tool_map(self) -> dict[frozenset[str], list[ToolAPI]]: + # {frozenset(["system.models", "organization.models"]): [ToolAPI(name="list_models", ...)]} + tool_map = defaultdict(list) + for t in self.tools: + permissions = [ + permission + for permission in t.api_info.method_info["tags"] + if permission.startswith(("system", "organization", "project")) + ] + key = frozenset(permissions) + tool_map[key].append(t) + return tool_map + + def list_tools( + self, + *, + user: UserAuth, + ) -> ListToolsResult: + has_sys_membership = has_permissions(user, ["system"], raise_error=False) + has_org_membership = len(user.org_memberships) > 0 + has_proj_membership = len(user.proj_memberships) > 0 + + org_permission = ( + max([r.role.rank for r in user.org_memberships]) + if has_org_membership + else RankedRole.GUEST + ) # Guest has basically no permissions + proj_permission = ( + max([r.role.rank for r in user.proj_memberships]) + if has_proj_membership + else RankedRole.GUEST + ) # Guest has basically no permissions + tool_list: list[ToolAPI] = [] + for permissions, tools in self.permission_tool_map.items(): + if has_sys_membership and "system" in permissions: + tool_list.extend(tools) + elif has_org_membership and "organization" in permissions: + tool_list.extend(tools) + elif has_proj_membership and "project" in permissions: + tool_list.extend(tools) + else: + for permission in permissions: + if ( + permission.startswith(("system.", "organization.")) + and RankedRole[permission.split(".")[1]] <= org_permission + ): + tool_list.extend(tools) + break + elif ( + permission.startswith("project.") + and RankedRole[permission.split(".")[1]] <= proj_permission + ): + tool_list.extend(tools) + break + # include all custom tools + tool_list.extend(self._custom_tools) + return ListToolsResult(tools=tool_list) + + async def call_tool( + self, + body: CallToolRequest, + *, + headers: dict[str, Any] | None = None, + ) -> CallToolResult: + # Call custom tools + if body.params.name in self._custom_models: + return await self._call_custom_tool( + tool_name=body.params.name, + tool_args=body.params.arguments, + headers=headers, + ) + tool = self.tools_map.get(body.params.name, None) + if tool is None: + raise ResourceNotFoundError(f'Tool "{body.params.name}" is not found.') + # Call the tool + path = tool.api_info.path + args_types = tool.api_info.args_types + args = body.params.arguments + # Process parameters + query_params = None + body_params = None + if args is not None: + if headers is None: + headers = {} + query_params = {} + body_params = {} + for arg_name, arg_value in args.items(): + args_type = args_types.get(arg_name, "") + # Path parameters + if args_type == "path" and f"{{{arg_name}}}" in path: + path = path.replace(f"{{{arg_name}}}", quote(arg_value)) + # Headers + elif args_type == "header": + headers[arg_name] = arg_value + # Query parameters + elif args_type == "query": + query_params[arg_name] = arg_value + # Body parameters + elif args_type == "body": + body_params[arg_name] = arg_value + if len(headers) == 0: + headers = None + if len(query_params) == 0: + query_params = None + if len(body_params) == 0: + body_params = None + if body.params.name == "chat_completion" and len(body_params) != 0: + body_params["stream"] = False + + response = await self.client.request( + tool.api_info.method, + path, + headers=headers, + params=query_params, + body=body_params, + ) + return CallToolResult(content=[TextContent(text=response.text)]) + + async def _call_custom_tool( + self, + tool_name: str, + *, + tool_args: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, + ) -> CallToolResult: + Model = self._custom_models[tool_name] + fn = self._custom_callables[tool_name] + + try: + validated = Model.model_validate(tool_args) + except ValidationError as e: + raise BadInputError(errors=e.errors()) from e + + kwargs = validated.model_dump() + if asyncio.iscoroutinefunction(fn): + out = await fn(**kwargs) + else: + out = fn(**kwargs) + return CallToolResult(content=[TextContent(text=str(out))]) + + async def _handle_request( + self, + user: UserAuth, + body: dict[str, Any], + *, + headers: dict[str, Any] | None = None, + ) -> JSONRPCResponse | JSONRPCEmptyResponse | JSONRPCError | None: + request_id = body.get("id", "") + method = body.get("method", "") + try: + if method.startswith("notifications/"): + return None + elif method == "ping": + response = JSONRPCEmptyResponse( + id=request_id, + ) + elif method == "initialize": + response = JSONRPCResponse[InitializeResult]( + id=request_id, + result=self.init_result, + ) + elif method == "tools/list": + response = JSONRPCResponse[ListToolsResult]( + id=request_id, + result=self.list_tools(user=user), + ) + elif method == "tools/call": + body = CallToolRequest.model_validate(body) + response = JSONRPCResponse[CallToolResult]( + id=request_id, + result=await self.call_tool(body, headers=headers), + ) + else: + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.METHOD_NOT_FOUND, + message=f'Method "{method}" is not supported.', + data=None, + ), + ) + except BadInputError as e: + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.INVALID_PARAMS, + message=str(e), + data=None, + ), + ) + except ForbiddenError as e: + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.FORBIDDEN, + message=str(e), + data=None, + ), + ) + except JamaiException as e: + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.INVALID_REQUEST, + message=str(e), + data=None, + ), + ) + except ValidationError as e: + logger.error(f"Failed to parse JSON-RPC body: {repr(e)}") + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.PARSE_ERROR, + message=str(e), + data=None, + ), + ) + except Exception as e: + logger.exception(f"Unexpected error: {repr(e)}") + response = JSONRPCError( + id=request_id, + error=ErrorData( + code=JSONRPCErrorCode.INTERNAL_ERROR, + message=INTERNAL_ERROR_MESSAGE, + data=None, + ), + ) + return response + + async def get(self): + """Return 405 for GET requests to /mcp""" + raise MethodNotAllowedError("SSE is not supported.") + + async def post( + self, + user: UserAuth, + body: dict[str, Any] | list[dict[str, Any]], + *, + headers: dict[str, Any] | None = None, + ) -> ORJSONResponse: + logger.debug("MCP request: {body}", body=body) + if isinstance(body, list): + response = [ + await self._handle_request(user=user, body=req, headers=headers) for req in body + ] + if any(r is None for r in response): + return ORJSONResponse( + status_code=202, + content={}, + media_type="application/json", + ) + else: + return ORJSONResponse( + status_code=200, + content=[ + r.model_dump(mode="json", by_alias=True, exclude_none=True) + for r in response + ], + media_type="application/json", + ) + else: + response = await self._handle_request(user=user, body=body, headers=headers) + if response is None: + return ORJSONResponse(status_code=202, content={}) + else: + return ORJSONResponse( + status_code=200, + content=response.model_dump(mode="json", by_alias=True, exclude_none=True), + media_type="application/json", + ) diff --git a/services/api/src/owl/utils/metrics.py b/services/api/src/owl/utils/metrics.py new file mode 100644 index 0000000..2fc7298 --- /dev/null +++ b/services/api/src/owl/utils/metrics.py @@ -0,0 +1,424 @@ +import re +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Sequence + +import httpx +from loguru import logger + +from owl.client import VictoriaMetricsAsync +from owl.types import Host, Metric, Usage, UsageResponse + +http_client = httpx.Client(timeout=5) + + +def filter_hostnames( + metric_sequence: Sequence[Metric], host_name_sequence: Sequence[str] +) -> list[Metric]: + return [metric for metric in metric_sequence if metric.hostname in host_name_sequence] + + +def group_metrics_by_hostname( + metrics: list[Metric], hostnames: list[str] | None = None +) -> list[Host]: + # If hostnames filter is provided, filter the metrics + if hostnames: + metrics = filter_hostnames(metrics, hostnames) + + # Group metrics by hostname + hostname_dict = defaultdict(list) + for metric in metrics: + hostname_dict[metric.hostname].append(metric) + + # Create list of hosts + hosts = [ + Host(name=hostname, metrics=metric_list) for hostname, metric_list in hostname_dict.items() + ] + + return hosts + + +class Telemetry(VictoriaMetricsAsync): + __QUERY_ENDPOINT = "/vm/prometheus/api/v1/query" + __QUERY_RANGE_ENDPOINT = "/vm/prometheus/api/v1/query_range" + + # __METRIC_QUERY_TUPLE = ( + # "label_set(sum(rate(container_cpu_usage_seconds_total{container!='',}[1m])) by (instance,job) / sum(machine_cpu_cores{}) by (instance,job) * 100, '__name__', 'cpu_util')", + # "label_set(sum(container_memory_working_set_bytes{container!='',}) by (instance,job) / sum(container_spec_memory_limit_bytes{container!='',}) by (instance,job) * 100,'__name__','memory_util')", + # "label_set(sum(rate(container_fs_reads_bytes_total{container!='',}[15s])) by (instance,job),'__name__','disk_read_bytes')", + # "label_set(sum(rate(container_fs_writes_bytes_total{container!='',}[15s])) by (instance,job),'__name__','disk_write_bytes')", + # "label_set(sum(rate(container_network_receive_bytes_total{container!='',}[15s])) by (instance,job),'__name__','network_receive_bytes')", + # "label_set(sum(rate(container_network_transmit_bytes_total{container!='',}[15s])) by (instance,job),'__name__','network_transmit_bytes')", + # "sort(topk(1, gpu_clock{clock_type='GPU_CLOCK_TYPE_SYSTEM',}))", + # "gpu_clock{clock_type='GPU_CLOCK_TYPE_MEMORY',}", + # "gpu_edge_temperature{}", + # "gpu_memory_temperature{}", + # "gpu_power_usage{}", + # "gpu_gfx_activity{}", + # "gpu_umc_activity{}", + # "gpu_free_vram{}", + # "used_memory{}", + # "DCGM_FI_DEV_SM_CLOCK{}", + # "DCGM_FI_DEV_MEM_CLOCK{}", + # "DCGM_FI_DEV_GPU_TEMP{}", + # "DCGM_FI_DEV_MEMORY_TEMP{}", + # "DCGM_FI_DEV_POWER_USAGE{}", + # "DCGM_FI_DEV_GPU_UTIL{}", + # "DCGM_FI_DEV_MEM_COPY_UTIL{}", + # "DCGM_FI_DEV_FB_FREE{}", + # "DCGM_FI_DEV_FB_USED{}", + # ) + + def _construct_metrics_query(self, queries: list[str]) -> str: + """Construct a metrics retrieval query string. + + Args: + queries (list[str]): A list of fields to query. + + Returns: + str: The constructed query string. + """ + return "union(" + ", ".join(f"{i}" for i in queries) + ")" + + def _construct_usage_query( + self, + range_func: str, + aggregate_func: str, + subject_id: str, + query_filter: list[str], + group_by: list[str], + window_size: str, + ) -> str: + """Construct a usage retrieval query string. + + Args: + range_func (str): The range function to use for the query, ex: max_over_time, increase_pure, etc.. + aggregate_func (str): The aggregate function to use for the query, ex: max, sum, etc.. + subject_id (str): The metric ID to query from, ex: owl_spent_total, owl_llm_token_usage_total, etc.. + group_by (list[str]): The group by fields for the query, ex: ["org_id", "proj_id"], etc.. + window_size (str): The window size to use for the query. + + Returns: + str: The constructed query string. + """ + return f"{aggregate_func}({range_func}({subject_id}{{{','.join(query_filter)}}}[{window_size}])) by ({', '.join(group_by)})" + + def _process_metrics(self, response: list[dict[str, Any]]) -> list[Metric]: + """Process the metrics received from response. + + Args: + response (list[dict[str, Any]]): JSON data from metrics provider. + + Returns: + list[Metric]: A list of processed metrics. + """ + metrics = [] + for metric in response: + try: + # Ensure "metric" key exists + if "metric" not in metric or not isinstance(metric["metric"], dict): + raise KeyError('"metric" key is missing or not a dictionary') + + # Safely retrieve the "__name__" field + metric_name = metric["metric"].get("__name__") + if metric_name == "gpu_clock": + # Safely retrieve the "clock_type" field + clock_type = metric["metric"].get("clock_type") + if clock_type == "GPU_CLOCK_TYPE_MEMORY": + metric["metric"]["__name__"] = "gpu_memory_clock" + + # Process the metric + metrics.append(Metric.from_response(metric)) + except (KeyError, TypeError) as e: + # Log the error and skip the problematic metric + logger.warning( + f"Skipping metric due to missing fields or invalid structure: {metric}. Error: {e}" + ) + continue + return metrics + + def _parse_duration(self, duration_str: str) -> timedelta: + """Parse a duration string into a timedelta object. + + The duration string is expected to be in the format of a sequence of + decimal numbers followed by a unit character. The unit characters + supported are 'ms', 's', 'm', 'h', 'd', 'w', 'y', which represent + milliseconds, seconds, minutes, hours, days, weeks, and years, + respectively. + + Args: + duration_str (str): The duration string to parse. + + Returns: + timedelta: The parsed timedelta object. + """ + pattern = r"(?P\d+)(?P[smhdwy])" + matches = re.findall(pattern, duration_str) + + delta = timedelta() + unit_multipliers = { + "ms": timedelta(milliseconds=1), + "s": timedelta(seconds=1), + "m": timedelta(minutes=1), + "h": timedelta(hours=1), + "d": timedelta(days=1), + "w": timedelta(weeks=1), + "y": timedelta(days=365), + } + + for value, unit in matches: + if unit == "ms": + delta += int(value) * unit_multipliers[unit] + else: + delta += int(value) * unit_multipliers[unit] + + return delta + + async def query_metrics( + self, + queries: list[str] | None = None, + hostnames: list[str] | None = None, + ) -> list[Host]: + """Retrieve the latest metrics from VictoriaMetrics. + + Args: + queries (list[str] | None, optional): A list of fields to query. Defaults to None (which means self.__METRIC_QUERY_TUPLE will be used). + hostnames (list[str] | None, optional): A list of hostnames to filter the results. If None, no filtering will be applied. + + Returns: + list[Host]: A list of Host(s) each contains a name and list[Metric]. + """ + queries = queries or self.__METRIC_QUERY_TUPLE + logger.info(self._construct_metrics_query(queries)) + params = {"query": self._construct_metrics_query(queries)} + response = await self._fetch_victoria_metrics(self.__QUERY_ENDPOINT, params) + response = response.json()["data"]["result"] + + if not response: + return [] + + metrics = self._process_metrics(response) + return group_metrics_by_hostname(metrics, hostnames) + + def _process_usage( + self, + usage: list[dict[str, Any]], + data_interval: timedelta, + group_by: list[str], + ) -> list[Usage]: + """Process usage data into a list of Usage objects. + + Args: + usage (list[dict[str, Any]]): The raw usage data from the query. + data_interval (timedelta): The data interval to adjust the window range. + group_by (list[str]): The group-by fields for the query. + + Returns: + list[Usage]: a list of the usage metrics. + """ + return [ + Usage.from_result(value, result["metric"], data_interval, group_by) + for result in usage + for value in result["values"] + ] + + async def query_usage( + self, + range_func: str, + aggregate_func: str, + subject_id: str, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + timeout_value: int = 5, + ) -> UsageResponse: + """ + Query VictoriaMetrics/Prometheus for usage metrics. + + Args: + range_func (str): The range function to use for the query, e.g., max_over_time, increase_pure, etc. + aggregate_func (str): The aggregate function to use for the query, e.g., max, sum, etc. + subject_id (str): The metric ID to query from, e.g., owl_spent_total, owl_llm_token_usage_total, etc. + filtered_by_org_id (list[str] | None): The organization IDs to filter by. None means no filtering. + filtered_by_proj_id (list[str] | None): The project IDs to filter by. None means no filtering. + from_ (datetime): The start time of the query. + to (datetime | None): The end time of the query. + group_by (list[str]): The group-by fields for the query, e.g., ["org_id", "proj_id"], etc. + window_size (str): The window size to use for the query. + timeout_value (int, optional): The timeout value in seconds. Defaults to 5. + + Returns: + UsageResponse: A response containing windowSize and a list of the usage metrics. + """ + # if "organization_id" in group_by: + # group_by.remove("organization_id") + # if "project_id" in group_by: + # group_by.remove("project_id") + # group_by.append("proj_id") + group_by = list(set(["org_id"] + group_by)) + query_filter = [ + "service.name=~'(owl|starling)'" + ] # always filter service by owl or starling + if filtered_by_org_id: + query_filter.append(f"org_id=~'{'|'.join(filtered_by_org_id)}'") + if filtered_by_proj_id: + query_filter.append( + f"proj_id=~'{'|'.join(filtered_by_proj_id)}'" + ) # Update to proj_id to align with Clickhouse Column + + # Convert datetime to Prometheus timestamp format + data_interval = self._parse_duration(window_size) + # Query VictoriaMetrics/Prometheus + # In VictoriaMetrics/Prometheus max_over_time and increase are rollup functions, + # which calculate the value over raw samples on the given lookbehind window d per each time series returned from the given series_selector. + # Example: start time 2024-12-01 with step 1d means data is from 2024-11-30 to 2024-12-01. + # Thus, the window_start is 2024-11-30 and window_end is 2024-12-01. + # During the query, we add data_interval to start_time (so that the first datapoint is [2024-12-01, 2024-12-02]). + # Otherwise, the first datetime will be [2024-11-30, 2024-12-01], which is not what we want. + params = { + "query": self._construct_usage_query( + range_func, aggregate_func, subject_id, query_filter, group_by, window_size + ), + "start": (from_ + data_interval).timestamp(), + "end": to.timestamp() if to else None, + "step": window_size, + "timeout": timeout_value, + } + response = await self._fetch_victoria_metrics(self.__QUERY_RANGE_ENDPOINT, params) + response = response.json()["data"]["result"] + + return UsageResponse( + windowSize=window_size, + data=self._process_usage(response, data_interval, group_by), + start=(from_ + data_interval).strftime("%Y-%m-%dT%H:%M:%SZ") if from_ else {}, + end=to.strftime("%Y-%m-%dT%H:%M:%SZ") if to else {}, + ) + + async def query_llm_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return await self.query_usage( + "increase_pure", + "sum", + "llm_token_usage", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) + + async def query_embedding_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return await self.query_usage( + "increase_pure", + "sum", + "embedding_token_usage", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) + + async def query_reranking_usage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return await self.query_usage( + "increase_pure", + "sum", + "reranker_search_usage", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) + + async def query_billing( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return await self.query_usage( + "increase_pure", + "sum", + "spent", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) + + def query_bandwidth( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return self.query_usage( + "increase_pure", + "sum", + "bandwidth_usage", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) + + def query_storage( + self, + filtered_by_org_id: list[str] | None, + filtered_by_proj_id: list[str] | None, + from_: datetime, + to: datetime | None, + group_by: list[str], + window_size: str, + ) -> UsageResponse: + return self.query_usage( + "max_over_time", + "max", + "storage_usage", + filtered_by_org_id, + filtered_by_proj_id, + from_, + to, + group_by, + window_size, + ) diff --git a/services/api/src/owl/utils/openapi.py b/services/api/src/owl/utils/openapi.py deleted file mode 100644 index 10707c9..0000000 --- a/services/api/src/owl/utils/openapi.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastapi.routing import APIRoute - - -def custom_generate_unique_id(route: APIRoute): - # return f"{route.tags[0]}-{route.name}" - return f"{route.name}" diff --git a/services/api/src/owl/utils/responses.py b/services/api/src/owl/utils/responses.py deleted file mode 100644 index b4a95ca..0000000 --- a/services/api/src/owl/utils/responses.py +++ /dev/null @@ -1,360 +0,0 @@ -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 deleted file mode 100644 index 4df67bc..0000000 --- a/services/api/src/owl/utils/tasks.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -Repeated tasks implemented by dmontagu - -https://github.com/dmontagu/fastapi-utils/blob/3ef27a6f67ac10fae6a8b4816549c0c44567a451/fastapi_utils/tasks.py -""" - -from __future__ import annotations - -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 - -from starlette.concurrency import run_in_threadpool - -NoArgsNoReturnFuncT = Callable[[], None] -NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]] -NoArgsNoReturnDecorator = Callable[ - [Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]], NoArgsNoReturnAsyncFuncT -] - - -def repeat_every( - *, - seconds: float, - wait_first: bool = False, - logger: logging.Logger | None = None, - raise_exceptions: bool = False, - max_repetitions: int | None = None, -) -> NoArgsNoReturnDecorator: - """ - This function returns a decorator that modifies a function so it is periodically re-executed after its first call. - - The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished - by using `functools.partial` or otherwise wrapping the target function prior to decoration. - - Parameters - ---------- - seconds: float - The number of seconds to wait between repeated calls - wait_first: bool (default False) - If True, the function will wait for a single period before the first call - logger: Optional[logging.Logger] (default None) - The logger to use to log any exceptions raised by calls to the decorated function. - If not provided, exceptions will not be logged by this function (though they may be handled by the event loop). - raise_exceptions: bool (default False) - If True, errors raised by the decorated function will be raised to the event loop's exception handler. - Note that if an error is raised, the repeated execution will stop. - Otherwise, exceptions are just logged and the execution continues to repeat. - See https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_exception_handler for more info. - max_repetitions: Optional[int] (default None) - The maximum number of times to call the repeated function. If `None`, the function is repeated forever. - """ - - def decorator( - func: NoArgsNoReturnAsyncFuncT | NoArgsNoReturnFuncT, - ) -> NoArgsNoReturnAsyncFuncT: - """ - Converts the decorated function into a repeated, periodically-called version of itself. - """ - is_coroutine = asyncio.iscoroutinefunction(func) - - @wraps(func) - async def wrapped() -> None: - repetitions = 0 - - async def loop() -> None: - nonlocal repetitions - if wait_first: - await asyncio.sleep(seconds) - while max_repetitions is None or repetitions < max_repetitions: - try: - if is_coroutine: - await func() # type: ignore - else: - await run_in_threadpool(func) - repetitions += 1 - except Exception as exc: - if logger is not None: - formatted_exception = "".join( - format_exception(type(exc), exc, exc.__traceback__) - ) - logger.error(formatted_exception) - if raise_exceptions: - raise exc - await asyncio.sleep(seconds) - - ensure_future(loop()) - - 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/utils/test.py b/services/api/src/owl/utils/test.py new file mode 100644 index 0000000..997a66a --- /dev/null +++ b/services/api/src/owl/utils/test.py @@ -0,0 +1,1084 @@ +import os +from collections import defaultdict +from contextlib import contextmanager +from datetime import datetime +from functools import lru_cache +from os.path import basename, join +from typing import Any, Generator, Self, TypeVar + +from loguru import logger +from pydantic import BaseModel, model_validator + +from jamaibase import JamAI +from jamaibase.types import ( + ActionTableSchemaCreate, + CellCompletionResponse, + CellReferencesResponse, + ChatCompletionChunkResponse, + ChatCompletionResponse, + ChatCompletionUsage, + ChatTableSchemaCreate, + ColumnSchemaCreate, + ConversationCreateRequest, + ConversationMetaResponse, + DeploymentCreate, + DeploymentRead, + FileUploadResponse, + KnowledgeTableSchemaCreate, + LLMGenConfig, + ModelConfigCreate, + ModelConfigRead, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowRegenRequest, + OkResponse, + OrganizationCreate, + OrganizationRead, + Page, + PasswordLoginRequest, + PricePlanCreate, + PricePlanRead, + Products, + ProjectCreate, + ProjectRead, + References, + RowCompletionResponse, + StripePaymentInfo, + TableDataImportRequest, + TableMetaResponse, + UserCreate, + UserRead, +) +from owl.configs import ENV_CONFIG +from owl.db.models import BASE_PLAN_ID +from owl.types import CloudProvider, ModelCapability, ModelType, TableType +from owl.utils.crypt import generate_key +from owl.utils.dates import utc_iso_from_uuid7_draft2 + +EMAIL = "carl@up.com" +DS_PARAMS = dict(argvalues=["clickhouse", "victoriametrics"], ids=["ch", "vm"]) + + +def get_file_map(test_file_dir: str) -> dict[str, str]: + _files = [join(root, f) for root, _, files in os.walk(test_file_dir) for f in files] + file_map = {basename(f): f for f in _files} + if not len(_files) == len(file_map): + raise ValueError(f'There are duplicate file names in "{test_file_dir}"') + return file_map + + +@contextmanager +def register_password( + body: dict[str, Any], + *, + token: str = ENV_CONFIG.service_key_plain, +): + user = JamAI(token=token).auth.register_password(UserCreate(**body)) + try: + assert isinstance(user, UserRead) + assert user.email == body["email"] + assert user.name == body["name"] + if "password" in body: + assert user.password_hash == "***" + else: + assert user.password_hash is None + yield user + finally: + try: + JamAI(user_id=user.id, token=token).users.delete_user() + except Exception as e: + logger.error(f"User cleanup failed: {repr(e)}") + + +@contextmanager +def create_plan( + body: dict[str, Any], + *, + user_id: str, + token: str = ENV_CONFIG.service_key_plain, +): + client = JamAI(user_id=user_id, token=token) + plan = client.prices.create_price_plan(body) + try: + yield plan + finally: + client.prices.delete_price_plan(plan.id, missing_ok=True) + + +@contextmanager +def create_user( + body: dict[str, Any] | None = None, + *, + token: str = ENV_CONFIG.service_key_plain, +): + if body is None: + body = dict(email=EMAIL, name="System Admin") + user = JamAI(token=token).users.create_user(UserCreate(**body)) + try: + assert isinstance(user, UserRead) + assert user.email == body["email"] + assert user.name == body["name"] + if "password" in body: + assert user.password_hash == "***", f"{user.password_hash=}" + # Test password login + user = JamAI(token=token).auth.login_password( + PasswordLoginRequest(email=body["email"], password=body["password"]) + ) + assert isinstance(user, UserRead) + else: + assert user.password_hash is None + yield user + finally: + try: + JamAI(user_id=user.id, token=token).users.delete_user() + except Exception as e: + logger.error(f"User cleanup failed: {repr(e)}") + + +@contextmanager +def create_organization( + body: OrganizationCreate | dict | None = None, + *, + user_id: str, + token: str = ENV_CONFIG.service_key_plain, + subscribe_plan: bool = True, +): + client = JamAI(user_id=user_id, token=token) + if body is None: + body = OrganizationCreate(name="Clubhouse") + # Create org + org = client.organizations.create_organization(body) + try: + assert isinstance(org, OrganizationRead) + assert org.created_by == user_id, f"{org.created_by=}, {user_id=}" + # Try to create price plan + if ENV_CONFIG.is_cloud: + plans = client.prices.list_price_plans() + if plans.total <= 1: + client.prices.create_price_plan( + PricePlanCreate( + id="pro", + name="Pro plan", + stripe_price_id_live="price_223", + stripe_price_id_test="price_1RT2EdCcpbd72IcYeAFWrbxw", + flat_cost=25.0, + credit_grant=15.0, + max_users=None, + products=Products.unlimited(), + ) + ) + client.prices.create_price_plan( + PricePlanCreate( + id="team", + name="Team plan", + stripe_price_id_live="price_323", + stripe_price_id_test="price_1RT2FfCcpbd72IcYPGIGyXmj", + flat_cost=250.0, + credit_grant=150.0, + max_users=None, + products=Products.unlimited(), + ) + ) + base_plan = next((p for p in plans.items if p.id == BASE_PLAN_ID), None) + assert isinstance(base_plan, PricePlanRead) + assert base_plan.flat_cost == 0.0 + if subscribe_plan and org.price_plan_id is None: + response = client.organizations.subscribe_plan(org.id, base_plan.id) + assert isinstance(response, StripePaymentInfo) + org.price_plan_id = base_plan.id + org.price_plan = base_plan + response = JamAI(user_id="0", token=token).organizations.set_credit_grant( + organization_id=org.id, amount=150 + ) + assert isinstance(response, OkResponse) + if isinstance(body, BaseModel): + body = body.model_dump() + assert org.name == body["name"] + yield org + finally: + try: + client.organizations.delete_organization(org.id) + except Exception as e: + logger.error(f"Organization cleanup failed: {repr(e)}") + + +@contextmanager +def create_project( + body: dict[str, Any] | None = None, + *, + user_id: str = "0", + organization_id: str = "0", + token: str = ENV_CONFIG.service_key_plain, +): + client = JamAI(user_id=user_id, token=token) + if body is None: + body = dict(name="Mickey 17") + body["organization_id"] = organization_id + project = client.projects.create_project(ProjectCreate(**body)) + try: + assert isinstance(project, ProjectRead) + assert project.created_by == user_id, f"{project.created_by=}, {user_id=}" + assert project.name.startswith(body["name"]) + yield project + finally: + try: + client.projects.delete_project(project.id) + except Exception as e: + logger.error(f"Project cleanup failed: {repr(e)}") + + +@contextmanager +def create_model_config( + body: ModelConfigCreate, + *, + user_id: str = "0", + token: str = ENV_CONFIG.service_key_plain, +): + client = JamAI(user_id=user_id, token=token) + model = client.models.create_model_config(body) + try: + assert isinstance(model, ModelConfigRead) + yield model + finally: + try: + client.models.delete_model_config(model.id) + except Exception as e: + logger.error(f"Model cleanup failed: {repr(e)}") + + +@contextmanager +def create_deployment( + body: DeploymentCreate | dict, + *, + user_id: str = "0", + token: str = ENV_CONFIG.service_key_plain, +): + client = JamAI(user_id=user_id, token=token) + deployment = client.models.create_deployment(body) + try: + assert isinstance(deployment, DeploymentRead) + yield deployment + finally: + try: + client.models.delete_deployment(deployment.id) + except Exception as e: + logger.error(f"Deployment cleanup failed: {repr(e)}") + + +class OrgContext(BaseModel): + superuser: UserRead + user: UserRead + superorg: OrganizationRead + org: OrganizationRead + + +@contextmanager +def setup_organizations(): + with ( + create_user() as superuser, + create_user(dict(email=f"russell-{generate_key(8)}@up.com", name="User")) as user, + ): + assert user.id != "0" + with ( + create_organization( + OrganizationCreate(name="System"), user_id=superuser.id + ) as superorg, + create_organization(OrganizationCreate(name="Clubhouse"), user_id=user.id) as org, + ): + assert superorg.id == "0" + assert org.id != "0" + yield OrgContext(superuser=superuser, user=user, superorg=superorg, org=org) + + +class ProjectContext(OrgContext): + projects: list[ProjectRead] + + +@contextmanager +def setup_projects(): + with setup_organizations() as ctx: + with ( + create_project(user_id=ctx.superuser.id, organization_id=ctx.superorg.id) as p0, + create_project(user_id=ctx.user.id, organization_id=ctx.org.id) as p1, + ): + assert p0.organization_id == ctx.superorg.id + assert p1.organization_id == ctx.org.id + # Using `**model_dump()` leads to serialization warnings + yield ProjectContext( + projects=[p0, p1], + superuser=ctx.superuser, + user=ctx.user, + superorg=ctx.superorg, + org=ctx.org, + ) + + +SMOL_LM2_CONFIG = ModelConfigCreate( + id="ellm/smollm2:135m", + name="ELLM SmolLM2 135M", + type=ModelType.LLM, + capabilities=[ModelCapability.CHAT], + context_length=4096, + owned_by="ellm", +) +CLAUDE_HAIKU_CONFIG = ModelConfigCreate( + id="anthropic/claude-3-5-haiku-latest", + name="Anthropic Claude 3.5 Haiku", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=128000, + languages=["en"], +) +GPT_41_MINI_CONFIG = ModelConfigCreate( + id="openai/gpt-4.1-mini", + name="OpenAI GPT-4.1 mini", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=1047576, + languages=["en"], +) +GPT_41_NANO_CONFIG = ModelConfigCreate( + id="openai/gpt-4.1-nano", + name="OpenAI GPT-4.1 nano", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=1047576, + languages=["en"], +) +GPT_4O_MINI_CONFIG = ModelConfigCreate( + id="openai/gpt-4o-mini", + name="OpenAI GPT-4o mini", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=128000, + languages=["en"], +) +GPT_5_MINI_CONFIG = ModelConfigCreate( + id="openai/gpt-5-mini", + name="OpenAI GPT-5 mini", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.REASONING, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=1280000, + languages=["en"], +) +OPENAI_O4_MINI_CONFIG = ModelConfigCreate( + id="openai/o4-mini", + name="OpenAI o4 mini", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.REASONING, + ModelCapability.IMAGE, + ModelCapability.TOOL, + ], + context_length=1280000, + languages=["en"], +) +ELLM_DESCRIBE_CONFIG = ModelConfigCreate( + id="ellm/describe", + name="Describe Message", + type=ModelType.LLM, + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.AUDIO, + ], + context_length=128000, + languages=["en"], + owned_by="ellm", +) +TEXT_EMBEDDING_3_SMALL_CONFIG = ModelConfigCreate( + id="openai/text-embedding-3-small", + name="OpenAI Text Embedding 3 Small", + type=ModelType.EMBED, + capabilities=[ModelCapability.EMBED], + context_length=8192, + embedding_size=1536, + embedding_dimensions=256, + languages=["en"], +) +ELLM_EMBEDDING_CONFIG = ModelConfigCreate( + id="ellm/embed-dim-256", + name="Mock Embedding (256-dim)", + type=ModelType.EMBED, + capabilities=[ModelCapability.EMBED], + context_length=8192, + embedding_size=256, + embedding_dimensions=256, + languages=["en"], + owned_by="ellm", +) +RERANK_ENGLISH_v3_SMALL_CONFIG = ModelConfigCreate( + id="cohere/rerank-english-v3.0", + name="Cohere Rerank English v3.0", + type=ModelType.RERANK, + capabilities=[ModelCapability.RERANK], + context_length=512, + languages=["en"], +) + +CLAUDE_HAIKU_DEPLOYMENT = DeploymentCreate( + model_id=CLAUDE_HAIKU_CONFIG.id, + name=f"{CLAUDE_HAIKU_CONFIG.name} Deployment", + provider=CloudProvider.ANTHROPIC, + routing_id=CLAUDE_HAIKU_CONFIG.id, + api_base="", +) +GPT_41_MINI_DEPLOYMENT = DeploymentCreate( + model_id=GPT_41_MINI_CONFIG.id, + name=f"{GPT_41_MINI_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=GPT_41_MINI_CONFIG.id, + api_base="", +) +GPT_41_NANO_DEPLOYMENT = DeploymentCreate( + model_id=GPT_41_NANO_CONFIG.id, + name=f"{GPT_41_NANO_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=GPT_41_NANO_CONFIG.id, + api_base="", +) +GPT_4O_MINI_DEPLOYMENT = DeploymentCreate( + model_id=GPT_4O_MINI_CONFIG.id, + name=f"{GPT_4O_MINI_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=GPT_4O_MINI_CONFIG.id, + api_base="", +) +GPT_5_MINI_DEPLOYMENT = DeploymentCreate( + model_id=GPT_5_MINI_CONFIG.id, + name=f"{GPT_5_MINI_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=GPT_5_MINI_CONFIG.id, + api_base="", +) +OPENAI_O4_MINI_DEPLOYMENT = DeploymentCreate( + model_id=OPENAI_O4_MINI_CONFIG.id, + name=f"{OPENAI_O4_MINI_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=OPENAI_O4_MINI_CONFIG.id, + api_base="", +) +ELLM_DESCRIBE_DEPLOYMENT = DeploymentCreate( + model_id=ELLM_DESCRIBE_CONFIG.id, + name=f"{ELLM_DESCRIBE_CONFIG.name} Deployment", + provider="custom", + routing_id=ELLM_DESCRIBE_CONFIG.id, + api_base=ENV_CONFIG.test_llm_api_base, +) +TEXT_EMBEDDING_3_SMALL_DEPLOYMENT = DeploymentCreate( + model_id=TEXT_EMBEDDING_3_SMALL_CONFIG.id, + name=f"{TEXT_EMBEDDING_3_SMALL_CONFIG.name} Deployment", + provider=CloudProvider.OPENAI, + routing_id=TEXT_EMBEDDING_3_SMALL_CONFIG.id, + api_base="", +) +ELLM_EMBEDDING_DEPLOYMENT = DeploymentCreate( + model_id=ELLM_EMBEDDING_CONFIG.id, + name=f"{ELLM_EMBEDDING_CONFIG.name} Deployment", + provider=CloudProvider.VLLM_CLOUD, + routing_id=ELLM_EMBEDDING_CONFIG.id, + api_base=ENV_CONFIG.test_llm_api_base, +) +RERANK_ENGLISH_v3_SMALL_DEPLOYMENT = DeploymentCreate( + model_id=RERANK_ENGLISH_v3_SMALL_CONFIG.id, + name=f"{RERANK_ENGLISH_v3_SMALL_CONFIG.name} Deployment", + provider=CloudProvider.COHERE, + routing_id=RERANK_ENGLISH_v3_SMALL_CONFIG.id, + api_base="", +) + + +@lru_cache(maxsize=1000) +def upload_file_cached(user_id: str, project_id: str, file_path: str) -> FileUploadResponse: + return JamAI(user_id=user_id, project_id=project_id).file.upload_file(file_path) + + +def upload_file(client: JamAI, file_path: str) -> FileUploadResponse: + return upload_file_cached( + user_id=client.user_id, + project_id=client.project_id, + file_path=file_path, + ) + + +STREAM_PARAMS = dict(argvalues=[True, False], ids=["stream", "non-stream"]) +TABLE_TYPES = list(TableType) +TEXTS = { + "EN": '"Arrival" is a 2016 film.', + "ZH-CN": "《é™ä¸´ã€‹æ˜¯ä¸€éƒ¨ 2016 年科幻片。", + "ZH-TW": "《異星入境》是2016年的電影。", + "JA": "「メッセージã€ã¯2016å¹´ã®æ˜ ç”»ã§ã™ã€‚", + "KR": '"컨íƒíЏ"는 2016ë…„ ì˜í™”입니다.', + "ES": '"La llegada" es una película de 2016.', + "IT": '"Arrival" è un film del 2016.', + "IS": '"Arrival" er kvikmynd frá 2016.', + "AR": '"الوصول" هو Ùيلم من عام 2016.', +} + + +@contextmanager +def create_table( + client: JamAI, + table_type: TableType, + table_id: str = "", + *, + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + chat_model: str = "", + embedding_model: str = "", +): + try: + if cols is None: + dtypes = ["int", "float", "bool", "str", "image", "audio", "document"] + cols = [ColumnSchemaCreate(id=dtype, dtype=dtype) for dtype in dtypes] + cols += [ + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=chat_model, + system_prompt="", + prompt="", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + if table_type == TableType.CHAT: + if chat_cols is None: + cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + cols + else: + cols = chat_cols + cols + + # info_col_ids = ["ID", "Updated at"] + # input_col_ids = [ + # col.id for col in cols if col.gen_config is None and col.id not in info_col_ids + # ] + # # output_col_ids = [col.id for col in cols if col.gen_config is not None] + # default_sys_prompt_col_ids = [ + # col.id + # for col in cols + # if isinstance(col.gen_config, LLMGenConfig) and col.gen_config.system_prompt == "" + # ] + # default_prompt_col_ids = [ + # col.id + # for col in cols + # if isinstance(col.gen_config, LLMGenConfig) and col.gen_config.prompt == "" + # ] + + if not table_id: + table_id = generate_key(80, "table-") + if table_type == TableType.ACTION: + table = client.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols), + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + elif table_type == TableType.CHAT: + table = client.table.create_chat_table( + ChatTableSchemaCreate(id=table_id, cols=cols), + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + assert table.id == table_id + # col_map = {col.id: col for col in table.cols} + # # Check default system prompt + # default_sys_phrase = ( + # "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." + # ) + # for col_id in default_sys_prompt_col_ids: + # gen_config = col_map[col_id].gen_config + # assert default_sys_phrase in gen_config.system_prompt + # # Check default prompt + # input_col_refs = ["${" + col + "}" for col in input_col_ids] + # for col_id in default_prompt_col_ids: + # gen_config = col_map[col_id].gen_config + # for ref in input_col_refs: + # assert ref in gen_config.prompt, f"Missing '{ref}' in '{gen_config.prompt}'" + # assert "${ID}" not in gen_config.prompt # Info columns + # assert "${Updated at}" not in gen_config.prompt # Info columns + # if table_type == TableType.KNOWLEDGE: + # assert "${Title Embed}" not in gen_config.prompt # Vector columns + # assert "${Text Embed}" not in gen_config.prompt # Vector columns + # elif table_type == TableType.CHAT: + # assert "${User}" in gen_config.prompt + yield table + finally: + try: + client.table.delete_table(table_type, table_id, missing_ok=True) + except Exception as e: + logger.error(f"Table cleanup failed: {repr(e)}") + + +def list_tables( + client: JamAI, + table_type: TableType, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "updated_at", + order_ascending: bool = True, + created_by: str | None = None, + parent_id: str | None = None, + search_query: str = "", + count_rows: bool = False, + **kwargs, +) -> Page[TableMetaResponse]: + tables = client.table.list_tables( + table_type, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + created_by=created_by, + parent_id=parent_id, + search_query=search_query, + count_rows=count_rows, + **kwargs, + ) + assert isinstance(tables, Page) + assert isinstance(tables.items, list) + assert all(isinstance(t, TableMetaResponse) for t in tables.items) + return tables + + +def compile_and_check_row_responses( + response: ( + MultiRowCompletionResponse + | Generator[CellReferencesResponse | CellCompletionResponse, None, None] + ), + *, + table_type: TableType, + stream: bool, + regen: bool, + check_usage: bool = True, +) -> MultiRowCompletionResponse: + if stream: + responses: list[CellReferencesResponse | CellCompletionResponse] = [r for r in response] + # dump_json( + # [r.model_dump(mode="json") for r in responses], f"stream-{table_type.value}.json" + # ) + for r in responses: + if isinstance(r, CellReferencesResponse): + assert r.object == "gen_table.references" + elif isinstance(r, CellCompletionResponse): + assert r.object == "gen_table.completion.chunk" + assert r.usage is None or isinstance(r.usage, ChatCompletionUsage) + assert isinstance(r.prompt_tokens, int) + assert isinstance(r.completion_tokens, int) + assert isinstance(r.total_tokens, int) + else: + raise ValueError(f"Unexpected response type: {type(r)}") + # Construct MultiRowCompletionResponse + row_chunks_map: dict[str, list[CellCompletionResponse]] = defaultdict(list) + refs_map: dict[tuple[str, str], CellReferencesResponse] = {} + for r in responses: + if isinstance(r, CellReferencesResponse): + refs_map[(r.row_id, r.output_column_name)] = r + continue + row_chunks_map[r.row_id].append(r) + rows = [] + for row_id, row_chunks in row_chunks_map.items(): + col_chunks_map: dict[str, list[CellCompletionResponse]] = defaultdict(list) + for c in row_chunks: + col_chunks_map[c.output_column_name].append(c) + columns = {col_id: chunks[0] for col_id, chunks in col_chunks_map.items()} + for col_id, chunks in col_chunks_map.items(): + content = "".join( + getattr(c.choices[0].message, "content", "") or "" for c in chunks + ) + reasoning_content = "".join( + getattr(c.choices[0].message, "reasoning_content", "") or "" for c in chunks + ) + columns[col_id].choices[0].message.content = content + columns[col_id].choices[0].message.reasoning_content = reasoning_content + columns[col_id].choices[0].delta = None + columns[col_id].usage = chunks[-1].usage # Last chunk should have usage data + columns[col_id].references = refs_map.get((row_id, col_id), None) + # columns[col_id] = ChatCompletionResponse.model_validate( + # columns[col_id].model_dump(exclude={"object", "references.object"}) + # ) + rows.append(RowCompletionResponse(columns=columns, row_id=row_id)) + response = MultiRowCompletionResponse(rows=rows) + # dump_json(response.model_dump(mode="json"), f"stream-{table_type.value}-converted.json") + # else: + # dump_json(response.model_dump(mode="json"), f"nonstream-{table_type.value}-converted.json") + assert isinstance(response, MultiRowCompletionResponse) + assert response.object == "gen_table.completion.rows" + for row in response.rows: + assert isinstance(row, RowCompletionResponse) + assert row.object == "gen_table.completion.chunks" + # if table_type == TableType.CHAT: + # assert "AI" in row.columns + # Check completion lengths + for completion in row.columns.values(): + assert isinstance(completion, (ChatCompletionChunkResponse, ChatCompletionResponse)) + # assert len(completion.content) > 0, f"{completion=}" + # Check usage + if check_usage and not completion.content.startswith("[ERROR] "): + assert isinstance(completion.usage, ChatCompletionUsage), f"{completion.usage=}" + assert isinstance(completion.prompt_tokens, int) + assert isinstance(completion.completion_tokens, int) + assert isinstance(completion.total_tokens, int) + # Regen will return zero usage for "RUN_BEFORE", "RUN_AFTER", "RUN_SELECTED" + min_value = 0 if regen else 1 + assert completion.prompt_tokens >= min_value, f"{completion.content=} {completion.usage=}" # fmt: off + assert completion.completion_tokens >= min_value, f"{completion.content=} {completion.usage=}" # fmt: off + assert completion.usage.total_tokens >= min_value, f"{completion.content=} {completion.usage=}" # fmt: off + # Check references + if isinstance(completion.references, References): + assert isinstance(completion.references.chunks, list) + else: + assert completion.references is None, ( + f"Unexpected type: {type(completion.references)=}" + ) + return response + + +def add_table_rows( + client: JamAI, + table_type: TableType, + table_name: str, + data: list[dict, Any], + *, + stream: bool, + check_usage: bool = True, +) -> MultiRowCompletionResponse: + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest(table_id=table_name, data=data, stream=stream), + ) + return compile_and_check_row_responses( + response, + table_type=table_type, + stream=stream, + regen=False, + check_usage=check_usage, + ) + + +def regen_table_rows( + client: JamAI, + table_type: TableType, + table_name: str, + row_ids: list[str], + *, + stream: bool, + check_usage: bool = True, + **kwargs: Any, +) -> MultiRowCompletionResponse: + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest(table_id=table_name, row_ids=row_ids, stream=stream, **kwargs), + ) + return compile_and_check_row_responses( + response, + table_type=table_type, + stream=stream, + regen=True, + check_usage=check_usage, + ) + + +def import_table_data( + client: JamAI, + table_type: TableType, + table_name: str, + file_path: str, + *, + stream: bool, + delimiter: str = ",", + check_usage: bool = True, + **kwargs: Any, +) -> MultiRowCompletionResponse: + response = client.table.import_table_data( + table_type, + TableDataImportRequest( + file_path=file_path, + table_id=table_name, + stream=stream, + delimiter=delimiter, + **kwargs, + ), + ) + return compile_and_check_row_responses( + response, + table_type=table_type, + stream=stream, + regen=False, + check_usage=check_usage, + ) + + +def assert_is_vector_or_none(x: Any, *, allow_none: bool): + if allow_none and x is None: + return + assert isinstance(x, list), f"Not a list: {x}" + assert len(x) > 0, f"List is empty: {x}" + assert all(isinstance(v, float) for v in x), f"Not a list of floats: {x}" + + +T = TypeVar("T") + + +class RowPage(Page[T]): + # For easier testing + values: list[dict[str, Any]] = [] + originals: list[dict[str, Any]] = [] + references: list[dict[str, References | Any]] = [] + + @model_validator(mode="after") + def flatten_row_data(self) -> Self: + rows: list[dict[str, Any]] = self.items + self.values = [ + # `value` key must be present + {c: v["value"] if isinstance(v, dict) else v for c, v in r.items()} + for r in rows + ] + self.originals = [ + # `original` key may be absent + {c: v.get("original", None) if isinstance(v, dict) else None for c, v in r.items()} + for r in rows + ] + references = [ + # `references` key may be absent + {c: v.get("references", None) if isinstance(v, dict) else None for c, v in r.items()} + for r in rows + ] + self.references = [ + {c: References.model_validate(v) if v else None for c, v in r.items()} + for r in references + ] + return self + + +def _check_fetched_row( + row: dict[str, Any], + *, + table_type: TableType, + vec_decimals: int = 0, + columns: list[str] | None = None, +): + assert isinstance(row, dict) + # Check info columns + assert isinstance(row["ID"], str) + assert isinstance(row["Updated at"], str) + id_datetime = datetime.fromisoformat(utc_iso_from_uuid7_draft2(row["ID"])) + updated_at = datetime.fromisoformat(row["Updated at"]) + time_diff = abs( + (id_datetime.replace(tzinfo=None) - updated_at.replace(tzinfo=None)).total_seconds() + ) + assert time_diff < (60 * 60), ( + f"ID datetime: {id_datetime}, Updated at: {updated_at}, Diff: {time_diff}" + ) + # Check vector columns + if table_type == TableType.KNOWLEDGE: + if vec_decimals < 0: + # Vector columns should be removed + assert "Text Embed" not in row + assert "Title Embed" not in row + else: + if columns is None or "Text Embed" in columns: + assert_is_vector_or_none(row["Text Embed"]["value"], allow_none=True) + if columns is None or "Title Embed" in columns: + assert_is_vector_or_none(row["Title Embed"]["value"], allow_none=True) + + +def list_table_rows( + client: JamAI, + table_type: TableType, + table_name: str, + *, + offset: int = 0, + limit: int = 100, + order_by: str = "ID", + order_ascending: bool = True, + columns: list[str] | None = None, + where: str = "", + search_query: str = "", + search_columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, +) -> RowPage[dict[str, Any]]: + rows = client.table.list_table_rows( + table_type, + table_name, + offset=offset, + limit=limit, + order_by=order_by, + order_ascending=order_ascending, + columns=columns, + where=where, + search_query=search_query, + search_columns=search_columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) + assert isinstance(rows, Page) + assert isinstance(rows.items, list) + assert rows.offset == offset + assert rows.limit == limit + if len(rows.items) > 0: + row = rows.items[0] + _check_fetched_row( + row, + table_type=table_type, + vec_decimals=vec_decimals, + columns=columns, + ) + rows = RowPage[dict[str, Any]].model_validate(rows.model_dump()) + return rows + + +def get_table_row( + client: JamAI, + table_type: TableType, + table_name: str, + row_id: str, + *, + columns: list[str] | None = None, + float_decimals: int = 0, + vec_decimals: int = 0, + **kwargs, +) -> dict[str, Any]: + row = client.table.get_table_row( + table_type, + table_name, + row_id, + columns=columns, + float_decimals=float_decimals, + vec_decimals=vec_decimals, + **kwargs, + ) + _check_fetched_row( + row, + table_type=table_type, + vec_decimals=vec_decimals, + columns=columns, + ) + return row + + +def check_rows( + rows: list[dict[str, Any]], + data: list[dict[str, Any]], + *, + info_cols_equal: bool = True, +): + assert len(rows) == len(data), f"Row count mismatch: {len(rows)=} != {len(data)=}" + for row, d in zip(rows, data, strict=True): + for col in d: + if col in ["ID", "Updated at"] and not info_cols_equal: + assert row[col] != d[col], f'Column "{col}" is not regenerated: {d[col]=}' + continue + if d[col] is None or d[col] == "": + assert row[col] is None, f'Column "{col}" mismatch: {row[col]=} != {d[col]=}' + else: + assert row[col] == d[col], f'Column "{col}" mismatch: {row[col]=} != {d[col]=}' + + +def create_conversation( + client: JamAI, + agent_id: str, + data: dict[str, Any], + title: str | None = None, +) -> list[ConversationMetaResponse | CellReferencesResponse | CellCompletionResponse]: + chunks = client.conversations.create_conversation( + ConversationCreateRequest(agent_id=agent_id, data=data, title=title) + ) + return list(chunks) + + +# class ModelContext(ProjectContext): +# tier: ModelTierRead +# model: ModelConfigRead +# deployment: DeploymentRead + + +# @contextmanager +# def setup_model(): +# async with setup_projects() as ctx: +# async with ( +# create_model_tier( +# dict( +# id="test-tier", +# name="Test PriceTier", +# llm_requests_per_minute=100, +# llm_tokens_per_minute=1000, +# ) +# ) as tier, +# create_model_config( +# ModelConfigCreate( +# id="openai/gpt-4o-mini", +# name="OpenAI GPT-4o mini", +# capabilities=["chat", "image"], +# context_length=128000, +# type=ModelType.LLM, +# languages=["en"], +# ) +# ) as model, +# create_deployment( +# DeploymentCreate( +# model_id=model.id, +# name="Test Deployment", +# provider=CloudProvider.OPENAI, +# routing_id="openai/gpt-4o-mini", +# ) +# ) as deployment, +# ): +# assert tier.id == "test-tier" +# assert model.id == "openai/gpt-4o-mini" +# assert deployment.model_id == "openai/gpt-4o-mini" +# # Using `**model_dump()` leads to serialization warnings +# yield ModelContext( +# tier=tier, +# model=model, +# deployment=deployment, +# projects=ctx.projects, +# superuser=ctx.superuser, +# user=ctx.user, +# superorg=ctx.superorg, +# org=ctx.org, +# ) diff --git a/services/api/src/owl/utils/types.py b/services/api/src/owl/utils/types.py new file mode 100644 index 0000000..8f0481b --- /dev/null +++ b/services/api/src/owl/utils/types.py @@ -0,0 +1,44 @@ +import base64 + +import orjson +from sqlalchemy import TypeDecorator +from sqlmodel import JSON + +from jamaibase.utils.types import ( # noqa: F401 + CLI, + get_enum_validator, +) +from owl.configs import ENV_CONFIG + + +class RqliteJSON(TypeDecorator): + impl = JSON + + def process_bind_param(self, value, dialect): + if value is not None: + # Encode JSON data as Base64 before storing it + return base64.b64encode(orjson.dumps(value)).decode("utf-8") + return value + + def process_result_value(self, value, dialect): + if value is not None: + # Handle empty strings explicitly + if value == "": + return None # or return an empty dict {} depending on your use case + # If the value is already a dictionary, return it directly + if isinstance(value, (list, dict)): + return value + # Ensure the value is a string before decoding + if isinstance(value, bytes): + value = value.decode("utf-8") + # Decode Base64 data back to JSON + return orjson.loads(base64.b64decode(value.encode("utf-8"))) + return value + + +if ENV_CONFIG.db_dialect == "rqlite": + JSON = RqliteJSON +elif ENV_CONFIG.db_dialect == "postgresql": + from sqlalchemy.dialects.postgresql import JSONB + + JSON = JSONB diff --git a/services/api/src/owl/utils/victoriametrics.py b/services/api/src/owl/utils/victoriametrics.py new file mode 100644 index 0000000..bde9d45 --- /dev/null +++ b/services/api/src/owl/utils/victoriametrics.py @@ -0,0 +1,45 @@ +import httpx +from loguru import logger + +http_client = httpx.Client(timeout=5) + + +class VictoriaMetricsClient: + def __init__(self, host: str, port: int, user: str = None, password: str = None): + """Initialize a class for communicating with Victoria Metrics server. + + Args: + host (str): The hostname or IP address of the VictoriaMetrics server. + port (int): The port number of the VictoriaMetrics server. + user (str | None, optional): The username for authentication. + password (str | None, optional): The password for authentication. + """ + self.endpoint = f"http://{host}:{port}" + self.user = user or "" + self.password = password or "" + + def _fetch_victoria_metrics( + self, endpoint: str, params: dict | None = None + ) -> httpx.Response | None: + """Send a GET request to the specified VictoriaMetrics API endpoint. + + Args: + endpoint (str): The API endpoint to send the request to. + params (dict | None, optional): Query parameters to include in the request. + + Returns: + httpx.Response | None: The HTTP response object if the request is successful, or None if the request fails. + + Raises: + httpx.HTTPError: If the HTTP request returns an error status code. + + """ + try: + response = http_client.get( + f"{self.endpoint}{endpoint}", params=params, auth=(self.user, self.password) + ) + response.raise_for_status() + return response + except httpx.HTTPError as e: + logger.warning(f"Error querying VictoriaMetrics: {e}") + return None diff --git a/services/api/src/owl/version.py b/services/api/src/owl/version.py index 6a9beea..3d18726 100644 --- a/services/api/src/owl/version.py +++ b/services/api/src/owl/version.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/services/api/tests/README.md b/services/api/tests/README.md new file mode 100644 index 0000000..6de8e1a --- /dev/null +++ b/services/api/tests/README.md @@ -0,0 +1,6 @@ +# API Server Tests + +Some tests are split into two files: + +- `test_.py` for OSS and Cloud tests. Cloud-only tests must be marked with `pytest.mark.cloud`. +- `test__cloud.py` for Cloud-only tests. Usually Cloud-only modules are imported. These files will be removed when running OSS tests. diff --git "a/services/api/tests/docling_ground_truth/GitHub \350\241\250\345\215\225\346\236\266\346\236\204\350\257\255\346\263\225 - GitHub \346\226\207\346\241\243.json" "b/services/api/tests/docling_ground_truth/GitHub \350\241\250\345\215\225\346\236\266\346\236\204\350\257\255\346\263\225 - GitHub \346\226\207\346\241\243.json" new file mode 100644 index 0000000..5dad414 --- /dev/null +++ "b/services/api/tests/docling_ground_truth/GitHub \350\241\250\345\215\225\346\236\266\346\236\204\350\257\255\346\263\225 - GitHub \346\226\207\346\241\243.json" @@ -0,0 +1,14 @@ +{ + "document": { + "filename": "GitHub è¡¨å•æž¶æž„语法 - GitHub 文档.pdf", + "md_content": "\n\n建设社区 / 问题和 PR æ¨¡æ¿ / GitHub è¡¨å•æž¶æž„的语法\n\n## GitHub è¡¨å•æž¶æž„的语法\n\n您å¯ä»¥ä½¿â½¤ GitHub çš„è¡¨å•æž¶æž„æ¥é…置⽀æŒçš„功能。\n\n本⽂内容\n\n关于 GitHub çš„è¡¨å•æž¶æž„\n\n密钥\n\n延伸阅读\n\n## 注æ„\n\nGitHub çš„è¡¨å•æž¶æž„⽬å‰ä¸ºå…¬å…±é¢„览版,å¯èƒ½ä¼šæ›´æ”¹ã€‚\n\n## 关于 GitHub çš„è¡¨å•æž¶æž„\n\n您å¯ä»¥ä½¿â½¤ GitHub çš„è¡¨å•æž¶æž„æ¥é…置⽀æŒçš„功能。有关详细信æ¯ï¼Œè¯·å‚阅 为仓库é…ç½®è®®é¢˜æ¨¡æ¿ ã€‚ ' '\n\nè¡¨å•æ˜¯è¯·æ±‚⽤⼾输⼊的⼀组元素。您å¯ä»¥é€šè¿‡åˆ›å»º YAML 表å•定义(这是⼀个表å•元素阵列)æ¥é…置表 å•。æ¯ä¸ªè¡¨å•元素是⼀组确定元素类型ã€å…ƒç´ å±žæ€§ä»¥åŠè¦åº”⽤于元素的约æŸçš„键值对。对于æŸäº›é”®ï¼Œå€¼æ˜¯ å¦â¼€ç»„键值对。\n\n例如,以下表å•定义包括四ç§è¡¨å•元素:⽤于æä¾›â½¤â¼¾æ“作系统的⽂本区域ã€â½¤äºŽé€‰æ‹©â½¤â¼¾è¿â¾çš„软件版 本的下拉èœå•ã€â½¤äºŽç¡®è®¤â¾ä¸ºå‡†åˆ™çš„å¤é€‰æ¡†ä»¥åŠæ„Ÿè°¢â½¤â¼¾å®Œæˆè¡¨å•çš„ Markdown 。\n\n\n\n```\nplaceholder: \"Example: macOS Big Sur\" value: operating system validations: required: true - type: dropdown attributes: label: Version description: What version of our software are you running? multiple: false options: - 1.0.2 (Default) - 1.0.3 (Edge) default: 0 validations: required: true - type: checkboxes attributes: label: Code of Conduct description: The Code of Conduct helps create a safe space for everyone. We require that everyone agrees to it. options: - label: I agree to follow this project's [Code of Conduct](link/to/coc) required: true - type: markdown attributes: value: \"Thanks for completing our form!\"\n```\n\n## 密钥\n\n## 对于æ¯ä¸ªè¡¨å•元素,您å¯ä»¥è®¾ç½®ä»¥ä¸‹é”®ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|------|---------------------------------|------|--------|------|----------------------------------------------|\n| type | 您想è¦å®šä¹‰çš„å…ƒ 素类型。 | | String | | checkboxe s dropdown input markdown textarea |\n| id | 元素的标识符, 除⾮ type 设置 为 markdown 。 | | String | | |\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------------|----------------------------------------------------------------------|------|------|------|-------|\n| | åªèƒ½ä½¿â½¤å­—⺟数 字字符〠- å’Œ _ 。在表å•定义 中必须是唯⼀ 的。如果æä¾›ï¼Œ id 是 URL 查询 傿•°é¢„填中字段 的规范标识符。 | | | | |\n| attributes | 定义元素属性的 ⼀组键值对。 | | 映射 | | |\n| validations | 设置元素约æŸçš„ ⼀组键值对。 | | 映射 | | |\n\n您å¯ä»¥ä»Žä»¥ä¸‹ç±»åž‹çš„表å•元素中选择。æ¯ä¸ªç±»åž‹éƒ½æœ‰å”¯â¼€çš„属性和验è¯ã€‚\n\n| 类型 | 说明 |\n|----------|---------------------------------------|\n| markdown | Markdown ⽂本显⽰在表å•中,为⽤⼾æä¾›é¢å¤–的上下 ⽂,但并未æäº¤ã€‚ |\n| textarea | 多â¾â½‚本字段。 |\n| input | å•â¾â½‚本字段。 |\n| dropdown | 下拉èœå•。 |\n\n## checkboxes\n\n⼀组å¤é€‰æ¡†ã€‚\n\n## markdown\n\nå¯ä»¥ä½¿â½¤ markdown 元素在表å•中显⽰ Markdown ,为⽤⼾æä¾›é¢å¤–çš„ä¸Šä¸‹â½‚ï¼Œä½†ä¸æäº¤ã€‚\n\nmarkdown 的属性\n\n对于 attributes 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------|--------------------|------|--------|------|-------|\n| value | 渲染的⽂本。⽀ æŒ Markdown | | String | | |\n\n## æâ½°\n\nYAML 处ç†å°†å“ˆå¸Œç¬¦å·è§†ä¸ºæ³¨é‡Šã€‚è¦æ’⼊ Markdown æ ‡é¢˜ï¼Œè¯·â½¤å¼•å·æ‹¬ä½â½‚本。\n\n对于多â¾â½‚本,您å¯ä»¥ä½¿â½¤ç«–线è¿ç®—符。\n\nmarkdown 的⽰例\n\n\n\n## textarea\n\nå¯ä»¥ä½¿â½¤ textarea 元素å‘è¡¨å•æ·»åŠ å¤šâ¾â½‚本字段。å‚与者还å¯ä»¥åœ¨ textarea 字段中附加⽂件。\n\n## textarea 的属性\n\n对于 attributes 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------|-------------------------|------|--------|------|-------|\n| label | 预期⽤⼾输⼊的 简短æè¿°ï¼Œä¹Ÿä»¥ 表å•形弿˜¾â½°ã€‚ | | String | | |\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------------|------------------------------------------------------------|------|--------|------|--------------------------------------|\n| description | æä¾›ä¸Šä¸‹â½‚或指 导的⽂本区域的 æè¿°ï¼Œä»¥è¡¨å•å½¢ 弿˜¾â½°ã€‚ | | String | 空字符串 | |\n| placeholder | åŠé€æ˜Žçš„å ä½ 符,在⽂本区域 空⽩时呈现。 | | String | 空字符串 | |\n| value | 在⽂本区域中预 填充的⽂本。 | | String | | |\n| render | 如果æä¾›äº†å€¼ï¼Œ æäº¤çš„⽂本将格 å¼åŒ–为代ç å—。 æä¾›æ­¤é”®æ—¶ï¼Œâ½‚ 本区域将ä¸ä¼šæ‰© 展到⽂件附件或 Markdown ç¼– | | String | | GitHub 已知的语 ⾔。有关详细信 æ¯ï¼Œè¯·å‚阅语⾔ YAML ⽂件。 |\n\n## textarea 的验è¯\n\n对于 validations 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|----------|-----------------------------|------|------|-------|-------|\n| required | é˜²â½Œåœ¨å…ƒç´ å®Œæˆ ä¹‹å‰æäº¤è¡¨å•。 仅适⽤于公共存 储库。 | | 布尔 | false | |\n\n## textarea 的⽰例\n\n\n\n```\nattributes: label: Reproduction steps description: \"How do you trigger this bug? Please walk us through it step by step.\" value: | 1. 2. 3. ... render: bash validations: required: true\n```\n\n## input\n\nå¯ä»¥ä½¿â½¤ input 元素å‘è¡¨å•æ·»åŠ å•â¾â½‚本字段。\n\n## input 的属性\n\n对于 attributes 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------------|----------------------------|------|--------|------|-------|\n| label | 预期⽤⼾输⼊的 简短æè¿°ï¼Œä¹Ÿä»¥ 表å•形弿˜¾â½°ã€‚ | | String | | |\n| description | æä¾›ä¸Šä¸‹â½‚或指 导的字段的æ 述,以表å•å½¢å¼ æ˜¾â½°ã€‚ | | String | 空字符串 | |\n| placeholder | åŠé€æ˜Žçš„å ä½ 符,在字段空⽩ 时呈现。 | | String | 空字符串 | |\n| value | 字段中预填的⽂ | | String | | |\n\n本。\n\n## input 的验è¯\n\n对于 validations 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|----------|-----------------------------|------|------|-------|-------|\n| required | é˜²â½Œåœ¨å…ƒç´ å®Œæˆ ä¹‹å‰æäº¤è¡¨å•。 仅适⽤于公共存 储库。 | | 布尔 | false | |\n\n## input 的⽰例\n\n```\nYAML body: - type: input id: prevalence attributes: label: Bug prevalence description: \"How often do you or others encounter this bug?\" placeholder: \"Example: Whenever I visit the personal account page (1-2 times a week)\" validations: required: true\n```\n\n## dropdown\n\nå¯ä»¥ä½¿â½¤ dropdown 元素在表å•中添加下拉èœå•。\n\ndropdown 的属性\n\n对于 attributes 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------------|------------------------------|------|--------|------|-------|\n| label | 预期⽤⼾输⼊的 简短æè¿°ï¼Œä»¥è¡¨ å•形弿˜¾â½°ã€‚ | | String | | |\n| description | æä¾›ä¸Šä¸‹â½‚或指 导的下拉列表的 æè¿°ï¼Œä»¥è¡¨å•å½¢ 弿˜¾â½°ã€‚ | | String | 空字符串 | |\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|----------|---------------------------------------------------------------|------|---------|-------|-------|\n| multiple | 确定⽤⼾是å¦å¯ 以选择多个选 项。 | | Boolean | false | |\n| options | ⽤⼾å¯ä»¥é€‰æ‹©çš„ 选项阵列。ä¸èƒ½ 为空,所有选择 必须是ä¸åŒçš„。 | | 字符串数组 | | |\n| default | options 数组 中预选选项的索 引。指定了默认 选项时,ä¸èƒ½åŒ… å« ' None ' 或 ' n/a ' 作为选项。 | | Integer | | |\n\n## dropdown 的验è¯\n\n## 对于 validations 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|----------|-----------------------------|------|------|-------|-------|\n| required | é˜²â½Œåœ¨å…ƒç´ å®Œæˆ ä¹‹å‰æäº¤è¡¨å•。 仅适⽤于公共存 储库。 | | 布尔 | false | |\n\n## dropdown 的⽰例\n\n\n\n```\n- MacPorts - apt-get default: 0 validations: required: true\n```\n\n## checkboxes\n\nå¯ä»¥ä½¿â½¤ checkboxes 元素å‘è¡¨å•æ·»åŠ â¼€ç»„å¤é€‰æ¡†ã€‚\n\ncheckboxes 的属性\n\n对于 attributes 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|-------------|------------------------------------|------|--------|------|-------|\n| label | 预期⽤⼾输⼊的 简短æè¿°ï¼Œä»¥è¡¨ å•形弿˜¾â½°ã€‚ | | String | | |\n| description | å¤é€‰æ¡†é›†çš„æ è¿°ï¼Œä»¥è¡¨å•å½¢å¼ æ˜¾â½°ã€‚â½€æŒ Markdown æ ¼ å¼ã€‚ | | String | 空字符串 | |\n| options | ⽤⼾å¯ä»¥é€‰æ‹©çš„ å¤é€‰æ¡†é˜µåˆ—。有 关语法,请å‚阅 下⽂。 | | Array | | |\n\n对于 options 数组中的æ¯ä¸ªå€¼ï¼Œå¯ä»¥è®¾ç½®ä»¥ä¸‹é”®ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 选项 |\n|-------|---------------------------------------------------|------|--------|------|------|\n| label | 选项的标识符, 显⽰在表å•中。 â½€æŒ Markdown ⽤于粗体或斜体 ⽂本格å¼åŒ–和超 ⽂本链接。 | | String | | |\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 选项 |\n|----------|-----------------------------|------|------|-------|------|\n| required | é˜²â½Œåœ¨å…ƒç´ å®Œæˆ ä¹‹å‰æäº¤è¡¨å•。 仅适⽤于公共存 储库。 | | 布尔 | false | |\n\n## checkboxes 的验è¯\n\n对于 validations 键的值,å¯ä»¥è®¾ç½®ä»¥ä¸‹å¯†é’¥ã€‚\n\n| 密钥 | 说明 | 必需 | 类型 | 默认 | 有效值 |\n|----------|-----------------------------|------|------|-------|-------|\n| required | é˜²â½Œåœ¨å…ƒç´ å®Œæˆ ä¹‹å‰æäº¤è¡¨å•。 仅适⽤于公共存 储库。 | | 布尔 | false | |\n\n## checkboxes 的⽰例\n\n\n\n| YAML |\n|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| body: |\n| - type: checkboxes id: operating-systems attributes: label: Which operating systems have you used? description: You may select more than one. options: - label: macOS - label: Windows - label: Linux |\n\n## 延伸阅读\n\n## YAML\n\n此内容中的⼀些内容å¯èƒ½æ˜¯æœºå™¨ç¿»è¯‘的或 AI 翻译的内容。\n\n© 2025 GitHub, Inc. 术语 éšç§ çŠ¶æ€ å®šä»· 专家æœåŠ¡ åšå®¢", + "json_content": null, + "html_content": null, + "text_content": null, + "doctags_content": null + }, + "status": "success", + "errors": [], + "processing_time": 9.824623378925025, + "timings": {} +} diff --git a/services/api/tests/docling_ground_truth/Swire_AR22_e_230406_sample.json b/services/api/tests/docling_ground_truth/Swire_AR22_e_230406_sample.json new file mode 100644 index 0000000..0f20836 --- /dev/null +++ b/services/api/tests/docling_ground_truth/Swire_AR22_e_230406_sample.json @@ -0,0 +1,14 @@ +{ + "document": { + "filename": "Swire_AR22_e_230406_sample.pdf", + "md_content": "\n\n\n\n2 0 2 2 ANNUAL REPORT\n\n## CONTENTS\n\n- 1 Corporate Statement\n- 3 2022 Performance Highlights\n- 4 Chairman's Statement\n\n## MANAGEMENT DISCUSSION AND ANALYSIS\n\n- 10 2022 Performance Review and Outlook\n- 59 Financial Review\n- 69 Financing\n\n## CORPORATE GOVERNANCE & SUSTAINABILITY\n\n- 79 Corporate Governance Report\n- 94 Risk Management\n- 98 Directors and Officers\n- 100 Directors' Report\n- 109 Sustainable Development Review\n\n## FINANCIAL STATEMENTS\n\n| 117 | Independent Auditor's Report |\n|-------|----------------------------------------------------------------|\n| 125 | Consolidated Statement of Profit or Loss |\n| 126 | Consolidated Statement of Other Comprehensive Income |\n| 127 | Consolidated Statement of Financial Position |\n| 128 | Consolidated Statement of Cash Flows |\n| 129 | Consolidated Statement of Changes in Equity |\n| 130 | Notes to the Financial Statements |\n| 205 | Principal Accounting Policies |\n| 208 | Principal Subsidiary, Joint Venture and Associated Companies |\n| 218 | Cathay Pacific Airways Limited - Abridged Financial Statements |\n\n## SUPPLEMENTARY INFORMATION\n\n- 220 Summary of Past Performance\n- 222 Schedule of Principal Group Properties\n- 232 Group Structure Chart\n- 234 Glossary\n- 236 Financial Calendar and Information for Investors\n- 236 Disclaimer\n\nNote: Definitions of the terms and ratios used in this report can be found in the Glossary.\n\n## CORPORATE STATEMENT\n\n## SUSTAINABLE GROWTH\n\nSwire Pacific is a Hong Kong-based international conglomerate with a diversified portfolio of market leading businesses. The Company has a long history in Greater China, where the name Swire or å¤ªå¤ has been established for over 150 years.\n\nOur aims are to deliver sustainable growth in shareholder value, achieved through sound returns on equity over the long term, and to return value to shareholders through sustainable growth in ordinary dividends. Our strategy is focused on Greater China and South East Asia, where we seek to grow our core Property, Beverages and Aviation divisions. New areas of growth, such as healthcare and sustainable foods, are being targeted.\n\n## Our Values\n\nIntegrity, endeavour, excellence, humility, teamwork, continuity.\n\n## Our Core Principles\n\n- - We focus on Asia, principally Greater China, because of its strong growth potential and because it is where the Group has long experience, deep knowledge and strong relationships.\n- - We mobilise capital, talent and ideas across the Group. Our scale and diversity increase our access to investment opportunities.\n- - We are prudent financial managers. This enables us to execute long-term investment plans irrespective of shortterm financial market volatility.\n- - We recruit the best people and invest heavily in their training and development. The welfare of our people is critical to our operations.\n- - We build strong and lasting relationships, based on mutual benefit, with those with whom we do business.\n- - We invest in sustainable development, because it is the right thing to do and because it supports long-term growth through innovation and improved efficiency.\n- - We are committed to the highest standards of corporate governance and to the preservation and development of the Swire brand and reputation.\n\n## Our Investment Principles\n\n- - We aim to build a portfolio of businesses that collectively deliver a steady dividend stream over time.\n- - We are long-term investors. We prefer to have controlling interests in our businesses and to manage them for longterm growth. We do not rule out minority investments in appropriate circumstances.\n- - We concentrate on businesses where we can contribute expertise, and where our expertise can add value.\n- - We invest in businesses that provide high-quality products and services and that are leaders in their markets.\n- - We divest from businesses which have reached their full potential under our ownership, and recycle the capital released into existing or new businesses.\n\n## Fleet profile*\n\n| | Number at 31st December 2022 | Number at 31st December 2022 | Number at 31st December 2022 | | | Orders | Orders | Orders | | | | | | | |\n|----------------------|--------------------------------|--------------------------------|--------------------------------|-------|-------------|----------|----------|----------------|-------|-----|-----|-----|-----|-----|----------------|\n| Aircraft type | Owned | Finance | Operating | Total | Average age | '23 | '24 | '25 and beyond | Total | '23 | '24 | '25 | '26 | '27 | '28 and beyond |\n| Cathay Pacific: | | | | | | | | | | | | | | | |\n| A320-200 | 4 | | | 4 | 19.3 | | | | | | | | | | |\n| A321-200 | 2 | | 1 | 3 | 19.8 | | | | | 1 | | | | | |\n| A321-200neo | | 2 | 5 | 7 | 1.4 | 5 (a) | 4 | | 9 | | | | | | 5 |\n| A330-300 | 31 | 8 | 4 | 43 | 14.3 | | | | | | | 2 | 2 | | |\n| A350-900 | 19 | 7 | 2 | 28 | 5.1 | 2 | | | 2 | | | | | | 2 |\n| A350-1000 | 11 | 7 | | 18 | 3.1 | | | | | | | | | | |\n| 747-400ERF | 6 | | | 6 | 14.0 | | | | | | | | | | |\n| 747-8F | 3 | 11 | | 14 | 9.9 | | | | | | | | | | |\n| 777-300 | 17 | | | 17 | 21.2 | | | | | | | | | | |\n| 777-300ER | 28 | 2 | 11 | 41 | 10.2 | | | | | 2 | 3 | 2 | 4 | | |\n| 777-9 | | | | | | | | 21 | 21 | | | | | | |\n| Total | 121 | 37 | 23 | 181 | 10.8 | 7 | 4 | 21 | 32 | 3 | 3 | 4 | 6 | | 7 |\n| HK Express: | | | | | | | | | | | | | | | |\n| A320-200 | | | 5 | 5 | 10.5 | | | | | 1 | 4 | | | | |\n| A320-200neo | | | 10 | 10 | 3.8 | | | | | | | | | | 10 |\n| A321-200 | | | 11 | 11 | 5.2 | | | | | | | 1 | 2 | | 8 |\n| A321-200neo | | | | | | 4 | 8 | 4 | 16 | | | | | | |\n| Total | | | 26 | 26 | 5.7 | 4 | 8 | 4 | 16 | 1 | 4 | 1 | 2 | | 18 |\n| Air Hong Kong*** (b) | : | | | | | | | | | | | | | | |\n| A300-600F | | | 9 | 9 | 18.6 | | | | | 7 | 2 | | | | |\n| A330-243F | | | 2 | 2 | 11.0 | | | | | | | | 2 | | |\n| A330-300P2F | | | 4 | 4 | 13.7 | | | | | | | | 3 | | 1 |\n| Total | | | 15 | 15 | 16.3 | | | | | 7 | 2 | | 5 | | 1 |\n| Grand total | 121 | 37 | 64 | 222 | 10.6 | 11 | 12 | 25 | 48 | 11 | 9 | 5 | 13 | | 26 |\n\n* The table does not reflect aircraft movements after 31st December 2022.\n\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 within the scope of HKFRS 16.\n\n*** The contractual arrangements relating to the freighters operated by Air Hong Kong do not constitute leases in accordance with HKFRS 16.\n\n(a) Two Airbus A321-200neo aircraft were delivered in February 2023.\n\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 (at 15), at least until 2024.\n\n## Responsibilities of Directors\n\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 the Board and its committees and through regular meetings with management at the head office and in the divisions. Directors are regularly 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 Board on their first appointment their interests as a Director or otherwise in other companies or organisations and such declarations of interests are updated regularly. No Director was a director of more than five other listed companies (excluding the Company) at 31st December 2022.\n\n\n\nDetails of Directors' other appointments are shown in their biographies in the section of this annual report headed Directors and Officers.\n\nAgendas and accompanying Board papers are circulated with sufficient time to allow the Directors to prepare before meetings.\n\n## Board Processes\n\nAll committees of the Board follow the same processes as the full Board.\n\nThe dates of the 2022 Board meetings were determined in 2021 and any amendments to this schedule were notified to Directors at least 14 days before regular meetings. Appropriate arrangements are in place to allow Directors to include items in the agenda for regular Board meetings.\n\nThe Board met seven times in 2022, including two strategy sessions. The attendance of individual Directors at meetings of the Board and its committees is set out in the table on page 83. Attendance at Board meetings was 100%. All Directors attended Board meetings in person or through electronic means of communication during the year.\n\nThe Chairman takes the lead to ensure that the Board acts in the best interests of the Company, that there is effective communication with the shareholders and that their views are communicated to the Board as a whole.\n\nBoard decisions are made by vote at Board meetings and supplemented by the circulation of written resolutions between Board meetings.\n\nMinutes of Board meetings are taken by the Company Secretary and, together with any supporting papers, are made available to all Directors. The minutes record the matters considered by the Board, the decisions reached, and any concerns raised or dissenting views expressed by Directors. Draft and final versions of the minutes are sent to all Directors for their comment and records respectively.", + "json_content": null, + "html_content": null, + "text_content": null, + "doctags_content": null + }, + "status": "success", + "errors": [], + "processing_time": 6.495824097888544, + "timings": {} +} diff --git a/clients/python/tests/files/bmp/cifar10-deer.bmp b/services/api/tests/files/bmp/cifar10-deer.bmp similarity index 100% rename from clients/python/tests/files/bmp/cifar10-deer.bmp rename to services/api/tests/files/bmp/cifar10-deer.bmp diff --git a/clients/python/tests/files/csv/company-profile.csv b/services/api/tests/files/csv/company-profile.csv similarity index 100% rename from clients/python/tests/files/csv/company-profile.csv rename to services/api/tests/files/csv/company-profile.csv diff --git a/clients/python/tests/files/csv/empty.csv b/services/api/tests/files/csv/empty.csv similarity index 100% rename from clients/python/tests/files/csv/empty.csv rename to services/api/tests/files/csv/empty.csv diff --git a/clients/python/tests/files/csv/weather_observations_long.csv b/services/api/tests/files/csv/weather_observations_long.csv similarity index 100% rename from clients/python/tests/files/csv/weather_observations_long.csv rename to services/api/tests/files/csv/weather_observations_long.csv diff --git a/clients/python/tests/files/doc/Recommendation Letter.doc b/services/api/tests/files/doc/Recommendation Letter.doc similarity index 100% rename from clients/python/tests/files/doc/Recommendation Letter.doc rename to services/api/tests/files/doc/Recommendation Letter.doc diff --git a/clients/python/tests/files/docx/Recommendation Letter.docx b/services/api/tests/files/docx/Recommendation Letter.docx similarity index 100% rename from clients/python/tests/files/docx/Recommendation Letter.docx rename to services/api/tests/files/docx/Recommendation Letter.docx diff --git a/clients/python/tests/files/gif/rabbit_cifar10-deer.gif b/services/api/tests/files/gif/rabbit_cifar10-deer.gif similarity index 100% rename from clients/python/tests/files/gif/rabbit_cifar10-deer.gif rename to services/api/tests/files/gif/rabbit_cifar10-deer.gif diff --git a/services/api/tests/files/gif/rabbit_cifar10-deer.gif.thumb.webp b/services/api/tests/files/gif/rabbit_cifar10-deer.gif.thumb.webp new file mode 100644 index 0000000000000000000000000000000000000000..d523765a8a1fb5c32da3fe451b59308885fbd00e GIT binary patch literal 4162 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/html/RAG and LLM Integration Guide.html b/services/api/tests/files/html/RAG and LLM Integration Guide.html similarity index 100% rename from clients/python/tests/files/html/RAG and LLM Integration Guide.html rename to services/api/tests/files/html/RAG and LLM Integration Guide.html diff --git a/clients/python/tests/files/html/multilingual-code-examples.html b/services/api/tests/files/html/multilingual-code-examples.html similarity index 100% rename from clients/python/tests/files/html/multilingual-code-examples.html rename to services/api/tests/files/html/multilingual-code-examples.html diff --git a/clients/python/tests/files/html/table.html b/services/api/tests/files/html/table.html similarity index 100% rename from clients/python/tests/files/html/table.html rename to services/api/tests/files/html/table.html diff --git a/clients/python/tests/files/jpeg/cifar10-deer.jpg b/services/api/tests/files/jpeg/cifar10-deer.jpg similarity index 100% rename from clients/python/tests/files/jpeg/cifar10-deer.jpg rename to services/api/tests/files/jpeg/cifar10-deer.jpg diff --git a/services/api/tests/files/jpeg/cifar10-deer.jpg.thumb.webp b/services/api/tests/files/jpeg/cifar10-deer.jpg.thumb.webp new file mode 100644 index 0000000000000000000000000000000000000000..fef7087b09cfdd76e614c9493281797701ac4aae GIT binary patch literal 274 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

R_^ST?gWb-KF#!W15a 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 p{hv3l^Z)Ajhfq@RVvgIHGVR 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/files/mp3/gutter.mp3.thumb.mp3 b/services/api/tests/files/mp3/gutter.mp3.thumb.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d5c6d691fb658afb2bd795d1188e170257dc542b GIT binary patch literal 40978 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/files/mp3/stars.mp3 b/services/api/tests/files/mp3/stars.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8e58ed498b9bd581122987457864645ec22aca41 GIT binary patch literal 94921 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 f4m5d5gTtyqhBHOR0 z`K8q^1>3`7>m{C^6|TwLtxB6nre|i2N&+mnHn<>ayjHLB6V+X3*6{6)cxQf=9O-X7 zKJu?cnmtN*^fxIdo&I)`qxN6S1wtA)y61kEEyZQ$S6z2$<$ro^ElIC&BAw+ELj0Mq zOxI~kbJMiyv_RvSXyBo_P$Zv?bDK|ZbZkM|;7)nEoB=CO9wgu$ukc!DVGS>zr4Y$N zQ!qnOE$^J9by!TgkfK{L^Uk$>Z#TWR#_V8TxKp5=JY$Q-q72f|P5OjHA*?PS9ntE%2379~gM?rfb^a z3IbVa_0#VAe9WLG)n> z7q8L_ADr2(Wsrt;m8h(?OoYMEUJL)R6Nd<8RAd5z8=N|}%)0P(HCC8;3aj)@`rB^u zh(qb+{mIHN>iI2mSLtJNWj$GGM0-XuX4p~$QAoeg7nLOYtXY=nmre)&O@t&2;%0eR zy?tV*HS_t;E~FuEu1)NG^R-I}6UU>D(RMj;Pqo-()yM16aZtxm+4|wS+V3R0jg<+Nty zPA8sN?fkI)RF_qzeY4cU|9*7JWomfLyF2uTc*rU1$nS8<&t}F%#ojF zmP=|5LMmq|8$_(~{b}}S41tJ|x5ZaPOiVwrRK3E&_Re4fri1)_DQ7YX_! z2d0^^B2|hlf@e0eN$vL6N6n<{?B*h{{t>#v`V86t5^`J(eUxGYT#0NqJJKo#CK{@Z z?uj2(kuMIOk>;}Vb!W%h^%o2BD^STG%O`C0d}GnUgK1rDWDz#qf%7{dCghWjv0$4G z(>(`i##&reRVSQs@88k9F)}`t>5nT_$qY;&_K5>yj#tcp`J%&_fp!Hlef_DEGB!Fn zH#E5ecG6>g1(lR~@r^d%?&VV;QZ51!_%P&MA!E#&A*MrCO?Fys$+dp4Sv#oL_@;}S zzPxd+y zIR)UY>7BgBbT8Iu%QV*~)P53vk&RUU)l{erqIU)SCsMAxMSj)UFkdcMKsisT?yKu6 zt*ac`+DSWHjTcFL<$OlUE8?B{bQmp-mZ}1Q7PhL0M+QG~sXgD~OJg_v;`~Drr!c8% znsPZC`*9MklCnc=fmi#H5(eKkY^JDx@c2)If$Cyh>~wYCBP`?*+vFA48i7k6oYqOw4_0Hp(lJ+Lf}J%ie(*6Dvn| zeDlRGn$JFbQm8N>tsA+HOU|82XTGxE+!Oy*pQjVsI&*6{ z7@I2hxsPB7h(BAYti~T$Xh-r98OMz~K9|ril~* zGk861d`gknJ`feF9~BZkSK{u>rhP|*2j4XC$zWkYpPBmPid8Y7`Z5X@u~6a~&7`Q( zOOH@<2IIW3j-!k41cHiuVZ*XugB&fv-0?OBUpA7OsPj7~B^jo~v3Q*J`%RPY$+Obg zM+>Tw!-qM_ddF(n&F>wXh{a0>xGKR&B9;H16>|p1diTXJNmPZWqq8%T8>gUR?}Hb1 znmt-aYP>m=S%rbcV*b0gt74n&gNT2Gj>9(srb+Pf(MfVWWnZsE>PEr3IR@!nH!~7i z36CpH)JAnN(mOnk97c{H$8?DVw8IxKScwpa&b5ffu%khGsb2ifhc& zz9rl~G1E_ibD#XF@NG!D+%2p=)+50NpHoA=zlerXD6iPI~^M= zv>7;*w*IWaB2cc@Xok9z9jb4!$FGPx747N5h%!;)*}iu^m$UkK~anvT$3SM zC#W{mJ7^E1t=wn3>@_C>t;>)|QK?Dr6gjE;_Jv*gM^9~%Sl3RBa17rqnr%EZ_8c~s zyx^YP)Mi%Vb8gRhe=CCX*jr_`ypa+3NJTbW6k9uosZOqq(T-se>yNNdj0fH%!Ervc zHFQ7KKbQlYCn%!eu+QpK?!DDfx$}?EZn&vu|G#=@sO`-Ta+tHb?#uSc_%}^pRoNu* zIHyAubXf)Io6;Xu z9nNSUf2HdJaX-&sfF%np^k=>ON9Ypsv+xG6QTS?T@J%`3isSxincQ%8fPHhjlwSjN zU4V=%8~H++i0g6RWRqpsF`RIjQFxY%zdV5T>O>Djgb?Z6AF?A0JsUhduS|ZXr$`)& zxF?UXCDbyIqD*qOW23cceE>7Zt3&B%iS2t5KhTUx#bL5l9@rr)Yse zyEU+BF7{o}EgV!;A~YUb-6X7)(jD%bsJzD5tXwjL3i|F|(`Nmy-Hc++5L+cGpbE1N zdDs{miy}|f(Vte=0|9xb#>a5@qR3`dg!z43ft7(916l7x3Sa^7ZAbb!iQyAw-!rkf z@r?@>8N-T{|3-{O61*p1I7h` z2~7KDq&8upBv}Ak4W}{#=RZQFIC-WW*2hi6z%+11+27iSVrdZ$?d|>@WWYOd`6%z% z98cmQkTc-UhkD9niN&);f5glRm<l;1kVabyJ7im@bUEP}3cfLuw0Fs!I)8X8z^G<^-l|f!N;bVy_l4`= zaA5n2?D}9qq7(Z=JM4cRn!8b}J>#U0nZmGJFRRjZTlWRSHo81Fk#oO^iuY6`zVf>c z_tV$uY&Pv<%g)3e^z4X-erADsDsaJ_HNUVfd8!~Mjd}y7(SMvU2YlZ3p(KTC~$tt$PDc&!sd*6@reix~K{Qm^K zs>nm@XbWalPga)5>}$4g6IUD=R{I#`-CFY3k~ME4h;>??aHE=Qnk;!hu_0^YV=SK% zV;gmH!}I{97;e)TlZFGep6A*3qdWTDg7kTu-kD~vFjEwMR&^l9w#{F>w<`5=>@6s8 z$M-0<{q7b2)uqQEnQ7`@3~fiEXeUXlY57%6rOTAA-*pR&t@#?}&;7zoek;uTyCU| zF)c&!Q6cAER3+J`9Sy97`0Bgwgm!=LSV~U@{rYl*)2cmOMC3iE zIiSUpJytNdGr#Pc+W}hWI9dIwpt+}~px9N#SLwg>!8&^6PeCkP&^7;8_tbF4C?dgm zT$`G{g2zRi-|Fh7(fh3cYlU?O8cxS)lh$VeDJDs4FT0Jk$k{HO%Qkk?n3_wp^lZBL zZvQyXfB%fFc$jOua_8tp*6!l3rP&s~EPIuu6}HlOFMGu&g<8?2`%)`9s-gbU)r1&3 zA%(lT-tB|_T48PC$tte=8#R0&`e>HU4uNDh%SQmiuYiQeoD53ORFiD#0#TXpnq z5&S9DgQ0VVe2o@( z7E)TTXm4RiDwddQ;nbVv_kJ|B`i0vnX?~}!*xL{6!EBt#x88k$6C|?|G^iYT^5}{k zOfnhrCb=t|`C0;YFBmr)G#?~Xi!wF!B(;f;pXM;=f|tXdDrgO16OKZp2_`>13ICQc z_;cQ|J&C6|Kv-bfKg}H{yKXP}O?D_=9ryj%60=T9f&FJ9H?<|OS*vFQA~-i-OT{;` z1LAUZ<1Qw$AuZ){{%|I1ViA0t{Wgt?-DO~_3UXVex6;D0f=g$NH3^axLHSFEE1aylyytqvT9b!;uL7`Cb?is~te zM-T;r9Gd02BMQKdsny;lg-2{a+RR|UY z8g3bR!*I?-(sVAlxW9zk{*%jnibSsTlh@=6Pt){lhhGWbtCMyBiOHef(ek;LZ97%| z-_p?{k^OFDfsK?|Oz*mH_bVr1?M7~2V}N^I(Wa&-_IR9%i!(Ft>pzr^Y+rxNQ4qx) zONmg#8G=9k?ge8y7-2}w*GHmWT$?)tS@H{JL3?&v_^tY#Jqi4TKZyH?tlz$S*%&ml z-6T!K8=}TuQ0Z?^eJ|<6YG~8NOA!RJC4E%^I+LD%)a}$BQGXa@o0g}>_$8LN6K#ym zXRrjHkePl7MZEz&cMp2NZX$a!WK$t(Y%Py4CxG=^HU{I|HmoEYv~LA}>5^)Nz*U zQ;Z$>okQqT{EFpNs+PscxMZr+*?gDhyCGS&zXH7ZmHRb%wubX|Imtdc`(r zU%)`?jf4&yNej`Ef(@Df+f8}g9-{r<0B;x}&8i$Ff7iRDt=~B`RCBslX=eP&mXL;5 zA}%g&@PMh_()-S*>zKdP`P*%RwBg$J0=?yy?W>l@^yXz)uEq*c&CI~X({QZ>jD7Y`YkG+|#~=R`=i zV^X51`CgFVT^bNC0N!U$Eh(G`ZL_Y$HR$Z}Usc)1BGYIe4J>vVay}N)Wxo>jdjq22 z|B*TMd3i6-&n}<)Q;Q0c$n{z{Fxf|~sBWCSv;X4MrzU@gIYv~}4cuZYIKeTD!6%vR zRsq6yL~$MHdscZjSaGwf8@=_8Zw~yAY0o>D{bYDl*u_3PQ7lsQb8#YZup^3<`;gF2 z9nte;^`y2{Sj-fCv>(x9>lW5-ki_f0{fz046Fp-H%r}XbALLZVri*c{ZRpC=K(M)8 zM&PL@>Pd-*Ak%GNClLDg89Kx=(HtejWlc+h^sq)>DM)<)eBc0a&sRfODx>)kktdV| zWJVg=xuD#LO&9$}8gk5mfsF4}i`_j%HOIX##${J)-`q1>Gsf2;kNm22(_W^8gV}l zFKye!$T`px>1`lhy2xscT|x77<$oWB(>X#UijuF}AKuFI^TDp49uKp}lF6FW@yo4d zjigb(3v=l7yq7HxvF~SECQsG|+Sgv{aut%{JhmN_I)%M8nKikw%n|FcFmWL!4GlOc zBeiBl>({b2sIM@jKGT12)j_UMa$gifP2`DgL*s&0mUNwfZY$?YsobKIch$!ANo9^0{#VtgeD z9I4BU(r{L;I!ECBRYnX@tD@11#%qVlE%n+cgriXifN>54 z4t;p|TqOcQF- zb-FKnjUx5>ifKK|jtVp8;JCOIZLX<|Nl{0WmLq*+_(ZAtBpMVxAkV2G1f*Ht@=*oG zI6I-XeIaTyBC909v_hRhLzO)hUEbqSj|^VC_KYFUrW|7jdF#Xg^aWMRF;1H4X z2R|H(r^mX4zsXY@h2ph0q_B%sy9B*kEf+7T9l5e7d)Afkz;erLQu}P8W<{>R;*ANJ zpEI$&?seCDy-W@AxPOF>qc(KM@&8v2Nk59Qgu7Q2pYY2~Wu|IS>aSZ+x{+@4boFPd zI$!I($mybLm@n3Id*F}@G+#biq|&E;hctcESVS)CHuB^av(Oi=k7&AtTS}U%voU9q z&S-ws457>3-?3>dwScm~I6#`(b@S1ewxJz0t>1wwsZD)7a=n)Y;_kzqc}+!K(FG~X zTExOOWW$|~Wgw({cZWD?uH-ZK8FyC6za=}uEUJtjXoyDRZcB|@^7 zXGB$#*qoyc=DzJJxHC*soT9ZqL7_VG+C?v|z?Mfhlt3$y^wd}pDv#W+UJ)hdo;=Z` zDoAgf`l(lB>Ak5jqu(6bH%&rQ4h0&EkwkSZGTyfdM={3(L6ypWs3!!&-iomq!SXa-v(}_9Eh^eL7=?1 zvid!)L&CInf4BFM%=drbOYpT>ytcw-a-W2KZiZ)7&&m5@x#zXk$l?T%XBu&a~Tc zvuTdT^nL(8`$y;`YNKiV-*{+vqPHv}N4}J_s`C>&t5K&c6Ggd`DQFj?^nMI8pr`ds z6YKex{MxVIgnt`5b|DlBU9?Gv;5jyMMsbz)7ZX5SD#1!m>5ssqXro70V8*cB)F(#S zal<6gE~mDgjZt7rf-~NEGf#6ucEj!~YUHf(KUDIUwQS6w0HQ#wT+6*Y&>3WL+LzOfm{tG%4Vz%fd~ z=jumz01AN63k+4#h#fIg9(z184&h+nybUHEVECCJR&7s~loH^i4WPHh2r*}s_>Yrq zxE8;YkGRbv?0hS0T&$>wB2C%Q)YwRRTJpJ=iA1$g^hs)(A~i(6oq{SJ*AJrZ$cI_% zbRFq8W-R>9zu3Yi@zT*Qe|LrQJ6pFp>{B5@ez!6!gc`nKZ?1wL@0wjZOJ(5^rXdG; zHAC|PS{oU2RSo%%mwft1IhrLVN6voo!{32sx-!v~1k3KsuMxtz)>Mm%McrN1@usm3 zU+~<8Wu7fS{}EaQY=T-So8%Lw3nkdLzqJROxd|Z0H@Yp~E3ev>p7m$b)%_6?QL=c{ z5aWw6VJHY`VfP95$Hxk-gj))EN0w6)WL+%IH0jKL)Flks%uSVs165oGo5Nj%_hONn zi41X-rL(7*ZfeAuEVu%4K>!0Ao{*IXN3b%6FY6Q`SePYt%e9WUg75ZY>?LBrPnSw- zd2docY^WTQh0~5~%G3o*#wG(Yn4v36$?#swKFkgJTcpZ1YEAWF$0umg*@k=hcXee8 z@E7X2kMck%ggjrQRM}Q(6nB}2X>r_itZ&L~ zg3w9LIhp{2lgN42J6O|q+xU2H5zHsp5p+cf(Tqv{lY`M^02_lroAhlJyNE#t-EbS% zx_iF)ub(oM@q4ZdJfUO3AG`N`C9zcT=cTZr0+e|8$g{!um+J))5LNy$ zP^wtGofSdlGoH^-d&aCU?@ju~-dU{%yGg&zI_Dt#PT8Upo1v?1*uP0~T5h3M^qRr( zrFD*A^F*U6^+j35TTEpBm)X(U1HK=KqYoMDZJ!#~d$`8@kbg}&KJ}FRz4~jiYabU3 zPz3;hpkYh^y-)zDz0vu&kS!)V-+6=(JGLLDQjsNW0N6!{t7K}5qZF?7W_}O$xaj7e znevJC>qunnF3l4iy-HComk)gQEkq)ETpwf6uADB14JoJ+CAy7^Q{2 z+`Kj7c`n%g*(>!QA)|0QoHBlW+mz{7{8iNb>45nWGEd zPP~`s(z&_0drG67I@^51iSi%{4+jiofptVCPuKR03Dw4DMv`9;azy|~Oa*4Jl*C+c zXD|j}ym<9Rlz=EDufe<%oPg4i0L)%$n+YkR>!p=^bMu@COpO?zfJ1dX{(f;ojqsFpp5}Xkl{fZs-+!8T?Z1B?YkbwB zMrYgJwNGpJwEWRKLIab){n>7)A<-*hJsET8r+B;AKn5RKz81sEFMdt;zI|S2UJE*g z_BjVV!k_>9b@TVsucT4HFQ?(#uTNjT*%y5K@9pjE=-}mCI%!7&29L^rer}Iz!;c~! z05-2!w<31V0Ralvpiw;y&DqX*ART^^K7>&#R47(kBMcVj!!Txz2tQZiX21qB03hB{ zpd4>ECfq1#4r)fZ3bp~ARy7(iyhM^rT(j1ztjUEtDX^)H6k8PmiHP2sr8UdYa!7P7 zpdNcJiYbo>UkU_N{YOXvz|N5`ExiT6@(|^77Y)Y*<`3yMUfs)X^iHRgzxSQa_?Du? zoPd0nYw!oUyf0o`gz}9z@%sLIPm<(gnT`7^GTxHB%#|K!bH+s%@b8?~)-Qu>fNRH0 zw#R!P_*;``ExVuYTWL>E9r;6R=-T@R3p4*FU}5?LQcI2Na3PwIO3x^&$V8#! z$e~vLuORM;SoaR3K|pwI`qxr?6F}>ot@&CJV3Lwm*etU#qRxCNV#M+(CM_F~CxP&0 z(Bo3!x5Bxpn^%;>q7&mP7zbe~>* zHhll*`xr&W`ds5ik9J507K!29X64!6m=B9aSEshwK~4pR{~bF$>vp>i11GBbXHv=@ z1pYOyAcZKtl#>Q#e0fuE>GkK`Mrr>vd5BKqqT^;kKW&Sw*R}OK(`O{#TUQFf#sJH* zrKngcVHOHGKssiWjVLMJ{{)Tr7}TuNTJn7W@}F%-jMZG491K7$@SsqkBR6%}JelR9 z8Jxj|9{dy;qFvBfJMA5D`=N|~%d1P=%&~e=@%p{+OG%1X#^b4H_zApd?bi z0jDR*X(FVDN`bK-QrJ|l0rI$DCMWOMneer97yGwzT0nz!@K1%hIS%8#&C*fWVoqz|89DvF{`0hM>^-Yfrj*X6Qx5L% zS6T@MB%IYwt8Yo1x-|wwXrBo`2CLLy$5v~xh|prEu$W@Op-4k(66Kvl@X}Gqqp@X> z6H?)6?T=K>wXCI-d^TeWFcZULEQ(JG`C+XQaT18O9q0jQ{Q>H>i8_EFgqbRNq|#v{ z#`ktjjA2e2{2|@e|(e3$>6Z)jB9-%d$w|#P(R@+ ze$t&@jXg_CxUevryizBeCMuTtT?844v8C|VD9DG=I1d8c@YFQI5G`ogl}K6uYF(4 zZef?5^nKkiQ(WMGVkWxs%Ovl}`lmAfCU4Ktf$I8|kZd>r)P`xOx4g|T%x4WyT9!Zt z;Nftw;kKjMMh_jg<1qjdS=2G)@ECeb`tgiFTp!le=g)7jI?)IZ$-!a=~o8%4ogYts*RT6UvUc@kh$PVBz3<=K;whY$@Goh%iW9Snvk6 z%j6e10zUfv-L2l)-=`^jfA|V~GM3Y8q}oZ?3jm1Y0-&!jz31UF@4fSNi4Og;Uz#mYS8*S*o?SYD$=|uP{aAMiy8i>hQn< zGS=wG^N8LZGR`qO%-S(FJ`6?jgaW08Id06!d{Kn+^ymEMV+}jPR~hf;r<+`&F&QN^m-wkyfvYI=GMIJ+UET-f;@KGHQ}FvOucs>BJC)tl!56y%q+2;@opXb z?)7Q!|DF+H{aIQ1)lv+;vugHG-TP&b!#d?^8kwoN=1>3i>;s)L5W;N7P0^9u!@3~% zsw)6H1>YCp7Cmo_HJrfsj}R4rq)pyRhh<^WuhEsKvI71oUO-p)E~9ctiIXIq#Dw|H z5^#Yn24}1(>zl!F{nN()?nzLd6-B9=vwSocZo%~vDBI$Fyh;)z%N+b1o04vl(oJBL zrSl$3Ue$7n7^;wk%kco{K^t>1!M8)IwrQDppNIbyov+SfEf_r6o9tMvM<0!fZ)9CZIKXN+?W_xB zncUWik4GSX-w)ji{S+Tj@*+{jAPr~fZbEajiZl&Lv~x9}8>^>SUbpQ;8w9Z>YwkEI zKD__?xpL>pHIjEn7b?#?fEx}BCkt2jl?P%@`D&#SwEcTRUi8`DpYkP`nO>V( z9vgOGiP$D&@!Fnhu4f+x(sJn~CFYT_L%Ed3LjFEfQ6?4r=uYI#Me}R$OTYhn*J*cT z>gH;%RVA8VSC!}ve|eYRSKYntx(JIN+>ROp;xcfXHQcbryu5E<{ku~C@?83h{K|08 zuF~U^0ssccd?VDEJBYjg5z+&Y6`ScdR*@w-*SOr&l(VL=tVqHglO##;oVK*IF+GfH^LMH=uFF~Y1*nkm zhTB-2RJfH*j}Om$g`%oxpOOVn4=JAzqq$Rf>BIqk>doM<2?&1eeKPv1QeN0sr#1-$|V z?}-CyS4Y3W2ShRfF+|T$qEhjrsuZ{cGakx9Hg}WuSQwZ%kxHbj&FVt@H@iGme5SNW zCH{a~0msTQQ$O-okyaa8k#BV-g^a3vS&cZK)$HYbaX$W7!Sm9trHTEzcUh-h95hAr z?lSRnYi`$KDI*DIMNP50J*G@-Hq1 zhNYWbp`AC?0QvZC^1Z@Y%Q2_K-Z~ZL{kLbq>oOhlrjfrtJp23Ob|&YY-iWKF>qOYM zImN&G;*wV{dy~ZM|Jrvn)F@PaqZ!UGIt$ViyuZe^N8=VW`h*T{;U-RyzUn5I8yJd; zd?JC|us2lgG#w(U@@9xwO!h|bzxTDYjWun{=R&nX3>GKlG93)xuIg|}*6(Mjpzvo* zMP;I_dUgQdNKVpM>h%uqabA0zAncOlzB&mnmO$Mp9)LPjwxD$%Kk3;%g_Z)n1y!;> z?g(n0c}k6>$zNwT*nAe9OwWVPCUTnXE(?UkzHt$Rur$r)DBAezO6flR^_?1k5f_g# z#rLP$2IB>}8#W*p(9JZD*!%Ujgx1-DR`Zz4#E^H%h z1sKu!%D2Pt$LEcbxr+{T`D%S>7iEesg!U)GqHjw9k$+z2Hl<+E?i(msNwy^}j|$l1 zds{!U6FR`VHA7`Nri9V%l#E0Cx**WI8ozm`_k!xFweDhmEnju@194RNwM7E#6%*R3 zy(=TlPE(j%`AElILwJ$gdryVyRm7g7Ex60#r_Lu+x8p_2NF69DRZqHgmV?H`2c+#5I3 zNU|6S!jVbJ6OVE5dAd1eTnzLXBQM7+07><`;M7>;$pz$M2f-aEEF=#DlC(rOGS%@h zG4QZ%Q=(XcRSwh3NYXcu)AaucEdiF43t5^V3#vqt_8768JeK8Z$OsK8WFowAgs8u^ zP{UQDSuabKU{k^w&F+N>Nyrg#9Y(+96LnUqc`FnRv3(NyU%K z*wOObn1Huj*cG3#v)rV37pAlGh0#Y$bE?=V#^-dGeL8zbFKzG=7TK{Qzf#iJh$yiM zvKmBdxga??3=(2VGD<3o+9C}~={U_WFr`@qoCgWGOT8avWu84%;xE6ndWdf)j$z-& zUa-xu~sq_ zhv7bz#*OiEz`T;k$V2Y7HOoT-n6K>OsEN*#BnZSJ3D$GUi&|W5k=)(mWs^AlK zN|*H8@~)#w1aBQ32eGvM|6=Gka_y`IOxPwgO)ObKRRlTg-69uEni074U;nW)lgqLw zd?c6UcI`vC6F|g z>)xUj7B%(VTc_4pwY8_|K_+~ag4DMJy^8J{x;Hz)1Uxu0)QTl^9tu)sxGT5qm@$oZ zH|!bOiZnL=p)xA|NR_f^kkcD$38<^Sb2ve>Ny+f4gs!|Ynv+DTiDp=l=p5P|lh?D* zEckNb>%c;yH-175e<>_pb4hzpA&VF8dJBmkuj+6a79%x* z2sP>wC*!+o<#KTG5==(@M=_&M1w^D(IXHXgf_QwgNjCg^tY&?lErqNEIMGkP+0lv7 zDa0NIYlB)Fwhqa{WCxWg{rv|9N1P*ni22Zl8}AQ}X~K?}?cAtBuH@>Lu-O7OK)7!) zg$DUPGSw|(ou%aR)ovuo2P{mUlY3RK>%qGvc)#tShRBN@mNN_GJa9`n0pL*7QB`k~O%kpw9Z2%ZWLIcCChl zm9yUW#4NPo;y_LK2n9cyO=n12QaG5C>8A5>&uyly4 zT|y>SwK6jpD&@7Xks23EOqd5Hv>BZDCXIivX;qHajCQi28;B_7u=SRi%T^{_p_cMn zHBzU(_am(2E&!qTN#GwD768?=%OezyO#LRAun1JV{PMr*GxUvSP1;jpDhI<-|z z6Wrac!5u=0OH67o%JcRY4G+j7oNo_Eld}d!j;O{z_e@kI_W#d3HWu{i?FzB*^ zwM@G$lmObQZ^M=hT~@hfJ5uwk#TZ4ZfVD-!GUGUQU%PCgk^Uic2jjMQfz>w;AO(uq=7|pj3^vTTjvdrb znk_28wHjcSPJ)zl3>YU$?9zbqtw5kRGe?70q==e{IS!5;8?st8Lzy-|oiSRE*MtA7 z0?oXy@qmOc%?FJEmv@(2M;U&zV=P6~a31%P(p2SxRf`$7%ERfoRV{>M!xZ zJ>7%QgNde>n{f@=@?^Z~J>ntf`}ngjW@@cz!WpS!*`SZ8eOhn67zp%taf#zuDDqj(5$s8ESKsoxl?$6cA1jyuW5=2rIBpBBF*H%ZPR=V$5GCm9kssmszW38HnIV0(&SKLXR+}3IRsu;!{ z@ZggE-N${7D%SD#Zx1h}t$l-5-9HBWwIqIhE}h+duB*-k-TnDC;C|^(M^7C8m|5O= zqx%MqbTB*x8YlX8Yc2$_=QH~wJnkm~VScqWVg(N&V8e(}W?X);2FB#4Az1r|kS+3-B%hGvJb*;8$8O}K>-m{hkYz~yfY{nYzzfAVI$+SkYk~Zp-!R&t>nH$Vas2j_m{_IJg^pW_&$*X9v+TQ zCAgDdyLm^YXzJ(q;i z*Y9rnzJ!cc@2}#K_I{$S_I`J7s2FQ0OyamCr-w4GJe5_2hDB2;AF+?rVc#1} z!3NMmwm_Dh$I&6eRyNSjxTTd-mu3EkkU|8LXeAHNw;Q=f0mrUEcpx4Iow^SY+s}%H z=>&&7zVg-ELYIrHzq^0O+qw2(-&;%UiHVL>(c6;2m?po>S2Zr|8Hi6e(~52N>8NZU+}G*N1Q7_#y)L%ZScH6 z41}k-ZBZprs-tZ_I!mj2Z*_Z47t0l%j)zP{pIi`H5P302c)3HK7aJr(gYtZU$j5q5 zA|}Q=2;Qka6sbz)sEnRlr+{A^r4of11OY3yqnA*y6JCH%rGStFKVwJQcP3SS(1 zw%@%9cAX&$vz-c|rdUaBrl_(uJy=yyObQlOmo>B60qOD#Ap`!gwk>>{t>;RtCoyB(NtAD$1zmITB$u5^VgN!XKV+1fpc| zPiSu8*+m{hhW{fn`@+%&N7IjU7yt!J0!i#~T||IX8ifog(ubRp;F3X-1qPC`0qEkz zs5l=>#*W!Vc|;-cSxAkz4Xf$BWBf;Gqy|zOWJomIeT%$(;XEVqgML))MkrA`rD| zl@w}Z#k)Z~9q2CKFhHk>mnct}6(TR=4nt|SIjrdug4SBU@(1D=L zF=u`*KHEBpEd)YRL(@f)2TMDs-HB^}SUl+G4#~#w^RxDP9E2R*ze7=0rph6P%l{bP zh=hrrO$nmMF123|(^0Sti{p73{1-x`z-^}lX*}q~2NeiQ#QfVpmWb{qN@GVo7LAIv zPPfq#eEX9LCvtnCy0Rx3KJ)kA3s|jR-n8=j&3z*6edYf3#f$JmVe=7-_08wo!N)qK zq_OPq;4-rmz;tXu85C6|!UA2UFbaNi+w9tVm~SOB32sZs;aY!LxoigIg)pYV-88Qc z;=MbIj$;aVM*NM~-wP)hQ6r?EaY5YQt0P{WJj!8GVN_=lgH zch=&GOesLGk+|wcbo|TW7ialWzvhXSuyv*-QvId>MDuz++N5%b z$pky z=h6JV%Chx2t~KO&H}Xm9@6i2HGxv?drRm>yTQRku3~;^%OHME;FqnX$9LvrKjAAoX zPG=6b6JSG1mYLW=M}b4aAn?toe+Uu4NcYo(HQRi!>G*8nQx&~fPnqnu>qMT0fhQ z!Capu{2q@~+(4PnW=z3h_1Ws0vPxi*pfm%`i`n~he&kMM*k zS-VUrg!&BIgFHv4l5Kmho>6KKPMXQF>NoEe{+BtzzB70vGs4v`)S%qZvUrZTa=x$M z2)-C6`f~_@a5EV6|Ir(7!^DQ36!KlxXW%G{Urch=%8*e6A0Lmr*m-amfk>-w(YPa) zLt|hsHekYir<^pXv7g)@12E9%qLZB3-WY{>rY|Urwm}nP(dbY`VP%nT0a43M=yFJ| z5;9nLgq5N(GKa$t65Yv}i7LPUAw(TT)|B&X59K%K+i8!v`!-M-KPxw{t<2-ibTAD) z(y}M~sLKoKI&GXOteqUYXOBVH#>e;sC1&Z^#G9Xx=z3Q%NRv-_H3-}J1}wU0Z(y3NysTNviuxhT&hA7uc`B&)MYW!`H3hY&>siC~I^mYvI}U7I6&PX&my#;0Xa{Dqz%hC`%` zSL?ak)u98`b#9Jacn)la0iVKP$+!v^0XYwjr564NOs78^rcMILhZJ z`@Wa97~}$vn@^|_o1&)4wv8F;Gz9ZK3o5fvTk7l7!X3{-76~UKMUokihM)BjZFEk< z5tW=Acx^oG1%M6ZOC*^JJ&k+tasF&{bkr&m#Ts>Gb2t zZ^Rk_s7);TBii!5rS5z0SiW+brpCS>Fij?25stX0c`~9u3vk9ekXdWJ-~h+~&@h1%BGvv#e9;kB9D!`QP`~(i z$ozCl6U7kM^>C!H{!paMF$p?mabV}~37-77syPxrFDzY*&wbiRk%B8*&^O(&DfM&a z?d%ng>n#U3-AIe7o~zc}cQ`-Sevp1K3$u`_y6bQ6j=8T-esrw8cIG0Obg}%sb_bXK zJttBQQzj5YPD(_M7dh#B8bZ`dO&7%7{#aM`kh%k~>BCfFzYCE>hmpdq`i-r2$k=$A zDixHFMVcvOYjCkETqGyG-L$9enR|COcscJL2h1+Tsy$h2#l63dU0x5kFwenDXry%o z+U?U@hMv4X?%B0VsoJiw?p*x%X-|M2c`1{>bT@>M_IT3NBOpc#8Alh3B%&Ai@f7wG zP3If_;kk>*r*Q7R)P3_q=Yfmfiy(R_{fdvm@ydbfjvuiG}Y(%aQ48} zDxR#!oxWzyp??9B=VMeqXmnZm=S@M2fXe}A-~Ffz3c%Ku3LvC_iwW_>wm0T6*JDPf zBLc7a2LcET=>aro1{ipPVC{QKD+-C=Y`d2{ACm&E-}*-#lWZBecseyfb|VF@6fL@$ z#c&HQ|&_w|^eunfkaS_s7uGiI85soxS?x%GqdtzP1Pp@Y zf`sL0iphxgA{6x?ButfM$gtIp=o;tZkO8Qq-=8-5%VPjrIL2{j8Urc?0A}`fCPc^k z<1tu<9Rn*!+&SStmD4FCVyAK`H%SjUx3=0>U3n%EPRIIL{A~qcZyORcXO?M{0u(8p z0=itWT}O0q<;g4S_XLPwZ1JdZ-q>|%^En=N@9gb+Hh%Kb)rq0X-4>oGzGO~ZBJ^B! z`5l6>@iA7enL0rF5OwuuI*V5)M#Y?mlA)}(icAFFhaxRIS_)ej0ET6b-5EVVP!`9A zML>+69-qyUSyZ@;0dq7h)^ zdK(Uy6v063l3+%oXMCZOP^qVajv>G-V0-@U!KwX6n8viV_F`wkQ8a&x_42F5>KRFv z=pR*TP^OPg!UDobfL-EIkN{p>=Yp8u^Umi^Fv!eDG?rn-?``Fzi8eT%G>>t|ga1C%D4XUe!4 zZDc$z0&|4rbK|RjF?5Sev5+V8txY0{7vhl8Rg+OM^%HyyhBSCSQQm;fZ8FL#m^=UU&UjSt*Qhbd&0g+M=o0|X((`v?F zMYCO>y_*S5jFpPGE6^burlCZ*#qj8_URy_&k{j2Z@0@e=sp5F@M^+157ux~K%H+<0 z(iRTUoNg6%A}eKMrSbmlEb?fIT}%}}igHo9mQ)HASHf`JQcrt+?`5Xu>N?B!teW=k zy~`Dm=P~84ASB(j-LTkk6*Y42szkVUv-EOQE=tr1Y==Pw2#^lDt=MK&WOYn=_4&icAvFnh12Q<3vb-;YOj2v7Gs;xEiNZl$`Atqg=Ki6IwEs z{tDhlqmxh1#ZmDAN{6d3OAt)*yN0Cyc8 z@Ibb+X_N7)pkz5Y-TA9r$KxcKTSp|&mRD>FfZr=Rb{`#XLqqtLkQ;-Z=Yu5 zraBNEa){>x;?2x3;)N ziICC*l?kmiI*Xit?ulQ0JH#2{`a+{(ntXCNbm35;ji#%~^dfc4`k_pOJN|Fbw+?x< zE8ABz&xEt*v1obXhNp@DMsmP7B+%IWPMV4Va?X8|P#57K9>=22jT{#% zD4T_YLuH7EO2Ni(nSpcNu$jwsc#UV`T)zTTKIOo0V>2IROv28okojx_K#e3LSS}x% z#$`HH+Qq96OLj1SYisYB8-hl1o{&O_dXUJ4vFM=oy|t|8CHeUfBJHl5m#Kb`GJjx9-sfs6A_q$9$sOw~F@GlhsAibapg zHCcm9LGcAWU&bAcJZ7DAOkdM&1(7!#AUH0h61LXnFp4*<;j3D zjwnYv4qR(6YZ#(RfVSdsqs&uo166)usfY#}QI#1_cO1@CIYr^mi=UxA1|H2nDX^0X zhL=m_L@82o?dZL%8*`9%^z6ZS7c>qc`5jLk5a3ZzYNOdKZKpT-1&|eAi!>a@+ghdW4sDz0=g{><_R&I#~B5b1ng8BH6bF zIqdA@iE*{XR{4_kQzO#{t-p=y%fR^aLCEJ&u)_OyHh=a{bjBaXHPPcOG+br3>|BK* zpux!gF@EG^Axs=&fcBVN6jdG?0G7c{0O-Mzl5N6Lj9zvN|;;c49^aXfB)$Ib67> z!k@yQSG_LdZjH(eY>v1EpN4JAx}mpj*p}pDzT1h@(OFLn^EpISZ&@`bCLrK>RD3wZ?gaQhq$PLI!lu9p~!_^q-H<=Cx z%6z8}S;@W7I`scf|L-nxXGR)~cfk+^tUk$EknFc#bCZopzy)Dg>WqvaN8pSC#l0!J z`j6|W+J(2wj@`CD3M0brmk^ttdJ8&HLYL}pjD60Ws=;qCe?NU9-V8|o)Yglb1o_KN zxG;|;uj{F%SNhzO29)=Hms1S?wf^Qu9AFljXZP?T28@*?8>}JCMIQy~EG&HHZCN+1 z##PWZT7xkn${M~Y!2not&x9gCGkg3nN_zyE!DBpQ7CvY6lJSDyb;u@nkALM)3zJ^O z1ROQXDCd>UGngQTp<1b$p%^`m4u$jImer?^{q+pmpoq_{SSc|qanrgc7~ba}5KBT` zK7Y3u)LL&dgcgi{h3`H6_Fq}oV(2SB!p#n9V|rB0V79A(a=aIWtRsPK`g7A>B7St_ z&o-f9%UWk*P|_>q-p61!vTBet=x zz0+4OUt*|;s{W^;c?qM|N#er$VlW!a=it|s#J)ayF4337Xx{?tGYHhu?!ou&Tb!R# zNg%Z^rt5hBm$FPM8sV-b?CRwaT*=BB$t=}R*EDJTlFeXtB{Fb!Hq+ip9{$E7-;iz= zURt-E)TOpqIu=KoNZnF$Xo>jJ`026cu@lP_Cu@z+CG=esIcZ$Rl#oP*>XQf<1&tr) zh!>(p2(^+T!WshtP{8N_fkZdbS*zwEEynggvR;4S+iS8JXP7lz0>#Jxefsl=Pd~{F zSKs77gH6Bs>nSETzLRias!*^;9+j>AB6DD6Yu_cubu8HYt{ZXtm2lkCu!}LTf7&2| zu*-0j`hECYii5#{qqug|={2HcwX}J3_2hD*@~|A{T(Vl5wew#q-}4pCHQUcJq}KfB zTL=rMX|h~6lelh~_!ecNOZ&B6`maGkuSR)1A+krlvm4-SFM&u!kuT_wGIkU|{WcyS zhz_drtN~x z#tNaos@3Pr43wQ@<}7nJNeFhsapw5tG4#xIi-hg-O1Y%;)T=A*Ncpj+-0xgPQQSKx zH4jByK7>tfmcCNXu`x_v6ngnGv&P+n4&FYfIOs@ z_=yGtleHJvn;;*XMN_tVAmT$el2Ooqn^lRPlo0ru5&rzA)h zxgXqhNd9h);rRYT+Zpy`=9xj1lPLD>)A#oNKN?!0LmiEzJ_EZOGys5h2TLy1WHp;f zA$0<(mlcez_qlA5oSt;#?W;PKKUV?pXWrI|Pk?COg3|>kXoLXRweA@4C**GYXFIPL zk>|70u?RpIIk7n)@qY+iA#Fc3km2pejM4TSE0 z$Y)0H<69%fy(PA*IYrQ)1^Pqlx~<%qI#l$>JA4NmWIj4Gs?PIiX|94pRYHAaPBd0H zs#=kNzzscZJ++8F9?#!s6eua=<4#1n^~Q5ybgT>666G5vR8sVE5uM}5pbMQ4vdo}( zc@Ogl8wJ+*$Z_}lv1)dvP8X2s8GHW5VE8`6TCUdCfb(+}9)OG$k<7z@!%86{U=vfm zR~S8#-m51i%h#^Zt@fP*U7V8#ZWYTUI8 zcVZdsN3Kjq8AJOC#pf|XTWl80z%s%3u1&@;4x4kaKKfY0;rH7&?8)&d=<5`;d@IEC zggo0MxI)LAZL06Ef(rVNL* zofvMp3(~2Bs3@PD+Je-3H%|^b)4uZKtM2_=qclKqYv9=u3p+?vhJxo_KXy5Vqyfj7 zvi5?zq(V0(5s0hj9RmV!d1ywt+c(I+_47H6_V2>|A({8+rDX_?%=6o)y8iP4fHW1d ziAgOmdT#)hy>ko%=P+_nyO|c(E{=^dFg3D2q!yK*=#NIRlAscCkh3u{MT(Shl+FuPdFXV;xaAJbqcI zynzQEkG}VgEBhBi*8naKTB5!M7*2Ty+(EbuKoHL+;v@O`qsX|`7!nUsA%qFJZu<*C zB$`MqN?^PQ1@b2e6_^-N=A)UxFVfXY(MGnYq>tV#j3_`;{+Uz>ekl^?;dXhNKu>}d zC8keHJqwdcd(R7)^Lg9cXQ8}W(v>&Gqb0ZWD5ugYEQSfXV;%N~z(plz_0xm4fnKvx zcwhfB$Hu7S=y+gjC=3zz`OA#UBLzIi@V8-)kJuk6l_Q6 zIaW75$;txVd6yx#*Z)e`&N|nqWbi`2L78?p&fx0f*kH-y`(G}bo;5?3t*&i|-oIu8 zJ7#_rjn>`?reC|z>)eM^QughfOX*zl=@)`PDY0e!x$JEr%%BZ)K8nU}7z0rw=`xi9 zBPnP`L|LX{*c-Tnf)^Fr3XChnND~@M8N-Fn6KwM-BrUIKaD9H|WvDOEd-emBwzzee z?L3Tztlejpe*8XeKiB*_tDpGa#mZSDWR=p`9BHJv3OtGC`D$VAI_J{F8qds6V}XYh zdFBmyoB354-hclgcNa8B~14Alht9clCdqd9rr=j8dtF4e7nGDug7XwVk*u*?} z8lWc=U~c$JnM9yej-r*7K2z2f$&7qEU=_v=5MhhV@Y$j4Ikz1bS*SoEz?@*aGWIMJ z;V?GeCct#ls0(Z^UgNPc09fFIM)e7;)rt+9)moo)Q?>ClU|ATRv1vNV^wTS}+;xHK zS@Xuv9V*_HNyL+sDL$%r)pv9>W&e2>*=qJ7qF>!l9v)X= zQn|+IcSHCFpSZ853P2v{cY)9St=mv2BWrgY=u|_@gVN!MwsbZJpf-q>2Id6m}Uk3`kCV2{wJa z!H27b68m*_0Jh#V!bY5Qh_aXy+(|)IjaisFBH4V#?Ym|pTd6sBu?O)-WK*HI_-Z{I zq%OtL9+ksqHtIiwoB_rWVUkn(Qc0vdHNErv1&^NCsYqfzOldk`P07)svIM5X&?Xcz6ktGV&S!RX3(Ac;eRNc zt{P5uhpb}l#uOK&&%e!n7Wh=X(RS?urBA|)3oK@lQoZ>SmH|)8t2|!b%J&LRsz|BX>PX+iIVbA5 zYzik;s_V>)k^}&@9?~<5+G`TDTw`hM<=2z!i7+WQ9GPZ?38Ox@7T^EiJ!EqC)zo#{ z{8Y+hbKW@r-ZgZ)`{Bg)P5J4>Y*g=|E?Z8bl?DN0ysE0_onB21)$Fw{Y!U-#O@ET! zNXPh{;)q6@sw$mMVce4nsTd#W^&46%5x|Zw$?;Q6yplS`Uxktzx<FwP zgPlI_#s+zD@#-(|*H4E%g`Q1U(g^~A5MA>nDr22DHYZ_SGQ~HTOuT*d_eKSHcEi4x zS<2NKaVJYMR-}&q5OR4|ISrGYD!7t+$6FgXCVBIVUL@FjTbM^UWRsOB*-YT!lTm>9 zPZLGDH?~{?7?l}?>BO~@+nfE*|5oXeWi6UL8r!-&lxsK|IFpR=(Ns#UUbr1PMw9}n z^+2*Z(5W0Ql6mq0k+d>9y`6~OL_}vyy`BS6av1TL!Y{@0uQLWnE$nCb&2?5f?L(1= zoD(NT_wb?yBF?XRUiVVgx*M62cbYEeg79aXf`B8ZhK3nmHW&cFDO$shA7@lrdo7kK zyP|zGD!_C#bkZaurfwb3vS0Fzh3Acqwd1V8E#Mm9Gh=`_*02|j!F=#7K!E@ zV_zLyLoMwiJqJn*&8t!KcPyW>;JPowvh$>LzE&#i1a_YYJz4lQx%RtrG>^5Ag9`@- z;viNm!O_)}v>-ez6j_iGF_D0?_h5bF+PAKAAuk@sz8h4=x;TX6KPK9bAMwtHeG05&eC#Wm4E%qr(mW>=~D$PgLC&Jnnd{ zs|o0N!`)XlcwZI?u94=-3SFLB{FW?m|F~^qLVeznce0^f9oTQtfJ9ao{po1uHDRYD zjR-!4Siwx~CrLCt`f1S=8x_k|3Mbj`dih?FpK46IOnp=e#R@cX<0yBxaVy+2jaup0 ztY_JK->~b{=>>h+8T=5R%E!nWIdXlM?H#JNyOyGq=%bQv>-P!jyAguH(9QjxqyEN# z;DlAOiA%v#qs@d7lAQd=?wm+eW89Tj4cTql(~{npDR?|}UST{RQL}gC$<4nw*5%^k z%t%D?zll&N6HK!1W9#nq(PfmQB7=Rl5{?d%keP_V!rdn$-`(Z z=lmF!js$&{^~@ki!<`k*!Mst8_Kr zTi}!PE5NAdpl4;TgKV+!1Lwy{R&M>b#A37KvACE_XPU`wiYvoMG#Iz0<`Y5sYT6a1 zYU!&#kUwk2gO_j)jbBB$N~dNz68ZAA{xriOw}WlsuK!XpwAXv~?_f{074(+V=7iT7 zG;A3`8m#A3;sZYZ1zF%yV-jJhJ9(rT-0+I>n@N{!ADB`(#~i-I%WZn0>eoks6nOf% zkS6ld=56wgk;?hu5dKH#g{y+#NVReMTKl;%$AzSG4DC0;UaF*lP;t{#%XDS-+1Wbz zXKT?Q$3D)r=1M1hl~m6dq(E>|eT?aDia=ES6b6Q@=0%#Wml>jU)M2@&FcpaT(Md@#c z{pyWL7INI6o|PA(dm-|@yMyi(VikoBX75C_mQrWZ8!sTY)8z9a7ft1*r8t#z>PMUk zIhT{9QX<%C#>vxC)x6Tj&BYDegldYm&ooOPxKhx~bl(s( z&aQ~H4ly;__$oxZq&EMN$|Ur6@=ubz#T&9!6aQ@9*%k>?O}9z=E~k6n+i#%7?Z14p z`>J(&U0U;MkpcY^P?)#Je|?Kg zer~siW{SGEXe#W49lF4t`S1J9rYZJ!ltZgKF!H(CM>h(;R|s%DT>-IdjFc~-N6EB z(u3HXk(HhduU^#>Tu$8hUkFXX*2=?K;@XI3%iE8jaqXguZM2D(v+Iv16;NXxo`OBW zx}#m;>N{fWEHaW#^Z5$BmGAt)dZcQK%j3U%RF#^p{nC$qgm+F)hQe6=>3*=djOgoWD4(%Hvu5Ed}+r=e71 z3{59jJRPyREU3j>Ctk`bX=F-e^x}Y|r+)sEi*#N3TfL%uI@pMIjXbN>7Oo5VAiyJa zQzW<%d{esRq)B~;I+NtzYEYs|2t`vPTIUe}P9>FV zRb|=GXjR>zpJ^`YP&-*%VBq81WvX7}96jn8*QUVFRp>uhb6c@mI@jfni!_S#Oz6sW zrS+GyC!p#9A9t|is|oPz{vq`2IE~vti~ekv%hJmTnq=vNR!(t}vQcHQTg`iG1i$2^ zRZ_4>*A0tRv?ZCs#$v4f9+J^wXcAo)Q{g-^!f*?8{W`r5Ix*5V(~efMc#ArA#muEk z&ns0i9CZ5#6t(ir#aSnfT1%4;ot)&(h&IL~;8ina&*m^@Xt8^!HyNn)A)##><8$Xe zi)Xw~`wQa+oPE#0*!|lX8brI94}LDX7L5m%mEQhpcx}j_;^^GOY*Z`4S-R2ov6nV_ zCb7l**Q+Wv#iC!YvVpp`R&9jd!0H&Qorrq|t~Nck@!4z%cS3RNV^T&9=!+tEG7`cv zCYc;8JN8Z-NPa9k2?J|}oC6^wqC~J}`n#l0XsT6bmomCOGrb#Le4(~KN=06LL2(hs zJe6`Bv628sgd;(F^6MSe^oWZ)g>UaQ?SP-NyPC36QrBH#Pe)($1JM8gJMC}y%yst+ zH12pv+O5T}@+Pba)bUlU>Uv9Gg2fWp9~-j4STimKw!@{SBJv`QOkUs28`*KLl9Uq; z4mTvl#U^+^k=DkoJL@s#{nuxF4ck#1BSD8pV%PI9FG2J|$y*nvZxjvsK z6H{P*Q(DgC*5!$pNXIENYS(Lc7=QW4PhtL}W^K}sGmjRNm&Va4^!@|ZR{PHjT7~SR zSOWwp@TZgf3sS67^{J+etO~!y7iyPOv=%daQYyhSYiGSPIXy=H_jc@({lAiB(JtSQ z;t)jQ5Xb-kc)+!2E$C{c#{*B3LHHe#3h&{G5^>cou_PoYd9YH>)o&omKbVE8|ZIVbqz}a|; zm0Siaf`tYHA6eZ_(=fA<`ca)-3O`0xchZK+4nA+LxC&lIrWy+7^zW=L+;HaLT>m|% z0Mc)^WG$m5 z`-ZFgT#)9>^y|z8^VTJke=&3gpak|&lKcSRs>m}rhz|pwf}T_h3Wd}@obv4*_Pqp( z^RWe=Sf>3I|1hOILd_IJ4= zxd5a{3YcJ@xCGz%DlGvzxlju(32li;`!8^-*#O0+o^2FrY?0T#KAVp_qsg2K)_ENZ@^K+f=1a~#;~!y z;X80Il$(jCx*PCJYZ{LP3}bN2;7Fq4&@6c4=xEr*7LZ_cIWG=V7pBQ4=|pkVZ+aLa zvHcWjxk$qkpFe)`eznC>vaaUIH@sqlvl**uddJtgTs{?)KAa_6$Amta#?r^nFUh|r zke^#>n`VzvsYs$dW?ESyE~0+ETl)6I{&_eExjD(i-+ouJwyQ~(+usQM)#nU{n0}}$ zhf;m|hfr-q)_sP&`41Sj8vp6>U|X+g-}?pM-@zZY3Obr>+gCK9YnU3iM_V!3F(Iuk zfLz4=1^_8tce*j6+sa1%WD{ou3lGZ~EfIx@CZChLO0d?>%GQnRoju4gQwHRnVpD@# zLyR3|e5-8wQnEZKIRDLuxzuJ?8CtR?Z=d}HK30YJ%;KJj=h_hBkG=a!B8VB(sZ6OS zeq48x=pr2OcklTN#JfKa?EJTl`P8Jrn`V=4mDNI7Hqg>vm7N|-h;OZTu~4ShYZZ}x zwWrH6aqZ+_MmazdEHzsup;Hcn5T!yUBTw+5$kkY9=R;^AB#(k`1t{WccjJmX8zyaf{!A4#5(NkceP#9`V=wp-r9QjR%&~C$pisbiIZaCBc-Q6zT5N-wfPQA$0W;~;p zZ~Ce<5P1bCt)(J{P^Wdtyk(2zE)@#5C1XJ5C+p)p_UnUwK`)LtJ_!bbj@JaD^JrWnUz0 zn^WYQSxK#BFk$<*0{~XfYmJCG4Z%E`Ng^6couCYw-}GZ{1#2wicW#PIkaQ@CX;_iE>T`<&GB#jTWs!zl?7Pjv@z{DYr|0qE`5l z*S)vPu7i2Ut6mI4Sgg)E=rRMtKXl4DO3w{fI#E11V6u_2(>HtFm);~X90pYJ# zxIrBnuSieP4;RnFKh&;YC_TMfylTmlZVNoa_yJ5bgP#MU0RTRN?>m8vUj!C70>EVg zBa9=TRbS6duEj)JgR$K!ogu?U5OdI&IT;>m1OPS?0mFGN5y*`&n&6K4Gew@Y_>wSq z|5tqY0LwKf;Y%HSb|Ptj8*Ej3y+_jJiuYS{Al9Y8%QZ zrk9%D%&!G!*)4Vh>Ks;;^uJ|x0~1IH%|IkEBH$ZchtygQ8N+p%!3@ljy0WaC1PynT z)(vhffu=!wv3JjU4Z4=lrkBKb*5VkfRWQIcrd&~H6!6*ewMdu3S3V+lj>XpjT@)=w zi5fGXM6REpuz&8yWnG}mD^Ls)Zm2DVRXQ{pi@udKLMaZT>uVp84h_fW2h?BaXK%A- zjvQKR7xf<}X##%}h3wqu2QvS(Psy~@6`awyVDrN6hu6)&ijFs*Co_=N?fT>Ws9EUb z*X)vi^;u}x(-_Ya)L8wmZ3=_KTb~ad`ah4j*z!EAV5X%ks8PvsRCueu+=vh+p zn!S^0lG7{RO{nce_7EU^Wrq{#buC~=+*h7~qsxcXu-Utr1x*ftkX~bRn z>+|Y^WDZZR=J2+yA=0pGj$4S14cGb;$BPJ**0(kJk~3}XMHXRnFCbOaFWji4aMejz zjkxpu{)}}wwfX-tQt7*wE>(PcRm!vRrPTL`cg>*84qd8MW=b|ivqlEf_9gghD!ZZr zMO|5Y&9~;>t2I`CD(EjMGYiV-=pj;;DDJ7+z+qGlZM)R%NT`+-u6t8dPdW ztr0vL4#_@pA;2zw?}C^={>2H^Bwr1m4i5@qvn4J1HsH~qe)Izuot7=%fqYS{spYpv z#5dSxd=mbSzw@%H$}p-ze9r9FQNNiyqH52H0lQsB9u~UY*Dve6G=( zj0LG`qT<}vi>q;AVU`YneN#q_xDT`c=G`A#x^~%iC!Wt*kt`Kp3;rZIAgB0a^@?}0 zl4ix;=pRCB5&1erOgvDJ(X>|EoSO1S^3`Gc$spsEcU@drrE$;N?b8p-#`IE?eq@a| z)kAMQ7(cO4b@6{G&}*q4!`^OsxE+z{4WJ}R2-Itf6sIU%yVF7X2qu1oJ6Z@8e~IEz z|DwieACQm~Yla~(r4CRILf7LzbcYN?QJ_*wQhAB|G9hGr@x znU2z!zt`X&e*MA$RP>e!XEp~3^SSzTeWhKh)|+-Fg1vgXQ}x4mHvK~Ev#f9??#i)^ z2#9DpH zRF`whWo!f!cAn$o^VRyZr{dOY6QR9xR7I|wb4m(V2a;(J$RbWAH(0oJ`rAasHE6?6 zCH3<}csk6p4F3v67<^9q8;Asz*R2@Vz015*D zVle>7(>Q=+B3v9I0vr=e4MP@ctSmkekkBhqe&0xOIENL7h0Q}E8t(iWb+S5TE^QTs z6)#0LX1)kvfouMUIFiQcmC!9Fw%JkG>1gBF>hRJ%CFjV9I8MF&iiEFOu2Y>g^nuJ{ zcivG=#ZHCFT6>ShV}FOjGd~+0;2c+6lUD$*6_ z$@XjhhtMsIYU2$RX&ek!cLeSrz6|`y`w3yWH-&id#-AqM3z4!ncKru@NvLes*t#8p zbD!ru$7%ZfYV@D#z`w090`a}zb?D#I|2_YAwCJRVlcqnXQvoQGlh#(62?J(yim!B^tC5B=?NuRsDjj8g`KaFyr9W5dU2_7TDixxuauURh z7m)V_mckC0&u?jR!bA41#Q*PSeudh%D}3Y~IDA3%-<6hr7x9H}{h@}}-&)i!f4}=H zi2KK>`_3Rd0SZxA+^ZtW-u zPYn8K9B!2HDl{Cvh(Z@}*VMwOc0Uy5DP5vO904me)YdM~vQ$PV3b$NL`$~~AhC34Y z|Il=nVNL(<+uz3Mfi$C}yGFNkj_#E1P5}irx}{qsM|Vj|H%dz?DXoBjQgZYC{f~PO z_h`rGJg(2K_w~Nc*C}pqk~=S{B=nKx<{(e)16QCzeN)r@NEHu5qjo62-j2b4zwghZ z)cfZCr8Uym>3<&HzN#k(eq1&M&&BY2f9X)qkmR{)i_iLl^}%j*1*AX^y25(yGEQzZ z3&;OYp%OsGUcQD!41zMb`jDbLZy5t>{?{bsiLpTlFlGV`ZGriUqiQ3R*kLkyUKw#I zbYg*e23MAi23E1H*G(O(F7Or-3Ui*Ljo8jsi{(jDU(=m44KCzL6jog(wx9AO?(ojO z7I$0Avq2h|`5VZWIQtBd!j>OXU#^0Ti;>}lQ<9~&j+_%EN^u)OkQk*l`>@2!T_yIADYxA#_+SyHuic{2f(ZqJ= z-$*7FMplO|l{kflN_dz(n7; z0@Egf9xC3(-&!%EZ}L^O0-Ng>|Nb6&ra>5oFwQw=_BfdROa-5d03V;E74}@1&0*3vej&FvSd5n;To+@Tw z{m!zG{62@qbgmKI?kfH0XjyFn9X8NY&L-7PDble6@Sc0151`9Kv0&JEakP;(#54#9 zgqc%ZvgqU^V%rp(AFrP^zlvW-j}Fsqm%!Lo1;eL79<8t`10mzUE1r`FSm(jL-mn!U z&qMj-RDa@PhEn4U)IlI9XNm&B3OB(xx(_Mg!H0REI{el@wT}F7EL+jvvSV8$+@U7-fipZ`gd$6tQ#=33C*TSCk z{}eh2C*LbkIPDRR5Ns?Tp6Us*?#KSch+7=Y^mofJm{s)i;TqFUMJ&L?_P-sNhv3P5 z6FA8zzaj^h(+-?z)(*9_v~*(fwzDl8R%91BP^zoO$AkFR`&u-m*LjUk!>4>~g>`3? zU<(JF>&r1J@c3ANyzgHN4{IV`BZ{n*3~2(O)0Mtauo3N}R#K(Z#;5307^qm(gFuk7 zgne{pJyMfRQ!ps~)d*1{4+LOck$xd@Z~iM@Wp(3l(QPk6q~Pe(_DHK|-gr^2AE{8FAUW?`}2ndg5B zMUaP-$hhxu4`Md&Ck?YA^&72L(FOdlNaAWHo||!>cEDQ>8!)PhlM>m4UO;0hfoWsR zH1Gdr|LZ85qJ80#ZmfYG(=yt4R`@}w#ow?)#LS+x+nDA}O@wRmD}0%?QzMCQM`Dl7-~V0TkvP?vJm1T5hf3n)zN{|TWX4jnr5q4= zri1{|2^K`s)36Kt zoJ3Wd!yr1r}K0_403z5Ek_hA6|G!P-|M`A~M$K~{YJYb6$M6h6ueV!;Z zUAwDHQUKJE_QpqeDJ^#uIu4RjM8L6=7ke%)D8AJyqZJ zLBt^ZbekAnZH^SYk`=q4ikm8BP1ZLb5Y71c8KFQ|nL&pAo<5EMj00AbVS;3|CDL{6 zJrJe}te(I+^>ki-oJ(b)6MWuiZ(PyAGSATqUMYKq=8ouiRO$X z$Oa~cI2Ula58(c2FI<*eCQI3EXcikFznqm0%E>G!GEUi=9dg_s6cbH}5O%CI&QgDV*2HFiQ&{%HP z!+xy*%JQ$T)l~FHDA8E^&aiiMv!#k57_#BEIFa%xSy8gvQSXo0BF-gIRw3f=yfKLX zS3`*G%6vYa9zM8~z@EW6pD3sJ+~mi@BY(l4wRUWsS%X;8Nw&AsDb!1Rgh*d(V|G%0 zX*6Gwd?vkFJp9;*eOWquFFU9v11AkEw!~`72S`v!^dnPoT1PAN`8=h_Q^FV+U|JNvW^=oP9@k%FtsNwz zDsGrECgjhV8J{sXqJ3R~HBRXh730(+2M^3|X#?x&Sq}W77n3zH9S`SfUEWQmo{);- z7U9|aI^V%b+n!taE{>Hk2U%mCGqqbP(8$>O+PixseG|s1pqD&)?igWi2BN<|=f1h@xE&cJqLA?z?-fK}c6lkB)ohY9<@j)5*g9(v|CJyANKrVq`mXU` z*TivC&-zXD?6((n@N>=m(Vm;2{0?OGl`8Y#F5mO%H5~c!TB@2&R1>zA#*80z0Q0B`B{+9aCN)}GH}A`G9IR89cuo*lHq43RWsgf9#cd z6I_o`lh8jQxKYbKWK2vsxo&LkP1D<0(_w5fb&3-ei>#5AsA%VpYdXUDY*aB>!t3N^ z))i@O)hdLjekGCj-A2Z*WCd%^GBZsqB-XUPM=;8l_?8AYG<37GhXHWBZ(1K7yPFmT1dB=%UGu2Oew!GT@VuBzY106gwN8_QNk=m1cP&)+np0gU4VKx}a1upW9u;2!Nv?1j37A( zOrA{Z6~C6Gv)lV&?aGEMq>E0p+I{A#rixpqp6kd)QnVZQG%XC5AWAXsDYzHt!7VM=s!~3e%sVU)OY#+b6~bSFwX>_nziV*OKeGx$itr^ThzhGKt~q~5WvTXj$Dol$7Vv%W6v}xJsHZw<&ew+AH1R*k!z)G7I5B_ zi9?U?!R$NmD~fd!TTP|Fj8Nl8P`Kg(CZVaR;IZYudN+p@PmT7Fu*WZ{_s?+LKDIT! z$m<*tzYE*|XF%UuEhRC9|d$ z2I;%O;PgZ?W5FLBsOkOjDfMtfSEg{muov?OVDESlza0mj;=IF~vUimf&1lVy(ErsC z$e1FAhsQo?@Ocvp#b`+`8Hf3j8v_LdlIrtb*$PSl8rY0r$OQIe{xbUK!Xv)Aa(pxY zqKK|%I=IYFP5oviD$u>{C%I$-G0i1o8n91Tgajj^8%ZVCxP%GXO+~5!iKq*=m372( z*)G(D-LhbDTI`~(FaL%8d#smcJ4>^xyribaXU{6Jr-ult#$XzQ-M3jkqQB=Jg9mU8 zh!A zCmID_3=h7u`yiwdg+9&QAzOw;#?(0<8QW#f!w1+Ll(k|Q*WRM<`c)AIb_9k!6U8)aG z^?};r2Iz%&AQ*%8GMA5FEZ#V2aY?bo!cNxIt&|_hZ@~`kLky}4Y3i&)w&`|LDyX5#-~9U7rp_b$;Mvo= zCTZ2I>?!jt&dcTXvWu;Ad$HLnnjSC!*MROOPaB`y3704lQ$(-g=ra9bc*Z)%} z!UUMj!{h8XNz7N)KP~i8aBCd&EXYE!Iwxj-3{j)Md%l&Ni1+^Y@58sM&CdcJAATCD zi0DXmF|JsBv8=zReIQ?6%5(pQ!3*}OS2Xx{6s!#msRH?fju)sr@s z(o1f4F83(9^A3|y5jrHR zL%j<&pvKf=dH9I8=;z)g=$XkybwN3U!FLM5u$0+CyL8YN3xKF#%l4@hT4B8;FDp>k zsBSv!a67unm761Q4wq3xz+^&dVxARXbf+cb`m$lv`9D<=?EB-INt)?VaM)uTFF%dr zg+>|YvYzYljsHV&G4;_rR+X4Kz=$=Qk6@V_e%wYzLCce0OL+e_*?pW0dXA(!2*kn! zqWy6s{r>1ke>0aHzQ(P*U?_OFpXoo?I01k>wcEgL>^I=_sPPg4qyR8okF0MNK#<6L zY=k(H!V}kV-rhj3aK-v0!a|KvWn-1 z{MY_)t&>q|#ZdG~(*$bty~29W^OiQ@a`#EFp#6@W5+KSGx(7Zrfu z5wv(Yg8tAfX41&g;>iNE8S5KO$3gp!I5x)v4?kCok>Gh;GR{(>#t8ex1i;0G0+J~c z%}#(p_GS$iBT?Ta<(Sd`Wy}QUGgs-IqlJyb5A}~XL*|t>g33D9mV~E5kD0yhd*^)$ zB`Z1F+ky6E16uUk+0h;?Jmya^QuiaM9U~u;38pNMxW|N!=g0SsPiNl8`uN9Rw84|V z{&_S0)pNYnsh!K)w66bTz=k{3@@j0S85EV$2kGBV;k$;#aw^aC;RsfpD~2 zYaM7$-_~Z&9}NeuxD}Sl>RZ1hoh7q1`w{k3Q&;<6Pm7ti>Td3dIqW-^oq;>`VcGyr zswZ>=pwB6_&6n1xKOGG{0>BO&R|y`ZihxHiGXnu+w^JY_y7Zg*;On%55+XOG(a$mBteY`Kxq_g0h4a^1oVvU>>bwbBL9`pF9cO< zCKLX_;(#ar5C8V}v!uJza)SbdXrPi3hKMN$1z(_lZV@1ngPH&%0wY?E4w?x1WGpVG ztB}|10zzia`y9@Ov^BeXxN&CoLR3z29V}S4#S|h0O<5Wfn=@_wXn`Pl+fp>TZR+wv zC$Fj)*$^(Y5XtUQCpZ0W&nmP0x;2feRjuNxX@^fw%!Vt=#|u|(>DNnd&0C2zRNqY1 zRX4|(#F8DT;Y;ZKz%FW&dxbus#Z#)Q$^^aDe@Z4arB4jqDju{(+8!bYtdUFrtVyLG zh%U;=cW_G-E|o)Vu>Pb%LB!Z8c8b*!H24woOVRZNqYGtB~wx+Mj46OP8Zrk%1Hnb;#(UcHbrJ3lxuht1kl*BDwU;OiijS-T) zMdL?DAKDw7O&$L{(e=x4fKqu~qPt*YhmyOK_^_!KQ-Ujwh+VZzYMfkfF5b1kH*Kz1 zQqZHPws$IN&*H)TqUWLfuYja$p!@HR-%VJdR}V+m{DYO%r}|n_PS8@Nh$6Zy1h_uO zjVJ?vV;bGJsW{?p!^s|}Iq1FWyc(qUc#4Kqa(xRz?DAOHcVGaVFLJ-WLwD*gZgs^! zDYKX?O$E2mLQ9Au=!xlOOm^leUAXgQlFO3X^2A5H+BAHGvxmw1%X!wYW*25sv0+hh zh?;!kZS1sS5*Q;9({!P^4Zq?-OTG=sLgtI@G5bi_u1Nk{_CkygZ zZ+bK3=42K-wgIY$Cf6uM`$cWujgH?gMO=s5EckM@%)0LQO#y35m>(7w;-yY!aG+R# z9{K}UB2o6Jt-O3QvSRAyDnZSYSKj}L8EHUf_mh54P%ZwInlx?0SNeASk{gvhV#n_( zOD~WvjE~EUo!9C{?(!Mho~JGri(ZOs)PZXc^5Wfkn%hK!R}py;%_XBm)F0Xg)#gg( zmkuvFY_hVCej)*9H{5fTqhFf2q9lpEj2 zou;1{l!S&iLi$_Z7Bl2itisLE-irD)q-6RzzZareqb*YZP{1j%9kDaec;zPVmi4km znM37}EKkYG@#_oGkP3J^8afF7 z+S*SBkwV1p2^TMtHvsblS1d-bN@LQDoAjk6Ml_A$oL6O8H&h3#E(@r6s(j|Xa(InD zw#Z7sM1>Gj_v=^et&^CnaoNePYVnXM&4xS`B`tXtYB`6DPjb5R!!}qMMY0O7)r^U< z)~QIoXyL{-6fie9z)U!`)y?@tqtch2_#B?xHAb(N`c3|t$BvN!yvn8l`wHaX)Q_>! z&oB?M+{I_N#=UI_VOQABU{ z*I+=v6m`hwN$g3F#p`(ca5wx0{WopC6o)U_E||+$F$ywS8+vEmh3nK_5h5=}<`ltL zgi=HuDxtpYcigX+f}NKh4*mG6j(|_scR;Hr|2MdwAhF+zxK8R=@ z&X2GiS{O@Zl`!E?S>~HzQ`)~LF)Zt&*YoX&`D!Nbjq5V>jCnz)W&5R>iTFy~e+vCZ ztWkU+dQxcOR)u-N+y@Vlre%FVfE2WD55N_fmk-}L+;<8h{PCd z?H6a23ejr+Dl>{{3q78^?pS0%YS@JpF%Y{T#>u)v$)IW@+8$3FI}r~UAvZ(^U_flQ zdF>4`>5gBUpVntP&onYeFHW}m2Y+lX0wJ{tw2e2}qr;0j5XeHQ)i5S+h>Ofep=FAc z*?xk>8+YaR_Wxo>T(f%cHvI!eaQn0|!9pVigS;v&i`D#VKcjsr0(8KA&oss0^y5!( z)^JpvJ}PxBy3#Zp*?5I%&R0+@#P6)_H(PQEfiD4REZ4WL`t+NW{^4-D0O$`GonzbCX&J71-KbV4ui>y;NhF_`OX8l zU|^e_oFc+-1TtJdq8HHuZZN7T8gJnigih)KVv}k{?9`r`?Wd6ffU}7n+OdIvfzOy_5Rsqo1$eCa^iKm(EIVqI_|jh(v+=pf)vyt^I?>&IjAW&5 zKSU@UzU;ku*(*;JZT_Im?`o0O$LCU)D51rbs6Ica1KFE<q1i$yPVP0=CLv` z0)-#HT#_!xvy9@B6H!(EJELb_O;OLCv6X?UHOqRvUMY>g)y?)&4<<+ zL$jG5ZG8v1>G7f@)9&mv({@9wx(f>U3=ck9qe+u~kixo>aENTI0g91P7 z%ge{P;aursT=T1ll4g9#jrQd4?h7${8FSRHeQdH8J2@n|Oh%Gx;pFX>lIOqH;=zDt z`oSrU8I`IAuDroCkOq0X`{^qfQe#RFB5H-k0FzIY^Uh$AoDGcxVHc#znD3?FtC2(9 z0MOisg~S8k9xB-+iODJ%TC!Jz1)a5wYR6N0KD>g~&vUCx<|l5dncZ_^HJ!_MSi!>m zHA{{x?||3K7zboosZd<|3Y7~RRfZ=z>GhCtdqtf5lAI)H&%lt)QbjMoSDfk+og%sCHB= zo1cMun}4)+2~T3KJw2-0^fhivxKL#~8z04=r(IH@p&`$*CF#tAR9v_rmx1=?g%!rX zplQH+Pf8}}U^LTKI4FidMe&VwqEme21u(;jMzki{C2ILO!sIej;ww=|W@)%~tuh8D zw`+r5?_~#1m-j9yijSi--0){xSQbkWJ)%5ZiwWi2!peJ;m_s`6hjNe`J>D zvo(6qYDInv&h&-9${HkdCTJTqbvL$rH9HVq`Azt|J~~#W&?)6mKsEl`Y7l12U`4E~ z;6r|Yn*HK#Tk@aJxZ7P)zduyI94Y^4_Okw`%R5|r#U2&8yaZB>z9~PoKQ{zu{#Lxl zd4(s#V=Ae*zm=d(n+x*Ynl=Rb1Oz$`(SF#=Sk6f~3nic@Q{;IBmda2BB+_K2p&@=O zR9$@r4>w&v4(U0H22{(QW-{T(m7X#vr!C)&$q~_5?$hCoTrV}C$ExsRc@xGwTG~$A zR88X3k@zayhrU8q;Moi^n|@nsV8D@IDT)5bH&xPC!jLu*SXyUMNqTT)k6Uyw?4wcDzs!}JiQOQ=ghC8ySBlW z5n@3apsaSZB+P2B225uIf(W4zp(z1b_=?pCF!8-s^+W&1&}2idioF)WlM+Ltuiq!k zV$98=Of)9mYl%qRo05-)*b&?KH*v50!KTWSKrGDp!?4C*^DrFI;55SUS<7AgGcIi2 z1d~+`PwKn$m{^DjP=>piW0_)r180tw_MDo#U3z|q=VX-8A|!gj#wc{Z?0y}eO5%n| zE;E&QZkMn>!X~^&UhZvW+1quf!~OF%ILc57kRhX31i=Q!^I>um5y&cJ?KTgfsQoFh zBf8|}eRkO8@5s9cAp82`LVgYX6mpxf3%s*L3=t(48~ke)=rf)MM^4gbq=I!GOC{uF z2`paB4WuYim(beSjPM6ni%exzrwR;96{bkcZczj_mjb`^Ftgr#nd82)eWv{1Jjoe3 z0j5OdyPzBWzk$ZXdJ6f;Medz*#@Eex&Q`x;&O;J5^TM|J#}G1VfnLmA)@gTbSs8KU zKct`=S{!i%jSkP;gMvhFSN?H4?ZyGbp73@Ndc~JQE{a~L*jK8HV4J7-%p9mKCLVp`)V`EY+4+!9Akb+w*s_t7y1o^H62dHg3tZaH?&|2ikE9v#^oU%cwZb ziVI9VBQ7+_4Cdp$mfG_E8VEw}VC4Sguo@*+B`H7SC}02nN4WjI{QOV*-Z--cGRn*` z>{0j!u$00*?rjwX5|{=^jua^z0PLb+P(KH$A?cejfH1u?92wC|607J@KV^RU*8ojW zvBzgYcPrZiRrLV17~KYNrL_{ z&-xV&`JMiK{ahkIG=9C7(;FIsoRYUs3v<;>$IG|o575JY;`abH2{g5J2Q7%i@%zB9BB<3^^9Ip73 zb@+3BPWgZ~!GKH5qyb6(`SE^e_$~Tbdf4ORCyy^|3$*{eh-gDMc({@3w4$w%St*l%OuhRV$Z3Q%d;}`y!IV#{`o?LO`7g z#sx~};9xq7RGBwcW4=;ms?25;QsvY{1rhJ%E!x;9HM$5;YQj2&_wIV;S zug1f|U}=-!kg!)e z=hIWXt&NA%D@CPkP~T70*znotjfsJD*3N5Vvg{{Zt|0v-gGuU!{VFs{X=yPl_ix9( z$K2IB!R+0u9b3V-JqXM7<2ORM6L`eoTfuD1e z6{zYhMGZKY>C6uq7z3RP1@TS}82J$?*rDj}Q*SZH;#wie20@E)_M>Q^ zB)U>L9bKP@@9@*deNUs}c&2`8iwLA|JT;5hx1(P>Puo>R@se3Gq|3w0nx)TW5DFOY zSkW*Nez-AFW1&=;KF?&#!*wkNO_99(28`Ndttp@xoM@Y9Kp&5Om9ww3#fLDi8o2up7~nwfw|6bJ~tii0&Vr$0^RvC>u(nA6P+d?T1C|Er-yBw?n#%BeNxfNgpCN;k8p zxy4}8TcR{3g|Ma1opb;&0S1@vW*@1jv3Msu2U|n|{MAlaoDHn;oa`=mlle6*4;%ye zlB{^c`alEskA{l@1{*PpnTu1!CP(he_S#yh)rB!Hza-+;Hjk@sZ>xiHb2p4FXp!=S ze@_>wsEnrUzvbq(Ki;|&jlomVSSe<&TJEeVBvEvYJou&#Cc!1icJwPW!s0C83w3p^tsW=OeUtcLKraASt7qKBasN_LJUwkI0CGmC`$aE zt#o#xMjU^a?YHi;lJ#m4eBnF8^f0Cj_4k7ztl7BVNx#?k3JFRmycHA{^CbAdGd>-j z!hib9)z>Viy7>^x_7v5>ife*(eQ5sKj66N;dc+~-e4{nVx0w(^o{N>_O=!q`Oqz38 z1sHhR!ot7f<_{v?>J7=(-8Ma~>8ac7bpBw%JUq3@S*AX{qesN4-$EEn0CGfNUVpwob($M-oYpLD#)zRE_cT%5y68%(oM()CaVy(YD>f#OjO2_WeDhkz#H)`T<4V&mFS0v{`Flhu7k znb~O&(RP((oh<>&HKv_Q8om`P@IVHOgbWI+cCUX47iTOGIIwHJazuNq`Gi2w8H*~7 z-(DVnSv7gJe59M$r}ff-c~oTN3!go-B(wYKH!9%Lx8y!>JKezRL}gR9BUA}X?WI`t zGXst-nJF`q;*SHVUA!l^tv7M^YoA4We&$})Ie&N}kx|ANudHL`-7HD=PAt#beD&Va zBUB~n9c>W}&nkx-+e{2ZOlQaes=hZZs6tU1OV2${iRf8AMDn-$k0DpxLq6BfPE|3Y z(M^R*m2REWd!Q|#?Oez;%{3!8eis*Mg(i^>T`52H?v2A~JD)xa9Dn{wL+mYpFXyEy z^)gI9aCR4SvGJiGYYD#i6Ww3HA<&=iNi{tjXK!!mtPE8>{|-9Kbnc2={7k{y_qW+P zLY|)lFLX^^zwU_Mp0VF!yPs`+f}-(PxcgMWWv_7!eqX}Tu+G!?RAT(;SHw#6-a-42lq%j8hR_427HkK=JRDw=*t z{yawil!MZqQ73Fxr#t4I-TX_y0vZvH3qO}W1~EQU5!<;^B+gQMrohZB$|!*;Fi-xK zVYJ|UUbmR=I#uAF5%y{Mhmg8SUO1hqqz64aX_V5KET0KKYLW|FI_8u!2PRogS|iWS zT`EUkQ)F$vg-`pvXZunT^zB<{rb%^~pUhJ~SDgATs2~g?DD|4{*oYhf5sb8%mCR%+f5=Vz}9mX|pQjDr-6@1^U|EJIaz*sd&@rjHT!Nyzh z@t$?ILzW|^6hkhte=86kTembAg@CW2a z-S@EWh%t~VTPSo9n^FWAUnimoU z*Iuucmi4=(Yh=>ugj?&wV%vRQ-Q!TAD2x-M0I|!Vq`;=bm$nzbbut#f-q zfPc!pT~AjSeichu$GAiBx!rQV`)fD4(w4C1<2-+Vg5)#(*MyS1l_jaBV>2j)IyjNl zfy9@&0TP0Gi1)WChD~0Gt^;EsUO0D-#TP&@NQyy8f6O~rr%$7ea z0=#KXyJ6DY>*DWr{!#N_>KX0$FP%&JV<>GGU;Dtx$$~`i|LAfDPmz2Hile!wlN3#C zfB&F9HlN0?*HbpXGphZU+_IkxXX}yW|Mj9F*N3nME*njT%NtT|#8GR_uJIIa&}Oxx z%JBEaEQNYK6#Hj#BQ(L-b^hp;^&^eqIJQ*9ys(dnVSj3qO)3TB{!+O2{GMjZDz|lJ zN6O0+sb?;e-ySObVxH$~hnQU^%3xY&U{bgB@}m{%7i<;&iQ9f!&(Q_D#5Co2oI1g`<*t>dQp={r zc{Fglk|=3{>V+Kfzr6T(pev^JJy+@F2gHR4fpT0!99>zIopJ=$r?e%X+eCZ8g3MUw z?J9fe#hRvGBbo}`>ilv}-OfP15~)&CftMmrN7>bhK@mGA=*s#5P9+CJ6i$unt)N4( zxoL$CQlRcz6*>T1)9GZm+e~!&OpMZ?u_e9}fAW|&QRkDk;-42msWsw%t+DF%%5_s^ zWO<;`Md7cb=oq5a5`)BBT$enV41T)_&TIt_nhvP7eKO8GA_o3fLni*L zT>GinZ)eP*^gt{eKj9>X8tgo>!Af)Wnf}_^_s@qM zoLP+}Z|zw;_5tYgMF)7!6r?+t02(5&V5GnTe5CQks7v)Hf+W*OC06>CrCuf=+Sl>7 zAQ^2sn;a!dC3b>q)(8nPW_>Q9Ci(f5HT@_mZ@~a{11*!E8N4{xW+ENS^QxSgm(rSp zc|Wm1Q2$blGjhyvn3Y2my6#WN#9^jd{GwsCwQu6~lAmyWfznZul64EkK4mH>QMIMS zym-WD*vFup?){A3FXA{Xe70!pY@OoS9fse!1BXV>TG)VyXvo#7Ns)H1Ae}%9s$IE{TDO|#xf5{WF z{4mwSFBvbij)4K@aGUh8ltckAna_}-{j9Mchh8ztZbn6{soOFCDfA2U)m;ZxBPEm| z$@hc4JpP8jj&P~osH0>&=<^Grq-6se6-|xWM7&Wy+s$u!SQUwF?*ylR2Rl55+>;4o zKp{U#v?`zdUhlxW^a`zujLOK#kKRf3a}9W_QHkP-DHAdIla63x0=trt2_lVpc zmR?=A&`cZ5J2MY_M z1lfm&1%*K+@zCtb>Xp!48h)GAYRLQ0?yXM0c2m^Hlv$@6TvV`#l-EV}6_@teyG&6W zlX3rOdh=6IYoOPV#e|SK86+haV#u;9vi>W5TI}yrKEh{|QN3X}qieNLv2&U}Q)Ldb z9hePhtH>0G`dU=Lp}X*%!$pH_Rkf{oR*JIfU8XD(8y>^2KbtqcC7Q6U5uvAvH;@wm z(eN4?mdycGV4(RntlprPI*nV^%q9_QaqJaMl z+W{8{?l(d7qixCorLhlR^x9}Bi%NKM8$Z`x<0|x`?n}f5^-tY?C-6mKC!v% zQY)?&TgGy5C2SJSm4LiH6@Wfy2-W6KE#a)O ziS?Aq+f&68md^DPh2p8IIi=+rP;rK1TdvUWu3cZ+Jq=51|ErZ?y>A$xBg8|~|I#D5=GKFr^3sOD6RTf|1y&%ZO( z=yZaL=!K~HG!BOIotqqK$|UXodOsQGYWiLb$d>5iC7OxH3g>`V>(F!ka{lqcHEiQ_ zWh0dLx=E`0lC|caWaAV&<9DAbp&_*uZ|P+qAmh*q6L7~8j_!b)t>gz7LCdyAj?EBB zQsq$MVS-U;kt{%f9lbhJpYn|{6bEQu%OA4bdu0}TLD7G@9&Z2lWZ~$2z4VRkZO^su z$SJ96bYF8ukF`3s5ia~pRc99F}jt&s-;8;3e}W9>w^A4v91hX191!CU7NebdQ^043*&q zX9zMd=LkdIEZFJDwncEeE`+}6_LrKnP}WPpN45(Y(w{l9&kUD;xcEiLc&|qp`j(92 zCpCm6328kej`8lBjnKEwKKkwkY!*y7$bOY*yTr$e8uteP2(yj{I-nKOqCts(%PG82 z+;DFgZ3-ej)xdDW$};Qy#^2NGePohk8U{v1#*s7tZU}c?DdW?|t`0OrRcfqDSmIm+ zFKy!!A$R|&kL~F02k8DTU%AcyIU8P4+!OrJQoChr1E;43{03sB#M!UPDaqK35U`qb z0WN^KIK&#y^cj7BqrU5}JtyPjPl+QYg!45G6jW3gCs>-%84>g#!T0`ZDpQykivuK~(?*FM(i0KMPx@^%;U_UU$eR;$`_$`aA^A zW+Yx7v=yb&x*NZpadP@+lR52cEzI~wn^ki@!H~BML{j~~b-CjR^6?@jEh)S~ktZa{ z57t;}Ex*41jay2pof!60cAr(Et1VcI>*9so}|6*9Iz1a2IElwZZ*I#bhO`4 z!%U<5oY@gRK;gqJ(DYb`;EO{O6GSA!0|3Rsyn{R6yl!L4-2TZuywdP)i69k z2{{jk`Qyp!@#QQUPsOlTxy3wbkh!I5_U;{`ZnQMi7&}7XAtIn^tHqLI33j zto*5ITxt4s@w?c%NUzD=i?d{h|CU^V!*V13JCrR?%#DF?L#uJxY=c~RP2#zqat>t| zfA+4;6Q0a5#sY741Et~xW!b1#YUb_c6#}>aAmuF+?XR5GkUmPQVs=wxI_irGJl;|blO|R+ z;}A1^^S1F?V@dOsVKY;F^M~%CFVu9p7`|IOp8M4kFRuuD;bD(mZE3o(U(dnIKJBG{ z?w;18KI5eT$mW&f;vf-=$M>7PwV##I`UrNie}b)tLh1e(7$5G|+>5b)@UVW0mS?R4 zWKd9~d+7oitVgp~etK}L%kbHgAD!?eDtKeDeZ=;I`92Tu1A6JCD7avaO)d2gvKK47 zm^ejSyFN)hw&z-+fl(?DlE8!fG}P$HmrWG}79a7t@kTJbo};&%eTuCl$(*zpwO+8- zDmfUVGk#8qs2=Ik6_K`~{pO4FJRF0b%+&qcRsHJ+=S!LY6zT!wP~Y(J*q=^X@;mI+ zK;tCBTZpR>OVrwp1fNd@GkcM#ce=t$IS3H)Xw9Br9tud%aRh=gTaQz!{FE z@J%KoF+o$fOnqrpA~ORH{L2Lt1hMsTmn}ciWB>l)Q~4qwX)OGio*lPs_Y(w7N~`EO zYm`Ya_tB78cRMCatc>cQeQAKKa#1p?JbjYTA*VdNTgWDNND<(M z)(7IUqadjmK&e)E53+u+TMGO-ZaQ8ojZ5nluJ|jppn?y}|0&cCSkVZP=IUOZ$SkM~ zipkgcIHkIRU{w8z{6;s+ zQrjONbXv-^=GMPI3_={Wz?m2e5}V4>@Ltro4PV|Z3;b}7`4vF6`>TWEPW4WVBD92) zNF6`d%L-D?AfMx+WXnhZz+w49$W4qXbMTT~2pcoa_|oViZLz#5+MM-69nm`90ijU7 zgh$!bUS6qbFf+P<6f%o5ZC&5I$^J-{Y1Ro_rL64ORDR5-#P=kmra1@wG5M3nLXnCh z&G+2cpWGY+U7W5dA0b8wP*Ox| zQM<<>XcF8>NN{&AP69y!K?@XjC{Sn%g%)@B07ZhkQ=qiP9g2Hf+zKt)U8;w@zjO2b z1?T3hyY)QlX3gI-ri?kp`(lemR-bh9x;UMSF7$o;s)jCRZTiW{uo^A#;(~mwN4Eae zPmjdhK#jZM#1B|3Y_BBI5R;GqC+>t()>=t|Vr#754A&4Y=_?;FFuSsBToJrMx)XA( zhxbyWj9qM)6aOVN9F_&SuRkSbHPBJw{&Y&h9bQiv=Pmr~eI~nd(YWrKli^@M?3QQK zP?3*#UQVxC0cQuT#*-3MVX$3_ZNF57rudPAI*&ttzKex!&BN4bwx!1z9N5qa=Yw-6 zv^n=b&-_gCE@3F=1nu2_=Jq&+Jx!;GPJ5M9UjipobZ$ic|wCuaS{j1Z+$WkB^(0!Z{sKXX2aA@8dxJ6>Aw~KxzEP311sY6F9)CGrR35LjwQI6o6Y_i=H5Tp^?e)e z_Msp4*^M9&v}v1XU3lX2q)2F4?9URb-s`wf20T0uwA7@7{=&z9hHpw^^G|glhW>1^ zZZ@v#(a(*lkX%sR2v(7_k_4<7DO)m^lfftA91ZOgt4fxu-rB5b;2^5Q`7cl+um}o~ z#^(bNrQPXG{!3^tEK8o=aKt{7P)n>D9h-_JSQygg&d_<4k|CKC6}235{yGQdlSJ}I z7-&~3b-5gtsCoVBzXA6{Fx!%U^$y~+IU#phm5W`g~XFPquG-&r&<4)ob6}LWW*Z= zo<>wp&nS*ACpdGn1u>?_s%jaKsA?%=zH};mqEu|DkHq8x;+FUOxHQDhntt{QK+}U;5|a0BHz89Y4OI@n?H3e4A*^ zZfmolkMAbk^|D{ZCs>-=^Ek{$$Y(x+Q>0BYz(D!-q~cRgG!*q76%;Jb*(g}L*axxQ zDV`_0LpRqi7Q%NLGUl~IyF{OV+>rj`ioSEZ57W&3`VS(f)imq3!27?odOam(EzbbOp{C_K*}BA3g3*= zO=y}Jm;Xxl^T~`sNKI@-%-HNfSQXiu3Fvv19)}y*R0rGl&pGu;2jckBc|VVc`)Zy+ z6U;?1l%MKF@B zCMPq;wC`!k73Fer)Ef=A0i9&NqFoqjf{q+_Sms#^fqHFRp97?ipgx9iwa=Q3@Y!IP zezv5;SC~ldzcF+go=@*6Rp0wDVpp7pK8FTNznUr(jWPKxNJ&Kg;ONIoAb}N1G`IiG zvIqQC6B2)Ea{5=)ZO%RJpeF92_1a^>Tv?9X6p=~W*t$)NHtJ1hr%q$HN473m{zTL? z*Y8m)%9DQ{UK!@Hu8mn$$HhlLb^N}F*Tgr>DZaS*9u6~CWXJ5_kI)n3zP&NvFFjAi zqb&22b1WI4Bc$g=J>HNr=F5_B@J~&^mVf zXW3(_O{<(z&g#q-s1LZLNvn`VUFaRtkt6eRT-~GmP2LPOjbrv-nMkLd4hU#^2GLf| zC?Vh=BvbdfEB){HyVTa-Lypx{K*Wus8T!YuB_Y*9dsplJsYNuc)ZAu+`s3#wMBwtl zYvaWkgh|*;%)f-b;A7KYic^dA4w1H3+$oZDPu2<*@)5CF$MT?V#!HG+vd&HemqSZO zzO3i=|AJB)Hv37;$6%%v)5gVeiMr)?XJIZ5o8EtiyXEGeSb`y_kJ&<5K?C}-rL=k3 zQaE|y@60`4R;(Ca+z}?cG=qpz8JqTF@e36gjHUnQiOQz<}{! zA&{c$uCJlY*U}fgAoPYXG(YV}8QF7!=BjTXy() z=61*e^t|^6E}gbQ#Hf0wmTOcEsx>AYP5Qv34z^bvDk-0;hzQ?u#K-qFiN}E8l<)}< zQ8jJn?_RI{2Ci;rb}b(h>C05ZnmDd($W|@7YBi9YZoWTi4d|1Z?a}^Q3Fg<_{n9Du zo-9Qqejh*Hzl5d<=Fd1)g|jxnkNI4eUg2hw1&t!)aVk!w05st+Pz#Yj`n~>0inLzm zj{m#)AmW}&E-qaXGD?PkMOD;TW?EEC8rCc6vzabGP(@Uq?ckN^;bd0YmYLIbczCpl z=CH=ni@T)gRTfyJaGFOQF`y~E2(`WpBP_WqZ&pwRicQN6sd01CS=zO9gys1X*co-5 z3-#f{UU=xsGgw(y$Zaw}Ru{SkSk3nYQ6xCuLJ=Y=EAWK?uhy?kn+{tdopd&D{B&}7 zw520!jN{%lkF%uYH~$eekR!8hkc;e~SEGcO{9ABJe?!b7y+lBkId$&ckNlmXzzrfL z*7aEFl?>>3sa}D-E24Gtz5GCzDhcyTrP zZJSPe8E#rkzz~DpFtgOf1C~((6kA(+9B}RMw17_hzQ&gdh}+dZFSh2 zB`gB7?O)u-lU7Z($2f~O5V zC!%pIVGhx|+-tc@ebAAV1!J<4sj@Kax4i zZOMN8sZCN+QWkqss@#Z!kQ?H+5M{)pRV3Unk0WY<5(tifNww8EKm)fmzVl+a(W| zkoygPWB=pO^W;NzvQv3<;eGDycXuK3>z(eLhS5%aCOAIn8^DyF)j_iyo}9yOVLTww zXKIiQ8Fmdttg9~RhYL)(lvLcFy1iwPQL`+I_n|)8QQi4A3t*;<-JuMlPdeg4krJaE zXp^8UC=&9dI6{;^Q4$?F`T~lcduw<6t7=6du+W56IIow8Z7i)oz} zGV)d7#+46u-~FyVK46(}sBN&wR5u}3o9)UA(jfZIPe&ckt~DfD zrP)4RLqDng0TM0(%YBM?09=pyyptD?G09JN!Y$CH2vfv)PApePLVdHR_5@S}kr3;H*kn-9P;*GQ!>bX`DzsBK|$!SaPXU)4?ifQ}IPS z7V(`r(bgj{%oLGjhif!c+|IxS6k>2Q+L&iSAFn%J zS`O0}x;k#nKb9rLFU6PZdxDh;bW^m< zLp+wWb_*%W+@Fm8c|S!~U8fS+?N)4MSDbYd{V0Jd3_skgPy>*fl2;0xO9xVfj7g0X zBd4-8@>~&U9Rh;9V+Z8`LWk5n#s@sY(pF;}k;TPs!qloSMC~ zS?J?Wl~6EM;zp-izT#RzZEt3>q`uc)KnP4JEG#WOKAps!{By$7yQJ$@shB!nzn_>j ztF2lEAs;An4Ui#O4pLgKke1kpJVEY#$Q0L~*~bT{;_2@`ce^}hq0dphU>gSS5k?E@ zsN(~e`QjxiVrNd zgTwt+&7P|i%Y>jN4oMDl2+V+mR!+`H%KQ)(ORX=^IEgZs70kIcCMG@!*bk|&;1MoA zIo^R9WAw`#$VbQwq~NH5lYVe=7OwyzX%(uYUiJ`-1vSceA9Felr%<6ynZmiBs#_-5 zXds!+dW{VP9LYh(!To5nr!=f|OO1Tmu@Yb04Y@#T+!^TPNSYftd6-Hx&BM&By4qi zoSCwPTaQE*w-f_&Ph9Bje)8E*Q72{6tKQG^ms$|D^r5!rQl>|crC0sz=T85*8f8;f z6LOBioQ5YjI-SWBM{bx2X<PUD9v7qe%4`0Z(HYewXcqk%O6vSA>bw&3fM+d4FD5zW+Zti%tY^NxuzEH zg!ADUsO4&olU8+kqh4z4$YdMMpd^yz*QjVyEBIB zS#0L7h--6Kt*hTY1RY)5g$xNWhdkyhZTEj5B}SKctc9dR5-d8l5H%imXT~~f*s}-O zf90d4ZE#Nz5qM>_+{m%<;s?)mzVD}Ywxq2?Y6yZ?wL}OmE-IU!_DUN6g%;e9`03FD zS#a&JL&#ILi1SbP&D0Ep--aB3(kYWTxvafz)qgT(MQmSbBWG*XW#xIHri}l^P{b<# zFas$zkx-`FAv>xWf9O#{iY|nALGj&5z53YveM7TMEGgvvM+V#*h!) z9M6==sHxk9s8_@TX?Fr0ztTgF*k1p(II$}S72fT|8D!txOm zuloQ`>#vOu;gi5E*_);wswI}UwLlJdj zWH1^QQfOgTK3rX(C|XETYWIV5RkS&;R^GlZMpvLfKq%7A_f~akv9sOfrxV7IhQiD3 zb&;qr;u(IS=Ieasp6AbhWIh^rh1~8B#d{>pQw*GVp9vT#8|U(aF{iC?Pz*7UCFH@W zqM&`h%*1J9!CNs};9rZYwO&eCdL$CtVd%9#TVbhtrOR5L*SFPel!g`fm(b_1HRe|g zq!qa+8ChKRR7=oZA7)Z|(Yezozh7F0l+l1Vkb{`R8=)%DOyZ>lCEXU=A;OcB){ybG zkGDqZjQ1ToeLMzEZMgj)-6oc59(Tu4?9ueVseBH3)LvsH>E8n&z%0>R#W7Byjr{%a zTA#0gVui5|$^{W6nX?WJ!^pLS0*~O;~ z*_Z)!B~mgbTicDE;Xydeuv5v^K>U-#rP+I*oA!Fq1+y2a-p-=v&Vi)fdEf&#y*K3% znV!aV9D6@nn>TUS4bSAmH2ehOHt9cop*wm=CxtRnucY60iq@qd%2(xMKCCXtm9RC$ z#D;qVY3biW9&5w)?$HtLWeOzW2Al*`x5h|#xIners&e6dd=Z+3KS>C+|0OgMMo0gW zt3hlsA*~$-sbt}&6T#ZRxs{?_;s-{6FR>dDS65SFIN9<+Js}^L14Wshei46)?#c2Uh?Hz0u-m=mRb{`gby8)x{r@QX=Jvn-}+?q3z)wYde>!9paxl6*B zAWs=N`rj!ksrR$G2L>bY8bkc6vD5qeo`ilx!UONQs*sUYcFGhI8q4T* zjJmR%H4~6{SMrB_c(0V=$X{GNk4sG^UoJ#6s@js+-?8CRlP{4hC*6ap@~G81E8YAL z;J28~XgmTOp(7kM?d6D62tBLfl|IME!{5{ zqKA##c5j3wZBg1;y3DFCtxV>F<1%Gg6L;dIWksoxWsS8sI(Fu8N>JK=>rmwC_9zSG z8~#W-*c|HglS5=dZYp-ZJfI>Bdw*8Q4N^UTLPj^BB!$!Cj{^d+4t2B(|znsQCO z;x70yCIVO5h8hdpH^BB$!khnaTc#yfjf$@i;wLz!KWIp=e?75XD}DPcYlS81ImT_x zc@P>_#EJ>qaJJQ)1F_QHGvsxvKM;FThMk>Z^SSed%i4iKy?%O2Yy`IU2#ztJvwiP5 z8RS^SwLOy9@DewjZ0!9pw+06s`RKT1SE?8^d_!mxCVbm`%;+4-B5pA2d3h0z+SbK46tvgldD+{a63XoPNezbsuDE z5SN%_dWBnxYUwVod7#H32Y|aN5Nuc@z*6T9R<@-z5}7@Br(ZGaRKgjYg>~P%rXNx z>NS7?$80V0;>yC(7L3)`51yy(R?nAjK3h23o6^S3eDvT2jSS`W-!FUTU(@#RStX4r zZaDSQJP*rdRd+b9yA9X85&Mukw6pt)8*CyOYV$>!TQS&A-W_AygwCEcM+=|Cwu%`-{JcynQRb7WdethKr>=f2)`swPG z!zSsw9aOKIpHeId%SEY2L~;1*Boc9y#_@(}1yr=>{eYKSieQ!AO2ayw`uUlwa@>@r zT%oETo(WP;bYk4$1LNIBd!1Q50o8N%`tC*)>~GI6+q8kP+|60?88O6x>JUC%P6rO- z;!H4Oyhq%AcCJ!C`sQ0eH#oQ5X7 z*Vm_e-WvFlwfT9JL3nKkvrQ@7K3HaE0U80`{kU!-YXOnzRprTAu*ah=6ywZ^RbA~| z^YgR&1KrxuQ{WKjrB1(PfpD4Fz3H{ljX@y`>~hq^=+TGzAq18 zqhe9rIiWG%NHIOT9j2naJ91{cWxRTh!IGmvaq*jPp6Ty8FB~ zlE^{vuEgb7GdOwpWCInP(ml3^wU%j97hBj|>-1dRP3WzamtS+X7MjH(6jO_@foXhNpQ5@fxvf2 zAv}v5aps^mQr)?>2Pbq{+=BNl&%Wj8!YIfV!2|-dzlp}{fm!LVwWxoOyf2f7!$b#J zhD5!-QNRTxGCprVJ?1yP>ALmPOW1FVaF?2zIIO2dCNgR&RohmyWe%u?l1Mn7gC_=O zzZ-JQn2=RPAclsS*C?AGXjp55iPB-kV5Tin&2+Z_Q4u zy#IjTuHE*oA900EJO6z8OZUgI?XGZu+FY@UZ(qV zuDq3#r3i~EY`nWZb-OOuXI>U^8{k^dWkgX*N_ zS{XYL7GAsc$v4RHK=((*{B~*BXITnKMS0VYDn0c`oras^hb6WL{L?Co3~Y=q<^mR# z+#GzGbB+5|>PW?__Y_bbK44BfsKY`9+L(;i`d&6TGPqi}F0RsoKZimyS(s0~^w;dp z(YA+q`=HDvA$Q%94DbeD4&&xxDt%!f`$KuaDHt7G3EkrKgPYVeFUYhqW zXCEy`&h|m^$19_$B5UOJR@=0_XvEUT!;h9x>ClBJ=f_R{nf|!>3(pAT@;c%^x|Js)=v}>*4D0A5abRnYvsGndHE*(2VYMRu5$Fc4*17hQT7x6^waTg`^;0r z@<%_w%UKvZ?-{K1c8i(*hxcZohV=+1uF>V=h|E zDfeGgs1mykQuTLL+WY$Zb?+nuH$AFTe~SpcUi!#6PZWF~laacbDb_z$P=M8tyEpvi zNoH(k_B-m=Zba_rJ)xltvz;+|cP2~!@$#J!sz$j&D@qCrUNy0C=Ku8nA}o^?5Jb_V?&gd2LmGL_GK-ruzd)FeKzT{Q2hWcgWQ60h zm=vCG_@n+x<=hEkn3~pM4GC*b>O0y%;-+N<<~rMtf*bB-#^^5Ow~)s|dP~iO`5(y6 z3|t7cAHxyeV)qN07-_9UL@gHlUD6-h5rlEz58G9@KliYWk{2f1(V79)v23(dT?`7$ z<(R?v>ek!+(9+i#uhIH4%$x3VsLukQO!|{7&rQlyE!n@YtI>I`VyWTWCK zy`f^TUiZ~*iP`1ymm55MULYRv#ep-0MNvzi9&cqpZG5k?Tg5}r^xMb07p5`|^njt@ z0Uv{b&u<>P^bl=j;C`rT?!G&npI z-R@2HR#}*fVQDp=t6f!qwMSd%?j+@wLzUSYH+2-kGD0>wh={T$>oMh6Al@y>H_%+9 zm@T@;X`j4zqj`<-Sc#Q!m|E7W`W(w+L}){v{UVqusGIKEaZy6%6xEZD4t;mg} zkS`6pl8_6#(VOVFs&f8bX8z>9Xz|zlCKVf*+TmKZkyL>d<^{SRJ#{b_2zE;|KIv((KnK!lBTs#!@qRNkz_&Zytz=_vm0OZK6JY5Wwe zXB3AdvL4YEETVj}#`eq@&2nh;h3R9ehUt=Ja$W)L_AG-9$FXQy(ur$fNiX`NG9N{9 zp=f+Xh6SENM)jn<@jR87$?e=kPK#*VUgk5BAsVP9paYaOXBk;mP>Q*gpZ(eFpIU$j8y~rjR%hF48)O-x@sUmKGTB@E^XL2K;&?*cR-9R{HK%hj=Nw{=H0qBHMUHwaP$| zw4t%Vf4oe7kSAB=_4~wx_XAF_YAD7dou_+h7i+avynnM=!3UeF1xZ|_0`(_*R;-#C%rjmBDkYxEGF8^4HiysEju<9 zdTbLWv1Q}gek;hOl;VKwPa#~{Ajk_7@8j^^2&a|l7Z^0GGl4kWGAZxEn z@MMWfEU-2ECa!3L1rjl>7f74dMxU$$Oc(Q<5IUWm*ltJFfz=1M;Rc+RR=pYT3dK;- zcHhr*`e|JpCV1ACZ1{IJmE=%n{3r0- zM|6D%^B6a2@MoC2)M_pB)FR}<96$+SbyBP(4bkQnBociV8B3NEEh>w1;nkO~_{b?B zwTvLV2d(D})3n2QOq6fO$>WCYDHvcS*I_&Tk%W2_blfmI*uR8MA{c+q{6WDJ6At8x zEGuE+*}_J2B+#z8uo8cJK&0-#Rc>J~fV!1~5zt>5#zyjZhD|oSywQbaCH9hN5<+y^ z$$A{A8LSVhIdEc>glH#l4edqKEP{L?jqaIh+C&>A{N$v!P%hLh&{i!X( z8b((fNAlr`no7B=b6jniqoQBW6Mlno_Yo}`y6#nUO8-oNijvMnMv5=;Wn3tAIb6mY zK2I`!{s5^54ttXuDYS*grQ4W=>o@c0HJ_=tSnwxK$ZrQSYt3;k$`MdKx=a?A6B-d3 zx_V^AP-iwg$jHbrjAcMOb5ey7G7%G_%b^9$+A-^0b8NSI;M5V=1=iHF%>)iA@{otY zv#rce$>!h_n#fXj%?&Wuop ziKi9^=VwZDG&(`4>1#=fh>MBU{;%g>Le~VmE+k)0dq0t58aKwHQsn>F7ytj||Lq9; EA31)$SO5S3 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/mp3/turning-a4-size-magazine.mp3 b/services/api/tests/files/mp3/turning-a4-size-magazine.mp3 similarity index 100% rename from clients/python/tests/files/mp3/turning-a4-size-magazine.mp3 rename to services/api/tests/files/mp3/turning-a4-size-magazine.mp3 diff --git a/services/api/tests/files/mp3/turning-a4-size-magazine.mp3.thumb.mp3 b/services/api/tests/files/mp3/turning-a4-size-magazine.mp3.thumb.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b27e45663b5c2e7170b19589803ab88f7bdd5e53 GIT binary patch literal 20733 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf b/services/api/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf similarity index 100% rename from clients/python/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf rename to services/api/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf diff --git a/services/api/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf.thumb.webp b/services/api/tests/files/pdf/1970_PSS_ThAT_mechanism.pdf.thumb.webp new file mode 100644 index 0000000000000000000000000000000000000000..81c4ff5c8c7f58e8b62b46e05474a3745cff6758 GIT binary patch literal 82826 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

  • nV{b59H9=bI-#`u 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

    XI#nhAmabVZb1hULM;XT1TPIl1F#{~ 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/png/cifar10-deer.png b/services/api/tests/files/png/cifar10-deer.png similarity index 100% rename from clients/python/tests/files/png/cifar10-deer.png rename to services/api/tests/files/png/cifar10-deer.png diff --git a/clients/python/tests/files/png/github-mark-white.png b/services/api/tests/files/png/github-mark-white.png similarity index 100% rename from clients/python/tests/files/png/github-mark-white.png rename to services/api/tests/files/png/github-mark-white.png diff --git a/clients/python/tests/files/png/rabbit.png b/services/api/tests/files/png/rabbit.png similarity index 100% rename from clients/python/tests/files/png/rabbit.png rename to services/api/tests/files/png/rabbit.png diff --git a/services/api/tests/files/png/rabbit.png.thumb.webp b/services/api/tests/files/png/rabbit.png.thumb.webp new file mode 100644 index 0000000000000000000000000000000000000000..53e61a816afd60460e075747f79e6ce1bf1df1c2 GIT binary patch literal 7792 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 CdIHY? literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt b/services/api/tests/files/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt similarity index 100% rename from clients/python/tests/files/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt rename to services/api/tests/files/ppt/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).ppt diff --git a/clients/python/tests/files/pptx/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).pptx b/services/api/tests/files/pptx/(2017.06.30) NMT in Linear Time (ByteNet).pptx similarity index 100% rename from clients/python/tests/files/pptx/(2017.06.30) Neural Machine Translation in Linear Time (ByteNet).pptx rename to services/api/tests/files/pptx/(2017.06.30) NMT in Linear Time (ByteNet).pptx diff --git a/clients/python/tests/files/tiff/cifar10-deer.tiff b/services/api/tests/files/tiff/cifar10-deer.tiff similarity index 100% rename from clients/python/tests/files/tiff/cifar10-deer.tiff rename to services/api/tests/files/tiff/cifar10-deer.tiff diff --git a/clients/python/tests/files/tiff/rabbit.tiff b/services/api/tests/files/tiff/rabbit.tiff similarity index 100% rename from clients/python/tests/files/tiff/rabbit.tiff rename to services/api/tests/files/tiff/rabbit.tiff diff --git a/clients/python/tests/files/tsv/weather_observations.tsv b/services/api/tests/files/tsv/weather_observations.tsv similarity index 100% rename from clients/python/tests/files/tsv/weather_observations.tsv rename to services/api/tests/files/tsv/weather_observations.tsv diff --git a/clients/python/tests/files/txt/creative-story.txt b/services/api/tests/files/txt/creative-story.txt similarity index 100% rename from clients/python/tests/files/txt/creative-story.txt rename to services/api/tests/files/txt/creative-story.txt diff --git a/clients/python/tests/files/txt/empty.txt b/services/api/tests/files/txt/empty.txt similarity index 100% rename from clients/python/tests/files/txt/empty.txt rename to services/api/tests/files/txt/empty.txt diff --git a/clients/python/tests/files/txt/weather.txt b/services/api/tests/files/txt/weather.txt similarity index 100% rename from clients/python/tests/files/txt/weather.txt rename to services/api/tests/files/txt/weather.txt diff --git a/services/api/tests/files/wav/gutter.wav b/services/api/tests/files/wav/gutter.wav new file mode 100644 index 0000000000000000000000000000000000000000..3b79a4fdab5e85c1e72cbc3bb55257aff35a864c GIT binary patch literal 222414 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 O@+ey9PCEbU=-&Yxj9xMT literal 0 HcmV?d00001 diff --git a/services/api/tests/files/wav/gutter.wav.thumb.mp3 b/services/api/tests/files/wav/gutter.wav.thumb.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..57c95a17e92b6e9a71b195f4103fc957329bc21c GIT binary patch literal 20707 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 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/wav/turning-a4-size-magazine.wav b/services/api/tests/files/wav/turning-a4-size-magazine.wav similarity index 100% rename from clients/python/tests/files/wav/turning-a4-size-magazine.wav rename to services/api/tests/files/wav/turning-a4-size-magazine.wav diff --git a/services/api/tests/files/wav/turning-a4-size-magazine.wav.thumb.mp3 b/services/api/tests/files/wav/turning-a4-size-magazine.wav.thumb.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a9d5e8ac1f5a964350ab5036d66229e300c2188d GIT binary patch literal 19897 zcmZsiWmH>FwDto82p+URp}4!dySux)OQEF%cPMVf-D%MRrIh0CP^5)YTuO@;%emoy z*In!V@Xm+GNmkZOezW&H^UOXf@_b;>Q=`+@(^Eh^NbIn0>Yd^f}A|u-2Z+0 zzYoB@_W$$l|Nm0O)6om@3F2o!Tp*A}5(omp!66_ZC8eaKqhn^~;Naln6Bd?`kdsqZ z*3{I~Gd4E1w6wExaq;x@^9v3R4ULM5ijPl8$;`~lD=Mm}sI6^oZfgwwo92_5? znOR(1TU*=Q+}%AoIy<|%y1N5_r=B4CY=`JG58u<9|7-LpY5)6DBZ+yXeeeG+|KBrk zpK1>RA)gUWXpm8Z1T>6hOCTVkH_=kUH(`(temW%(H7E@6Wu#yCt-l|?uR|f)!iay% z8cJ`w-v>PGB_o58qGPwhs39WO|0IQ}Ld~hrX9l<6dT~g9La5ODzew+BLXgp^b0zTx zLH}UjegKM$^fm$%8{dC(>m5QR#rYCIv`5X5eHgGWRXZ&Ujbeh!QtXS&OvCm?rthHp zaK)!@ATGgD3pHU!AyFWxLh_$5P#7c(L`ZY`>!+@$HtNBgQp%tξZKuI76dC7odE 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 z

    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 CH(|j5 literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/xls/Claims Form.xls b/services/api/tests/files/xls/Claims Form.xls similarity index 100% rename from clients/python/tests/files/xls/Claims Form.xls rename to services/api/tests/files/xls/Claims Form.xls diff --git a/clients/python/tests/files/xlsx/Claims Form.xlsx b/services/api/tests/files/xlsx/Claims Form.xlsx similarity index 100% rename from clients/python/tests/files/xlsx/Claims Form.xlsx rename to services/api/tests/files/xlsx/Claims Form.xlsx diff --git a/services/api/tests/files/xlsx/Claims Form.xlsx.thumb.gen.webp b/services/api/tests/files/xlsx/Claims Form.xlsx.thumb.gen.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ec017bf2a09c7e09fb35bff2407a58a764767d4 GIT binary patch literal 3354 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 BO9=n~ literal 0 HcmV?d00001 diff --git a/clients/python/tests/files/xml/weather-forecast-service.xml b/services/api/tests/files/xml/weather-forecast-service.xml similarity index 100% rename from clients/python/tests/files/xml/weather-forecast-service.xml rename to services/api/tests/files/xml/weather-forecast-service.xml diff --git a/services/api/tests/gen_table/test_empty_db.py b/services/api/tests/gen_table/test_empty_db.py new file mode 100644 index 0000000..0dd88cd --- /dev/null +++ b/services/api/tests/gen_table/test_empty_db.py @@ -0,0 +1,32 @@ +import pytest + +from jamaibase import JamAI +from jamaibase.types import OrganizationCreate, TableType +from owl.utils.exceptions import ResourceNotFoundError +from owl.utils.test import ( + create_organization, + create_project, + create_user, + list_tables, +) + + +def test_get_list_tables_no_schema(): + with ( + create_user() as superuser, + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as superorg, + # Create project + create_project( + dict(name="Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + super_client = JamAI(user_id=superuser.id, project_id=p0.id) + # No gen table schema + for table_type in TableType: + tables = list_tables(super_client, table_type) + assert len(tables.items) == 0 + assert tables.total == 0 + with pytest.raises(ResourceNotFoundError, match="Table .+ is not found."): + super_client.table.get_table(table_type, "123") diff --git a/services/api/tests/gen_table/test_import_export.py b/services/api/tests/gen_table/test_import_export.py new file mode 100644 index 0000000..12fd1b9 --- /dev/null +++ b/services/api/tests/gen_table/test_import_export.py @@ -0,0 +1,1025 @@ +import builtins +from dataclasses import dataclass +from os.path import dirname, join, realpath +from tempfile import TemporaryDirectory +from types import NoneType +from typing import Any + +import httpx +import pandas as pd +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + ColumnReorderRequest, + ColumnSchemaCreate, + EmbedGenConfig, + GetURLResponse, + LLMGenConfig, + OkResponse, + OrganizationCreate, + TableImportRequest, + TableMetaResponse, + TableType, +) +from owl.utils.exceptions import ( + BadInputError, +) +from owl.utils.io import csv_to_df, df_to_csv +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + STREAM_PARAMS, + TABLE_TYPES, + TEXTS, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + assert_is_vector_or_none, + check_rows, + create_deployment, + create_model_config, + create_organization, + create_project, + create_table, + create_user, + get_file_map, + import_table_data, + list_table_rows, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + +FILE_COLUMNS = ["image", "audio", "document", "File ID"] + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + superorg_id: str + project_id: str + embedding_size: int + image_uri: str + audio_uri: str + document_uri: str + chat_model_id: str + embed_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + create_user() as superuser, + create_organization( + body=OrganizationCreate(name="Superorg"), user_id=superuser.id + ) as superorg, + create_project( + dict(name="Superorg Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superorg.id == "0" + # Create models + with ( + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config(ELLM_EMBEDDING_CONFIG) as embed_config, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + ): + # Create deployments + with ( + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + client = JamAI(user_id=superuser.id, project_id=p0.id) + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + audio_uri = upload_file(client, FILES["gutter.mp3"]).uri + document_uri = upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri + yield ServingContext( + superuser_id=superuser.id, + superorg_id=superorg.id, + project_id=p0.id, + embedding_size=embed_config.final_embedding_size, + image_uri=image_uri, + audio_uri=audio_uri, + document_uri=document_uri, + chat_model_id=desc_llm_config.id, + embed_model_id=embed_config.id, + rerank_model_id=rerank_config.id, + ) + + +@dataclass(slots=True) +class Data: + data_list: list[dict[str, Any]] + action_data_list: list[dict[str, Any]] + knowledge_data: dict[str, Any] + chat_data: dict[str, Any] + extra_data: dict[str, Any] + + +def _default_data(setup: ServingContext): + action_data_list = [ + { + "ID": str(i), + "Updated at": "1990-05-13T09:01:50.010756+00:00", + "int": 1 if i % 2 == 0 else (1.0 if i % 4 == 1 else None), + "float": -1.25 if i % 2 == 0 else (5 if i % 4 == 1 else None), + "bool": True if i % 2 == 0 else (False if i % 4 == 1 else None), + "str": t, + "image": setup.image_uri if i % 2 == 0 else None, + "audio": setup.audio_uri if i % 2 == 0 else None, + "document": setup.document_uri if i % 2 == 0 else None, + "summary": t if i % 2 == 0 else ("" if i % 4 == 1 else None), + } + for i, t in enumerate(TEXTS.values()) + ] + # Assert integers and floats contain a mix of int, float, None + _ints = [type(d["int"]) for d in action_data_list] + assert int in _ints + assert float in _ints + assert NoneType in _ints + _floats = [type(d["float"]) for d in action_data_list] + assert int in _floats + assert float in _floats + assert NoneType in _floats + # Assert booleans contain a mix of True, False, None + _bools = [d["bool"] for d in action_data_list] + assert True in _bools + assert False in _bools + assert None in _bools + # Assert strings contain a mix of empty string and None + _summaries = [d["summary"] for d in action_data_list] + assert None in _summaries + assert "" in _summaries + knowledge_data = { + "Title": "Dune: Part Two.", + "Text": '"Dune: Part Two" is a film.', + # We use values that can be represented exactly as IEEE floats to ease comparison + "Title Embed": [-1.25] * setup.embedding_size, + "Text Embed": [0.25] * setup.embedding_size, + "File ID": setup.document_uri, + } + chat_data = dict(User=".", AI=".") + extra_data = dict(good=True, words=5) + return Data( + data_list=[ + dict(**d, **knowledge_data, **chat_data, **extra_data) for d in action_data_list + ], + action_data_list=action_data_list, + knowledge_data=knowledge_data, + chat_data=chat_data, + extra_data=extra_data, + ) + + +def _default_dtype( + data: list[dict[str, Any]], + *, + cast_to_string: bool = False, +) -> dict[str, pd.Int64Dtype | pd.Float32Dtype | pd.BooleanDtype | pd.StringDtype]: + cols = set() + for row in data: + cols |= set(row.keys()) + dtype = { + "ID": pd.StringDtype(), + "Updated at": pd.StringDtype(), + "int": pd.Int64Dtype() if not cast_to_string else pd.StringDtype(), + "float": pd.Float32Dtype() if not cast_to_string else pd.StringDtype(), + "bool": pd.BooleanDtype() if not cast_to_string else pd.StringDtype(), + "str": pd.StringDtype(), + "image": pd.StringDtype(), + "audio": pd.StringDtype(), + "document": pd.StringDtype(), + "summary": pd.StringDtype(), + "Title": pd.StringDtype(), + "Text": pd.StringDtype(), + "Title Embed": object, + "Text Embed": object, + "File ID": pd.StringDtype(), + "User": pd.StringDtype(), + "AI": pd.StringDtype(), + "good": pd.BooleanDtype() if not cast_to_string else pd.StringDtype(), + "words": pd.Int64Dtype() if not cast_to_string else pd.StringDtype(), + } + return {k: v for k, v in dtype.items() if k in cols} + + +def _as_df( + data: list[dict[str, Any]], + *, + cast_to_string: bool = False, +) -> pd.DataFrame: + dtype = _default_dtype(data, cast_to_string=cast_to_string) + if cast_to_string: + data = [{k: None if v is None else str(v) for k, v in d.items()} for d in data] + df = pd.DataFrame.from_dict(data).astype(dtype) + return df + + +def _check_rows( + rows: list[dict[str, Any]], + data: list[dict[str, Any]], +): + return check_rows(rows, data, info_cols_equal=False) + + +def _check_knowledge_chat_data( + table_type: TableType, + rows: list[dict[str, Any]], + data: Data, +): + if table_type == TableType.KNOWLEDGE: + _check_rows(rows, [data.knowledge_data] * len(data.data_list)) + elif table_type == TableType.CHAT: + _check_rows(rows, [data.chat_data] * len(data.data_list)) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_complete( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + """ + Test table data import. + - All column types including vector + - Ensure "ID" and "Updated at" columns are regenerated + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream or not. + delimiter (str): Delimiter. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_complete.csv") + data = _default_data(setup) + df = _as_df(data.data_list) + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + _check_rows(rows.values, data.action_data_list) + _check_knowledge_chat_data(table_type, rows.values, data) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_dtype_coercion( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + """ + Test table data import. + - Column dtype coercion (nulls, int <=> float, bool <=> int) + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream or not. + delimiter (str): Delimiter. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_dtype_coercion.csv") + header = ["int", "float", "bool", "str", "image", "audio", "document", "summary", "AI"] + data = [ + # Base case + [1, 2.0, True, '""', '""', '""', '""', '""', '""'], + # Coercion + [1.0, 2, 1, '""', "", "", "", "", ""], + [-1.0, -2, 0, "", "", "", "", "", ""], + ["", "", "", "", "", "", "", "", ""], + ] + with open(file_path, "w", encoding="utf-8") as f: + f.write(f"{delimiter.join(header)}\n") + f.write("\n".join(delimiter.join(map(str, d)) for d in data)) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data) + assert rows.total == len(data) + # All strings are null + for col in ["str", "image", "audio", "document", "summary", "AI"]: + assert all(v.get(col, None) is None for v in rows.values) + # Check values + for col in ["int", "float", "bool"]: + for v, d in zip(rows.values, data, strict=True): + if d[header.index(col)] in ["", '""']: + assert v[col] is None + else: + assert v[col] == d[header.index(col)] + assert isinstance(v[col], getattr(builtins, col)) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_cast_to_string( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + dtypes = ["int", "float", "bool", "str", "image", "audio", "document"] + cols = [ColumnSchemaCreate(id=dtype, dtype="str") for dtype in dtypes] + cols += [ + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig(model="", system_prompt="", prompt=""), + ), + ] + with create_table(client, table_type, cols=cols) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_cast_to_string.csv") + data = _default_data(setup) + df = _as_df(data.data_list) + # Assert some columns are not string type + assert not all(d == pd.StringDtype() for d in df.dtypes.tolist()) + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + action_data_list = [ + { + k: None + if v is None + else str(int(v) if k == "int" else (float(v) if k == "float" else v)) + for k, v in d.items() + } + for d in data.action_data_list + ] + _check_rows(rows.values, action_data_list) + _check_knowledge_chat_data(table_type, rows.values, data) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_cast_from_string( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_cast_from_string.csv") + data = _default_data(setup) + df = _as_df(data.data_list, cast_to_string=True) + # Assert all columns (except embedding) are string type + assert all( + v == pd.StringDtype() for k, v in df.dtypes.to_dict().items() if "Embed" not in k + ), df.dtypes.to_dict() + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + _check_rows(rows.values, data.action_data_list) + _check_knowledge_chat_data(table_type, rows.values, data) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_missing_input_column( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_missing_input_column.csv") + data = _default_data(setup) + df = _as_df(data.data_list) + df = df.drop(columns=["int"]) + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + _check_rows( + rows.values, + [{k: v for k, v in d.items() if k != "int"} for d in data.action_data_list], + ) + _check_knowledge_chat_data(table_type, rows.values, data) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_with_generation( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_import_with_generation.csv") + data = _default_data(setup) + df = _as_df(data.data_list) + df = df.drop(columns=["summary"]) + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # LLM is called + assert len(response.rows) == len(data.data_list) + assert all(len(r.columns) == 1 for r in response.rows) + assert all("summary" in r.columns for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + _check_rows( + rows.values, + [{k: v for k, v in d.items() if k != "summary"} for d in data.action_data_list], + ) + _check_knowledge_chat_data(table_type, rows.values, data) + # Check LLM generation + summaries = [row["summary"] for row in rows.values] + assert all("There is a text" in s for s in summaries) + assert sum("There is an image with MIME type [image/jpeg]" in s for s in summaries) > 0 + assert sum("There is an audio with MIME type [audio/mpeg]" in s for s in summaries) > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_import_empty( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + # Empty file + file_path = join(tmp_dir, "empty.csv") + with open(file_path, "w", encoding="utf-8") as f: + f.write("") + with pytest.raises(BadInputError, match="is empty"): + import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # No rows + file_path = join(tmp_dir, "no_rows.csv") + with open(file_path, "w", encoding="utf-8") as f: + f.write(delimiter.join(c.id for c in table.cols) + "\n") + with pytest.raises(BadInputError, match="no rows"): + import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == 0 + + +def _export_table_rows( + client: JamAI, + table_type: TableType, + table: TableMetaResponse, + *, + data: Data, + delimiter: str, + columns: list[str] | None = None, +) -> tuple[list[dict[str, Any]], pd.DataFrame]: + csv_bytes = client.table.export_table_data( + table_type, + table.id, + delimiter=delimiter, + ) + dtype = _default_dtype(data.data_list, cast_to_string=False) + if columns is None: + columns = [c.id for c in table.cols] + csv_df = csv_to_df( + csv_bytes.decode("utf-8"), + sep=delimiter, + keep_default_na=True, + ).astype({k: v for k, v in dtype.items() if k in columns}) + exported_rows = csv_df.to_dict(orient="records") + assert len(exported_rows) == len(data.data_list) + assert all(isinstance(row, dict) for row in exported_rows) + assert all("ID" in row for row in exported_rows) + assert all("Updated at" in row for row in exported_rows) + return exported_rows, csv_df + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_data_export( + setup: ServingContext, + table_type: TableType, + stream: bool, + delimiter: str, +): + """ + Test table data export. + - Export all columns (round trip) + - Export subset of columns (round trip) + - Export after column reorder (check column order, round trip) + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream or not. + delimiter (str): Delimiter. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_data_export.csv") + data = _default_data(setup) + df_original = _as_df(data.data_list) + df_to_csv(df_original, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + # Check imported data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + row_ids = [r["ID"] for r in rows.items] + + ### --- Export all columns, round trip --- ### + exported_rows, _ = _export_table_rows( + client, + table_type, + table, + data=data, + delimiter=delimiter, + ) + # Check row order + exported_row_ids = [r["ID"] for r in exported_rows] + assert row_ids == exported_row_ids + # Check row content + _check_rows(exported_rows, data.action_data_list) + + ### --- Export subset of columns --- ### + columns = [c.id for c in table.cols][:2] + assert len(columns) < len(table.cols) + exported_rows, _ = _export_table_rows( + client, + table_type, + table, + data=data, + delimiter=delimiter, + columns=columns, + ) + assert len(exported_rows) == len(data.data_list) + _check_rows( + exported_rows, + [{k: v for k, v in d.items() if k in columns} for d in data.action_data_list], + ) + + ### --- Export after column reorder --- ### + new_order = ["int", "float", "bool", "str", "image", "audio", "document"][::-1] + new_order += ["summary"] + if table_type == TableType.KNOWLEDGE: + new_order = [ + "Title", + "Title Embed", + "Text", + "Text Embed", + "File ID", + "Page", + ] + new_order + elif table_type == TableType.CHAT: + new_order = ["User", "AI"] + new_order + table = client.table.reorder_columns( + table_type=table_type, + request=ColumnReorderRequest(table_id=table.id, column_names=new_order), + ) + assert isinstance(table, TableMetaResponse) + exported_rows, exported_df = _export_table_rows( + client, + table_type, + table, + data=data, + delimiter=delimiter, + ) + _check_rows(exported_rows, data.action_data_list) + # Check column order + expected_columns = ["ID", "Updated at"] + new_order + assert expected_columns == list(exported_df.columns) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("blocking", [True, False], ids=["blocking", "non-blocking"]) +def test_table_import_export( + setup: ServingContext, + table_type: TableType, + blocking: bool, +): + """ + Test table import and export. + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + stream = False + delimiter = "," + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + # Export empty table + pq_data = client.table.export_table(table_type, table.id) + assert len(pq_data) > 0 + # Add data + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_table_import_export.csv") + data = _default_data(setup) + df_original = _as_df(data.data_list) + df_to_csv(df_original, file_path, delimiter) + response = import_table_data( + client, + table_type, + table.id, + file_path, + stream=stream, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + + ### --- Export table --- ### + table_id_dst = f"{table.id}_import" + try: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, f"{table.id}.parquet") + with open(file_path, "wb") as f: + f.write(client.table.export_table(table_type, table.id)) + + ### --- Import table --- ### + # Bad name + with pytest.raises(BadInputError): + client.table.import_table( + table_type, + TableImportRequest( + file_path=file_path, + table_id_dst=f"_{table_id_dst}", + blocking=blocking, + ), + ) + # OK + response = client.table.import_table( + table_type, + TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + blocking=blocking, + ), + ) + if blocking: + table_dst = response + else: + # Poll progress + assert isinstance(response, OkResponse) + assert isinstance(response.progress_key, str) + assert len(response.progress_key) > 0 + prog = client.tasks.poll_progress(response.progress_key, max_wait=30) + assert isinstance(prog, dict) + table_dst = TableMetaResponse.model_validate(prog["data"]["table_meta"]) + assert isinstance(table_dst, TableMetaResponse) + assert table_dst.id == table_id_dst + # Source data + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == len(data.data_list) + assert rows.total == len(data.data_list) + # Destination data + rows_dst = list_table_rows(client, table_type, table_dst.id, vec_decimals=2) + # Compare + for row, row_dst in zip(rows.items, rows_dst.items, strict=True): + assert len(row) == len(row_dst) + for col in row: + if col in FILE_COLUMNS: + # File columns should not match due to different S3 URI, unless it is None + value_ori = row[col]["value"] + value_dst = row_dst[col]["value"] + if value_ori is None: + assert value_dst is None + else: + assert value_ori != value_dst + # But content should match + urls = client.file.get_raw_urls([value_ori, value_dst]) + assert isinstance(urls, GetURLResponse) + file_ori = httpx.get(urls.urls[0]).content + file_dst = httpx.get(urls.urls[1]).content + assert file_ori == file_dst + else: + # Regular columns should match exactly (including info columns) + assert row[col] == row_dst[col] + # All "File ID" values should be populated + if table_type == TableType.KNOWLEDGE: + for row_dst in rows_dst.values: + assert isinstance(row_dst["File ID"], str) + assert len(row_dst["File ID"]) > 0 + assert len(set(r["File ID"] for r in rows_dst.values)) == 1 + finally: + client.table.delete_table(table_type, table_id_dst) + + +@pytest.mark.parametrize("delimiter", [","], ids=["comma"]) +def test_table_import_wrong_type( + setup: ServingContext, + delimiter: str, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, TableType.ACTION) as table: + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_table_import_wrong_type.csv") + data = _default_data(setup) + df = _as_df(data.data_list) + df_to_csv(df, file_path, delimiter) + response = import_table_data( + client, + TableType.ACTION, + table.id, + file_path, + stream=False, + delimiter=delimiter, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == len(data.data_list) + assert all(len(r.columns) == 0 for r in response.rows) + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, "test_table_import_wrong_type.parquet") + # Export + with open(file_path, "wb") as f: + f.write(client.table.export_table(TableType.ACTION, table.id)) + table_id_dst = f"{table.id}_import" + # Import as knowledge + with pytest.raises(BadInputError): + client.table.import_table( + TableType.KNOWLEDGE, + TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + ), + ) + # Import as chat + with pytest.raises(BadInputError): + client.table.import_table( + TableType.CHAT, + TableImportRequest( + file_path=file_path, + table_id_dst=table_id_dst, + ), + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("version", ["v0.4"]) +def test_table_import_parquet( + setup: ServingContext, + table_type: TableType, + version: str, +): + """ + Test table import from an existing Parquet file. + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + ### --- Basic tables --- ### + if table_type == TableType.CHAT: + parquet_filepath = FILES[f"export-{version}-chat-agent.parquet"] + else: + parquet_filepath = FILES[f"export-{version}-{table_type}.parquet"] + # Embedding model cannot be swapped for another model + if table_type == TableType.KNOWLEDGE: + with pytest.raises(BadInputError, match="Embedding model .+ is not found"): + client.table.import_table( + table_type, + TableImportRequest(file_path=parquet_filepath, table_id_dst=None), + ) + # Add the required embedding model + embed_model = "ellm/BAAI/bge-m3" + model = ELLM_EMBEDDING_CONFIG.model_copy(update=dict(id=embed_model, owned_by="ellm")) + deployment = ELLM_EMBEDDING_DEPLOYMENT.model_copy(update=dict(model_id=embed_model)) + with create_model_config(model), create_deployment(deployment): + table = client.table.import_table( + table_type, + TableImportRequest(file_path=parquet_filepath, table_id_dst=None), + ) + try: + assert isinstance(table, TableMetaResponse) + ### Table ID should be derived from the Parquet data + if table_type == TableType.CHAT: + assert table.id == "test-agent" + else: + assert table.id == f"test-{table_type}" + assert table.parent_id is None + col_map = {c.id: c for c in table.cols} + embed_cols = ["Title Embed", "Text Embed"] + + ### Check gen config + if table_type == TableType.ACTION: + gen_config = col_map["answer"].gen_config + assert isinstance(gen_config, LLMGenConfig) + assert gen_config.model == setup.chat_model_id + elif table_type == TableType.KNOWLEDGE: + for c in embed_cols: + gen_config = col_map[c].gen_config + assert isinstance(gen_config, EmbedGenConfig) + assert gen_config.embedding_model == embed_model + else: + gen_config = col_map["AI"].gen_config + assert isinstance(gen_config, LLMGenConfig) + assert gen_config.model == setup.chat_model_id + assert gen_config.multi_turn is True + + ### List rows + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == 1 + assert rows.total == 1 + row = rows.values[0] + if table_type == TableType.ACTION: + # Check text content + assert row["question"] == "What is this" + assert row["answer"] == "This is a deer." + assert row["null"] == "" + # Check image content + urls = client.file.get_raw_urls([row["image"]]) + assert isinstance(urls, GetURLResponse) + image = httpx.get(urls.urls[0]).content + with open(FILES["cifar10-deer.jpg"], "rb") as f: + assert image == f.read() + elif table_type == TableType.KNOWLEDGE: + # Check text content + assert row["Title"] == "Gunicorn: A Python WSGI HTTP Server" + assert row["Text"] == "Gunicorn is a Python WSGI HTTP Server." + # Check vector content + for c in embed_cols: + assert_is_vector_or_none(row[c], allow_none=False) + else: + # Check text content + assert row["User"] == "Hi" + assert row["AI"] == ( + "Hello! How can I assist you today? " + "Let me know what you're looking for, and I'll do my best to help. 😊" + ) + + ### Try generation + if table_type == TableType.ACTION: + response = add_table_rows( + client, table_type, table.id, [{"question": "Why"}], stream=False + ) + assert len(response.rows) == 1 + assert "There is a text" in response.rows[0].columns["answer"].content + elif table_type == TableType.KNOWLEDGE: + response = add_table_rows(client, table_type, table.id, [{}], stream=False) + assert len(response.rows) == 1 + else: + response = add_table_rows( + client, table_type, table.id, [{"User": "Hi"}], stream=False + ) + assert len(response.rows) == 1 + assert "There is a text" in response.rows[0].columns["AI"].content + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == 2 + assert rows.total == 2 + finally: + client.table.delete_table(table_type, table.id) + + ### --- Chat table (child table) --- ### + if table_type == TableType.CHAT: + table = client.table.import_table( + table_type, + TableImportRequest( + file_path=FILES[f"export-{version}-chat-agent-1.parquet"], table_id_dst=None + ), + ) + try: + assert isinstance(table, TableMetaResponse) + # Table ID should be derived from the Parquet data + assert table.id == "test-agent-1" + # TODO: Perhaps need to handle missing parent and RAG table + assert table.parent_id == "test-agent" + # List rows + rows = list_table_rows(client, table_type, table.id, vec_decimals=2) + assert len(rows.items) == 2 + assert rows.total == 2 + # Check text content + assert rows.values[0]["User"] == "Hi" + assert rows.values[0]["AI"].startswith("Hello! How can I assist you today?") + assert rows.values[1]["User"] == "What is 美洲驼?" + assert rows.values[1]["AI"].startswith( + "**美洲驼** (MÄ›izhÅu tuó) 是以下两ç§å—美洲骆驼科动物的中文统称: \n\n1. **羊驼**" + ) + rows_r = list_table_rows( + client, table_type, table.id, order_ascending=False, vec_decimals=2 + ) + assert all(rr == r for rr, r in zip(rows_r.values[::-1], rows.values, strict=True)) + finally: + client.table.delete_table(table_type, table.id) diff --git a/services/api/tests/gen_table/test_row_ops.py b/services/api/tests/gen_table/test_row_ops.py new file mode 100644 index 0000000..8f13efb --- /dev/null +++ b/services/api/tests/gen_table/test_row_ops.py @@ -0,0 +1,2206 @@ +import re +from contextlib import contextmanager +from dataclasses import dataclass +from decimal import Decimal +from os.path import basename, dirname, join, realpath +from tempfile import TemporaryDirectory +from time import sleep +from typing import Generator + +import httpx +import pandas as pd +import pytest +from flaky import flaky + +from jamaibase import JamAI +from jamaibase.types import ( + ActionTableSchemaCreate, + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + CellCompletionResponse, + ChatTableSchemaCreate, + ChatThreadResponse, + CodeInterpreterTool, + ColumnReorderRequest, + ColumnSchema, + ColumnSchemaCreate, + DeploymentCreate, + GenConfigUpdateRequest, + KnowledgeTableSchemaCreate, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + MultiRowRegenRequest, + MultiRowUpdateRequest, + OkResponse, + RowCompletionResponse, + SearchRequest, + TableMetaResponse, + WebSearchTool, +) +from jamaibase.utils.io import df_to_csv +from owl.types import ( + ChatRole, + CloudProvider, + LLMGenConfig, + ModelCapability, + RegenStrategy, + Role, + TableType, +) +from owl.utils.exceptions import ( + BadInputError, + JamaiException, + ResourceNotFoundError, +) +from owl.utils.test import ( + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_4O_MINI_CONFIG, + GPT_4O_MINI_DEPLOYMENT, + GPT_5_MINI_CONFIG, + GPT_5_MINI_DEPLOYMENT, + OPENAI_O4_MINI_CONFIG, + OPENAI_O4_MINI_DEPLOYMENT, + STREAM_PARAMS, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, + get_file_map, + list_table_rows, + regen_table_rows, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + +TABLE_TYPES = [TableType.ACTION, TableType.KNOWLEDGE, 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/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx + "text/tab-separated-values", # tsv + "text/csv", # csv +] + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + user_id: str + org_id: str + project_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + # Create superuser + create_user() as superuser, + # Create user + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + # Create organization + create_organization(user_id=superuser.id) as org, + # Create project + create_project(dict(name="Bucket A"), user_id=superuser.id, organization_id=org.id) as p0, + ): + assert org.id == "0" + client = JamAI(user_id=superuser.id) + # Join organization and project + client.organizations.join_organization( + user_id=user.id, organization_id=org.id, role=Role.ADMIN + ) + client.projects.join_project(user_id=user.id, project_id=p0.id, role=Role.ADMIN) + + # Create models + with ( + create_model_config(GPT_4O_MINI_CONFIG), + create_model_config(GPT_5_MINI_CONFIG), + create_model_config(OPENAI_O4_MINI_CONFIG), + create_model_config( + { + # "id": "openai/Qwen/Qwen-2-Audio-7B", + "id": "openai/gpt-4o-mini-audio-preview", + "type": "llm", + # "name": "ELLM Qwen2 Audio (7B)", + "name": "OpenAI GPT-4o Mini Audio Preview", + "capabilities": ["chat", "audio"], + "context_length": 128000, + "languages": ["en"], + } + ) as llm_config_audio, + create_model_config(ELLM_EMBEDDING_CONFIG), + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG), + ): + # Create deployments + with ( + create_deployment(GPT_4O_MINI_DEPLOYMENT), + create_deployment(GPT_5_MINI_DEPLOYMENT), + create_deployment(OPENAI_O4_MINI_DEPLOYMENT), + create_deployment( + DeploymentCreate( + model_id=llm_config_audio.id, + # name="ELLM Qwen2 Audio (7B) Deployment", + name="OpenAI GPT-4o Mini Audio Preview Deployment", + # provider=CloudProvider.ELLM, + provider=CloudProvider.OPENAI, + routing_id=llm_config_audio.id, + # api_base="https://llmci.embeddedllm.com/audio/v1", + api_base="", + ) + ), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + yield ServingContext( + superuser_id=superuser.id, + user_id=user.id, + org_id=org.id, + project_id=p0.id, + ) + + +def _get_chat_model(client: JamAI) -> str: + models = client.model_ids(prefer="openai/gpt-4o-mini", capabilities=["chat"]) + return models[0] + + +def _get_reasoning_model(client: JamAI) -> str: + models = client.model_ids(prefer="openai/gpt-5-mini", capabilities=["reasoning"]) + return models[0] + + +def _get_reranking_model(client: JamAI) -> str: + models = client.model_ids(capabilities=["rerank"]) + return models[0] + + +@contextmanager +def _create_table( + client: JamAI, + table_type: TableType, + table_id: str = TABLE_ID_A, + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + embedding_model: str | None = None, +): + try: + if cols is None: + cols = [ + ColumnSchemaCreate(id="good", dtype="bool"), + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate(id="stars", dtype="float"), + ColumnSchemaCreate(id="inputs", dtype="str"), + ColumnSchemaCreate(id="photo", dtype="image"), + ColumnSchemaCreate(id="audio", dtype="audio"), + ColumnSchemaCreate(id="paper", dtype="document"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + 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, + ), + ), + ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=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=20, + ), + ), + ColumnSchemaCreate( + id="narration", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt="${audio} \n\nWhat happened?", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ColumnSchemaCreate( + id="concept", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt="${paper} \n\nTell the main concept of the paper in 5 words.", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + if chat_cols is None: + chat_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + + if table_type == TableType.ACTION: + table = client.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == TableType.KNOWLEDGE: + if embedding_model is None: + embedding_model = "" + table = client.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + elif table_type == TableType.CHAT: + table = client.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: + client.table.delete_table(table_type, table_id) + + +def _add_row( + client: JamAI, + table_type: 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=upload_file(client, FILES["rabbit.jpeg"]).uri, + audio=upload_file(client, FILES["turning-a4-size-magazine.mp3"]).uri, + paper=upload_file(client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"]).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 == 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + return response + assert isinstance(response, MultiRowCompletionResponse) + assert len(response.rows) == 1 + return response.rows[0] + + +def _collect_reasoning( + responses: MultiRowCompletionResponse | Generator[CellCompletionResponse, None, None], + col: str, +): + if isinstance(responses, MultiRowCompletionResponse): + return "".join(r.columns[col].reasoning_content for r in responses.rows) + return "".join(r.reasoning_content for r in responses if r.output_column_name == col) + + +def _collect_text( + responses: MultiRowCompletionResponse | Generator[CellCompletionResponse, None, None], + col: str, +): + if isinstance(responses, MultiRowCompletionResponse): + return "".join(r.columns[col].content for r in responses.rows) + return "".join(r.content for r in responses if r.output_column_name == col) + + +def _get_exponent(x: float) -> int: + return Decimal(str(x)).as_tuple().exponent + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_full_text_search( + setup: ServingContext, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ColumnSchemaCreate(id="text", dtype="str")] + with _create_table(client, "action", cols=cols) as table: + assert isinstance(table, 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 = client.table.add_table_rows( + "action", + MultiRowAddRequest( + 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, CellCompletionResponse) for r in responses) + else: + assert isinstance(response, MultiRowCompletionResponse) + + # Search + def _search(query: str): + return client.table.hybrid_search( + "action", SearchRequest(table_id=table.id, query=query) + ) + + assert len(_search("AND")) == 0 # SQL-like statements should still work + assert len(_search("《")) == 1 + assert len(_search("scien*")) == 1 + assert len(_search("film")) == 2 + assert len(_search("science -fiction")) == 0 # Not supported + assert len(_search("science-fiction")) == 1 + assert len(_search("science -fiction\n2016")) == 1 + assert len(_search("美国")) == 1 + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_conversation_starter( + setup: ServingContext, + stream: bool, +): + table_type = TableType.CHAT + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You help remember facts.", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are an assistant", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + with _create_table(client, table_type, cols=[], chat_cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Add the starter + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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, CellCompletionResponse) for r in responses) + else: + assert isinstance(response.rows[0], RowCompletionResponse) + # Chat with it + response = add_table_rows( + client, + table_type, + table.id, + [dict(User="How many apples does Jim have?")], + stream=stream, + ) + assert len(response.rows) == 1 + row = response.rows[0] + assert "summary" in row.columns + answer = row.columns["AI"].content + assert "5" in answer or "five" in answer.lower() + + +@pytest.mark.timeout(180) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False]) +@pytest.mark.parametrize( + "doc", + [ + FILES["salary 总结.pdf"], + # FILES["1978_APL_FP_detrapping.PDF"], + # FILES["digital_scan_combined.pdf"], + FILES["creative-story.md"], + FILES["creative-story.txt"], + FILES["multilingual-code-examples.html"], + FILES["weather-forecast-service.xml"], + FILES["ChatMed_TCM-v0.2-5records.jsonl"], + FILES["Recommendation Letter.docx"], + FILES["(2017.06.30) NMT in Linear Time (ByteNet).pptx"], + FILES["Claims Form.xlsx"], + FILES["weather_observations.tsv"], + FILES["weather_observations_long.csv"], + ], + ids=lambda x: basename(x), +) +def test_add_row_document_dtype( + setup: ServingContext, + table_type: TableType, + stream: bool, + doc: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="doc", dtype="document"), + ColumnSchemaCreate( + id="content", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt="Document: \n${doc} \n\nReply 0 if document received, else -1. Omit any explanation, only answer 0 or -1.", + ), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + upload_response = upload_file(client, doc) + response = add_table_rows( + client, + table_type, + table.id, + [dict(doc=upload_response.uri)], + stream=stream, + ) + assert len(response.rows) == 1 + row = response.rows[0] + assert "content" in row.columns + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["doc"] == upload_response.uri, row["doc"] + assert "0" in row["content"] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_regen_with_reordered_columns( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="number", dtype="int"), + ColumnSchemaCreate( + id="col1-english", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in English, " + "only output the answer in uppercase without explanation." + ), + ), + ), + ColumnSchemaCreate( + id="col2-malay", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Malay, " + "only output the answer in uppercase without explanation." + ), + ), + ), + ColumnSchemaCreate( + id="col3-mandarin", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Mandarin (Chinese Character), " + "only output the answer in uppercase without explanation." + ), + ), + ), + ColumnSchemaCreate( + id="col4-roman", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt=( + "Number: ${number} \n\nTell the 'Number' in Roman Numerals, " + "only output the answer in uppercase without explanation." + ), + ), + ), + ] + + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + row = _add_row( + client, + table_type, + False, + data=dict(number=1), + ) + assert isinstance(row, RowCompletionResponse) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + row = rows.values[0] + _id = row["ID"] + assert row["number"] == 1, row["number"] + assert row["col1-english"] == "ONE", row["col1-english"] + assert row["col2-malay"] == "SATU", row["col2-malay"] + assert row["col3-mandarin"] in ("一", "壹"), row["col3-mandarin"] + assert row["col4-roman"] == "I", row["col4-roman"] + + # Update Input + Regen + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={_id: dict(number=2)}, + ), + ) + + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + table_id=table.id, + row_ids=[_id], + regen_strategy=RegenStrategy.RUN_ALL, + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["number"] == 2, row["number"] + assert row["col1-english"] == "TWO", row["col1-english"] + assert row["col2-malay"] == "DUA", row["col2-malay"] + assert row["col3-mandarin"] == "二", row["col3-mandarin"] + assert row["col4-roman"] == "II", row["col4-roman"] + + # Reorder + Update Input + Regen + # [1, 2, 3, 4] -> [3, 1, 4, 2] + new_cols = [ + "ID", + "Updated at", + "number", + "col3-mandarin", + "col1-english", + "col4-roman", + "col2-malay", + ] + if table_type == TableType.KNOWLEDGE: + new_cols += ["Title", "Text", "Title Embed", "Text Embed", "File ID", "Page"] + elif table_type == TableType.CHAT: + new_cols += ["User", "AI"] + client.table.reorder_columns( + table_type=table_type, + request=ColumnReorderRequest( + table_id=TABLE_ID_A, + column_names=new_cols, + ), + ) + # RUN_SELECTED + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={_id: dict(number=5)}, + ), + ) + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=RegenStrategy.RUN_SELECTED, + output_column_id="col1-english", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["number"] == 5, row["number"] + assert row["col3-mandarin"] == "二", row["col3-mandarin"] + assert row["col1-english"] == "FIVE", row["col1-english"] + assert row["col4-roman"] == "II", row["col4-roman"] + assert row["col2-malay"] == "DUA", row["col2-malay"] + + # RUN_BEFORE + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={_id: dict(number=6)}, + ), + ) + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=RegenStrategy.RUN_BEFORE, + output_column_id="col4-roman", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["number"] == 6, row["number"] + assert row["col3-mandarin"] == "å…­", row["col3-mandarin"] + assert row["col1-english"] == "SIX", row["col1-english"] + assert row["col4-roman"] == "VI", row["col4-roman"] + assert row["col2-malay"] == "DUA", row["col2-malay"] + + # RUN_AFTER + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={_id: dict(number=7)}, + ), + ) + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + table_id=TABLE_ID_A, + row_ids=[_id], + regen_strategy=RegenStrategy.RUN_AFTER, + output_column_id="col4-roman", + stream=stream, + ), + ) + if stream: + _ = [r for r in response] + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["number"] == 7, row["number"] + assert row["col3-mandarin"] == "å…­", row["col3-mandarin"] + assert row["col1-english"] == "SIX", row["col1-english"] + assert row["col4-roman"] == "VII", row["col4-roman"] + assert row["col2-malay"] == "TUJUH", row["col2-malay"] + + +# @pytest.mark.parametrize("table_type", TABLE_TYPES) +# @pytest.mark.parametrize("stream", [True, False]) +# def test_add_row_file_type_output_column( +# setup: ServingContext, +# table_type: TableType, +# stream: bool, +# ): +# client = JamAI(user_id=setup.user_id, project_id=setup.project_id) +# cols = [ +# ColumnSchemaCreate(id="photo", dtype="image"), +# ColumnSchemaCreate(id="question", dtype="str"), +# ColumnSchemaCreate( +# id="captioning", +# dtype="file", +# gen_config=LLMGenConfig(model="", prompt="${photo} What's in the image?"), +# ), +# ColumnSchemaCreate( +# id="answer", +# dtype="file", +# gen_config=LLMGenConfig( +# model="", +# prompt="${photo} ${question}?", +# ), +# ), +# ColumnSchemaCreate( +# id="compare", +# dtype="image", +# gen_config=LLMGenConfig( +# model="", +# prompt="Compare ${captioning} and ${answer}.", +# ), +# ), +# ] +# with _create_table(client, table_type, cols=cols) as table: +# assert isinstance(table, TableMetaResponse) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_add_row_output_column_referred_image_input_with_chat_model( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="photo", dtype="image"), + ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=LLMGenConfig(model="", prompt="${photo} What's in the image?"), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + with create_model_config( + { + "id": "openai/Qwen/Qwen2.5-7B-Instruct", + "type": "llm", + "name": "OpenAI GPT-4o Mini", + "capabilities": ["chat"], + "context_length": 32000, + "languages": ["en"], + } + ) as llm_config_chat_only_model: + with create_deployment( + DeploymentCreate( + model_id=llm_config_chat_only_model.id, + name="ELLM Qwen2.5 (7B) Deployment", + provider=CloudProvider.OPENAI, + routing_id=llm_config_chat_only_model.id, + api_base="http://192.168.80.2:9192/v1", + ) + ): + # 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 = llm_config_chat_only_model.id + cols = [ + ColumnSchemaCreate( + id="captioning2", + dtype="str", + gen_config=LLMGenConfig(model=chat_only_model), + ), + ] + with pytest.raises(BadInputError): + if table_type == TableType.ACTION: + client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=cols) + ) + elif table_type == TableType.KNOWLEDGE: + client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == TableType.CHAT: + client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False]) +def test_add_row_sequential_completion_with_error( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt="Summarise ${input}.", + ), + ), + ColumnSchemaCreate( + id="rephrase", + dtype="str", + gen_config=LLMGenConfig( + model="", + prompt="Rephrase ${summary}", + ), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + response = add_table_rows( + client, + table_type, + table.id, + [dict(input="a" * 10000000)], + stream=stream, + ) + assert len(response.rows) == 1 + row = response.rows[0] + assert "summary" in row.columns + assert "rephrase" in row.columns + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["summary"].startswith("[ERROR] ") + second_output = (row["rephrase"]).upper() + if stream: + assert second_output.startswith("[ERROR] ") + else: + assert "WARNING" in second_output or "ERROR" in second_output + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@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( + setup: ServingContext, + table_type: TableType, + stream: bool, + img_filename: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + with pytest.raises( + BadInputError, + match=re.compile( + f"^.*{re.escape('Unsupported file type. Make sure the file belongs to one of the following formats:')}.*" + f"{re.escape('[Image File Types]:')}.*" + f"{re.escape('[Audio File Types]:')}.*" + f"{re.escape('[Document File Types]:')}.*$" + ), + ): + response = _add_row( + client, + table_type, + stream, + data=dict(photo=img_filename), + ) + if stream: + _ = [r for r in response] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_add_row_wrong_dtype( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + response = add_table_rows( + client, + table_type, + table.id, + [ + dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo=upload_file(client, FILES["rabbit.jpeg"]).uri, + audio=upload_file(client, FILES["turning-a4-size-magazine.mp3"]).uri, + paper=upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri, + ) + ], + stream=stream, + ) + assert len(response.rows) == 1 + row = response.rows[0] + assert "summary" in row.columns + assert "captioning" in row.columns + assert "narration" in row.columns + assert "concept" in row.columns + + # Test adding data with wrong dtype + response = add_table_rows( + client, + table_type, + table.id, + [dict(good="dummy1", words="dummy2", stars="dummy3", inputs=TEXT)], + stream=stream, + ) + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 2 + row = rows.items[-1] + 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"] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_add_row_missing_columns( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + response = add_table_rows( + client, + table_type, + table.id, + [ + dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo=upload_file(client, FILES["rabbit.jpeg"]).uri, + audio=upload_file(client, FILES["turning-a4-size-magazine.mp3"]).uri, + paper=upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri, + ) + ], + stream=stream, + ) + assert len(response.rows) == 1 + row = response.rows[0] + assert "summary" in row.columns + assert "captioning" in row.columns + assert "narration" in row.columns + assert "concept" in row.columns + + # Test adding data with missing column + response = _add_row( + client, + table_type, + stream, + TABLE_ID_A, + data=dict(good="dummy1", inputs=TEXT), + ) + if stream: + responses = [r for r in response] + assert all(isinstance(r, CellCompletionResponse) for r in responses) + else: + assert isinstance(response, RowCompletionResponse) + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 2 + row = rows.items[-1] + 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"] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_add_rows_all_input( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="0", dtype="int"), + ColumnSchemaCreate(id="1", dtype="float"), + ColumnSchemaCreate(id="2", dtype="bool"), + ColumnSchemaCreate(id="3", dtype="str"), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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, MultiRowCompletionResponse) + assert len(response.rows) == 2 + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 2 + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("reasoning_model", ["openai/gpt-5-mini", "openai/o4-mini"][:1]) +def test_reasoning_model_with_reasoning_effort( + setup: ServingContext, + table_type: TableType, + stream: bool, + reasoning_model: str, +): + """ + Tests that different `reasoning.effort` levels produce different outputs + when using a reasoning model with the Responses API. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + system_prompt = "You are a brilliant logician. Always think twice and give your reasoning before answering!" + prompt = ( + "Solve this riddle: " + "If a plane crashes on the border between the USA and Canada, " + "where do you bury the survivors? " + ) + + cols = [ + ColumnSchemaCreate(id="Riddle", dtype="str"), + ColumnSchemaCreate( + id="LowEffortAnswer", + dtype="str", + gen_config=LLMGenConfig( + model=reasoning_model, + system_prompt=system_prompt, + prompt=prompt, + reasoning_effort="low", + reasoning_summary="auto", + ), + ), + ColumnSchemaCreate( + id="MediumEffortAnswer", + dtype="str", + gen_config=LLMGenConfig( + model=reasoning_model, + system_prompt=system_prompt, + prompt=prompt, + reasoning_effort="medium", + reasoning_summary="auto", + ), + ), + ColumnSchemaCreate( + id="MinimalEffortAnswer", + dtype="str", + gen_config=LLMGenConfig( + model=reasoning_model, + system_prompt=system_prompt, + prompt=prompt, + reasoning_effort="minimal", + reasoning_summary="auto", + ), + ), + ] + + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + response = add_table_rows( + client, + table_type, + table.id, + [dict(Riddle="Trigger")], + stream=stream, + ) + + low_effort_reasoning = _collect_reasoning(response, "LowEffortAnswer").lower() + medium_effort_reasoning = _collect_reasoning(response, "MediumEffortAnswer").lower() + minimal_effort_reasoning = _collect_reasoning(response, "MinimalEffortAnswer").lower() + assert "survivors" in low_effort_reasoning + assert "survivors" in medium_effort_reasoning + + assert (len(medium_effort_reasoning) > len(low_effort_reasoning)) or ( + len(low_effort_reasoning) > len(minimal_effort_reasoning) + ) + + low_effort_result = _collect_text(response, "LowEffortAnswer").lower() + medium_effort_result = _collect_text(response, "MediumEffortAnswer").lower() + minimal_effort_result = _collect_text(response, "MinimalEffortAnswer").lower() + + assert "bury" in low_effort_result and "survivors" in low_effort_result + assert "bury" in medium_effort_result and "survivors" in medium_effort_result + if reasoning_model == "openai/gpt-5-mini": + assert "bury" in minimal_effort_result and "survivors" in minimal_effort_result + else: + assert "'minimal' is not supported" in minimal_effort_result + + assert response.rows[0].columns["LowEffortAnswer"].usage is not None + assert response.rows[0].columns["MediumEffortAnswer"].usage is not None + assert response.rows[0].columns["MinimalEffortAnswer"].usage is not None + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("table_type", TABLE_TYPES[:1]) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("capability", [ModelCapability.CHAT, ModelCapability.REASONING]) +def test_agentic_column_with_web_search( + setup: ServingContext, + table_type: TableType, + stream: bool, + capability: ModelCapability, +): + """ + Tests an agentic column that uses web_search to perform a fact-checking task. + Also validates usage metrics. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="Claim", dtype="str"), + ColumnSchemaCreate( + id="FactCheck", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client) + if capability == ModelCapability.CHAT + else _get_reasoning_model(client), + prompt="You are a meticulous fact-checker. Your goal is to verify the following claim: `${Claim}`. " + "Use web search to determine if the claim is true or false and provide a brief explanation.", + tools=[WebSearchTool()], + reasoning_effort="low" if capability == ModelCapability.REASONING else None, + ), + ), + ] + + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + response = add_table_rows( + client, + table_type, + table.id, + [dict(Claim="The sun revolves around the Earth.")], + stream=stream, + ) + + reasoning = _collect_reasoning(response, "FactCheck") + assert "Searched the web for " in reasoning and "Ran Python code:" not in reasoning + reasoning = reasoning.lower() + assert "earth" in reasoning + assert "sun" in reasoning + assert "revolve" in reasoning or "orbit" in reasoning + + result = _collect_text(response, "FactCheck").lower() + assert result is not None + assert "false" in result + assert "earth" in result + assert "sun" in result + assert "revolve" in result or "orbit" in result + + usage = response.rows[0].columns["FactCheck"].usage + assert usage is not None + assert usage.prompt_tokens > 0 + assert usage.completion_tokens > 0 + assert usage.tool_usage_details is not None + assert usage.tool_usage_details.web_search_calls > 0 + assert usage.tool_usage_details.code_interpreter_calls == 0 + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.timeout(120) +@pytest.mark.parametrize("table_type", TABLE_TYPES[:1]) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("capability", [ModelCapability.CHAT, ModelCapability.REASONING]) +def test_agentic_column_with_code_interpreter( + setup: ServingContext, + table_type: TableType, + stream: bool, + capability: ModelCapability, +): + """ + Tests an agentic column that reads numerical data from other columns + and uses the code_interpreter to perform a calculation. Also validates usage metrics. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="Revenue", dtype="int"), + ColumnSchemaCreate(id="Expenses", dtype="int"), + ColumnSchemaCreate( + id="ProfitMargin", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client) + if capability == ModelCapability.CHAT + else _get_reasoning_model(client), + prompt="You are a financial analyst. Check the Revenue: `${Revenue}` and Expenses: `${Expenses}`." + "Then, use the code interpreter to calculate the profit margin percentage. " + "The formula is `(Revenue - Expenses) / Revenue * 100`. " + "Return only the final numerical answer, formatted as a percentage string like '25.0%'.", + tools=[CodeInterpreterTool()], + reasoning_effort="low" if capability == ModelCapability.REASONING else None, + ), + ), + ] + + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + response = add_table_rows( + client, + table_type, + table.id, + [dict(Revenue=200000, Expenses=50000)], + stream=stream, + ) + + reasoning = _collect_reasoning(response, "ProfitMargin") + assert "Ran Python code:" in reasoning and "Searched the web for " not in reasoning + assert "200000" in reasoning + assert "50000" in reasoning + + result = _collect_text(response, "ProfitMargin") + assert result is not None + assert "75" in result # 150000 / 200000 = 0.75 + assert "%" in result + + usage = response.rows[0].columns["ProfitMargin"].usage + assert usage is not None + assert usage.prompt_tokens > 0 + assert usage.completion_tokens > 0 + assert usage.tool_usage_details is not None + assert usage.tool_usage_details.web_search_calls == 0 + assert usage.tool_usage_details.code_interpreter_calls > 0 + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.timeout(180) +@pytest.mark.parametrize("table_type", TABLE_TYPES[:1]) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("capability", [ModelCapability.CHAT, ModelCapability.REASONING]) +def test_agentic_column_with_multiple_tools( + setup: ServingContext, + table_type: TableType, + stream: bool, + capability: ModelCapability, +): + """ + Tests an agentic column that requires chaining multiple tools (web search and code interpreter) + to complete its goal, and validates the usage metrics for both. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="Country", dtype="str"), + ColumnSchemaCreate( + id="PopulationDensityReport", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client) + if capability == ModelCapability.CHAT + else _get_reasoning_model(client), + system_prompt="You are a geography research assistant. Always give short and concise answers.", + prompt="Your task is for the country '${Country}'. " + "1. First, use web search to find its current estimated population. " + "2. Second, use web search to find its total land area in square kilometers. " + "3. Third, use the code interpreter to calculate the population density (population / area). " + "4. Finally, report the result in a single sentence, including the calculated density.", + tools=[WebSearchTool(), CodeInterpreterTool()], + reasoning_effort="low" if capability == ModelCapability.REASONING else None, + ), + ), + ] + + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + response = add_table_rows( + client, + table_type, + table.id, + [dict(Country="Japan")], + stream=stream, + ) + + reasoning = _collect_reasoning(response, "PopulationDensityReport") + assert "Searched the web for " in reasoning and "Ran Python code:" in reasoning + reasoning = reasoning.lower() + assert "japan" in reasoning + assert "population" in reasoning + assert "density" in reasoning + + result = _collect_text(response, "PopulationDensityReport").lower() + assert result is not None + assert "japan" in result + assert "population" in result + assert "density" in result + # Check for a number, which would be the calculated density + assert any(char.isdigit() for char in result) + + usage = response.rows[0].columns["PopulationDensityReport"].usage + assert usage is not None + assert usage.prompt_tokens > 0 + assert usage.completion_tokens > 0 + assert usage.tool_usage_details is not None + assert usage.tool_usage_details.web_search_calls > 0 + assert usage.tool_usage_details.code_interpreter_calls > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_regen_rows( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + + image_upload_response = upload_file(client, FILES["rabbit.jpeg"]) + audio_upload_response = upload_file(client, FILES["turning-a4-size-magazine.mp3"]) + response = _add_row( + client, + table_type, + False, + data=dict( + good=True, + words=10, + stars=9.9, + inputs=TEXT, + photo=image_upload_response.uri, + audio=audio_upload_response.uri, + ), + ) + assert isinstance(response, RowCompletionResponse) + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + _id = row["ID"] + original_ts = row["Updated at"] + assert "arrival" in row["summary"].lower() + # Regen + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={ + _id: dict( + inputs="Dune: Part Two is a 2024 American epic science fiction film directed and produced by Denis Villeneuve" + ) + }, + ), + ) + response = regen_table_rows(client, table_type, table.id, [_id], stream=stream) + row = response.rows[0] + assert "summary" in row.columns + assert "captioning" in row.columns + assert "narration" in row.columns + assert "concept" in row.columns + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["good"] is True + assert row["words"] == 10 + assert row["stars"] == 9.9 + assert row["photo"] == image_upload_response.uri + assert row["audio"] == audio_upload_response.uri + assert row["Updated at"] > original_ts + assert "dune" in row["summary"].lower() + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_regen_rows_all_input( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="0", dtype="int"), + ColumnSchemaCreate(id="1", dtype="float"), + ColumnSchemaCreate(id="2", dtype="bool"), + ColumnSchemaCreate(id="3", dtype="str"), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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, MultiRowCompletionResponse) + assert len(response.rows) == 2 + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 2 + # Regen + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + 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, MultiRowCompletionResponse) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_delete_rows( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") + _add_row(client, table_type, False, data=data) + _add_row(client, table_type, False, data=data) + _add_row(client, table_type, False, data=data) + _add_row(client, table_type, False, data=data) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=7.9, inputs=TEXT_CN), + ) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=7.9, inputs=TEXT_JP), + ) + ori_rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(ori_rows.items) == 6 + delete_id = ori_rows.values[0]["ID"] + + # Delete one row + response = client.table.delete_table_row(table_type, TABLE_ID_A, delete_id) + assert isinstance(response, OkResponse) + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 5 + row_ids = set(r["ID"] for r in rows.values) + assert delete_id not in row_ids + # Delete multiple rows + delete_ids = [r["ID"] for r in ori_rows.values[1:4]] + response = client.table.delete_table_rows( + table_type, + MultiRowDeleteRequest( + table_id=TABLE_ID_A, + row_ids=delete_ids, + ), + ) + assert isinstance(response, OkResponse) + rows = list_table_rows(client, table_type, TABLE_ID_A) + assert len(rows.items) == 2 + row_ids = set(r["ID"] for r in rows.values) + assert len(set(row_ids) & set(delete_ids)) == 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_column_interpolate( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + cols = [ + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are a concise assistant.", + prompt='Say "Jan has 5 apples.".', + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ColumnSchemaCreate(id="input0", dtype="int"), + ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + 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(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + def _add_row_wrapped(stream, data): + return _add_row( + client, + 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"].content + assert "yes" in answer.lower(), f'columns={response.columns} answer="{answer}"' + response = _add_row_wrapped(False, dict(input0=6)) + answer = response.columns["output1"].content + assert "no" in answer.lower(), f'columns={response.columns} answer="{answer}"' + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_history_and_sequential_add( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=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(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Initialise chat thread and set output format + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_history_and_sequential_regen( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=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(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Initialise chat thread and set output format + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + 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 = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + 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 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_convert_into_multi_turn( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=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(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Initialise chat thread and set output format + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + 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 = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + output=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, TableMetaResponse) + # Regen + rows = list_table_rows(client, table_type, table.id) + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest( + table_id=table.id, + row_ids=[rows.values[-1]["ID"]], + stream=stream, + ), + ) + output = _collect_text(response, "output") + assert "4" in output, output + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_get_conversation_thread( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="output", + dtype="str", + gen_config=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(client, table_type, cols=cols) as table: + assert isinstance(table, 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 = client.table.add_table_rows( + table_type, MultiRowAddRequest(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, ChatThreadResponse) + for i, message in enumerate(_chat.thread): + assert isinstance(message.content, str) + assert len(message.content) > 0 + if i == 0: + assert message.role == ChatRole.SYSTEM + elif i % 2 == 1: + assert message.role == ChatRole.USER + assert message.content == data[(i - 1) // 2]["input"] + else: + assert message.role == ChatRole.ASSISTANT + assert message.content == data[(i // 2) - 1]["output"] + + # --- Fetch complete thread --- # + chat = client.table.get_conversation_threads( + table_type, + table.id, + ["output"], + ).threads["output"] + _check_thread(chat) + assert len(chat.thread) == 9 + assert chat.thread[-1].content == "6" + # --- Row ID filtering --- # + # Filter (include = True) + chat = client.table.get_conversation_threads( + table_type, + table.id, + ["output"], + row_id=row_ids[2], + ).threads["output"] + _check_thread(chat) + assert len(chat.thread) == 7 + assert chat.thread[-1].content == "3" + # Filter (include = False) + chat = client.table.get_conversation_threads( + table_type, + table.id, + ["output"], + row_id=row_ids[2], + include_row=False, + ).threads["output"] + _check_thread(chat) + assert len(chat.thread) == 5 + assert chat.thread[-1].content == "1" + # --- Non-existent column --- # + with pytest.raises( + ResourceNotFoundError, + match="Column .*x.* is not found. Available multi-turn columns:.*output.*", + ): + client.table.get_conversation_threads(table_type, table.id, ["x"]) + # --- Invalid column --- # + with pytest.raises( + ResourceNotFoundError, + match="Column .*input.* is not a multi-turn LLM column. Available multi-turn columns:.*output.*", + ): + client.table.get_conversation_threads(table_type, table.id, ["input"]) + + +def test_hybrid_search( + setup: ServingContext, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + table_type = TableType.KNOWLEDGE + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + data = dict(good=True, words=5, stars=9.9, inputs=TEXT, summary="dummy") + rows = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + table_id=TABLE_ID_A, + data=[dict(Title="Resume 2012", Text="Hi there, I am a farmer.", **data)], + stream=False, + ), + ) + assert isinstance(rows, MultiRowCompletionResponse) + rows = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + table_id=TABLE_ID_A, + data=[dict(Title="Resume 2013", Text="Hi there, I am a carpenter.", **data)], + stream=False, + ), + ) + assert isinstance(rows, MultiRowCompletionResponse) + rows = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + table_id=TABLE_ID_A, + data=[ + dict( + Title="Byte Pair Encoding", + Text="BPE is a subword tokenization method.", + **data, + ) + ], + stream=False, + ), + ) + assert isinstance(rows, MultiRowCompletionResponse) + sleep(1) # Optional, give it some time to index + # Rely on embedding + rows = client.table.hybrid_search( + table_type, + SearchRequest( + table_id=TABLE_ID_A, + query="language", + reranking_model=_get_reranking_model(client), + limit=2, + ), + ) + assert len(rows) == 2 + assert "BPE" in rows[0]["Text"]["value"], rows + # Rely on FTS + rows = client.table.hybrid_search( + table_type, + SearchRequest( + table_id=TABLE_ID_A, + query="candidate 2013", + reranking_model=_get_reranking_model(client), + limit=2, + ), + ) + assert len(rows) == 2 + assert "2013" in rows[0]["Title"]["value"], rows + # hybrid_search without reranker (RRF only) + rows = client.table.hybrid_search( + table_type, + 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 + + +FILE_PAGES = { + FILES["salary 总结.pdf"]: 1, + FILES["Swire_AR22_e_230406_sample.pdf"]: 5, + FILES["1978_APL_FP_detrapping.PDF"]: 4, + FILES["digital_scan_combined.pdf"]: 15, + FILES["(2017.06.30) NMT in Linear Time (ByteNet).pptx"]: 3, + FILES["Claims Form.xlsx"]: 2, +} + + +@pytest.mark.parametrize( + "file_path", + [ + FILES["salary 总结.pdf"], + FILES["Swire_AR22_e_230406_sample.pdf"], + # FILES["1978_APL_FP_detrapping.PDF"], + # FILES["digital_scan_combined.pdf"], + FILES["creative-story.md"], + FILES["creative-story.txt"], + FILES["RAG and LLM Integration Guide.html"], + FILES["multilingual-code-examples.html"], + FILES["table.html"], + FILES["weather-forecast-service.xml"], + FILES["company-profile.json"], + FILES["llm-models.jsonl"], + FILES["ChatMed_TCM-v0.2-5records.jsonl"], + FILES["Recommendation Letter.docx"], + FILES["(2017.06.30) NMT in Linear Time (ByteNet).pptx"], + FILES["Claims Form.xlsx"], + FILES["weather_observations.tsv"], + FILES["company-profile.csv"], + FILES["weather_observations_long.csv"], + ], + ids=lambda x: basename(x), +) +def test_embed_file( + setup: ServingContext, + file_path: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + table_type = TableType.KNOWLEDGE + with _create_table(client, table_type, cols=[]) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + response = client.table.embed_file(file_path, table.id) + assert isinstance(response, OkResponse) + rows = list_table_rows(client, table_type, table.id) + assert rows.total > 0 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) > 0 + for r in rows.values: + assert isinstance(r["Title"], str) + assert len(r["Title"]) > 0 + assert isinstance(r["Text"], str) + assert len(r["Text"]) > 0 + assert r["Page"] > 0 + assert isinstance(r["Title Embed"], list) + assert len(r["Title Embed"]) > 0 + assert all(isinstance(v, float) for v in r["Title Embed"]) + assert isinstance(r["Text Embed"], list) + assert len(r["Text Embed"]) > 0 + assert all(isinstance(v, float) for v in r["Text Embed"]) + if file_path in FILE_PAGES: + assert r["Page"] == FILE_PAGES[file_path] + else: + assert r["Page"] == 1 + + +@pytest.mark.parametrize( + "file_path", + [ + FILES["empty.pdf"], + FILES["empty_3pages.pdf"], + FILES["empty.txt"], + FILES["empty.csv"], + ], + ids=lambda x: basename(x), +) +def test_embed_empty_file( + setup: ServingContext, + file_path: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + table_type = TableType.KNOWLEDGE + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + with pytest.raises(BadInputError, match="is empty"): + response = client.table.embed_file(file_path, table.id) + assert isinstance(response, OkResponse) + + +@pytest.mark.parametrize( + "file_path", + [ + FILES["rabbit.jpeg"], + join(dirname(dirname(TEST_FILE_DIR)), "pyproject.toml"), + ], + ids=lambda x: basename(x), +) +def test_embed_file_invalid_file_type( + setup: ServingContext, + file_path: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + table_type = TableType.KNOWLEDGE + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + with pytest.raises(JamaiException, match=r"File type .+ is unsupported"): + client.table.embed_file(file_path, table.id) + + +def test_embed_file_options(setup: ServingContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + response = client.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"" + + +def test_embed_long_file( + setup: ServingContext, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, "knowledge", cols=[]) as table: + assert isinstance(table, 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, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + response = client.table.embed_file(file_path, table.id) + assert isinstance(response, OkResponse) + rows = list_table_rows(client, "knowledge", table.id) + assert rows.total == 300 + assert rows.offset == 0 + assert rows.limit == 100 + assert len(rows.items) == 100 + assert all(isinstance(r["Title"], str) for r in rows.values) + assert all(len(r["Title"]) > 0 for r in rows.values) + assert all(isinstance(r["Text"], str) for r in rows.values) + assert all(len(r["Text"]) > 0 for r in rows.values) + assert all(r["Page"] > 0 for r in rows.values) diff --git a/services/api/tests/gen_table/test_row_ops_v2.py b/services/api/tests/gen_table/test_row_ops_v2.py new file mode 100644 index 0000000..6133eb8 --- /dev/null +++ b/services/api/tests/gen_table/test_row_ops_v2.py @@ -0,0 +1,2097 @@ +import re +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from os.path import basename, dirname, join, realpath +from tempfile import TemporaryDirectory +from types import NoneType +from typing import Any + +import httpx +import pytest +from flaky import flaky + +from jamaibase import JamAI +from jamaibase.types import ( + CITATION_PATTERN, + CellCompletionResponse, + ChatCompletionChunkResponse, + ChatCompletionResponse, + ColumnSchemaCreate, + GenConfigUpdateRequest, + GetURLResponse, + LLMGenConfig, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowUpdateRequest, + OkResponse, + OrganizationCreate, + PythonGenConfig, + RAGParams, + References, + RowCompletionResponse, + S3Content, + TextContent, + WebSearchTool, +) +from owl.configs import ENV_CONFIG +from owl.types import ( + ModelCapability, + ModelType, + RegenStrategy, + TableType, +) +from owl.utils.exceptions import BadInputError, ResourceNotFoundError +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + GPT_5_MINI_CONFIG, + GPT_5_MINI_DEPLOYMENT, + GPT_41_MINI_CONFIG, + GPT_41_MINI_DEPLOYMENT, + STREAM_PARAMS, + TABLE_TYPES, + TEXT_EMBEDDING_3_SMALL_CONFIG, + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, + TEXTS, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + assert_is_vector_or_none, + create_deployment, + create_model_config, + create_organization, + create_project, + create_table, + create_user, + get_file_map, + get_table_row, + list_table_rows, + regen_table_rows, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + +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/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx + "text/tab-separated-values", # tsv + "text/csv", # csv +] + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + superorg_id: str + project_id: str + embedding_size: int + image_uri: str + audio_uri: str + document_uri: str + gpt_llm_model_id: str + gpt_llm_reasoning_config_id: str + desc_llm_model_id: str + lorem_llm_model_id: str + short_llm_model_id: str + echo_model_id: str + embed_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + create_user() as superuser, + create_organization( + body=OrganizationCreate(name="Superorg"), user_id=superuser.id + ) as superorg, + create_project( + dict(name="Superorg Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superorg.id == "0" + # Create models + with ( + create_model_config(GPT_41_MINI_CONFIG) as gpt_llm_config, + create_model_config(GPT_5_MINI_CONFIG) as gpt_llm_reasoning_config, + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config( + dict( + id="ellm/lorem-ttft-20-tpot-10", # TTFT 20 ms, TPOT 10 ms + type=ModelType.LLM, + name="ELLM Lorem Ipsum Generator", + capabilities=[ + ModelCapability.CHAT, + ModelCapability.IMAGE, + ModelCapability.AUDIO, + ], + context_length=128000, + languages=["en"], + owned_by="ellm", + ) + ) as lorem_llm_config, + create_model_config( + dict( + # Max context length = 10 + id="ellm/lorem-context-10", + type=ModelType.LLM, + name="Short-Context Chat Model", + capabilities=[ModelCapability.CHAT], + context_length=5, + languages=["en"], + owned_by="ellm", + ) + ) as short_llm_config, + create_model_config( + dict( + id="ellm/echo-prompt", + type=ModelType.LLM, + name="Echo Prompt Model", + capabilities=[ModelCapability.CHAT], + context_length=1000000, + languages=["en"], + owned_by="ellm", + ) + ) as echo_config, + create_model_config(TEXT_EMBEDDING_3_SMALL_CONFIG) as embed_config, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + ): + # Create deployments + with ( + create_deployment(GPT_41_MINI_DEPLOYMENT), + create_deployment(GPT_5_MINI_DEPLOYMENT), + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment( + dict( + model_id=lorem_llm_config.id, + name=f"{lorem_llm_config.name} Deployment", + provider="custom", + routing_id=lorem_llm_config.id, + api_base=ENV_CONFIG.test_llm_api_base, + ) + ), + create_deployment( + dict( + model_id=short_llm_config.id, + name="Short chat Deployment", + provider="custom", + routing_id=short_llm_config.id, + api_base=ENV_CONFIG.test_llm_api_base, + ) + ), + create_deployment( + dict( + model_id=echo_config.id, + name="Echo Prompt Deployment", + provider="custom", + routing_id=echo_config.id, + api_base=ENV_CONFIG.test_llm_api_base, + ) + ), + create_deployment(TEXT_EMBEDDING_3_SMALL_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + client = JamAI(user_id=superuser.id, project_id=p0.id) + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + audio_uri = upload_file(client, FILES["gutter.mp3"]).uri + document_uri = upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri + yield ServingContext( + superuser_id=superuser.id, + superorg_id=superorg.id, + project_id=p0.id, + embedding_size=embed_config.final_embedding_size, + image_uri=image_uri, + audio_uri=audio_uri, + document_uri=document_uri, + gpt_llm_model_id=gpt_llm_config.id, + gpt_llm_reasoning_config_id=gpt_llm_reasoning_config.id, + desc_llm_model_id=desc_llm_config.id, + lorem_llm_model_id=lorem_llm_config.id, + short_llm_model_id=short_llm_config.id, + echo_model_id=echo_config.id, + embed_model_id=embed_config.id, + rerank_model_id=rerank_config.id, + ) + + +@dataclass(slots=True) +class Data: + data_list: list[dict[str, Any]] + action_data_list: list[dict[str, Any]] + knowledge_data: dict[str, Any] + chat_data: dict[str, Any] + extra_data: dict[str, Any] + + +INPUT_COLUMNS = ["int", "float", "bool", "str", "image", "audio", "document"] +FILE_COLUMNS = ["image", "audio", "document"] +OUTPUT_COLUMNS = ["summary (1.0)", "summary (2.0)"] + + +def _default_data(setup: ServingContext): + action_data_list = [ + { + "ID": str(i), + "Updated at": "1990-05-13T09:01:50.010756+00:00", + "int": 1 if i % 2 == 0 else (1.0 if i % 4 == 1 else None), + "float": -1.25 if i % 2 == 0 else (5 if i % 4 == 1 else None), + "bool": True if i % 2 == 0 else (False if i % 4 == 1 else None), + # `str` will sort in opposite order to ID + "str": f"{100 - i:04d}: {t}", + "image": setup.image_uri if i % 2 == 0 else None, + "audio": setup.audio_uri if i % 2 == 0 else None, + "document": setup.document_uri if i % 2 == 0 else None, + } + for i, t in enumerate(list(TEXTS.values()) + ["", None]) + ] + # Assert integers and floats contain a mix of int, float, None + _ints = [type(d["int"]) for d in action_data_list] + assert int in _ints + assert float in _ints + assert NoneType in _ints + _floats = [type(d["float"]) for d in action_data_list] + assert int in _floats + assert float in _floats + assert NoneType in _floats + # Assert booleans contain a mix of True, False, None + _bools = [d["bool"] for d in action_data_list] + assert True in _bools + assert False in _bools + assert None in _bools + # # Assert strings contain a mix of empty string and None + # _summaries = [d["str"] for d in action_data_list] + # assert None in _summaries + # assert "" in _summaries + knowledge_data = { + "Title": "Dune: Part Two.", + "Text": '"Dune: Part Two" is a film.', + # We use values that can be represented exactly as IEEE floats to ease comparison + "Title Embed": [-1.25] * setup.embedding_size, + "Text Embed": [0.25] * setup.embedding_size, + } + chat_data = dict(User=".") + extra_data = dict(good=True, words=5) + return Data( + data_list=[ + dict(**d, **knowledge_data, **chat_data, **extra_data) for d in action_data_list + ], + action_data_list=action_data_list, + knowledge_data=knowledge_data, + chat_data=chat_data, + extra_data=extra_data, + ) + + +def _add_row_default_data( + setup: ServingContext, + client: JamAI, + *, + table_type: TableType, + table_name: str, + stream: bool, +) -> tuple[MultiRowCompletionResponse, Data]: + data = _default_data(setup) + response = add_table_rows(client, table_type, table_name, data.data_list, stream=stream) + # Check returned chunks / response + for row in response.rows: + for col_name, col_value in row.columns.items(): + assert isinstance(col_name, str) + assert isinstance(col_value, (ChatCompletionResponse, ChatCompletionChunkResponse)) + assert isinstance(col_value.content, str) + assert len(col_value.content) > 0 + assert len(response.rows) == len(data.data_list) + # Check expected output columns + expected_columns = set(OUTPUT_COLUMNS) + if table_type == TableType.CHAT: + expected_columns |= {"AI"} + assert all(set(r.columns.keys()) == expected_columns for r in response.rows), ( + f"{response.rows[0].columns.keys()=}" + ) + return response, data + + +def _check_rows( + rows: list[dict[str, Any]], + data: list[dict[str, Any]], +): + assert len(rows) == len(data), f"Row count mismatch: {len(rows)=} != {len(data)=}" + for row, d in zip(rows, data, strict=True): + assert row["image"] is None or row["image"].endswith("/rabbit.jpeg"), row["image"] + assert row["audio"] is None or row["audio"].endswith("/gutter.mp3"), row["audio"] + assert row["document"] is None or row["document"].endswith( + "/LLMs as Optimizers [DeepMind ; 2023].pdf" + ), row["document"] + for col in d: + if col in ["ID", "Updated at"]: + assert row[col] != d[col], f'Column "{col}" is not regenerated: {d[col]=}' + continue + if col in FILE_COLUMNS: + continue + if d[col] not in [None, ""] or col == "str": + assert row[col] == d[col], f'Column "{col}" mismatch: {row[col]=} != {d[col]=}' + else: + assert row[col] is None, f'Column "{col}" mismatch: {row[col]=} != {d[col]=}' + + +def _check_knowledge_chat_data( + table_type: TableType, + rows: list[dict[str, Any]], + data: Data, +): + if table_type == TableType.KNOWLEDGE: + _check_rows(rows, [data.knowledge_data] * len(data.data_list)) + elif table_type == TableType.CHAT: + _check_rows(rows, [data.chat_data] * len(data.data_list)) + + +def _check_columns( + table_type: TableType, + rows: list[dict[str, Any]], +): + expected_cols = set(["ID", "Updated at"] + INPUT_COLUMNS + OUTPUT_COLUMNS) + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert all(isinstance(r, dict) for r in rows) + assert all(set(r.keys()) == expected_cols for r in rows), [list(r.keys()) for r in rows] + + +def _get_exponent(x: float) -> int: + return Decimal(str(x)).as_tuple().exponent + + +def _extract_number(text: str) -> int: + match = re.search(r"\[(\d+)\]", text) + return int(match.group(1)) if match else 0 + + +def _assert_dict_equal(d1: dict[str, Any], d2: dict[str, Any], exclude: list[str] | None = None): + if exclude is None: + exclude = [] + d1 = {k: v for k, v in d1.items() if k not in exclude} + d2 = {k: v for k, v in d2.items() if k not in exclude} + assert d1 == d2 + + +# TODO: Test add row with complete data including output columns + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_multi_image_input( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + image_uris = [ + upload_file(client, FILES["rabbit.jpeg"]).uri, + upload_file(client, FILES["doe.jpg"]).uri, + ] + cols = [ + ColumnSchemaCreate(id="file", dtype="file"), # Test `file` dtype compatibility + ColumnSchemaCreate(id="image", dtype="image"), + ColumnSchemaCreate( + id="o1", + dtype="str", + gen_config=LLMGenConfig(model=setup.desc_llm_model_id), + ), + ColumnSchemaCreate( + id="o2", + dtype="str", + gen_config=LLMGenConfig(model=setup.desc_llm_model_id, prompt="${image} ${o1}"), + ), + ] + with create_table(client, table_type, cols=cols) as table: + # Add rows + data = [ + dict(file=image_uris[0], image=image_uris[1]), + dict(file=image_uris[0], image=image_uris[1], o1="yeah"), + ] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + rows = {r.row_id: {k: v.content for k, v in r.columns.items()} for r in response.rows} + for row in response.rows: + o2 = row.columns["o2"].content + assert "image with MIME type [image/jpeg], shape [(307, 205, 3)]" in o2 + if "o1" in row.columns: + assert "text with [47] tokens" in o2 + o1 = row.columns["o1"].content + assert "image with MIME type [image/jpeg], shape [(1200, 1600, 3)]" in o1 + assert "image with MIME type [image/jpeg], shape [(307, 205, 3)]" in o1 + else: + assert "text with [1] tokens" in o2 + # List rows + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 2 + for row in _rows.values: + assert row["file"] == image_uris[0] + assert row["image"] == image_uris[1] + assert row["o1"] == rows[row["ID"]].get("o1", "yeah") + assert row["o2"] == rows[row["ID"]]["o2"] + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_reasoning_model_and_agentic_tools( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Tests reasoning and non-reasoning models, with and without web search tool. + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="Question", dtype="str"), + ColumnSchemaCreate( + id="Reasoning Model", + dtype="str", + gen_config=LLMGenConfig( + model=setup.gpt_llm_reasoning_config_id, + prompt="${Question}", + reasoning_effort="low", + ), + ), + ColumnSchemaCreate( + id="Reasoning Model with Agent Mode", + dtype="str", + gen_config=LLMGenConfig( + model=setup.gpt_llm_reasoning_config_id, + prompt="${Question}", + tools=[WebSearchTool()], + reasoning_effort="low", + ), + ), + ColumnSchemaCreate( + id="Chat Model", + dtype="str", + gen_config=LLMGenConfig( + model=setup.gpt_llm_model_id, + prompt="${Question}", + ), + ), + ColumnSchemaCreate( + id="Chat Model with Agent Mode", + dtype="str", + gen_config=LLMGenConfig( + model=setup.gpt_llm_model_id, + prompt="${Question}", + tools=[WebSearchTool()], + ), + ), + ] + with create_table(client, table_type, cols=cols) as table: + data = [dict(Question="What is the current US interest rate?")] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + for row in response.rows: + reasoning = row.columns["Reasoning Model"].reasoning_content + assert "Searched the web for " not in reasoning + assert len(reasoning) > 0 + answer = row.columns["Reasoning Model"].content.lower() + assert len(answer) > 0 + assert "ERROR" not in answer + + reasoning = row.columns["Reasoning Model with Agent Mode"].reasoning_content + assert "Searched the web for " in reasoning + reasoning = reasoning.lower() + assert len(reasoning) > 0 + answer = row.columns["Reasoning Model with Agent Mode"].content.lower() + assert len(answer) > 0 + assert "ERROR" not in answer + + reasoning = row.columns["Chat Model"].reasoning_content + assert reasoning is None or reasoning == "" + answer = row.columns["Chat Model"].content.lower() + assert len(answer) > 0 + assert "ERROR" not in answer + + reasoning = row.columns["Chat Model with Agent Mode"].reasoning_content + assert "Searched the web for " in reasoning + answer = row.columns["Chat Model with Agent Mode"].content.lower() + assert len(answer) > 0 + assert "ERROR" not in answer + # List rows + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 1 + + +@pytest.mark.parametrize("table_type", [TableType.KNOWLEDGE]) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_knowledge_table_embedding( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Test Knowledge Table embeddings: + - Missing Title, Text, or both + - Embedding vector with invalid length + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type, cols=[]) as table: + data = [ + # Complete + dict( + Title="Six-spot burnet", + Text="The six-spot burnet is a moth of the family Zygaenidae.", + ), + # Missing Title + dict( + Text="A neural network is a model inspired by biological neural networks.", + ), + # Missing Text + dict( + Title="A supercomputer has a high level of performance.", + ), + # Missing both + dict(), + ] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else len(data) + assert all(len(r.columns) == 0 for r in response.rows) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + # Check embeddings + for row in rows.values: + assert_is_vector_or_none(row["Title Embed"], allow_none=False) + assert_is_vector_or_none(row["Text Embed"], allow_none=False) + # Check values + row = rows.values[0] + assert row["Title"] == data[0]["Title"], row + assert row["Text"] == data[0]["Text"], row + row = rows.values[1] + assert row["Title"] is None, row + assert row["Text"] == data[1]["Text"], row + row = rows.values[2] + assert row["Title"] == data[2]["Title"], row + assert row["Text"] is None, row + row = rows.values[3] + assert row["Title"] is None, row + assert row["Text"] is None, row + # If embedding with invalid length is added, it will be coerced to None + # Original vector will be saved into state + response = add_table_rows( + client, + table_type, + table.id, + [{"Title": "test", "Title Embed": [1, 2, 3]}], + stream=stream, + ) + # We currently dont return anything if LLM is not called + assert len(response.rows) == 0 if stream else 1 + assert all(len(r.columns) == 0 for r in response.rows) + # Check the vectors + rows = list_table_rows(client, table_type, table.id) + assert rows.total == 5 + row = rows.values[-1] + assert row["Title"] == "test", f"{row['Title']=}" + assert row["Title Embed"] is None, f"{row['Title Embed']=}" + assert row["Text"] is None, f"{row['Title']=}" + assert_is_vector_or_none(row["Text Embed"], allow_none=False) + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_rag( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Test RAG: + - Empty Knowledge Table + - Text query + - Single-turn and multi-turn + - Add and regen + - Text + Image query + - Single-turn and multi-turn + - Add and regen + - Chat thread references + - Inline citations + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table( + client, TableType.KNOWLEDGE, cols=[ColumnSchemaCreate(id="Species", dtype="str")] + ) as kt: + ### --- Perform RAG --- ### + system_prompt = 'Reply "Unsure" if you don\'t know the answer. Do not guess. Be concise.' + gen_config_kwargs = dict( + model=setup.gpt_llm_model_id, + system_prompt=system_prompt, + prompt="${image}\n\nquestion: ${question}" if table_type == TableType.CHAT else "", + max_tokens=50, + temperature=0.001, + top_p=0.001, + ) + rag_kwargs = dict( + table_id=kt.id, + search_query="", # Generate using LM + k=2, + ) + cols = [ + ColumnSchemaCreate(id="question", dtype="str"), + ColumnSchemaCreate(id="image", dtype="image"), + ColumnSchemaCreate( + id="single", + dtype="str", + gen_config=LLMGenConfig( + multi_turn=False, + rag_params=RAGParams(reranking_model=None, **rag_kwargs), + **gen_config_kwargs, + ), + ), + ColumnSchemaCreate( + id="single-rerank", + dtype="str", + gen_config=LLMGenConfig( + multi_turn=False, + rag_params=RAGParams(reranking_model="", inline_citations=False, **rag_kwargs), + **gen_config_kwargs, + ), + ), + ColumnSchemaCreate( + id="multi", + dtype="str", + gen_config=LLMGenConfig( + multi_turn=True, + rag_params=RAGParams( + reranking_model=None, inline_citations=False, **rag_kwargs + ), + **gen_config_kwargs, + ), + ), + ] + + def _check_references(ref: References | None): + if ref is None: + return + _rows = list_table_rows(client, TableType.KNOWLEDGE, kt.id).values + ref_document_ids = {d["File ID"] for d in _rows[:2]} + document_ids = set(r.document_id for r in ref.chunks) + assert document_ids == ref_document_ids + ref_texts = {d["Text"] for d in _rows[:2]} + texts = set(r.text for r in ref.chunks) + assert len(texts) == min(len(_rows), rag_kwargs["k"]) + assert texts == ref_texts + contexts = [r.context for r in ref.chunks] + assert all("Species" in m for m in contexts) + metas = [r.metadata for r in ref.chunks] + assert all("rrf_score" in m for m in metas) + + def _check_row_references(references: list[dict[str, References]]): + for ref in references: + for r in ref.values(): + _check_references(r) + + def _get_content(row: RowCompletionResponse, col: str) -> str: + ref = row.columns[col].references + assert isinstance(ref, References) + _check_references(ref) + return row.columns[col].content.lower().strip() + + ### --- RAG on empty Knowledge Table --- ### + with create_table(client, table_type, cols=cols) as table: + col_map = {col.id: col.gen_config for col in table.cols} + # Assert that a default reranking model is set + assert col_map["single-rerank"].rag_params.reranking_model == setup.rerank_model_id + assert col_map["single"].rag_params.reranking_model is None + assert col_map["multi"].rag_params.reranking_model is None + # RAG + data = [dict(question="What is the name of the rabbit?")] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + # List rows (should have references) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + assert len(rows.references) == len(data) + _check_row_references(rows.references) + + ### --- Add data into Knowledge Table --- ### + data = [ + # Context + { + "Title": "Animal", + "Text": "Its name is Latte.", + "Species": "rabbit", + "File ID": "s3://animal-rabbit.jpeg", + }, + { + "Title": "Animal", + "Text": "Its name is Bambi.", + "Species": "doe", + "File ID": "s3://animal-doe.jpeg", + }, + # Distractor + { + "Title": "Country", + "Text": "Kuala Lumpur is the capital of Malaysia.", + "File ID": "s3://country-kuala-lumpur.pdf", + }, + ] + response = add_table_rows(client, TableType.KNOWLEDGE, kt.id, data, stream=False) + assert len(response.rows) == len(data) + kt_rows = list_table_rows(client, TableType.KNOWLEDGE, kt.id) + assert kt_rows.total == len(data) + + ### Text query + with create_table(client, table_type, cols=cols) as table: + col_map = {col.id: col.gen_config for col in table.cols} + # Assert that a default reranking model is set + assert col_map["single-rerank"].rag_params.reranking_model == setup.rerank_model_id + assert col_map["single"].rag_params.reranking_model is None + assert col_map["multi"].rag_params.reranking_model is None + # RAG + data = [ + dict(question="What is the name of the rabbit?"), # Latte + dict(question="What is its name again?"), # Unsure (single), Latte (multi) + ] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + # List rows (should have references) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + assert len(rows.references) == len(data) + _check_row_references(rows.references) + # Check answers + single = _get_content(response.rows[0], "single") + assert "latte" in single + assert len(re.findall(CITATION_PATTERN, single)) > 0 + assert "latte" in _get_content(response.rows[0], "single-rerank") + assert "latte" in _get_content(response.rows[0], "multi") + # "Unsure" tests are fragile + # assert "unsure" in _get_content(response.rows[1], "single") + # assert "unsure" in _get_content(response.rows[1], "single-rerank") + assert len(_get_content(response.rows[1], "single")) > 0 + assert len(_get_content(response.rows[1], "single-rerank")) > 0 + assert "latte" in _get_content(response.rows[1], "multi") + ### Update and regen + # Update question + row_ids = [r["ID"] for r in rows.items] + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row_ids[0]: dict(question="What is the name of the deer?")}, # Bambi + ), + ) + assert isinstance(response, OkResponse) + response = regen_table_rows(client, table_type, table.id, row_ids, stream=stream) + assert len(response.rows) == len(data) + # Check answers + single = _get_content(response.rows[0], "single") + assert "bambi" in single + assert len(re.findall(CITATION_PATTERN, single)) > 0 + assert "bambi" in _get_content(response.rows[0], "single-rerank") + assert "bambi" in _get_content(response.rows[0], "multi") + assert len(_get_content(response.rows[1], "single")) > 0 + assert len(_get_content(response.rows[1], "single-rerank")) > 0 + assert "bambi" in _get_content(response.rows[1], "multi") + + ### Text + Image query + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + with create_table(client, table_type, cols=cols) as table: + col_map = {col.id: col.gen_config for col in table.cols} + # Assert that a default reranking model is set + assert col_map["single-rerank"].rag_params.reranking_model == setup.rerank_model_id + assert col_map["single"].rag_params.reranking_model is None + assert col_map["multi"].rag_params.reranking_model is None + # RAG + data = [ + # Latte + dict(question="What is the name of the animal?", image=image_uri, User="lala"), + # Unsure (single), Latte (multi) + dict(question="What is its name again?", User="lala"), + ] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + # List rows (should have references) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + assert len(rows.references) == len(data) + _check_row_references(rows.references) + assert "latte" in _get_content(response.rows[0], "single") + assert "latte" in _get_content(response.rows[0], "single-rerank") + assert "latte" in _get_content(response.rows[0], "multi") + # "Unsure" tests are fragile + # assert "unsure" in _get_content(response.rows[1], "single") + # assert "unsure" in _get_content(response.rows[1], "single-rerank") + assert len(_get_content(response.rows[1], "single")) > 0 + assert len(_get_content(response.rows[1], "single-rerank")) > 0 + assert "latte" in _get_content(response.rows[1], "multi") + ### Update and regen + # Update KT + kt_row_ids = [r["ID"] for r in kt_rows.items] + response = client.table.update_table_rows( + TableType.KNOWLEDGE, + MultiRowUpdateRequest( + table_id=kt.id, + data={kt_row_ids[1]: dict(Text="Its name is Daisy")}, + ), + ) + assert isinstance(response, OkResponse) + # Update image + row_ids = [r["ID"] for r in rows.items] + image_uri = upload_file(client, FILES["doe.jpg"]).uri + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + # Daisy + data={row_ids[0]: dict(image=image_uri)}, + ), + ) + assert isinstance(response, OkResponse) + response = regen_table_rows(client, table_type, table.id, row_ids, stream=stream) + assert len(response.rows) == len(data) + # Check answers + assert "daisy" in _get_content(response.rows[0], "single") + assert "daisy" in _get_content(response.rows[0], "single-rerank") + assert "daisy" in _get_content(response.rows[0], "multi") + assert len(_get_content(response.rows[1], "single")) > 0 + assert len(_get_content(response.rows[1], "single-rerank")) > 0 + assert "daisy" in _get_content(response.rows[1], "multi") + + ### Chat thread references + col = "multi" + response = client.table.get_conversation_threads(table_type, table.id) + assert col in response.threads + assert response.table_id == table.id + thread = response.threads[col].thread + assert response.threads[col].column_id == col + for message in thread: + if message.role == "assistant": + assert isinstance(message.references, References) + assert len(message.references.chunks) == rag_kwargs["k"] + _check_references(message.references) + assert isinstance(message.row_id, str) + assert len(message.row_id) > 0 + elif message.role == "user": + assert isinstance(message.row_id, str) + assert len(message.row_id) > 0 + assert message.user_prompt is None + else: + assert isinstance(message.content, str) + assert message.row_id is None + message = thread[1] + assert message.role == "user" + assert isinstance(message.content, list) + assert len(message.content) == 2 + assert isinstance(message.content[0], S3Content) + assert message.content[0].uri == image_uri + assert isinstance(message.content[1], TextContent) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_column_dependency( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Test column dependency graph. + - Add and regen rows + - No dependency (single-turn, multi-turn) + - Single dependency (single-turn, multi-turn) + - Chain dependency + - Fan-in (with and without chain) and fan-out dependencies + - Multi-single-multi + - Gen config partial update + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + gen_config_kwargs = dict(model=setup.echo_model_id, system_prompt="^") + cols = [ + ColumnSchemaCreate(id="c0", dtype="str"), + # ["s1", "m1", "s2", "s3", "m2", "s4", "s5", "s6", "s7", "m3", "s8", "m4"] + # Single dependency (single-turn) + ColumnSchemaCreate( + id="s1", + dtype="str", + gen_config=LLMGenConfig(prompt="s1 ${c0}", **gen_config_kwargs), + ), + # Single dependency (multi-turn) + ColumnSchemaCreate( + id="m1", + dtype="str", + gen_config=LLMGenConfig(prompt="m1 ${c0}", multi_turn=True, **gen_config_kwargs), + ), + # Chain dependency + ColumnSchemaCreate( + id="s2", + dtype="str", + gen_config=LLMGenConfig(prompt="s2 ${s1}", **gen_config_kwargs), + ), + # No dependency (single-turn) + ColumnSchemaCreate( + id="s3", + dtype="str", + gen_config=LLMGenConfig(prompt="s3", **gen_config_kwargs), + ), + # No dependency (multi-turn) + ColumnSchemaCreate( + id="m2", + dtype="str", + gen_config=LLMGenConfig(prompt="m2", multi_turn=True, **gen_config_kwargs), + ), + # Fan-out after chain dependency + ColumnSchemaCreate( + id="s4", + dtype="str", + gen_config=LLMGenConfig(prompt="s4 ${s2}", **gen_config_kwargs), + ), + ColumnSchemaCreate( + id="s5", + dtype="str", + gen_config=LLMGenConfig(prompt="s5 ${s2}", **gen_config_kwargs), + ), + ColumnSchemaCreate( + id="s6", + dtype="str", + gen_config=LLMGenConfig(prompt="s6 ${s5}", **gen_config_kwargs), + ), + # Fan-in (single-turn) + ColumnSchemaCreate( + id="s7", + dtype="str", + gen_config=LLMGenConfig(prompt="s7 ${s4} ${s6}", **gen_config_kwargs), + ), + # Fan-in (multi-turn) + ColumnSchemaCreate( + id="m3", + dtype="str", + gen_config=LLMGenConfig(prompt="m3 ${s4} ${s6}", multi_turn=True, **gen_config_kwargs), + ), + # Single dependency (single-turn after multi-turn) + ColumnSchemaCreate( + id="s8", + dtype="str", + gen_config=LLMGenConfig(prompt="s8 ${m3}", **gen_config_kwargs), + ), + # Multi-single-multi + ColumnSchemaCreate( + id="m4", + dtype="str", + gen_config=LLMGenConfig(prompt="m4 ${s8}", multi_turn=True, **gen_config_kwargs), + ), + ] + + def _content(row: RowCompletionResponse, col: str) -> str | None: + return getattr(row.columns.get(col, None), "content", "").strip() + + def _check(rows: list[RowCompletionResponse], base: str, exc: list[str] = None): + if exc is None: + exc = [] + # Check single-turn + for i, row in enumerate(rows): + assert "s1" in exc or _content(row, "s1") == f"^ s1 {base}{i}" + assert "s2" in exc or _content(row, "s2") == f"^ s2 {_content(row, 's1')}" + assert "s3" in exc or _content(row, "s3") == "^ s3" + assert "s4" in exc or _content(row, "s4") == f"^ s4 {_content(row, 's2')}" + assert "s5" in exc or _content(row, "s5") == f"^ s5 {_content(row, 's2')}" + assert "s6" in exc or _content(row, "s6") == f"^ s6 {_content(row, 's5')}" + assert "s7" in exc or _content(row, "s7") == f'^ s7 {_content(row, "s4")} {_content(row, "s6")}' # fmt:off + # Check multi-turn + gt = dict( + m1=[ + f"^ m1 {base}0", + f"^ m1 {base}0 m1 {base}1", + ], + m2=[ + "^ m2", + "^ m2 m2", + ], + m3=[ + f"^ m3 {_content(rows[0], 's4')} {_content(rows[0], 's6')}", + f"^ m3 {_content(rows[0], 's4')} {_content(rows[0], 's6')} m3 {_content(rows[1], 's4')} {_content(rows[1], 's6')}", + ], + s8=[ + f"^ s8 {_content(rows[0], 'm3')}", + f"^ s8 {_content(rows[1], 'm3')}", + ], + m4=[ + f"^ m4 {_content(rows[0], 's8')}", + f"^ m4 {_content(rows[0], 's8')} m4 {_content(rows[1], 's8')}", + ], + ) + for i, row in enumerate(response.rows): + assert "m1" in exc or _content(row, "m1") == gt["m1"][i] + assert "m2" in exc or _content(row, "m2") == gt["m2"][i] + assert "m4" in exc or _content(row, "m3") == gt["m3"][i] + assert "s8" in exc or _content(row, "s8") == gt["s8"][i] + assert "m4" in exc or _content(row, "m4") == gt["m4"][i] + + with create_table(client, table_type, cols=cols) as table: + ### --- Add rows --- ### + data = [dict(c0="r0"), dict(c0="r1")] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + _check(response.rows, "r") + ### --- Regen rows --- ### + row_ids = [r.row_id for r in response.rows] + # Regen all + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row.row_id: dict(c0=f"z{i}") for i, row in enumerate(response.rows)}, + ), + ) + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_ALL, + ) + assert len(response.rows) == len(data) + _check(response.rows, "z") + # Regen before + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row.row_id: dict(c0=f"aa{i}") for i, row in enumerate(response.rows)}, + ), + ) + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_BEFORE, + output_column_id="m3", + ) + assert len(response.rows) == len(data) + # _check(response.rows, "z", ["s1", "m1", "s2", "s3", "m2", "s4", "s5", "s6", "s7", "m3"]) + _check(response.rows, "aa", ["s8", "m4"]) + # Regen after + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row.row_id: dict(c0=f"bb{i}") for i, row in enumerate(response.rows)}, + ), + ) + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_AFTER, + output_column_id="s2", + ) + assert len(response.rows) == len(data) + assert _content(response.rows[0], "s2") == "^ s2 ^ s1 aa0" # Still "aa" + assert _content(response.rows[1], "s2") == "^ s2 ^ s1 aa1" # Still "aa" + _check(response.rows, "aa", ["s1", "m1", "s2"]) # Still "aa" + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_AFTER, + output_column_id="s1", + ) + assert len(response.rows) == len(data) + _check(response.rows, "bb") + # Regen selected + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row.row_id: dict(c0=f"cc{i}") for i, row in enumerate(response.rows)}, + ), + ) + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_SELECTED, + output_column_id="m1", + ) + assert len(response.rows) == len(data) + # _check(response.rows, "bb", ["m1"]) + assert _content(response.rows[0], "m1") == "^ m1 cc0" + assert _content(response.rows[1], "m1") == "^ m1 cc0 m1 cc1" + # Update gen config and regen + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, column_map=dict(s8=LLMGenConfig(prompt="s8 ${m2}")) + ), + ) + gen_configs = {c.id: c.gen_config for c in table.cols} + assert gen_configs["s8"].system_prompt == "^" + assert gen_configs["s8"].prompt == "s8 ${m2}" + response = regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=RegenStrategy.RUN_AFTER, + output_column_id="s8", + ) + assert _content(response.rows[0], "m4") == "^ m4 ^ s8 ^ m2" + assert _content(response.rows[1], "m4") == "^ m4 ^ s8 ^ m2 m4 ^ s8 ^ m2 m2" + + +@pytest.mark.parametrize( + "python_code", + [ + { + "input": "Hello, World!", + "code": "row['result_column']=row['input']", + "expected": "Hello, World!", + }, + { + "input": "2", + "code": "row['result_column'] = int(row['input']) + int(row['input'])", + "expected": "4", + }, + # Test error handling: + { + "input": "DUMMY", + "code": "row['result_column']=row['undefined']", + "expected": "KeyError: 'undefined'", + }, + ], +) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +async def test_python_fixed_function_str( + setup: ServingContext, + stream: bool, + python_code: dict, +): + table_type = TableType.ACTION + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input", dtype="str"), + ColumnSchemaCreate( + id="result_column", + dtype="str", + gen_config=PythonGenConfig(python_code=python_code["code"]), + ), + ] + with create_table(client, table_type, cols=cols) as table: + data = [{"input": python_code["input"]}] + # Add rows + response = add_table_rows( + client, table_type, table.id, data, stream=stream, check_usage=False + ) + assert len(response.rows) == len(data) + rows = list_table_rows(client, table_type, table.id) + row_ids = [r.row_id for r in response.rows] + assert rows.total == len(data) + assert rows.values[0]["result_column"] == python_code["expected"] + # Regen rows + response = regen_table_rows( + client, table_type, table.id, row_ids, stream=stream, check_usage=False + ) + assert len(response.rows) == len(data) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + assert rows.values[0]["result_column"] == python_code["expected"] + + +def _read_file_content(file_path): + with open(file_path, "rb") as f: + return f.read() + + +@pytest.mark.parametrize( + "image_path", + [ + FILES["cifar10-deer.jpg"], + FILES["rabbit.png"], + FILES["rabbit_cifar10-deer.gif"], + FILES["rabbit_cifar10-deer.webp"], + ], + ids=lambda x: basename(x), +) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +async def test_python_fixed_function_image( + setup: ServingContext, + stream: bool, + image_path: str, +): + table_type = TableType.ACTION + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="source_image", dtype="image"), + ColumnSchemaCreate( + id="result_column", + dtype="image", + gen_config=PythonGenConfig(python_code="row['result_column']=row['source_image']"), + ), + ] + + with create_table(client, table_type, cols=cols) as table: + image_uri = upload_file(client, image_path).uri + data = [{"source_image": image_uri}] + # Add rows + response = add_table_rows( + client, table_type, table.id, data, stream=stream, check_usage=False + ) + assert len(response.rows) == len(data) + rows = list_table_rows(client, table_type, table.id) + row_ids = [r.row_id for r in response.rows] + assert rows.total == len(data) + file_uri = rows.values[0]["result_column"] + assert file_uri.startswith(("file://", "s3://")) + response = client.file.get_raw_urls([file_uri]) + assert isinstance(response, GetURLResponse) + # Compare the contents + downloaded_content = httpx.get(response.urls[0]).content + original_content = _read_file_content(image_path) + assert original_content == downloaded_content, f"Content mismatch for file: {image_path}" + # Regen rows + response = regen_table_rows( + client, table_type, table.id, row_ids, stream=stream, check_usage=False + ) + assert len(response.rows) == len(data) + rows = list_table_rows(client, table_type, table.id) + assert rows.total == len(data) + file_uri = rows.values[0]["result_column"] + assert file_uri.startswith(("file://", "s3://")) + response = client.file.get_raw_urls([file_uri]) + assert isinstance(response, GetURLResponse) + # Compare the contents + downloaded_content = httpx.get(response.urls[0]).content + original_content = _read_file_content(image_path) + assert original_content == downloaded_content, f"Content mismatch for file: {image_path}" + + +def _assert_context_error(content: str) -> None: + assert "maximum context length is 10 tokens" in content + assert content.startswith("[ERROR]") + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_error_cases( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Test error cases. + - Row add & regen: Downstream columns exceed context length + - Row add & regen: All columns exceed context length + - Error circuit breaker + - Non-existent output column during regen + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + max_tokens = 8 + num_output_cols = 2 + cols = [ColumnSchemaCreate(id="c0", dtype="str")] + cols += [ + ColumnSchemaCreate( + id=f"c{i + 1}", + dtype="str", + gen_config=LLMGenConfig( + model=setup.short_llm_model_id, + system_prompt=".", + prompt=f"${{c{i}}}", + max_tokens=max_tokens, + ), + ) + for i in range(num_output_cols) + ] + with create_table(client, table_type, cols=cols) as table: + ### --- Context length --- ### + ### Downstream exceed context length + # Row add + data = [dict(c0="0"), dict(c0="1")] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + for row in response.rows: + assert "Lorem ipsum dolor sit amet" in row.columns["c1"].content + _assert_context_error(row.columns["c2"].content) + # Row regen + response = regen_table_rows( + client, + table_type, + table.id, + [r.row_id for r in response.rows], + stream=stream, + regen_strategy=RegenStrategy.RUN_ALL, + ) + for row in response.rows: + assert "Lorem ipsum dolor sit amet" in row.columns["c1"].content + _assert_context_error(row.columns["c2"].content) + ### All exceed context length + # Row add + data = [dict(c0="0 0"), dict(c0="1 1")] + response = add_table_rows(client, table_type, table.id, data, stream=stream) + assert len(response.rows) == len(data) + for row in response.rows: + _assert_context_error(row.columns["c1"].content) + assert "Upstream columns errored out" in row.columns["c2"].content + # Row regen + response = regen_table_rows( + client, + table_type, + table.id, + [r.row_id for r in response.rows], + stream=stream, + regen_strategy=RegenStrategy.RUN_ALL, + ) + for row in response.rows: + _assert_context_error(row.columns["c1"].content) + assert "Upstream columns errored out" in row.columns["c2"].content + + ### --- Regen rows with invalid column --- ### + row_ids = [r.row_id for r in response.rows] + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map={ + f"c{i + 1}": LLMGenConfig(max_tokens=2) for i in range(num_output_cols) + }, + ), + ) + strategies = [ + RegenStrategy.RUN_ALL, + RegenStrategy.RUN_BEFORE, + RegenStrategy.RUN_AFTER, + RegenStrategy.RUN_SELECTED, + ] + for strategy in strategies: + with pytest.raises(ResourceNotFoundError): + regen_table_rows( + client, + table_type, + table.id, + row_ids, + stream=stream, + regen_strategy=strategy, + output_column_id="x", + ) + + +def _assert_consecutive(lst: list) -> bool: + """ + Assert that identical elements occur consecutively in the list. + + Args: + lst: List of strings + + Raises: + AssertionError: If identical elements are not grouped together + """ + if not lst: + raise AssertionError("List is empty") + seen = {lst[0]} + current_element = lst[0] + for element in lst[1:]: + if element != current_element: + # We're starting a new group + if element in seen: + return False + seen.add(element) + current_element = element + return True + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_concurrency_stream( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + max_tokens = 10 + num_output_cols = 3 + num_rows = 2 + cols = [ColumnSchemaCreate(id="str", dtype="str")] + cols += [ + ColumnSchemaCreate( + id=f"o{i + 1}", + dtype="str", + gen_config=LLMGenConfig( + model=setup.lorem_llm_model_id, + system_prompt="", + prompt="", + max_tokens=max_tokens, + ), + ) + for i in range(num_output_cols) + ] + with create_table(client, table_type, cols=cols) as table: + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + table_id=table.id, + data=[dict(str="Lorem ipsum dolor sit amet")] * num_rows, + stream=True, + ), + ) + chunks = [r for r in response if isinstance(r, CellCompletionResponse)] + ### --- Column concurrency --- ### + # Assert that all columns are concurrently generated + rows: dict[str, list[CellCompletionResponse]] = defaultdict(list) + for c in chunks: + rows[c.row_id].append(c) + for row in rows.values(): + chunk_cols = [r.output_column_name for r in row] + assert len(chunk_cols) > num_output_cols * num_rows + _cols = set(chunk_cols[: len(chunk_cols) // 2]) + assert len(_cols) >= 1 + assert not _assert_consecutive(chunk_cols) + ### --- Row concurrency --- ### + row_ids = list(rows.keys()) + chunk_rows = [c.row_id for c in chunks] + # print(f"{[row_ids.index(c.row_id) for c in chunks]=}") + multiturn_cols = [c for c in table.cols if getattr(c.gen_config, "multi_turn", False)] + if len(multiturn_cols) > 0: + # Tables with multi-turn column must have its rows are sequentially generated + for i, row_id in enumerate(row_ids): + chunks_per_row = len(chunk_rows) // len(row_ids) + _chunks = chunk_rows[i * chunks_per_row : (i + 1) * chunks_per_row] + assert row_id in _chunks + assert _assert_consecutive(chunk_rows) + else: + # Tables without must have its rows concurrently generated + _rows = set(chunk_rows[: len(chunk_rows) // num_rows]) + assert len(_rows) == num_rows + for row_id in row_ids: + assert row_id in _rows + assert not _assert_consecutive(chunk_rows) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_multimodal_multiturn( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Tests multimodal multiturn generation. + - Ensure files are fetched/interpolated from the correct row in a multiturn setting + - Ensure files in history are updated after an earlier row is updated + - Add and regen row + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="str", dtype="str"), + ColumnSchemaCreate(id="image", dtype="image"), + ColumnSchemaCreate(id="audio", dtype="audio"), + ColumnSchemaCreate(id="document", dtype="document"), + ColumnSchemaCreate( + id="chat", + dtype="str", + gen_config=LLMGenConfig( + model=setup.desc_llm_model_id, + system_prompt="", + prompt="${str} ${image} ${audio} ${document}", + max_tokens=20, + multi_turn=True, + ), + ), + ] + with ( + TemporaryDirectory() as tmp_dir, + create_table(client, table_type, cols=cols) as table, + ): + text_fp = join(tmp_dir, "test.txt") + with open(text_fp, "w") as f: + f.write("Two tokens") + doc_uri = upload_file(client, text_fp).uri + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + audio_uri = upload_file(client, FILES["gutter.mp3"]).uri + ### --- Add rows --- ### + response = add_table_rows( + client, + table_type, + table.id, + [ + dict(str="one", image=image_uri, audio=audio_uri, document=doc_uri), + dict(str="one", image=image_uri, audio=audio_uri, document=doc_uri), + ], + stream=stream, + ) + # Check returned chunks / response + for row in response.rows: + chat = row.columns["chat"].content + # print(chat) + chat_contents = chat.split("\n") + assert "System prompt:" in chat_contents[0] + assert _extract_number(chat_contents[0]) > 10 + assert "[image/jpeg], shape [(1200, 1600, 3)]" in chat + assert "[image/jpeg], shape [(32, 32, 3)]" not in chat + assert "[audio/mpeg]" in chat + assert "text with [5] tokens" in chat + assert len(response.rows) == 2 + chat = response.rows[0].columns["chat"].content + chat_contents = chat.split("\n") + assert len(chat.split("\n")) == 4 + chat = response.rows[1].columns["chat"].content + chat_contents = chat.split("\n") + assert len(chat.split("\n")) == 7 + # Update image in first row + image_uri = upload_file(client, FILES["cifar10-deer.jpg"]).uri + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={response.rows[0].row_id: dict(image=image_uri)}, + ), + ) + # Add a row + response = add_table_rows( + client, + table_type, + table.id, + [dict(str="one")], + stream=stream, + ) + assert len(response.rows) == 1 + chat = response.rows[0].columns["chat"].content + # print(chat) + assert "[image/jpeg], shape [(1200, 1600, 3)]" in chat + assert "[image/jpeg], shape [(32, 32, 3)]" in chat # Updated image + assert "[audio/mpeg]" in chat + assert "text with [5] tokens" in chat + assert "text with [1] tokens" in chat + ### --- Regen row --- ### + row = response.rows[0] + response = regen_table_rows(client, table_type, table.id, [row.row_id], stream=stream) + assert len(response.rows) == 1 + chat = response.rows[0].columns["chat"].content + assert "[image/jpeg], shape [(1200, 1600, 3)]" in chat + assert "[image/jpeg], shape [(32, 32, 3)]" in chat # Updated image + assert "[audio/mpeg]" in chat + assert "text with [5] tokens" in chat + assert "text with [1] tokens" in chat + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_add_get_list_rows( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Test adding a row to a table. + - All column dtypes + - Various languages + + Test get row and list rows from a table. + - offset and limit + - order_by and order_ascending + - where + - search_query and search_columns + - column subset + - float & vector precision + - vector column exclusion + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ColumnSchemaCreate(id=c, dtype=c) for c in INPUT_COLUMNS] + cols += [ + ColumnSchemaCreate( + id=c, + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="", + max_tokens=10, + ), + ) + for c in OUTPUT_COLUMNS + ] + with create_table(client, table_type, cols=cols) as table: + ### --- Add row with all dtypes --- ### + _, data = _add_row_default_data( + setup, + client, + table_type=table_type, + table_name=table.id, + stream=stream, + ) + num_data = len(data.data_list) + return + + ### --- List rows --- ### + rows = list_table_rows(client, table_type, table.id) + # Check row count + assert len(rows.items) == len(data.data_list), ( + f"Row count mismatch: {len(rows.items)=} != {num_data=}" + ) + assert rows.total == len(data.data_list), ( + f"Row count mismatch: {rows.total=} != {num_data=}" + ) + # Check row data + _check_rows(rows.values, data.action_data_list) + _check_knowledge_chat_data(table_type, rows.values, data) + # Check output columns + for row in rows.values: + for c in OUTPUT_COLUMNS: + summary = row[c] + assert "There is a text" in summary, summary + if row["image"]: + assert "There is an image with MIME type [image/jpeg]" in summary, summary + if row["audio"]: + assert "There is an audio with MIME type [audio/mpeg]" in summary, summary + # Check columns + _check_columns(table_type, rows.items) + + ### --- Get row --- ### + for row in rows.items: + _row = get_table_row(client, table_type, table.id, row["ID"]) + assert _row == row, f'Row "{row["ID"]}" mismatch: {_row=} != {row=}' + + ### --- List rows (offset and limit) --- ### + _rows = list_table_rows(client, table_type, table.id, offset=0, limit=1) + assert len(_rows.items) == 1 + assert _rows.total == num_data + assert _rows.items[0]["ID"] == rows.items[0]["ID"], f"{_rows.items=}" + _rows = list_table_rows(client, table_type, table.id, offset=1, limit=1) + assert len(_rows.items) == 1 + assert _rows.total == num_data + assert _rows.items[0]["ID"] == rows.items[1]["ID"], f"{_rows.items=}" + # Offset >= num rows + _rows = list_table_rows(client, table_type, table.id, offset=num_data, limit=1) + assert len(_rows.items) == 0 + assert _rows.total == num_data + _rows = list_table_rows(client, table_type, table.id, offset=num_data + 1, limit=1) + assert len(_rows.items) == 0 + assert _rows.total == num_data + # Invalid offset and limit + with pytest.raises(BadInputError): + list_table_rows(client, table_type, table.id, offset=0, limit=0) + with pytest.raises(BadInputError): + list_table_rows(client, table_type, table.id, offset=-1, limit=1) + + ### --- List rows (order_by and order_ascending) --- ### + _rows = list_table_rows(client, table_type, table.id, order_ascending=False) + assert len(_rows.items) == num_data + assert _rows.total == num_data + assert _rows.items[::-1] == rows.items + _rows = list_table_rows(client, table_type, table.id, order_by="str") + assert len(_rows.items) == num_data + assert _rows.total == num_data + assert _rows.items[::-1] == rows.items + + ### --- List rows (where) --- ### + _rows = list_table_rows(client, table_type, table.id, search_query="Arri") + assert len(_rows.items) == 3 + assert _rows.total == 3 + assert _rows.total != num_data + _id = rows.items[0]["ID"] + _rows = list_table_rows( + client, table_type, table.id, search_query="Arri", where=f""""ID" > '{_id}'""" + ) + assert len(_rows.items) == 2 + assert _rows.total == 2 + _rows = list_table_rows(client, table_type, table.id, where=f""""ID" = '{_id}'""") + assert len(_rows.items) == 1 + assert _rows.total == 1 + + ### --- List rows (search_query and search_columns) --- ### + _rows = list_table_rows(client, table_type, table.id, search_query="Arri") + assert len(_rows.items) == 3 + assert _rows.total == 3 + assert _rows.total != num_data + _rows = list_table_rows(client, table_type, table.id, search_query="Arri", offset=1) + assert len(_rows.items) == 2 + assert _rows.total == 3 + assert _rows.total != num_data + _rows = list_table_rows( + client, table_type, table.id, search_query="Arri", search_columns=["str"] + ) + assert len(_rows.items) == 3 + assert _rows.total == 3 + assert _rows.total != num_data + _rows = list_table_rows( + client, table_type, table.id, search_query="Arri", search_columns=OUTPUT_COLUMNS + ) + assert len(_rows.items) == 0 + assert _rows.total == 0 + + ### --- Get & List rows (column subset) --- ### + _rows = list_table_rows(client, table_type, table.id, limit=2, columns=["str", "bool"]) + expected_columns = {"ID", "Updated at", "str", "bool"} + for row in _rows.items: + cols = set(row.keys()) + assert cols == expected_columns, ( + f"Column order mismatch: {cols=} != {expected_columns=}" + ) + _row = get_table_row(client, table_type, table.id, row["ID"], columns=["str", "bool"]) + assert _row == row, f'Row "{row["ID"]}" mismatch: {_row=} != {row=}' + assert "value" in row["bool"], _row + assert "value" in _row["bool"], _row + + ### --- Get & List rows (float & vector precision) --- ### + # Round to 1 decimal + _rows = list_table_rows( + client, table_type, table.id, limit=2, float_decimals=1, vec_decimals=1 + ) + for row in _rows.items: + exponent = _get_exponent(row["float"]["value"]) + assert exponent >= -1, exponent + if table_type == TableType.KNOWLEDGE: + for col in ["Title Embed", "Text Embed"]: + exponents = [_get_exponent(v) for v in row[col]["value"]] + assert all(e >= -1 for e in exponents), exponents + _row = get_table_row( + client, table_type, table.id, row["ID"], float_decimals=1, vec_decimals=1 + ) + assert _row == row, f'Row "{row["ID"]}" mismatch: {_row=} != {row=}' + # No vector columns + _rows = list_table_rows( + client, table_type, table.id, limit=2, float_decimals=1, vec_decimals=-1 + ) + for row in _rows.items: + exponent = _get_exponent(row["float"]["value"]) + assert exponent >= -1, exponent + assert "Title Embed" not in row + assert "Text Embed" not in row + _row = get_table_row( + client, table_type, table.id, row["ID"], float_decimals=1, vec_decimals=-1 + ) + assert _row == row, f'Row "{row["ID"]}" mismatch: {_row=} != {row=}' + + +def test_list_rows_case_insensitive_sort(setup: ServingContext): + table_type = TableType.ACTION + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ColumnSchemaCreate(id="str", dtype="str")] + with create_table(client, table_type, cols=cols) as table: + add_table_rows( + client, + table_type, + table.id, + [dict(str="a"), dict(str="B"), dict(str="C"), dict(str="d")][::-1], + stream=False, + ) + ### --- List rows --- ### + rows = list_table_rows(client, table_type, table.id) + assert [r["str"] for r in rows.values] == ["a", "B", "C", "d"][::-1] + rows = list_table_rows(client, table_type, table.id, order_by="str") + assert [r["str"] for r in rows.values] == ["a", "B", "C", "d"] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_update_row( + setup: ServingContext, + table_type: TableType, +): + """ + Test row updates. + - All column dtypes + - ID should not be updated even if provided + - Updating data with wrong dtype or vector length should store None + - Updating embedding directly should work + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + ### --- Add row with all dtypes --- ### + data = [ + { + "ID": "0", + "Updated at": "1990-05-13T09:01:50.010756+00:00", + "int": 1, + "float": -1.25, + "bool": True, + "str": "moka", + "image": setup.image_uri, + "audio": setup.audio_uri, + "document": setup.document_uri, + "Title": "Dune: Part Two.", + "Text": '"Dune: Part Two" is a film.', + "Title Embed": [-1.25] * setup.embedding_size, + "Text Embed": [0.25] * setup.embedding_size, + "User": "Hi", + "AI": "Hello", + } + ] + add_table_rows(client, table_type, table.id, data, stream=False) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + row = rows.values[0] + t0 = datetime.fromisoformat(row["Updated at"]) + + # ID should not be updated, the rest OK + data = dict(ID="2", float=1.0, bool=False) + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest(table_id=table.id, data={row["ID"]: data}), + ) + assert isinstance(response, OkResponse) + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 1 + _row = _rows.values[0] + t1 = datetime.fromisoformat(_row["Updated at"]) + assert _row["float"] == data["float"] + assert _row["bool"] == data["bool"] + _assert_dict_equal(row, _row, exclude=["Updated at", "float", "bool"]) + assert t1 > t0 + + # Test updating data with wrong dtype + data = dict(ID="2", int="str", float="str", bool="str") + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest(table_id=table.id, data={row["ID"]: data}), + ) + assert isinstance(response, OkResponse) + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 1 + _row = _rows.values[0] + t2 = datetime.fromisoformat(_row["Updated at"]) + assert _row["int"] is None + assert _row["float"] is None + assert _row["bool"] is None + _assert_dict_equal(row, _row, exclude=["Updated at", "int", "float", "bool"]) + assert t2 > t1 + + if table_type == TableType.KNOWLEDGE: + # Test updating embedding columns directly + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={ + row["ID"]: { + "Title Embed": [0] * len(row["Title Embed"]), + "Text Embed": [1] * len(row["Text Embed"]), + } + }, + ), + ) + assert isinstance(response, OkResponse) + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 1 + _row = _rows.values[0] + t3 = datetime.fromisoformat(_row["Updated at"]) + assert sum(_row["Title Embed"]) == 0 + assert sum(_row["Text Embed"]) == len(row["Text Embed"]) + assert t3 > t2 + # Test updating embedding columns with wrong length + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={row["ID"]: {"Title Embed": [0], "Text Embed": [0]}}, + ), + ) + assert isinstance(response, OkResponse) + _rows = list_table_rows(client, table_type, table.id) + assert len(_rows.items) == 1 + _row = _rows.values[0] + t4 = datetime.fromisoformat(_row["Updated at"]) + assert _row["Title Embed"] is None + assert _row["Text Embed"] is None + assert t4 > t3 + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_regen_embedding( + setup: ServingContext, + stream: bool, +): + table_type = TableType.KNOWLEDGE + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type, cols=[]) as table: + # Add row + data = [{"Title": "Dune: Part Two.", "Text": '"Dune: Part Two" is a film.'}] + add_table_rows(client, table_type, table.id, data, stream=False) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + r0 = rows.values[0] + t0 = datetime.fromisoformat(r0["Updated at"]) + # Update row + response = client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={r0["ID"]: {"Title": "hi", "Text": "papaya"}}, + ), + ) + assert isinstance(response, OkResponse) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + r1 = rows.values[0] + t1 = datetime.fromisoformat(r1["Updated at"]) + assert t1 > t0 + assert r1["Title"] != r0["Title"] + assert r1["Text"] != r0["Text"] + assert r1["Title Embed"] == r0["Title Embed"] + assert r1["Text Embed"] == r0["Text Embed"] + # Regen row + regen_table_rows(client, table_type, table.id, [r0["ID"]], stream=stream) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + r2 = rows.values[0] + t2 = datetime.fromisoformat(r2["Updated at"]) + assert t2 > t1 + assert r2["Title"] != r0["Title"] + assert r2["Text"] != r0["Text"] + assert r2["Title Embed"] != r0["Title Embed"] + assert r2["Text Embed"] != r0["Text Embed"] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_multiturn_regen( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + """ + Tests multiturn row regen. + - Each row correctly sees the regenerated output of the previous row + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + stream (bool): Stream (SSE) or not. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=setup.gpt_llm_model_id, + system_prompt="", + prompt="${User}", + max_tokens=20, + multi_turn=True, + ), + ), + ] + if table_type == TableType.CHAT: + chat_cols, cols = cols, [] + else: + chat_cols = None + with create_table(client, table_type, cols=cols, chat_cols=chat_cols) as table: + ### --- Add rows --- ### + response = add_table_rows( + client, + table_type, + table.id, + [ + dict(User="Hi", AI="How are you?"), + dict(User="Repeat your previous response."), + dict(User="Repeat your previous response."), + ], + stream=stream, + ) + # Check returned chunks / response + if stream: + assert len(response.rows) == 2 + else: + assert len(response.rows) == 3 + response.rows = response.rows[1:] + for row in response.rows: + chat = row.columns["AI"].content.strip() + assert chat == "How are you?", f"{row.columns=}" + # Update the second row + client.table.update_table_rows( + table_type, + MultiRowUpdateRequest( + table_id=table.id, + data={response.rows[0].row_id: dict(User="Good. What is 5+5?")}, + ), + ) + ### --- Regen rows --- ### + response = regen_table_rows( + client, + table_type, + table.id, + [response.rows[0].row_id, response.rows[1].row_id], + stream=stream, + ) + assert len(response.rows) == 2 + for row in response.rows: + chat = row.columns["AI"].content.strip() + assert chat != "How are you?", f"{row.columns=}" + assert "10" in chat, f"{row.columns=}" diff --git a/services/api/tests/gen_table/test_table_ops.py b/services/api/tests/gen_table/test_table_ops.py new file mode 100644 index 0000000..9512ff6 --- /dev/null +++ b/services/api/tests/gen_table/test_table_ops.py @@ -0,0 +1,2160 @@ +import re +from contextlib import contextmanager +from dataclasses import dataclass +from os.path import dirname, join, realpath +from typing import Generator + +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + ActionTableSchemaCreate, + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + CellCompletionResponse, + ChatCompletionResponse, # Assuming this might be needed for detailed checks later + ChatTableSchemaCreate, + ColumnDropRequest, + ColumnRenameRequest, + ColumnReorderRequest, + ColumnSchema, + ColumnSchemaCreate, + DeploymentCreate, + GenConfigUpdateRequest, + KnowledgeTableSchemaCreate, + MultiRowAddRequest, + MultiRowCompletionResponse, + OrganizationCreate, + RAGParams, + RowCompletionResponse, + TableMetaResponse, +) +from owl.types import ( + CloudProvider, + LLMGenConfig, + Role, + TableType, +) +from owl.utils.exceptions import ( + BadInputError, + ResourceExistsError, + ResourceNotFoundError, +) +from owl.utils.test import ( + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_4O_MINI_CONFIG, + GPT_4O_MINI_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, + get_file_map, + list_table_rows, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) +EMBEDDING_MODEL = "openai/text-embedding-3-small" +TABLE_TYPES = [TableType.ACTION, TableType.KNOWLEDGE, 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", "Page"] +CT_FIXED_COLUMN_IDS = ["User"] + +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ドラマ映画ã§ã™ã€‚' + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + user_id: str + org_id: str + project_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + # Create superuser + create_user() as superuser, + # Create user + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + # Create organization + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as org, + # Create project + create_project(dict(name="Bucket A"), user_id=superuser.id, organization_id=org.id) as p0, + ): + assert superuser.id == "0" + assert org.id == "0" + client = JamAI(user_id=superuser.id) + # Join organization and project + client.organizations.join_organization( + user_id=user.id, organization_id=org.id, role=Role.ADMIN + ) + client.projects.join_project(user_id=user.id, project_id=p0.id, role=Role.ADMIN) + + # Create models + with ( + create_model_config(GPT_4O_MINI_CONFIG), + create_model_config( + { + "id": "openai/Qwen/Qwen-2-Audio-7B", + "type": "llm", + "name": "ELLM Qwen2 Audio (7B)", + "capabilities": ["chat", "audio"], + "context_length": 128000, + "languages": ["en"], + } + ) as llm_config_audio, + create_model_config(ELLM_EMBEDDING_CONFIG), + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG), + ): + # Create deployments + with ( + create_deployment(GPT_4O_MINI_DEPLOYMENT), + create_deployment( + DeploymentCreate( + model_id=llm_config_audio.id, + name="ELLM Qwen2 Audio (7B) Deployment", + provider=CloudProvider.ELLM, + routing_id=llm_config_audio.id, + api_base="https://llmci.embeddedllm.com/audio/v1", + ) + ), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + yield ServingContext( + superuser_id=superuser.id, + user_id=user.id, + org_id=org.id, + project_id=p0.id, + ) + + +def _get_chat_model(client: JamAI) -> str: + models = client.model_ids(prefer="openai/gpt-4o-mini", capabilities=["chat"]) + return models[0] + + +def _get_image_models(client: JamAI) -> list[str]: + models = client.model_ids(prefer="openai/gpt-4o-mini", capabilities=["image"]) + return models + + +def _get_chat_only_model(client: JamAI) -> str: + chat_models = client.model_ids(capabilities=["chat"]) + image_models = _get_image_models(client) + chat_only_models = [model for model in chat_models if model not in image_models] + if not chat_only_models: + pytest.skip("No chat-only model available for testing.") + return chat_only_models[0] + + +def _get_reranking_model(client: JamAI) -> str: + models = client.model_ids(capabilities=["rerank"]) + return models[0] + + +@contextmanager +def _create_table( + client: JamAI, + table_type: TableType, + table_id: str = TABLE_ID_A, + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + embedding_model: str | None = None, +): + try: + if cols is None: + cols = [ + ColumnSchemaCreate(id="good", dtype="bool"), + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate(id="stars", dtype="float"), + ColumnSchemaCreate(id="inputs", dtype="str"), + ColumnSchemaCreate(id="photo", dtype="image"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + 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, + ), + ), + ColumnSchemaCreate( + id="captioning", + dtype="str", + gen_config=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 = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are a wacky assistant.", + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + + if table_type == TableType.ACTION: + table = client.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == TableType.KNOWLEDGE: + if embedding_model is None: + embedding_model = "" + table = client.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + elif table_type == TableType.CHAT: + table = client.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: + try: + client.table.delete_table(table_type, table_id) + except ResourceNotFoundError: + pass # Ignore if already deleted + + +@contextmanager +def _create_table_v2( + client: JamAI, + table_type: TableType, + table_id: str = TABLE_ID_A, + cols: list[ColumnSchemaCreate] | None = None, + chat_cols: list[ColumnSchemaCreate] | None = None, + llm_model: str = "", + embedding_model: str = "", + system_prompt: str = "", + prompt: str = "", +) -> Generator[TableMetaResponse, None, None]: + try: + if cols is None: + _input_cols = [ + ColumnSchemaCreate(id=f"in_{dtype}", dtype=dtype) + for dtype in REGULAR_COLUMN_DTYPES + ] + _output_cols = [ + ColumnSchemaCreate( + id=f"out_{dtype}", + dtype=dtype, + gen_config=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 = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=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 == TableType.ACTION: + table = client.table.create_action_table( + ActionTableSchemaCreate(id=table_id, cols=cols) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=table_id, cols=cols, embedding_model=embedding_model) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + table = client.table.create_chat_table( + 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, TableMetaResponse) + col_ids = set(c.id for c in table.cols) + assert col_ids == expected_cols + yield table + finally: + try: + client.table.delete_table(table_type, table_id) + except Exception: + pass # Ignore if already deleted + + +def _add_row( + client: JamAI, + table_type: 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: + # Use a placeholder URI, actual file upload isn't needed for table ops tests + data = dict( + good=True, + words=5, + stars=7.9, + inputs=TEXT, + photo="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 == 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + # Consume the stream to ensure completion for tests that need data populated + return response + # list(response) + # return None # Streamed responses are handled differently + assert isinstance(response, MultiRowCompletionResponse) + assert len(response.rows) == 1 + return response.rows[0] + + +def _add_row_v2( + client: JamAI, + table_type: 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, +) -> MultiRowCompletionResponse | None: + 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 == 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 = client.table.add_table_rows( + table_type, + MultiRowAddRequest(table_id=table_name, data=[data], stream=stream), + ) + if stream: + # Consume the stream + _ = list(response) + # For simplicity in table ops tests, we might not need to reconstruct the full response + return None + assert isinstance(response, MultiRowCompletionResponse) + assert response.object == "gen_table.completion.rows" + assert len(response.rows) == 1 + return response + + +@contextmanager +def _rename_table( + client: JamAI, + table_type: TableType, + table_id_src: str, + table_id_dst: str, +): + try: + table = client.table.rename_table(table_type, table_id_src, table_id_dst) + assert isinstance(table, TableMetaResponse) + yield table + finally: + try: + client.table.delete_table(table_type, table_id_dst) + except ResourceNotFoundError: + pass # Ignore if already deleted + + +@contextmanager +def _duplicate_table( + client: JamAI, + table_type: TableType, + table_id_src: str, + table_id_dst: str, + include_data: bool = True, + create_as_child: bool = False, +): + try: + table = client.table.duplicate_table( + table_type, + table_id_src, + table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + ) + assert isinstance(table, TableMetaResponse) + yield table + finally: + try: + client.table.delete_table(table_type, table_id_dst) + except ResourceNotFoundError: + pass # Ignore if already deleted + + +@contextmanager +def _create_child_table( + client: JamAI, + table_type: TableType, + table_id_src: str, + table_id_dst: str | None, +): + created_id = None + try: + table = client.table.duplicate_table( + table_type, table_id_src, table_id_dst, create_as_child=True + ) + created_id = table.id # Store the actual ID created + assert isinstance(table, TableMetaResponse) + yield table + finally: + if created_id: + try: + client.table.delete_table(table_type, created_id) + except ResourceNotFoundError: + pass # Ignore if already deleted + + +def _collect_text( + responses: MultiRowCompletionResponse | Generator[ChatCompletionResponse, None, None], + col: str, +): + if isinstance(responses, MultiRowCompletionResponse): + # Assuming only one row for simplicity in these tests + if col in responses.rows[0].columns: + return responses.rows[0].columns[col].content + else: + return "" # Column might not exist (e.g., AI in non-chat table) + # Handling stream (simplified for table ops) + content = "" + for r in responses: + if hasattr(r, "output_column_name") and r.output_column_name == col: + content += r.content + return content + + +@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( + setup: ServingContext, + table_type: TableType, + table_id: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type, table_id) as table: + assert isinstance(table, TableMetaResponse) + assert table.id == table_id + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_table_valid_column_id( + setup: ServingContext, + table_type: TableType, +): + table_id = TABLE_ID_A + col_ids = ["a", "0", "a b", "a-b", "a_b", "a-_b", "a-_0b", "a -_0b", "0_0"] + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # --- Test input column --- # + cols = [ColumnSchemaCreate(id=_id, dtype="str") for _id in col_ids] + with _create_table(client, table_type, table_id, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + created_col_ids = {c.id for c in table.cols if c.id in col_ids} + assert created_col_ids == set(col_ids) + + client.table.delete_table(table_type, table_id) + # --- Test output column --- # + cols = [ + ColumnSchemaCreate( + id=_id, + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="You are a concise assistant.", + prompt="Reply yes", + temperature=0.001, + top_p=0.001, + max_tokens=3, + ), + ) + for _id in col_ids + ] + with _create_table(client, table_type, table_id, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + created_col_ids = {c.id for c in table.cols if c.id in col_ids} + assert created_col_ids == set(col_ids) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize( + "invalid_table_id", ["a_", "_a", "_aa", "aa_", "_a_", "-a", ".a", "a" * 101] +) +def test_create_table_invalid_table_id( + setup: ServingContext, + table_type: TableType, + invalid_table_id: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ColumnSchemaCreate(id="valid_col", dtype="str")] + with pytest.raises(BadInputError): + with _create_table(client, table_type, invalid_table_id, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("column_id", ["a_", "_a", "_aa", "aa_", "_a_", "-a", ".a", "a" * 101]) +def test_create_table_invalid_column_id( + setup: ServingContext, + table_type: TableType, + column_id: str, +): + table_id = TABLE_ID_A + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # --- Test input column --- # + cols = [ + ColumnSchemaCreate(id=column_id, dtype="str"), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, table_id, cols=cols): + pass + + # --- Test output column --- # + cols = [ + ColumnSchemaCreate( + id=column_id, + dtype="str", + gen_config=LLMGenConfig(), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, table_id, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_table_invalid_model( + setup: ServingContext, + table_type: TableType, +): + table_id = TABLE_ID_A + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(model="INVALID_MODEL_ID"), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, table_id, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_table_invalid_column_ref( + setup: ServingContext, + table_type: TableType, +): + table_id = TABLE_ID_A + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(prompt="Summarise ${input_non_existent}"), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, table_id, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_table_invalid_rag( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # Create the knowledge table first + with _create_table(client, TableType.KNOWLEDGE, TABLE_ID_B, cols=[]) as ktable: + # --- Valid knowledge table ID --- # + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + rag_params=RAGParams(table_id=ktable.id), + ), + ), + ] + # --- Invalid knowledge table ID --- # + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + rag_params=RAGParams(table_id="INVALID_KT_ID"), + ), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, cols=cols): + pass + + # --- Valid reranker --- # + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + rag_params=RAGParams( + table_id=ktable.id, reranking_model=_get_reranking_model(client) + ), + ), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + + # --- Invalid reranker --- # + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + rag_params=RAGParams(table_id=ktable.id, reranking_model="INVALID_RERANKER"), + ), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_default_llm_model( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(), + ), + ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert isinstance(cols_dict["output0"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output0"].gen_config.model, str) + assert len(cols_dict["output0"].gen_config.model) > 0 + assert cols_dict["output1"].gen_config is None + if table_type == TableType.CHAT: + assert isinstance(cols_dict["AI"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["AI"].gen_config.model, str) + assert len(cols_dict["AI"].gen_config.model) > 0 + + # --- Update gen config --- # + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=None, + output1=LLMGenConfig(), + ), + ), + ) + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert cols_dict["output0"].gen_config is None + assert isinstance(cols_dict["output1"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output1"].gen_config.model, str) + assert len(cols_dict["output1"].gen_config.model) > 0 + if table_type == TableType.CHAT: + assert isinstance(cols_dict["AI"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["AI"].gen_config.model, str) + assert len(cols_dict["AI"].gen_config.model) > 0 + + # --- Add column --- # + add_cols = [ + ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=None, + ), + ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=LLMGenConfig(), + ), + ] + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=add_cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert cols_dict["output0"].gen_config is None + assert isinstance(cols_dict["output1"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output1"].gen_config.model, str) + assert len(cols_dict["output1"].gen_config.model) > 0 + assert cols_dict["output2"].gen_config is None + assert isinstance(cols_dict["output3"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output3"].gen_config.model, str) + assert len(cols_dict["output3"].gen_config.model) > 0 + if table_type == TableType.CHAT: + assert isinstance(cols_dict["AI"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["AI"].gen_config.model, str) + assert len(cols_dict["AI"].gen_config.model) > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_default_image_model( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + available_image_models = _get_image_models(client) + if not available_image_models: + pytest.skip("No image model available for testing.") + + cols = [ + ColumnSchemaCreate(id="input0", dtype="image"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(prompt="${input0}"), + ), + ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=None, + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert isinstance(cols_dict["output0"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output0"].gen_config.model, str) + assert cols_dict["output0"].gen_config.model in available_image_models + assert cols_dict["output1"].gen_config is None + if table_type == TableType.CHAT: + assert isinstance(cols_dict["AI"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["AI"].gen_config.model, str) + # Default AI model might not be an image model if not needed + # assert cols_dict["AI"].gen_config.model in available_image_models + + # --- Update gen config --- # + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=None, + output1=LLMGenConfig(prompt="${input0}"), + ), + ), + ) + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert cols_dict["output0"].gen_config is None + assert isinstance(cols_dict["output1"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output1"].gen_config.model, str) + assert cols_dict["output1"].gen_config.model in available_image_models + + # --- Add column --- # + add_cols_1 = [ + ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=LLMGenConfig(prompt="${input0}"), + ), + ColumnSchemaCreate(id="file_input1", dtype="image"), + ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=LLMGenConfig(prompt="${file_input1}"), + ), + ] + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=add_cols_1) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=add_cols_1) + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns( + AddChatColumnSchema(id=table.id, cols=add_cols_1) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + + # Add a column with default prompt (should pick image model if image inputs exist) + add_cols_2 = [ + ColumnSchemaCreate( + id="output4", + dtype="str", + gen_config=LLMGenConfig(), + ), + ] + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=add_cols_2) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=add_cols_2) + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns( + AddChatColumnSchema(id=table.id, cols=add_cols_2) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert cols_dict["output0"].gen_config is None + for output_column_name in ["output1", "output2", "output3", "output4"]: + assert isinstance(cols_dict[output_column_name].gen_config, LLMGenConfig) + model = cols_dict[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}' + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_invalid_image_model( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + available_image_models = _get_image_models(client) + if not available_image_models: + pytest.skip("No image model available for testing.") + try: + chat_only_model = _get_chat_only_model(client) + except IndexError: + pytest.skip("No chat-only model available for testing.") + + cols = [ + ColumnSchemaCreate(id="input0", dtype="image"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(model=chat_only_model, prompt="${input0}"), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, cols=cols): + pass + + cols_valid = [ + ColumnSchemaCreate(id="input0", dtype="image"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(prompt="${input0}"), + ), + ] + with _create_table(client, table_type, cols=cols_valid) as table: + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert isinstance(cols_dict["output0"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output0"].gen_config.model, str) + assert cols_dict["output0"].gen_config.model in available_image_models + + # --- Update gen config --- # + with pytest.raises(BadInputError): + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=LLMGenConfig( + model=chat_only_model, + prompt="${input0}", + ), + ), + ), + ) + # Ensure update with valid model works + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=TABLE_ID_A, + column_map=dict( + output0=LLMGenConfig(prompt="${input0}"), + ), + ), + ) + assert isinstance(table, TableMetaResponse) + # Check gen configs + cols_dict = {c.id: c for c in table.cols} + assert isinstance(cols_dict["output0"].gen_config, LLMGenConfig) + assert isinstance(cols_dict["output0"].gen_config.model, str) + assert cols_dict["output0"].gen_config.model in available_image_models + + # --- Add column --- # + add_cols = [ + ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=LLMGenConfig(model=chat_only_model, prompt="${input0}"), + ) + ] + with pytest.raises(BadInputError): + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns( + AddChatColumnSchema(id=table.id, cols=add_cols) + ) + else: + raise ValueError(f"Invalid table type: {table_type}") + + +def test_default_embedding_model( + setup: ServingContext, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, TableType.KNOWLEDGE, cols=[], embedding_model="") as table: + assert isinstance(table, TableMetaResponse) + for col in table.cols: + if col.vlen == 0: + continue + assert len(col.gen_config.embedding_model) > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_default_reranker( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # Create the knowledge table first + with _create_table(client, TableType.KNOWLEDGE, TABLE_ID_B, cols=[]) as ktable: + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig( + rag_params=RAGParams(table_id=ktable.id, reranking_model=""), + ), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + cols_dict = {c.id: c for c in table.cols} + rag_params = cols_dict["output0"].gen_config.rag_params + assert isinstance(rag_params, RAGParams) + reranking_model = rag_params.reranking_model + assert isinstance(reranking_model, str) + assert len(reranking_model) > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_default_prompts( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="input0", dtype="str"), + ColumnSchemaCreate(id="input1", dtype="str"), + ColumnSchemaCreate( + id="output0", + dtype="str", + gen_config=LLMGenConfig(), # Empty gen_config to trigger defaults + ), + ColumnSchemaCreate( + id="output1", + dtype="str", + gen_config=LLMGenConfig(), # Empty gen_config to trigger defaults + ), + ColumnSchemaCreate( + id="output2", + dtype="str", + gen_config=LLMGenConfig( + system_prompt="You are an assistant.", + prompt="Summarise ${input0}.", + ), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + # Define expected input columns based on table type + input_cols_set = {"input0", "input1"} + if table_type == TableType.KNOWLEDGE: + input_cols_set |= {"Title", "Text", "File ID", "Page"} + elif table_type == TableType.CHAT: + input_cols_set |= {"User"} + + cols_dict = {c.id: c for c in table.cols} + + # Check ["output0", "output1"] for default prompts referencing all inputs + for col_id in ["output0", "output1"]: + gen_config = cols_dict[col_id].gen_config + assert isinstance(gen_config, LLMGenConfig) + assert isinstance(gen_config.prompt, str) + referenced_cols = set(re.findall(r"\$\{(\w+(?:\s\w+)*)\}", gen_config.prompt)) + # Default prompt should reference all non-ID, non-updated_at, non-output, non-vector columns + expected_referenced = { + c.id + for c in table.cols + if c.id not in ("ID", "Updated at") + and c.gen_config is None + and "Embed" not in c.id + } + assert referenced_cols == expected_referenced, ( + f"Col {col_id}: Expected {expected_referenced}, got {referenced_cols}" + ) + + # Check ["output2"] for provided prompts + gen_config_2 = cols_dict["output2"].gen_config + assert isinstance(gen_config_2, LLMGenConfig) + assert gen_config_2.system_prompt == "You are an assistant." + assert gen_config_2.prompt == "Summarise ${input0}." + referenced_cols_2 = set(re.findall(r"\$\{(\w+(?:\s\w+)*)\}", gen_config_2.prompt)) + assert referenced_cols_2 == {"input0"} + + # --- Add column --- # + add_cols = [ + ColumnSchemaCreate( + id="input2", + dtype="int", + ), + ColumnSchemaCreate( + id="output3", + dtype="str", + gen_config=LLMGenConfig(), # Trigger default prompt + ), + ] + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=add_cols) + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=add_cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + + cols_dict = {c.id: c for c in table.cols} + + # Check ["output3"] for default prompt referencing all *current* inputs + gen_config_3 = cols_dict["output3"].gen_config + assert isinstance(gen_config_3, LLMGenConfig) + assert isinstance(gen_config_3.prompt, str) + referenced_cols_3 = set(re.findall(r"\$\{(\w+(?:\s\w+)*)\}", gen_config_3.prompt)) + expected_referenced_3 = { + c.id + for c in table.cols + if c.id not in ("ID", "Updated at") and c.gen_config is None and "Embed" not in c.id + } + assert referenced_cols_3 == expected_referenced_3, ( + f"Col output3: Expected {expected_referenced_3}, got {referenced_cols_3}" + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_add_drop_columns( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table_v2(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + _add_row_v2( + client, + table_type, + stream=False, + include_output_data=False, + ) + + # --- COLUMN ADD --- # + _input_cols = [ + ColumnSchemaCreate(id=f"add_in_{dtype}", dtype=dtype) + for dtype in REGULAR_COLUMN_DTYPES + ] + _output_cols = [ + ColumnSchemaCreate( + id=f"add_out_{dtype}", + dtype=dtype, + gen_config=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 == TableType.ACTION: + table = client.table.add_action_columns(AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + expected_cols |= {"User", "AI"} + table = client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, 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 = list_table_rows(client, table_type, table.id) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 1 + row = rows.values[0] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col 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(client, table_type, False, data=data) + rows = list_table_rows(client, table_type, table.id) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 2 + row = rows.values[-1] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col is not None + + # --- COLUMN DROP --- # + table = client.table.drop_columns( + table_type, + 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 == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + rows = list_table_rows(client, table_type, table.id) + 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(client, table_type, False, data=data) + rows = list_table_rows(client, table_type, table.id) + 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 + ] + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_add_drop_file_column( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table_v2(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + _add_row_v2( + client, + table_type, + stream=False, + include_output_data=False, + ) + + # --- COLUMN ADD --- # + cols = [ + ColumnSchemaCreate(id="add_in_file", dtype="image"), + ColumnSchemaCreate( + id="add_out_str", + dtype="str", + gen_config=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 == TableType.ACTION: + table = client.table.add_action_columns(AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + expected_cols |= {"User", "AI"} + table = client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, 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 = list_table_rows(client, table_type, table.id) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 1 + row = rows.values[0] + for col_id, col in row.items(): + if not col_id.startswith("add_"): + continue + assert col is None + # Test adding a new row + upload_response = upload_file(client, FILES["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(client, table_type, False, data=data) + assert len(response.rows[0].columns["add_out_str"].content) > 0 + rows = list_table_rows(client, table_type, table.id) + assert all(set(r.keys()) == expected_cols for r in rows.items) + assert len(rows.items) == 2 + row = rows.values[-1] + for col_id, col in row.items(): + if not col_id.startswith("add_in_"): + continue + assert col is not None + + # Block file output column + with pytest.raises(BadInputError): + cols = [ + ColumnSchemaCreate( + id="add_out_file", + dtype="image", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="Describe image ${add_in_file}", + max_tokens=10, + ), + ), + ] + if table_type == TableType.ACTION: + client.table.add_action_columns(AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == TableType.KNOWLEDGE: + client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=cols) + ) + elif table_type == TableType.CHAT: + client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols)) + else: + raise ValueError(f"Invalid table type: {table_type}") + + # --- COLUMN DROP --- # + table = client.table.drop_columns( + table_type, + 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 == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == TableType.CHAT: + expected_cols |= {"User", "AI"} + else: + raise ValueError(f"Invalid table type: {table_type}") + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + cols = set(c.id for c in table.cols) + assert cols == expected_cols, cols + rows = list_table_rows(client, table_type, table.id) + 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(client, table_type, False, data={"add_in_file": upload_response.uri}) + rows = list_table_rows(client, table_type, table.id) + 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 + ] + + +def test_kt_drop_invalid_columns(setup: ServingContext): + table_type = "knowledge" + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + for col in KT_FIXED_COLUMN_IDS: + with pytest.raises(BadInputError): + client.table.drop_columns( + table_type, + ColumnDropRequest(table_id=table.id, column_names=[col]), + ) + + +def test_ct_drop_invalid_columns(setup: ServingContext): + table_type = "chat" + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + for col in CT_FIXED_COLUMN_IDS: + with pytest.raises(BadInputError): + client.table.drop_columns( + table_type, + ColumnDropRequest(table_id=table.id, column_names=[col]), + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_rename_columns( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="x", dtype="str"), + ColumnSchemaCreate( + id="y", + dtype="str", + gen_config=LLMGenConfig(prompt=r"Summarise ${x}, \${x}"), + ), + ] + with _create_table(client, table_type, cols=cols) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + # Test rename on empty table + table = client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map=dict(y="z")), + ) + assert isinstance(table, TableMetaResponse) + expected_cols = {"ID", "Updated at", "x", "z"} + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == 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 = client.table.get_table(table_type, table.id) + assert isinstance(table, TableMetaResponse) + cols = set(c.id for c in table.cols) + assert cols == expected_cols + # Test adding data with new column names + _add_row(client, table_type, False, data=dict(x="True", z="")) + # Test rename table with data + # Test also auto gen config reference update + table = client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map=dict(x="a")), + ) + assert isinstance(table, TableMetaResponse) + expected_cols = {"ID", "Updated at", "a", "z"} + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_cols |= {"Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"} + elif table_type == 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 = client.table.get_table(table_type, table.id) + assert isinstance(table, 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(ResourceExistsError): + client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="b")), + ) + # Rename to existing column name + with pytest.raises(ResourceExistsError): + client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map=dict(z="a")), + ) + # Overlapping new and old column names is OK depending on rename order + client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map=dict(a="b", z="a")), + ) + table = client.table.get_table(table_type, table.id) + assert isinstance(table, TableMetaResponse) + cols = set(c.id for c in table.cols) + assert len({"ID", "Updated at", "b", "a"} - cols) == 0 + + +def test_kt_rename_invalid_columns(setup: ServingContext): + table_type = "knowledge" + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + for col in KT_FIXED_COLUMN_IDS: + with pytest.raises(BadInputError): + client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map={col: col}), + ) + + +def test_ct_rename_invalid_columns(setup: ServingContext): + table_type = "chat" + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + for col in CT_FIXED_COLUMN_IDS: + with pytest.raises(BadInputError): + client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map={col: col}), + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_reorder_columns( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + table = client.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, TableMetaResponse) + + column_names = [ + "ID", + "Updated at", + "inputs", + "good", + "words", + "stars", + "photo", + "summary", + "captioning", + ] + expected_order = [ + "ID", + "Updated at", + "good", + "words", + "stars", + "inputs", + "photo", + "summary", + "captioning", + ] + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order = ( + expected_order[:2] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + + expected_order[2:] + ) + elif table_type == 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 = client.table.reorder_columns( + table_type, + 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 == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + expected_order += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + elif table_type == 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 = client.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, TableMetaResponse) + cols = [c.id for c in table.cols] + assert cols == expected_order, cols + # Test add row + response = _add_row( + client, + table_type, + True, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT), + ) + summary = _collect_text(list(response), "summary") + assert len(summary) > 0 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_reorder_columns_invalid( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + assert all(isinstance(c, ColumnSchema) for c in table.cols) + table = client.table.get_table(table_type, TABLE_ID_A) + assert isinstance(table, TableMetaResponse) + + column_names = [ + "ID", + "Updated at", + "inputs", + "good", + "words", + "stars", + "photo", + "summary", + "captioning", + ] + expected_order = [ + "ID", + "Updated at", + "good", + "words", + "stars", + "inputs", + "photo", + "summary", + "captioning", + ] + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + expected_order = ( + expected_order[:2] + + ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + + expected_order[2:] + ) + elif table_type == 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 = [ + "ID", + "Updated at", + "inputs", + "good", + "stars", + "summary", + "words", + "photo", + "captioning", + ] + if table_type == TableType.ACTION: + pass + elif table_type == TableType.KNOWLEDGE: + column_names += ["Title", "Title Embed", "Text", "Text Embed", "File ID", "Page"] + elif table_type == TableType.CHAT: + column_names += ["User", "AI"] + else: + raise ValueError(f"Invalid table type: {table_type}") + with pytest.raises(BadInputError): + client.table.reorder_columns( + table_type, + ColumnReorderRequest(table_id=TABLE_ID_A, column_names=column_names), + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +def test_null_gen_config( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest(table_id=table.id, column_map=dict(summary=None)), + ) + response = _add_row( + client, 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, CellCompletionResponse) for r in responses) + else: + assert isinstance(response, RowCompletionResponse) + rows = list_table_rows(client, table_type, table.id) + assert len(rows.items) == 1 + row = rows.values[0] + assert row["summary"] is None + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_invalid_referenced_column( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # --- Non-existent column --- # + cols = [ + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are a concise assistant.", + prompt="Summarise ${inputs}", + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + with pytest.raises(BadInputError): + with _create_table(client, table_type, cols=cols): + pass + + # --- Vector column --- # + cols = [ + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + 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(BadInputError): + with _create_table(client, table_type, cols=cols): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("stream", [True, False], ids=["stream", "non-stream"]) +def test_gen_config_empty_prompts( + setup: ServingContext, + table_type: TableType, + stream: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="words", dtype="int"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + temperature=0.001, + top_p=0.001, + max_tokens=10, + ), + ), + ] + chat_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + temperature=0.001, + top_p=0.001, + max_tokens=5, + ), + ), + ] + with _create_table(client, table_type, cols=cols, chat_cols=chat_cols) as table: + assert isinstance(table, TableMetaResponse) + data = dict(words=5) + if table_type == TableType.KNOWLEDGE: + data["Title"] = "Dune: Part Two." + data["Text"] = "Dune: Part Two is a 2024 American epic science fiction film." + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest(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, CellCompletionResponse) for r in responses) + summary = "".join(r.content for r in responses if r.output_column_name == "summary") + assert len(summary) > 0 + if table_type == TableType.CHAT: + ai = "".join(r.content for r in responses if r.output_column_name == "AI") + assert len(ai) > 0 + else: + assert isinstance(response.rows[0], RowCompletionResponse) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_table_search_and_parent_id( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # _delete_tables(client) + with ( + _create_table(client, table_type, "beast") as table, + _create_table(client, table_type, "feast"), + _create_table(client, table_type, "bear"), + _create_table(client, table_type, "fear"), + ): + assert isinstance(table, TableMetaResponse) + with ( + _create_child_table(client, table_type, "beast", "least"), + _create_child_table(client, table_type, "beast", "lease"), + _create_child_table(client, table_type, "beast", "yeast"), + ): + # Regular list + tables = client.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, TableMetaResponse) for r in tables.items) + # Search + tables = client.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, TableMetaResponse) for r in tables.items) + # Search + tables = client.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, TableMetaResponse) for r in tables.items) + # Search with parent ID + tables = client.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, TableMetaResponse) for r in tables.items) + # Search with parent ID + tables = client.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, TableMetaResponse) for r in tables.items) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_duplicate_table( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + # Duplicate with data + with _duplicate_table(client, table_type, TABLE_ID_A, TABLE_ID_B) as table: + # Add another to table A + _add_row( + client, + 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 = list_table_rows(client, table_type, TABLE_ID_B) + assert len(rows.items) == 1 + + # Duplicate without data + with _duplicate_table( + client, table_type, TABLE_ID_A, TABLE_ID_C, include_data=False + ) as table: + assert table.id == TABLE_ID_C + rows = list_table_rows(client, table_type, TABLE_ID_C) + assert len(rows.items) == 0 + + # # Deploy with data + # with _duplicate_table(client, 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 = list_table_rows(client,table_type, TABLE_ID_B) + # assert len(rows.items) == 2 + + # # Deploy will always include data + # with _duplicate_table( + # client, 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 = list_table_rows(client,table_type, TABLE_ID_C) + # assert len(rows.items) == 2 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize("table_id_dst", ["Y", None]) +@pytest.mark.parametrize("include_data", [True, False]) +@pytest.mark.parametrize("create_as_child", [True, False]) +def test_duplicate_table_nonexistent( + setup: ServingContext, + table_type: TableType, + table_id_dst: str | None, + include_data: bool, + create_as_child: bool, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with pytest.raises(ResourceNotFoundError): + client.table.duplicate_table( + table_type, + "X", + table_id_dst, + include_data=include_data, + create_as_child=create_as_child, + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +@pytest.mark.parametrize( + "table_id_dst", + ["a_", "_a", "_aa", "aa_", "_a_", "-a", ".a", "a" * 101], +) +def test_duplicate_table_invalid_name( + setup: ServingContext, + table_type: TableType, + table_id_dst: str, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + with pytest.raises(BadInputError): + with _duplicate_table(client, table_type, TABLE_ID_A, table_id_dst): + pass + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_child_table( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type) as table_a: + assert isinstance(table_a, TableMetaResponse) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + # Duplicate with data + with _create_child_table(client, table_type, TABLE_ID_A, TABLE_ID_B) as table_b: + assert isinstance(table_b, TableMetaResponse) + # Add another to table A + _add_row( + client, + 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 = list_table_rows(client, table_type, TABLE_ID_B) + assert len(rows.items) == 1 + + # Create child table with no dst id + with _create_child_table(client, 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 = list_table_rows(client, table_type, table_c.id) + assert len(rows.items) == 2 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_rename_table( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + with _create_table(client, table_type, TABLE_ID_A) as table: + assert isinstance(table, TableMetaResponse) + _add_row( + client, + table_type, + False, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + # Create child table + with _create_child_table(client, table_type, TABLE_ID_A, TABLE_ID_B) as child: + assert isinstance(child, TableMetaResponse) + # Rename + with _rename_table(client, table_type, TABLE_ID_A, TABLE_ID_C) as table: + rows = list_table_rows(client, table_type, TABLE_ID_C) + assert len(rows.items) == 1 + # Assert the old table is gone + with pytest.raises(ResourceNotFoundError): + list_table_rows(client, table_type, TABLE_ID_A) + # Assert the child table parent ID is updated + assert client.table.get_table(table_type, child.id).parent_id == TABLE_ID_C + # Add rows to both tables + _add_row( + client, + table_type, + False, + TABLE_ID_B, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + _add_row( + client, + table_type, + False, + TABLE_ID_C, + data=dict(good=True, words=5, stars=9.9, inputs=TEXT, summary=""), + ) + + +def test_chat_table_gen_config( + setup: ServingContext, +): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=_get_chat_model(client), + system_prompt="You are a concise assistant.", + multi_turn=False, + temperature=0.001, + top_p=0.001, + max_tokens=20, + ), + ), + ] + with _create_table(client, "chat", cols=[], chat_cols=cols) as table: + cfg_map = {c.id: c.gen_config for c in table.cols} + # AI column gen config will be multi turn regardless of input params + assert cfg_map["AI"].multi_turn is True diff --git a/services/api/tests/gen_table/test_table_ops_v2.py b/services/api/tests/gen_table/test_table_ops_v2.py new file mode 100644 index 0000000..6b27b36 --- /dev/null +++ b/services/api/tests/gen_table/test_table_ops_v2.py @@ -0,0 +1,824 @@ +from copy import deepcopy +from dataclasses import dataclass +from os.path import dirname, join, realpath +from tempfile import TemporaryDirectory + +import pytest +from sqlmodel import text + +from jamaibase import JamAI +from jamaibase.types import ( + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + ColumnSchemaCreate, + GenConfigUpdateRequest, + OkResponse, + OrganizationCreate, + OrgMemberRead, + ProjectMemberRead, + RAGParams, + TableImportRequest, + TableMetaResponse, +) +from owl.db import sync_session +from owl.types import ( + LLMGenConfig, + Role, + TableType, +) +from owl.utils.exceptions import BadInputError, ResourceNotFoundError +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + create_deployment, + create_model_config, + create_organization, + create_project, + create_table, + create_user, + list_table_rows, + list_tables, +) + +TEST_DIR = dirname(dirname(realpath(__file__))) +TABLE_TYPES = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + user_id: str + superorg_id: str + project_id: str + llm_model_id: str + desc_llm_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + # Create superuser and user + create_user() as superuser, + create_user(dict(email="user@up.com", name="User")) as user, + # Create organization + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as superorg, + # Create project + create_project( + dict(name="Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superuser.id == "0" + assert superorg.id == "0" + # Join organization and project as member + client = JamAI(user_id=superuser.id) + membership = client.organizations.join_organization( + user.id, + organization_id=superorg.id, + role=Role.MEMBER, + ) + assert isinstance(membership, OrgMemberRead) + membership = client.projects.join_project( + user.id, + project_id=p0.id, + role=Role.MEMBER, + ) + assert isinstance(membership, ProjectMemberRead) + + # Create models + gpt_config = deepcopy(GPT_41_NANO_CONFIG) + gpt_config.name = "A OpenAI GPT-4.1 nano" + with ( + # Purposely include a model name that starts with A to test default model sorting + create_model_config(gpt_config) as llm_config, + # Default model should still prefer ELLM model + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config(ELLM_EMBEDDING_CONFIG), + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + ): + # Create deployments + with ( + create_deployment(GPT_41_NANO_DEPLOYMENT), + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + yield ServingContext( + superuser_id=superuser.id, + user_id=user.id, + superorg_id=superorg.id, + project_id=p0.id, + llm_model_id=llm_config.id, + desc_llm_model_id=desc_llm_config.id, + rerank_model_id=rerank_config.id, + ) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_default_model_default_prompts( + setup: ServingContext, + table_type: TableType, +): + """ + Test default model and prompts: + - Default model + - Default prompts + - Table creation (should set default prompts) + - Multi-turn column + - Column add (should set default prompts) + - Gen config update (should NOT set default prompts) + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="str", dtype="str"), + # Default for system prompt and prompt + ColumnSchemaCreate( + id="o1", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="", + ), + ), + ColumnSchemaCreate(id="float", dtype="float"), + # Default for system prompt + ColumnSchemaCreate( + id="o2", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="What is love?", + ), + ), + # Default for prompt + ColumnSchemaCreate( + id="o3", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="Baby don't hurt me", + prompt="", + ), + ), + # Default for system prompt and prompt (multi-turn) + ColumnSchemaCreate( + id="o4", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="", + multi_turn=True, + ), + ), + ] + with create_table(client, table_type, cols=cols) as table: + table = client.table.get_table(table_type, table.id) + assert isinstance(table, TableMetaResponse) + ### --- Default model --- ### + for col in ["o1", "o2", "o3", "o4"]: + gen_config = table.cfg_map[col] + assert isinstance(gen_config, LLMGenConfig) + assert gen_config.model == setup.desc_llm_model_id + ### --- Default prompts --- ### + default_sys_phrase = ( + "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." + ) + # Table creation + assert default_sys_phrase in table.cfg_map["o1"].system_prompt + assert default_sys_phrase in table.cfg_map["o2"].system_prompt + assert table.cfg_map["o3"].system_prompt == "Baby don't hurt me" + assert "You are an agent named" in table.cfg_map["o4"].system_prompt + + def _check_prompt(prompt: str): + assert "${str}" in prompt + assert "${ID}" not in prompt # Info columns + assert "${Updated at}" not in prompt # Info columns + if table_type == TableType.KNOWLEDGE: + assert "${Title}" in prompt + assert "${Text}" in prompt + assert "${File ID}" in prompt + assert "${Page}" in prompt + assert "${Title Embed}" not in prompt # Vector columns + assert "${Text Embed}" not in prompt # Vector columns + elif table_type == TableType.CHAT: + assert "${User}" in prompt + + gen_config = table.cfg_map["o1"] + assert "${float}" not in gen_config.prompt # Columns on its right + _check_prompt(gen_config.prompt) + assert table.cfg_map["o2"].prompt == "What is love?" + gen_config = table.cfg_map["o3"] + assert "${float}" in gen_config.prompt + _check_prompt(gen_config.prompt) + gen_config = table.cfg_map["o4"] + assert "${float}" in gen_config.prompt + _check_prompt(gen_config.prompt) + # Column add + cols = [ + ColumnSchemaCreate( + id="o5", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="", + ), + ), + ] + if table_type == TableType.ACTION: + client.table.add_action_columns(AddActionColumnSchema(id=table.id, cols=cols)) + elif table_type == TableType.KNOWLEDGE: + client.table.add_knowledge_columns(AddKnowledgeColumnSchema(id=table.id, cols=cols)) + else: + client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols)) + table = client.table.get_table(table_type, table.id) + gen_config = table.cfg_map["o5"] + assert default_sys_phrase in gen_config.system_prompt + assert "${float}" in gen_config.prompt + _check_prompt(gen_config.prompt) + # Update gen config to empty prompt + client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map={ + "o5": LLMGenConfig( + model="", + system_prompt="", + prompt="", + ) + }, + ), + ) + table = client.table.get_table(table_type, table.id) + gen_config = table.cfg_map["o5"] + assert isinstance(gen_config, LLMGenConfig) + assert gen_config.model == setup.desc_llm_model_id # Default model + assert gen_config.system_prompt == "" + assert gen_config.prompt == "" + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_create_delete_table( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + with create_table(client, table_type) as table: + assert isinstance(table, TableMetaResponse) + # Delete + response = client.table.delete_table(table_type, table.id) + assert isinstance(response, OkResponse) + # After deleting + with pytest.raises(ResourceNotFoundError, match="is not found."): + client.table.get_table(table_type, table.id) + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_get_list_tables( + setup: ServingContext, + table_type: TableType, +): + """ + Test get table and list tables. + - offset and limit + - order_by and order_ascending + - created_by + - parent_id (list project with agents, chat agent, chat, all tables) + - search_query + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + super_client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ColumnSchemaCreate(id="int", dtype="int")] + + ### --- Test get and list on DB without schemas --- ### + with sync_session() as session: + for table_type in TableType: + session.exec(text(f'DROP SCHEMA IF EXISTS "{setup.project_id}_{table_type}" CASCADE')) + session.commit() + tables = list_tables(client, table_type) + assert len(tables.items) == 0 + assert tables.total == 0 + with pytest.raises(ResourceNotFoundError, match="Table .+ is not found."): + client.table.get_table(table_type, "123") + + ### --- Create tables --- ### + with ( + create_table(super_client, table_type, "Table 2", cols=cols) as t0, + create_table(super_client, table_type, "table 1", cols=cols) as t1, + create_table(client, table_type, "Table 0", cols=cols) as t2, + ): + assert isinstance(t0, TableMetaResponse) + assert isinstance(t1, TableMetaResponse) + assert isinstance(t2, TableMetaResponse) + num_tables = 3 + ### --- List tables --- ### + tables = list_tables(client, table_type) + assert len(tables.items) == num_tables + assert tables.total == num_tables + assert [t.id for t in tables.items] == [t0.id, t1.id, t2.id] + + ### --- Get table --- ### + for table in tables.items: + _table = client.table.get_table(table_type, table.id) + assert isinstance(_table, TableMetaResponse) + assert _table.model_dump(exclude={"num_rows"}) == table.model_dump( + exclude={"num_rows"} + ) + + ### --- List tables (case-insensitive sort) --- ### + _tables = list_tables(client, table_type, order_by="id") + assert _tables.total == num_tables + assert [t.id for t in _tables.items] == [t2.id, t1.id, t0.id] + + ### --- List tables (offset and limit) --- ### + _tables = list_tables(client, table_type, offset=0, limit=1) + assert len(_tables.items) == 1 + assert _tables.total == num_tables + assert _tables.items[0].id == tables.items[0].id, f"{_tables.items=}" + _tables = list_tables(client, table_type, offset=1, limit=1) + assert len(_tables.items) == 1 + assert _tables.total == num_tables + assert _tables.items[0].id == tables.items[1].id, f"{_tables.items=}" + # Offset >= num tables + _tables = list_tables(client, table_type, offset=num_tables, limit=1) + assert len(_tables.items) == 0 + assert _tables.total == num_tables + _tables = list_tables(client, table_type, offset=num_tables + 1, limit=1) + assert len(_tables.items) == 0 + assert _tables.total == num_tables + # Invalid offset and limit + with pytest.raises(BadInputError): + list_tables(client, table_type, offset=0, limit=0) + with pytest.raises(BadInputError): + list_tables(client, table_type, offset=-1, limit=1) + + ### --- List tables (order_by and order_ascending) --- ### + _tables = list_tables(client, table_type, order_ascending=False) + assert len(tables.items) == num_tables + assert _tables.total == num_tables + assert [t.id for t in _tables.items[::-1]] == [t.id for t in tables.items] + _tables = list_tables(client, table_type, order_by="id") + assert len(tables.items) == num_tables + assert _tables.total == num_tables + assert [t.id for t in _tables.items[::-1]] == [t.id for t in tables.items] + + ### --- List tables (created_by) --- ### + _tables = list_tables(client, table_type, created_by=setup.superuser_id) + assert len(_tables.items) == 2 + assert _tables.total == 2 + assert _tables.total != num_tables + _tables = list_tables(client, table_type, created_by=setup.user_id) + assert len(_tables.items) == 1 + assert _tables.total == 1 + assert _tables.total != num_tables + + ### --- List tables (parent_id) --- ### + if table_type == TableType.CHAT: + # Create a child table + _table = client.table.duplicate_table(table_type, t0.id, None, create_as_child=True) + try: + assert isinstance(_table, TableMetaResponse) + # List projects with chat agent list + projects = client.projects.list_projects(setup.superorg_id, list_chat_agents=True) + assert len(projects.items) == 1 + assert projects.total == 1 + _project = projects.items[0] + assert len(_project.chat_agents) == num_tables + # List all chat agents + _tables = list_tables(client, table_type, parent_id="_agent_") + assert len(_tables.items) == num_tables + assert _tables.total == num_tables + assert {t.id for t in _tables.items} == {t.id for t in _project.chat_agents} + _tables = list_tables(client, table_type, parent_id="_agent_", offset=1) + assert len(_tables.items) == num_tables - 1 + assert _tables.total == num_tables + # List all chats + _tables = list_tables(client, table_type, parent_id="_chat_") + assert len(_tables.items) == 1 + assert _tables.total == 1 + # List all tables + _tables = list_tables(client, table_type, parent_id=None) + assert len(_tables.items) == num_tables + 1 + assert _tables.total == num_tables + 1 + finally: + client.table.delete_table(table_type, _table.id) + + ### --- List tables (search_query) --- ### + _tables = list_tables(client, table_type, search_query="1") + assert len(_tables.items) == 1 + assert _tables.total == 1 + assert _tables.total != num_tables + assert _tables.items[0].id == t1.id + _tables = list_tables(client, table_type, search_query="1", offset=1) + assert len(_tables.items) == 0 + assert _tables.total == 1 + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_update_gen_config( + setup: ServingContext, + table_type: TableType, +): + """ + Test updating table generation config: + - Partial update + - Switch to/from None + - Chat AI column must always have gen config + - Chat AI column multi-turn must always be True + - Invalid column reference + - Invalid LLM model + - Invalid knowledge table ID + - Invalid reranker model + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="i0", dtype="str"), + ColumnSchemaCreate(id="o0", dtype="str", gen_config=LLMGenConfig()), + ColumnSchemaCreate(id="o1", dtype="str", gen_config=None), + ] + with ( + create_table(client, TableType.KNOWLEDGE) as kt, + create_table(client, table_type, cols=cols) as table, + ): + assert isinstance(table.cfg_map["o0"], LLMGenConfig) + assert len(table.cfg_map["o0"].system_prompt) > 0 + assert len(table.cfg_map["o0"].prompt) > 0 + assert table.cfg_map["o1"] is None + if table_type == TableType.CHAT: + assert isinstance(table.cfg_map["AI"], LLMGenConfig) + + # --- Partial update --- # + old_cfg = table.cfg_map["o0"].model_dump() + # Update prompt + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o0=LLMGenConfig(prompt="test")), + ), + ) + assert isinstance(table, TableMetaResponse) + assert isinstance(table.cfg_map["o0"], LLMGenConfig) + assert len(table.cfg_map["o0"].system_prompt) > 0 + assert table.cfg_map["o0"].prompt == "test" + new_cfg = table.cfg_map["o0"].model_dump() + assert old_cfg != new_cfg + old_cfg["prompt"] = "test" + assert old_cfg == new_cfg + + # --- Switch to/from None --- # + # Flip configs + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o0=None, o1=LLMGenConfig()), + ), + ) + assert isinstance(table, TableMetaResponse) + assert table.cfg_map["o0"] is None + assert isinstance(table.cfg_map["o1"], LLMGenConfig) + assert len(table.cfg_map["o1"].system_prompt) == 0 + assert len(table.cfg_map["o1"].prompt) == 0 + if table_type == TableType.CHAT: + assert isinstance(table.cfg_map["AI"], LLMGenConfig) + + # --- Chat AI column must always have gen config --- # + # --- Chat AI column multi-turn must always be True --- # + if table_type == TableType.CHAT: + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(AI=None), + ), + ) + assert isinstance(table, TableMetaResponse) + assert isinstance(table.cfg_map["AI"], LLMGenConfig) + table.cfg_map["AI"].multi_turn = False + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(AI=table.cfg_map["AI"]), + ), + ) + assert isinstance(table, TableMetaResponse) + assert isinstance(table.cfg_map["AI"], LLMGenConfig) + assert table.cfg_map["AI"].multi_turn is True + + # --- Invalid column reference --- # + with pytest.raises(BadInputError, match="invalid source columns"): + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o1=LLMGenConfig(prompt="${o2}")), + ), + ) + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o1=LLMGenConfig(prompt="${o0}")), + ), + ) + assert table.cfg_map["o1"].prompt == "${o0}" + + # --- Invalid LLM model --- # + with pytest.raises(BadInputError, match="LLM model .+ is not found"): + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o0=LLMGenConfig(model="INVALID")), + ), + ) + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict(o0=LLMGenConfig(model=setup.llm_model_id)), + ), + ) + assert table.cfg_map["o0"].model == setup.llm_model_id + + # --- Invalid knowledge table ID --- # + with pytest.raises(BadInputError, match="Knowledge Table .+ does not exist"): + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + o0=LLMGenConfig(rag_params=RAGParams(table_id="INVALID")), + ), + ), + ) + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + o0=LLMGenConfig(rag_params=RAGParams(table_id=kt.id)), + ), + ), + ) + assert isinstance(table.cfg_map["o0"].rag_params, RAGParams) + assert table.cfg_map["o0"].rag_params.table_id == kt.id + + # --- Invalid reranker model --- # + with pytest.raises(BadInputError, match="Reranking model .+ is not found"): + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + o0=LLMGenConfig(rag_params=RAGParams(reranking_model="INVALID")), + ), + ), + ) + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map=dict( + o0=LLMGenConfig( + rag_params=RAGParams(reranking_model=setup.rerank_model_id), + ), + ), + ), + ) + assert isinstance(table.cfg_map["o0"].rag_params, RAGParams) + assert table.cfg_map["o0"].rag_params.reranking_model == setup.rerank_model_id + assert table.cfg_map["o0"].rag_params.table_id == kt.id + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_long_table_column_ids( + setup: ServingContext, + table_type: TableType, +): + """ + Test various table and row operations on a table with long table and column IDs (100 characters). + - Check default prompts + - Update gen config + - Rename table and column + - Add row before and after: + - Table and column renames + - Column add and drop + - List rows + - Hybrid search + - RAG + - Import and export + + Args: + setup (ServingContext): Setup. + table_type (TableType): Table type. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 100 characters + kt_id = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen (0)" + table_id = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen (1)" + col_ids = [table_id, table_id.replace("one", "111"), table_id.replace("one", "112")] + cols = [ + ColumnSchemaCreate(id=col_ids[0], dtype="str").model_dump(), + ColumnSchemaCreate( + id=col_ids[1], dtype="str", gen_config=LLMGenConfig(model=setup.desc_llm_model_id) + ).model_dump(), + ColumnSchemaCreate( + id=col_ids[2], + dtype="str", + gen_config=LLMGenConfig( + model=setup.desc_llm_model_id, rag_params=RAGParams(table_id=kt_id) + ), + ), + ] + with ( + create_table(client, TableType.KNOWLEDGE, table_id=kt_id, cols=[]) as kt, + create_table(client, table_type, table_id=table_id, cols=cols) as table, + ): + assert kt.id == kt_id + assert table.id == table_id + col_map = {c.id: c for c in table.cols} + # Add knowledge data + add_table_rows(client, TableType.KNOWLEDGE, kt.id, [dict(), dict()], stream=False) + rows = list_table_rows(client, TableType.KNOWLEDGE, kt.id) + assert len(rows.values) == 2 + assert rows.total == 2 + # Check default prompts + gen_cfg = col_map[col_ids[1]].gen_config + assert isinstance(gen_cfg, LLMGenConfig) + assert isinstance(gen_cfg.system_prompt, str) + assert len(gen_cfg.system_prompt) > 1 + assert isinstance(gen_cfg.prompt, str) + assert len(gen_cfg.prompt) > 1 + assert f'Table name: "{table.id}"' in gen_cfg.prompt + assert f"{col_ids[0]}: ${{{col_ids[0]}}}" in gen_cfg.prompt + assert f'column "{col_ids[1]}"' in gen_cfg.prompt + # Update prompt and multi-turn + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest( + table_id=table.id, + column_map={ + col_ids[1]: LLMGenConfig(prompt=f"${{{col_ids[0]}}}", multi_turn=True) + }, + ), + ) + assert isinstance(table, TableMetaResponse) + # Add row + row_data = {"Title": "", "Text": "", "User": "Hi", "AI": "Hello"} + response = add_table_rows( + client, table_type, table.id, [{col_ids[0]: "one", **row_data}], stream=False + ) + content = response.rows[0].columns[col_ids[1]].content + assert "System prompt: There is a text with [40] tokens." in content + assert "There is a text with [1] tokens." in content + # Rename table + table_id_dst = table.id.replace("one", "two") + table = client.table.rename_table(table_type, table.id, table_id_dst) + assert isinstance(table, TableMetaResponse) + assert table.id == table_id_dst + # Rename column + col_id_dst = col_ids[1].replace("111", "222") + table = client.table.rename_columns( + table_type, + dict(table_id=table.id, column_map={col_ids[1]: col_id_dst}), + ) + assert isinstance(table, TableMetaResponse) + col_ids[1] = col_id_dst + col_map = {c.id: c for c in table.cols} + assert col_id_dst in col_map + # Add row + response = add_table_rows( + client, table_type, table.id, [{col_ids[0]: "one two", **row_data}], stream=True + ) + content = response.rows[0].columns[col_ids[1]].content + assert "System prompt: There is a text with [40] tokens." in content + assert "There is a text with [1] tokens." in content + assert "There is a text with [2] tokens." in content + # Add column + new_col_id = col_ids[1].replace("222", "333") + new_cols = [ + ColumnSchemaCreate( + id=new_col_id, dtype="str", gen_config=LLMGenConfig(model=setup.desc_llm_model_id) + ).model_dump(), + ] + if table_type == TableType.ACTION: + table = client.table.add_action_columns(dict(id=table.id, cols=new_cols)) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns(dict(id=table.id, cols=new_cols)) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns(dict(id=table.id, cols=new_cols)) + else: + raise ValueError(f"Unknown table type: {table_type}") + col_map = {c.id: c for c in table.cols} + assert new_col_id in col_map + # Check default prompts + gen_cfg = col_map[new_col_id].gen_config + assert isinstance(gen_cfg, LLMGenConfig) + assert isinstance(gen_cfg.system_prompt, str) + assert len(gen_cfg.system_prompt) > 1 + assert isinstance(gen_cfg.prompt, str) + assert len(gen_cfg.prompt) > 1 + assert f'Table name: "{table.id}"' in gen_cfg.prompt + assert f"{col_ids[0]}: ${{{col_ids[0]}}}" in gen_cfg.prompt + assert f'column "{new_col_id}"' in gen_cfg.prompt + # Add row + response = add_table_rows( + client, table_type, table.id, [{col_ids[0]: "a b c", **row_data}], stream=True + ) + content = response.rows[0].columns[col_ids[1]].content + assert "System prompt: There is a text with [40] tokens." in content + assert "There is a text with [1] tokens." in content + assert "There is a text with [2] tokens." in content + assert "There is a text with [3] tokens." in content + content = response.rows[0].columns[new_col_id].content + assert "There is a text with" in content + # Drop column + table = client.table.drop_columns( + table_type, dict(table_id=table.id, column_names=[new_col_id]) + ) + assert isinstance(table, TableMetaResponse) + col_map = {c.id: c for c in table.cols} + assert new_col_id not in col_map + # Add row + response = add_table_rows( + client, table_type, table.id, [{col_ids[0]: "a b c d", **row_data}], stream=True + ) + content = response.rows[0].columns[col_ids[1]].content + assert "System prompt: There is a text with [40] tokens." in content + assert "There is a text with [1] tokens." in content + assert "There is a text with [2] tokens." in content + assert "There is a text with [3] tokens." in content + assert "There is a text with [4] tokens." in content + assert len(response.rows[0].columns) == 2 + # List rows + rows = list_table_rows(client, table_type, table.id) + assert len(rows.values) == 4 + assert rows.total == 4 + for r in rows.references: + assert len(r[col_ids[2]].chunks) == 2 + rows = list_table_rows(client, table_type, table.id, where=f""""{col_ids[1]}" ~* '3'""") + assert len(rows.values) == 2 + assert rows.total == 2 + with pytest.raises(BadInputError): + list_table_rows(client, table_type, table.id, where=f""""{col_ids[1]}" ~* 3""") + # Hybrid search + results = client.table.hybrid_search( + table_type, dict(table_id=table.id, query="token", limit=2) + ) + assert isinstance(results, list) + assert len(results) == 2 + for r in results: + assert "rrf_score" in r + for c in col_ids: + assert c in r + # Export table + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, f"{table.id}.parquet") + with open(file_path, "wb") as f: + f.write(client.table.export_table(table_type, table.id)) + # Import table + import_table_id = table.id.replace("(1)", "(2)") + response = client.table.import_table( + table_type, + TableImportRequest( + file_path=file_path, table_id_dst=import_table_id, blocking=True + ), + ) + rows = list_table_rows(client, table_type, import_table_id) + assert len(rows.values) == 4 + assert rows.total == 4 + for r in rows.references: + assert len(r[col_ids[2]].chunks) == 2 diff --git a/services/api/tests/gen_table/test_v1.py b/services/api/tests/gen_table/test_v1.py new file mode 100644 index 0000000..1f1cf48 --- /dev/null +++ b/services/api/tests/gen_table/test_v1.py @@ -0,0 +1,366 @@ +from dataclasses import dataclass +from os.path import dirname, join, realpath +from tempfile import TemporaryDirectory + +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + ActionTableSchemaCreate, + AddActionColumnSchema, + AddChatColumnSchema, + AddKnowledgeColumnSchema, + ChatTableSchemaCreate, + ChatThreadResponse, + ColumnDropRequest, + ColumnRenameRequest, + ColumnReorderRequest, + ColumnSchemaCreate, + GenConfigUpdateRequest, + KnowledgeTableSchemaCreate, + MultiRowAddRequest, + MultiRowCompletionResponse, + MultiRowDeleteRequest, + MultiRowRegenRequest, + OkResponse, + OrganizationCreate, + Page, + RowUpdateRequest, + SearchRequest, + TableDataImportRequest, + TableImportRequest, + TableMetaResponse, +) +from owl.types import ( + LLMGenConfig, + TableType, +) +from owl.utils.crypt import generate_key +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, +) + +TEST_DIR = dirname(dirname(realpath(__file__))) +TABLE_TYPES = [TableType.ACTION, TableType.KNOWLEDGE, TableType.CHAT] + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + superorg_id: str + project_id: str + llm_model_id: str + desc_llm_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + # Create superuser + create_user() as superuser, + # Create organization + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as superorg, + # Create project + create_project( + dict(name="Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superuser.id == "0" + assert superorg.id == "0" + + # Create models + with ( + create_model_config(GPT_41_NANO_CONFIG) as llm_config, + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config(ELLM_EMBEDDING_CONFIG), + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG), + ): + # Create deployments + with ( + create_deployment(GPT_41_NANO_DEPLOYMENT), + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + yield ServingContext( + superuser_id=superuser.id, + superorg_id=superorg.id, + project_id=p0.id, + llm_model_id=llm_config.id, + desc_llm_model_id=desc_llm_config.id, + ) + + +def _gen_id() -> str: + return generate_key(8, "table-") + + +@pytest.mark.parametrize("table_type", TABLE_TYPES) +def test_gen_table_v1( + setup: ServingContext, + table_type: TableType, +): + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + cols = [ + ColumnSchemaCreate(id="int", dtype="int"), + ColumnSchemaCreate( + id="summary", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="", + prompt="", + max_tokens=10, + ), + ), + ] + # Create table + if table_type == TableType.ACTION: + table = client.table.create_action_table( + ActionTableSchemaCreate(id=_gen_id(), cols=cols), v1=True + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.create_knowledge_table( + KnowledgeTableSchemaCreate(id=_gen_id(), cols=cols, embedding_model=""), v1=True + ) + elif table_type == TableType.CHAT: + cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model="", + system_prompt="You are a wacky assistant.", + max_tokens=5, + ), + ), + ] + cols + table = client.table.create_chat_table( + ChatTableSchemaCreate(id=_gen_id(), cols=cols), v1=True + ) + else: + raise ValueError(f"Unknown table type: {table_type}") + assert isinstance(table, TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert "int" in cols + + # Duplicate table + new_table = client.table.duplicate_table(table_type, table_id_src=table.id, v1=True) + assert isinstance(new_table, TableMetaResponse) + assert new_table.id != table.id + + # Get table + _table = client.table.get_table(table_type, table_id=table.id, v1=True) + assert isinstance(_table, TableMetaResponse) + assert _table.id == table.id + + # List tables + tables = client.table.list_tables(table_type, v1=True) + assert isinstance(tables, Page) + assert len(tables.items) == 2 + assert tables.total == 2 + + # Rename table + table_id_dst = _gen_id() + _table = client.table.rename_table( + table_type, new_table.id, table_id_dst=table_id_dst, v1=True + ) + assert isinstance(_table, TableMetaResponse) + assert _table.id != new_table.id + assert _table.id == table_id_dst + new_table = _table + + # Delete table + response = client.table.delete_table(table_type, table_id=new_table.id, v1=True) + assert isinstance(response, OkResponse) + + # Add columns + cols = [ColumnSchemaCreate(id="str", dtype="str")] + if table_type == TableType.ACTION: + table = client.table.add_action_columns( + AddActionColumnSchema(id=table.id, cols=cols), v1=True + ) + elif table_type == TableType.KNOWLEDGE: + table = client.table.add_knowledge_columns( + AddKnowledgeColumnSchema(id=table.id, cols=cols), v1=True + ) + elif table_type == TableType.CHAT: + table = client.table.add_chat_columns(AddChatColumnSchema(id=table.id, cols=cols), v1=True) + else: + raise ValueError(f"Unknown table type: {table_type}") + assert isinstance(table, TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert "int" in cols + assert "str" in cols + + # Rename columns + table = client.table.rename_columns( + table_type, + ColumnRenameRequest(table_id=table.id, column_map={"int": "integer"}), + v1=True, + ) + assert isinstance(table, TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert "int" not in cols + assert "integer" in cols + + # Update gen config + table = client.table.update_gen_config( + table_type, + GenConfigUpdateRequest(table_id=table.id, column_map={"summary": None}), + v1=True, + ) + assert isinstance(table, TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert cols["summary"].gen_config is None + + # Reorder columns + if table_type == TableType.ACTION: + table = client.table.reorder_columns( + table_type, + ColumnReorderRequest(table_id=table.id, column_names=["str", "integer", "summary"]), + v1=True, + ) + assert isinstance(table, TableMetaResponse) + assert [c.id for c in table.cols][-3:] == ["str", "integer", "summary"] + + # Drop columns + table = client.table.drop_columns( + table_type, + ColumnDropRequest(table_id=table.id, column_names=["integer"]), + v1=True, + ) + assert isinstance(table, TableMetaResponse) + cols = {c.id: c for c in table.cols} + assert "integer" not in cols + + # Add rows + response = client.table.add_table_rows( + table_type, + MultiRowAddRequest( + table_id=table.id, data=[{"str": "foo", "summary": "bar"}] * 3, stream=False + ), + v1=True, + ) + assert isinstance(response, MultiRowCompletionResponse) + assert len(response.rows) == 3 + + # List rows + rows = client.table.list_table_rows(table_type, table.id, v1=True) + assert isinstance(rows, Page) + assert len(rows.items) == 3 + assert rows.total == 3 + for row in rows.items: + assert "value" in row["str"] + + # List rows (V1 value bug) + rows = client.table.list_table_rows(table_type, table.id, columns=["str"], v1=True) + assert isinstance(rows, Page) + assert len(rows.items) == 3 + assert rows.total == 3 + for row in rows.items: + assert "value" not in row["str"] + + # Get row + row_id = rows.items[0]["ID"] + row = client.table.get_table_row(table_type, table.id, row_id, v1=True) + assert isinstance(row, dict) + + # Get conversation thread + if table_type == TableType.CHAT: + thread = client.table.get_conversation_thread(table_type, table.id, "AI") + assert isinstance(thread, ChatThreadResponse) + + # Hybrid search + response = client.table.hybrid_search( + table_type, + SearchRequest(table_id=table.id, query="foo"), + v1=True, + ) + assert isinstance(response, list) + assert len(response) == 3 + + # Regen rows + response = client.table.regen_table_rows( + table_type, + MultiRowRegenRequest(table_id=table.id, row_ids=[row_id], stream=False), + v1=True, + ) + assert isinstance(response, MultiRowCompletionResponse) + assert len(response.rows) == 1 + + # Update row + response = client.table.update_table_row( + table_type, + RowUpdateRequest(table_id=table.id, row_id=row_id, data={"str": "baz"}), + ) + assert isinstance(response, OkResponse) + + # Delete rows + response = client.table.delete_table_rows( + table_type, + MultiRowDeleteRequest(table_id=table.id, row_ids=[row_id]), + v1=True, + ) + assert isinstance(response, OkResponse) + + # Delete row + response = client.table.delete_table_row(table_type, table.id, rows.items[1]["ID"]) + assert isinstance(response, OkResponse) + + # Data import export + csv_bytes = client.table.export_table_data(table_type, table.id, v1=True) + assert len(csv_bytes) > 0 + with TemporaryDirectory() as tmp_dir: + fp = join(tmp_dir, "test.csv") + with open(fp, "wb") as f: + f.write(csv_bytes) + response = client.table.import_table_data( + table_type, + TableDataImportRequest(file_path=fp, table_id=table.id, stream=False), + v1=True, + ) + assert isinstance(response, MultiRowCompletionResponse) + assert len(response.rows) == 1 + + # Table import export + parquet_bytes = client.table.export_table(table_type, table.id, v1=True) + assert len(parquet_bytes) > 0 + with TemporaryDirectory() as tmp_dir: + fp = join(tmp_dir, "test.parquet") + with open(fp, "wb") as f: + f.write(parquet_bytes) + _table = client.table.import_table( + table_type, + TableImportRequest(file_path=fp, table_id_dst=_gen_id()), + v1=True, + ) + assert isinstance(_table, TableMetaResponse) + assert _table.id != table.id + + # Embed file + if table_type == TableType.KNOWLEDGE: + with TemporaryDirectory() as tmp_dir: + fp = join(tmp_dir, "test.txt") + with open(fp, "w") as f: + f.write("Lorem ipsum") + response = client.table.embed_file(fp, table.id, v1=True) + assert isinstance(response, OkResponse) diff --git a/services/api/tests/gen_table_core/test_gen_table_core.py b/services/api/tests/gen_table_core/test_gen_table_core.py new file mode 100644 index 0000000..1a5fcb5 --- /dev/null +++ b/services/api/tests/gen_table_core/test_gen_table_core.py @@ -0,0 +1,1909 @@ +import asyncio +import csv +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from jamaibase.types import ProjectRead +from owl.db.gen_table import ( + GENTABLE_ENGINE, + ColumnDtype, + ColumnMetadata, + GenerativeTableCore, + TableMetadata, +) +from owl.types import LLMGenConfig, TableType +from owl.utils.exceptions import BadInputError, ResourceNotFoundError +from owl.utils.test import ( + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + create_deployment, + create_model_config, + create_project, + setup_organizations, +) + +VECTOR_LEN = 2 + + +@dataclass(slots=True) +class Session: + projects: list[ProjectRead] + chat_model_id: str + + +@dataclass(slots=True) +class Setup: + projects: list[ProjectRead] + chat_model_id: str + table_type: str + table_id: str + schema_id: str + table: GenerativeTableCore + + +@pytest.fixture(autouse=True, scope="module") +def session(): + with setup_organizations() as ctx: + with ( + create_project(dict(name="Mickey 17"), user_id=ctx.superuser.id) as p0, + create_project(dict(name="Mickey 18"), user_id=ctx.superuser.id) as p1, + create_model_config(GPT_41_NANO_CONFIG) as llm_config, + create_deployment(GPT_41_NANO_DEPLOYMENT), + ): + yield Session( + projects=[p0, p1], + chat_model_id=llm_config.id, + ) + + +@pytest.fixture(autouse=True, scope="function") +async def setup(session: Session): + """Fixture to set up and tear down test environment""" + table_type = TableType.ACTION + table_id = "Table (test)" + project_id = session.projects[0].id + schema_id = f"{project_id}_{table_type}" + # Drop schema + await GenerativeTableCore.drop_schema(project_id=project_id, table_type=table_type) + + # Create table + table = await GenerativeTableCore.create_table( + project_id=project_id, + table_type=table_type, + table_metadata=TableMetadata( + table_id=table_id, + title="Test Table", + parent_id=None, + version="1", + versioning_enabled=True, + meta={}, + ), + column_metadata_list=[ + ColumnMetadata( + column_id="col (1)", + table_id=table_id, + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=1, + meta={}, + ), + ColumnMetadata( + column_id="col (2)", + table_id=table_id, + dtype=ColumnDtype.INT, + vlen=0, + gen_config=None, + column_order=2, + meta={}, + ), + ColumnMetadata( + column_id="vector_col", + table_id=table_id, + dtype=ColumnDtype.FLOAT, + vlen=VECTOR_LEN, + gen_config=None, + column_order=3, + meta={}, + ), + ], + ) + yield Setup( + projects=session.projects, + chat_model_id=session.chat_model_id, + table_type=table_type, + table_id=table_id, + schema_id=schema_id, + table=table, + ) + # Clean up table + async with GENTABLE_ENGINE.transaction() as conn: + await conn.execute(f""" + DROP SCHEMA IF EXISTS "{schema_id}" CASCADE + """) + # https://github.com/MagicStack/asyncpg/issues/293#issuecomment-395069799 + # Need to close the connection, such that the next test will create pool on the new event loop + await GENTABLE_ENGINE.close() + + +@contextmanager +def assert_updated_time(table: GenerativeTableCore): + """Assert that table "updated_at" has been updated""" + start_time = table.table_metadata.updated_at + try: + yield + finally: + assert table.table_metadata.updated_at > start_time + + +class TestImportExportOperations: + async def test_export_empty_table(self, setup: Setup, tmp_path): + """Test exporting and importing an empty table preserves schema""" + table = setup.table + + # Export empty table + export_path = tmp_path / "empty_export.parquet" + await table.export_table(export_path) + assert export_path.exists() + + # Import empty table + imported_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_empty_table", + ) + + # Verify schema preserved + assert len((await imported_table.list_rows()).items) == 0 + assert len(imported_table.column_metadata) == len(table.column_metadata) + for orig_col, imp_col in zip( + table.column_metadata, imported_table.column_metadata, strict=True + ): + assert orig_col.column_id == imp_col.column_id + assert orig_col.dtype == imp_col.dtype + assert orig_col.vlen == imp_col.vlen + + async def test_import_table_to_new_project(self, setup: Setup, tmp_path): + """Test exporting and importing an empty table preserves schema""" + table = setup.table + new_project_id = setup.projects[1].id + # cleanup before test + await GenerativeTableCore.drop_schema( + project_id=new_project_id, table_type=setup.table_type + ) + + # Export empty table + export_path = tmp_path / "empty_export.parquet" + await table.export_table(export_path) + assert export_path.exists() + + # Import empty table + imported_table = await GenerativeTableCore.import_table( + project_id=new_project_id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_empty_table", + ) + + # Verify schema preserved + assert len((await imported_table.list_rows()).items) == 0 + assert len(imported_table.column_metadata) == len(table.column_metadata) + for orig_col, imp_col in zip( + table.column_metadata, imported_table.column_metadata, strict=True + ): + assert orig_col.column_id == imp_col.column_id + assert orig_col.dtype == imp_col.dtype + assert orig_col.vlen == imp_col.vlen + + async def test_state_column_preservation(self, setup: Setup, tmp_path): + """Test state columns are preserved during export/import""" + table = setup.table + + # Add row with state values + new_row = { + "col (1)": "test", + "col (2)": 123, + "vector_col": np.random.rand(VECTOR_LEN), + } + await table.add_rows([new_row]) + + # Export table + export_path = tmp_path / "state_export.parquet" + await table.export_table(export_path) + + # Import table + imported_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_state_table", + ) + + # Verify state columns preserved + rows = (await imported_table.list_rows(remove_state_cols=False)).items + assert len(rows) == 1 + for state_col, _ in new_row.items(): + assert rows[0].get(f"{state_col}_") is not None + + async def test_export_table_basic(self, setup: Setup, tmp_path): + """Test basic table export functionality""" + # Create table + table = setup.table + + # Add test data + await table.add_rows( + [{"col (1)": "test1", "col (2)": 123, "vector_col": np.random.rand(VECTOR_LEN)}] + ) + + # Export table + export_path = tmp_path / "exported_table.parquet" + await table.export_table(export_path) + + # Verify file exists + assert export_path.exists() + assert export_path.stat().st_size > 0 + + async def test_export_table_error_cases(self, setup: Setup, tmp_path): + """Test error cases for table export""" + # Create table + table = setup.table + + # Test invalid path + invalid_path = Path("/invalid/path/export.parquet") + with pytest.raises(ResourceNotFoundError): + await table.export_table(invalid_path) + + async def test_import_table_basic(self, setup: Setup, tmp_path): + """Test basic table import functionality""" + # Create and export test table + table = setup.table + await table.add_rows( + [{"col (1)": "test1", "col (2)": 123, "vector_col": np.random.rand(VECTOR_LEN)}] + ) + export_path = tmp_path / "exported_table.parquet" + await table.export_table(export_path) + + # Import table with new name + imported_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_table", + ) + + # Verify imported data + rows = (await imported_table.list_rows()).items + assert len(rows) == 1 + assert rows[0]["col (1)"] == "test1" + assert rows[0]["col (2)"] == 123 + assert len(rows[0]["vector_col"]) == VECTOR_LEN + + async def test_import_table_error_cases(self, setup: Setup, tmp_path): + """Test error cases for table import""" + # Test invalid path + invalid_path = Path("/invalid/path/import.parquet") + with pytest.raises(ResourceNotFoundError): + await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=invalid_path, + table_id_dst="imported_table", + ) + + async def test_export_import_parquet_basic(self, setup: Setup, tmp_path): + """Test basic Parquet export/import functionality with detailed verification""" + table = setup.table + + # Add test data with different types + test_data = [ + { + "col (1)": "test1", + "col (2)": 123, + "vector_col": np.random.rand(VECTOR_LEN), + } + ] + await table.add_rows(test_data) + + # Get original metadata and columns + original_metadata = table.table_metadata + original_columns = table.column_metadata + + # Export to Parquet + export_path = tmp_path / "exported_table.parquet" + await table.export_table(export_path) + + # Verify file exists + assert export_path.exists() + assert export_path.stat().st_size > 0 + + # Import with new name + imported_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_table_parquet", + ) + + # Verify imported data + rows = (await imported_table.list_rows()).items + assert len(rows) == 1 + + # Detailed data comparison + original_row = (await table.list_rows()).items[0] + imported_row = rows[0] + + # Compare all fields except internal IDs + for data in test_data: + for key in data.keys(): + if isinstance(data[key], np.ndarray): + np.testing.assert_array_equal(original_row[key], imported_row[key]) + else: + assert original_row[key] == imported_row[key] + + # Verify metadata preservation + imported_metadata = imported_table.table_metadata + assert imported_metadata.title == original_metadata.title + assert imported_metadata.meta == original_metadata.meta + # assert imported_metadata.version == original_metadata.version + + # Verify column preservation + imported_columns = imported_table.column_metadata + assert len(imported_columns) == len(original_columns) + + for orig_col, imp_col in zip(original_columns, imported_columns, strict=True): + assert orig_col.column_id == imp_col.column_id + assert orig_col.dtype == imp_col.dtype + assert orig_col.vlen == imp_col.vlen + assert orig_col.column_order == imp_col.column_order + + async def test_import_recreates_indexes(self, setup: Setup, tmp_path): + """Verify imported tables have all indexes recreated""" + # Export original table + export_path = tmp_path / "export.parquet" + await setup.table.export_table(export_path) + + # Import to new table + imported = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_with_indexes", + ) + + # Verify indexes exist + async with GENTABLE_ENGINE.transaction() as conn: + # Check FTS index + fts_index = await conn.fetchval( + """ + SELECT COUNT(*) FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 AND indexname LIKE '%_fts_idx' + """, + imported.schema_id, + imported.table_id, + ) + assert fts_index > 0 + + # Check vector indexes + vec_indexes = await conn.fetchval( + """ + SELECT COUNT(*) FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 AND indexname LIKE '%_vec_idx' + """, + imported.schema_id, + imported.table_id, + ) + assert vec_indexes == len(imported.vector_column_names) + + async def test_export_import_parquet_large_data(self, setup: Setup, tmp_path): + """Test Parquet export/import with large dataset and detailed verification""" + table = setup.table + + # Add 1000 rows of test data + test_data = [ + { + "col (1)": f"test{i}", + "col (2)": i, + "vector_col": np.random.rand(VECTOR_LEN), + } + for i in range(1000) + ] + await table.add_rows(test_data) + + # Get original metadata and columns + original_metadata = table.table_metadata + original_columns = table.column_metadata + + # Export to Parquet + export_path = tmp_path / "large_export.parquet" + await table.export_table(export_path) + + # Import with new name + imported_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="large_import_parquet", + ) + + # Verify all data was imported + rows = (await imported_table.list_rows(limit=1000)).items + assert len(rows) == 1000 + + # Get original rows for comparison + original_rows = (await table.list_rows(limit=1000)).items + + # Detailed data comparison + for orig_row, imp_row in zip(original_rows, rows, strict=True): + assert orig_row["col (1)"] == imp_row["col (1)"] + assert orig_row["col (2)"] == imp_row["col (2)"] + np.testing.assert_array_equal(orig_row["vector_col"], imp_row["vector_col"]) + + # Verify metadata preservation + imported_metadata = imported_table.table_metadata + assert imported_metadata.title == original_metadata.title + assert imported_metadata.meta == original_metadata.meta + # assert imported_metadata.version == original_metadata.version + + # Verify column preservation + imported_columns = imported_table.column_metadata + assert len(imported_columns) == len(original_columns) + + for orig_col, imp_col in zip(original_columns, imported_columns, strict=True): + assert orig_col.column_id == imp_col.column_id + assert orig_col.dtype == imp_col.dtype + assert orig_col.vlen == imp_col.vlen + assert orig_col.column_order == imp_col.column_order + + async def test_export_parquet_error_cases(self, setup: Setup, tmp_path): + """Test Parquet export error cases""" + table = setup.table + + # Test invalid path + invalid_path = Path("/invalid/path/export.parquet") + with pytest.raises(ResourceNotFoundError): + await table.export_table(invalid_path) + + # Test invalid format + with pytest.raises(BadInputError): + await table.export_table(tmp_path / "test.csv") + + async def test_import_parquet_invalid_path_cases(self, setup: Setup, tmp_path): + """Test Parquet import invalid case cases""" + # Test invalid path + invalid_path = Path("/invalid/path/import.parquet") + with pytest.raises(ResourceNotFoundError): + await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=invalid_path, + table_id_dst="imported_table", + ) + + async def test_import_corrupt_files(self, setup: Setup, tmp_path): + """Test handling of corrupted import files.""" + # Setup + corrupt_path = tmp_path / "corrupt.parquet" + + # Test malformed file + corrupt_path.write_bytes(b"PAR1\x00\x00INVALID\x00PAR1") + with pytest.raises(BadInputError, match="contains bad data"): + await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=corrupt_path, + table_id_dst="corrupt_test_table", + ) + + # Test partial file (truncated) + with open(corrupt_path, "wb") as f: + f.write(b"PAR1") # Only magic bytes + with pytest.raises(BadInputError, match="contains bad data"): + await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=corrupt_path, + table_id_dst="corrupt_test_table", + ) + + # Test invalid metadata + df = pd.DataFrame({"col (1)": [1, 2, 3]}) + df.to_parquet(corrupt_path) + # Corrupt the metadata by overwriting footer + with open(corrupt_path, "r+b") as f: + f.seek(-100, 2) + f.write(b"X" * 100) + with pytest.raises(BadInputError, match="contains bad data"): + await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=corrupt_path, + table_id_dst="corrupt_test_table", + ) + + async def test_export_data_basic(self, setup: Setup, tmp_path): + """Test basic data export to CSV""" + # Create table + table = setup.table + + # Add test data + await table.add_rows( + [{"col (1)": "test1", "col (2)": 123, "vector_col": np.random.rand(VECTOR_LEN)}] + ) + + # Export data + export_path = tmp_path / "exported_data.csv" + await table.export_data(export_path) + + # Verify CSV content + with open(export_path, "r") as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 1 + assert rows[0]["col (1)"] == "test1" + assert rows[0]["col (2)"] == "123" + assert len(rows[0]["vector_col"].split(",")) == VECTOR_LEN + + async def test_export_data_error_cases(self, setup: Setup, tmp_path): + """Test error cases for data export""" + # Create table + table = setup.table + + # Test invalid path + invalid_path = Path("/invalid/path/export.csv") + with pytest.raises(BadInputError): + await table.export_data(invalid_path) + + async def test_import_data(self, setup: Setup, tmp_path): + """Test importing data from CSV""" + # Create table + table = setup.table + + # Create test CSV + csv_path = tmp_path / "import.csv" + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["col (1)", "col (2)", "vector_col"]) + writer.writeheader() + writer.writerow( + { + "col (1)": "import1", + "col (2)": "1", + "vector_col": np.random.rand(VECTOR_LEN).tolist(), + } + ) + writer.writerow( + { + "col (1)": "import2", + "col (2)": "2", + "vector_col": np.random.rand(VECTOR_LEN).tolist(), + } + ) + + # Import data + await table.import_data(csv_path) + + # Verify imported data + rows = (await table.list_rows()).items + assert len(rows) == 2 + assert rows[0]["col (1)"] == "import1" + assert rows[0]["col (2)"] == 1 + assert len(rows[0]["vector_col"]) == VECTOR_LEN + + async def test_import_with_column_mapping(self, setup: Setup, tmp_path): + """Test importing data with column mapping""" + # Create table + table = setup.table + + # Create test CSV with different column names + csv_path = tmp_path / "import.csv" + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["csv_col1", "csv_col2", "csv_vector"]) + writer.writeheader() + writer.writerow( + { + "csv_col1": "mapped1", + "csv_col2": "1", + "csv_vector": np.random.rand(VECTOR_LEN).tolist(), + } + ) + + # Import with column mapping + column_id_mapping = { + "csv_col1": "col (1)", + "csv_col2": "col (2)", + "csv_vector": "vector_col", + } + await table.import_data(csv_path, column_id_mapping=column_id_mapping) + + # Verify imported data + rows = (await table.list_rows()).items + assert len(rows) == 1 + assert rows[0]["col (1)"] == "mapped1" + assert rows[0]["col (2)"] == 1 + assert len(rows[0]["vector_col"]) == VECTOR_LEN + + async def test_import_error_handling(self, setup: Setup, tmp_path): + """Test error handling during import""" + # Create table + table = setup.table + + # Test missing file + with pytest.raises(ResourceNotFoundError): + await table.import_data(Path("/nonexistent/file.csv")) + + # Test invalid column mapping + csv_path = tmp_path / "import.csv" + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["invalid_col"]) + writer.writeheader() + writer.writerow({"invalid_col": "value"}) + await table.import_data(csv_path) + assert len((await table.list_rows()).items) == 0 + + # Test invalid vector data + with open(csv_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["vector_col"]) + writer.writeheader() + writer.writerow({"vector_col": "invalid,vector,data"}) + with pytest.raises(BadInputError): + await table.import_data(csv_path) + + +# Include existing test classes here... +class TestTableOperations: + async def test_table_creation(self, setup: Setup): + """Test creating a new data table with metadata""" + # Verify table exists + async with GENTABLE_ENGINE.transaction() as conn: + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."TableMetadata" WHERE table_id = $1)', + "Table (test)", + ) + assert exists + + async def test_table_creation_concurrent(self, session: Session): + """Test creating a new data table with metadata concurrently""" + table_type = TableType.ACTION + project_id = session.projects[0].id + # Drop schema + await GenerativeTableCore.drop_schemas(project_id) + # Create table + num_tables = 3 + await asyncio.gather( + *[ + GenerativeTableCore.create_table( + project_id=project_id, + table_type=table_type, + table_metadata=TableMetadata( + table_id=f"Table {i}", + title="Test Table", + parent_id=None, + version="1", + versioning_enabled=True, + meta={}, + ), + column_metadata_list=[ + ColumnMetadata( + column_id="col", + table_id=f"Table {i}", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=1, + meta={}, + ), + ], + ) + for i in range(num_tables) + ] + ) + # Verify table exists + async with GENTABLE_ENGINE.transaction() as conn: + for i in range(num_tables): + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{project_id}_{table_type}"."TableMetadata" WHERE table_id = $1)', + f"Table {i}", + ) + assert exists + + async def test_table_duplication(self, setup: Setup): + """Test duplicating a table with data""" + # Create original table + table = setup.table + + # Insert test data + test_data = [ + { + "col (1)": "value1", + "col (2)": 1, + }, + { + "col (1)": "value2", + "col (2)": 2, + }, + { + "col (1)": None, + "col (2)": 3, + }, # Test null handling + ] + await table.add_rows(test_data) + + # Duplicate table + new_table = await GenerativeTableCore.duplicate_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + table_id_src=setup.table_id, + table_id_dst="test_table_copy", + ) + + # Verify new table exists + async with GENTABLE_ENGINE.transaction() as conn: + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."TableMetadata" WHERE table_id = $1)', + "test_table_copy", + ) + assert exists + + # Verify data was copied correctly + original_rows = (await table.list_rows()).items + new_rows = (await new_table.list_rows()).items + + # Verify row count matches + assert len(new_rows) == len(original_rows) + + # Verify specific data values + for row, test_row in zip(new_rows, test_data, strict=True): + assert row["col (1)"] == test_row["col (1)"] + assert row["col (2)"] == test_row["col (2)"] + + async def test_duplicate_recreates_indexes(self, setup: Setup): + """Verify duplicated tables have all indexes recreated""" + original = setup.table + duplicated = await GenerativeTableCore.duplicate_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + table_id_src=original.table_id, + table_id_dst="duplicated_with_indexes", + ) + + # Verify indexes exist + async with GENTABLE_ENGINE.transaction() as conn: + # Check FTS index + fts_index = await conn.fetchval( + """ + SELECT COUNT(*) FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 AND indexname LIKE '%_fts_idx' + """, + duplicated.schema_id, + duplicated.table_id, + ) + assert fts_index > 0 + + # Check vector indexes match original count + original_vec_count = len(original.vector_column_names) + duplicated_vec_count = await conn.fetchval( + """ + SELECT COUNT(*) FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 AND indexname LIKE '%_vec_idx' + """, + duplicated.schema_id, + duplicated.table_id, + ) + assert duplicated_vec_count == original_vec_count + + async def test_rename_table(self, setup: Setup): + """Verify renaming table works properly by checking it can be opened and the associated ColumnMetadata and TableMetadata exists""" + table = setup.table + new_name = "renamed_table" + with assert_updated_time(table): + # Rename table + await table.rename_table(new_name) + # Verify table was renamed by opening it. + new_table = await GenerativeTableCore.open_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + table_id=new_name, + ) + # verify associated ColumnMetadata and TableMetadata exist + assert all([col.table_id == new_name for col in new_table.column_metadata]) + assert new_table.table_metadata.table_id == new_name + + async def test_rename_table_has_indexes(self, setup: Setup): + """Verify renaming a table updates all associated indexes""" + table = setup.table + new_name = "renamed_table" + + # Get original index names + async with GENTABLE_ENGINE.transaction() as conn: + original_indexes = await conn.fetch( + """ + SELECT indexname FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 + """, + table.schema_id, + table.table_id, + ) + + # Rename table + await table.rename_table(new_name) + + # Verify indexes were renamed + async with GENTABLE_ENGINE.transaction() as conn: + new_indexes = await conn.fetch( + """ + SELECT indexname FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 + """, + table.schema_id, + new_name, + ) + + # Check all indexes were renamed properly + assert len(new_indexes) == len(original_indexes) + for new_idx in new_indexes: + assert new_name in new_idx["indexname"] + + async def test_table_drop(self, setup: Setup): + """Test dropping a table""" + table = setup.table + + # Verify table exists + async with GENTABLE_ENGINE.transaction() as conn: + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."TableMetadata" WHERE table_id = $1)', + setup.table_id, + ) + assert exists + + # Drop table + await table.drop_table() + + # Verify table does not exists + async with GENTABLE_ENGINE.transaction() as conn: + # check TableMetadata + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."TableMetadata" WHERE table_id = $1)', + setup.table_id, + ) + assert not exists + # check columnmetadata + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."ColumnMetadata" WHERE table_id = $1)', + setup.table_id, + ) + assert not exists + + # check table not in schema + ret = await conn.fetch( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = $1 + """, + setup.schema_id, + ) + assert setup.table_id not in [r["table_name"] for r in ret] + + +class TestColumnOperations: + async def test_add_column(self, setup: Setup): + """Test adding a new column""" + table = setup.table + with assert_updated_time(table): + # Add new column + new_column = ColumnMetadata( + column_id="new_col", + table_id=setup.table_id, + dtype=ColumnDtype.FLOAT, + vlen=0, + gen_config=None, + column_order=4, + ) + await table.add_column(new_column) + # Verify new column exists + async with GENTABLE_ENGINE.transaction() as conn: + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."ColumnMetadata" WHERE column_id = $1)', + "new_col", + ) + assert exists + + # Verify column was added to the actual table + columns = await conn.fetch( + "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2", + setup.schema_id, + setup.table_id, + ) + assert "new_col" in [c["column_name"] for c in columns] + + async def test_drop_columns(self, setup: Setup): + """Test removing a column""" + table = setup.table + with assert_updated_time(table): + # Remove column + await table.drop_columns(["col (1)"]) + # Verify column removed + async with GENTABLE_ENGINE.transaction() as conn: + exists = await conn.fetchval( + f'SELECT EXISTS (SELECT 1 FROM "{setup.schema_id}"."ColumnMetadata" WHERE column_id = $1)', + "col (1)", + ) + assert not exists + + async def test_column_dtype_storage(self, setup: Setup): + """Verify ColumnMetadata stores original dtype not PostgreSQL type""" + table = setup.table + + # Check existing columns + for col in table.column_metadata: + assert isinstance(col.dtype, ColumnDtype) # Should be enum value + assert not col.dtype.isnumeric() # Shouldn't be PostgreSQL type string + + # Add new column and verify + new_col = ColumnMetadata( + column_id="test_dtype", + table_id=table.table_id, + dtype=ColumnDtype.FLOAT, + vlen=VECTOR_LEN, + ) + table = await table.add_column(new_col) + + # verify dtype + test_col = next(c for c in table.column_metadata if c.column_id == "test_dtype") + assert test_col.dtype == ColumnDtype.FLOAT + + async def test_column_ordering(self, setup: Setup): + """Test column ordering""" + table = setup.table + with assert_updated_time(table): + # Reorder columns + await table.reorder_columns(["ID", "Updated at", "col (2)", "vector_col", "col (1)"]) + # Verify new order + async with GENTABLE_ENGINE.transaction() as conn: + columns = await conn.fetch( + f'SELECT column_id FROM "{setup.schema_id}"."ColumnMetadata" ORDER BY column_order' + ) + columns = [c["column_id"] for c in columns if not c["column_id"].endswith("_")] + assert columns == ["ID", "Updated at", "col (2)", "vector_col", "col (1)"] + + async def test_update_column_gen_config_to_null(self, setup: Setup): + """Test updating column gen_config to NULL""" + table = setup.table + with assert_updated_time(table): + # Add column with proper LLMGenConfig instance + new_column = ColumnMetadata( + column_id="output_col", + table_id=setup.table_id, + dtype=ColumnDtype.STR, + vlen=0, + gen_config=LLMGenConfig( + model=setup.chat_model_id, + temperature=0.7, + system_prompt="Test system", + prompt="Test prompt", + multi_turn=False, + ), + column_order=4, + ) + table = await table.add_column(new_column) + assert {c.column_id: c for c in table.column_metadata}[ + "output_col" + ].gen_config is not None + # Update gen_config to NULL + table = await table.update_gen_config(update_mapping={"output_col": None}) + assert {c.column_id: c for c in table.column_metadata}["output_col"].gen_config is None + + async def test_update_gen_config_basic(self, setup: Setup): + """Test basic gen_config updates""" + table = setup.table + with assert_updated_time(table): + # Add column with NULL config + new_col = ColumnMetadata( + column_id="output_col", + table_id=table.table_id, + dtype=ColumnDtype.STR, + gen_config=None, + ) + table = await table.add_column(new_col) + # Update to valid config + new_config = LLMGenConfig( + model=setup.chat_model_id, + temperature=0.7, + system_prompt="Test", + prompt="Test prompt", + ) + updated = await table.update_gen_config(update_mapping={"output_col": new_config}) + # Verify update + col = next(c for c in updated.column_metadata if c.column_id == "output_col") + assert col.gen_config == new_config + assert col.is_output_column + + async def test_update_gen_config_change_existing(self, setup: Setup): + """Test updating from one gen_config to another""" + table = setup.table + with assert_updated_time(table): + # Initial config + initial_config = LLMGenConfig( + model=setup.chat_model_id, + temperature=0.5, + system_prompt="Initial", + prompt="Initial prompt", + ) + + # Add column with initial config + new_col = ColumnMetadata( + column_id="output_col", + table_id=table.table_id, + dtype=ColumnDtype.STR, + gen_config=initial_config, + ) + table = await table.add_column(new_col) + + # New config with different values + updated_config = LLMGenConfig( + model=setup.chat_model_id, + temperature=0.7, + system_prompt="Updated", + prompt="Updated prompt", + ) + + # Update config + updated = await table.update_gen_config(update_mapping={"output_col": updated_config}) + + # Verify all fields changed + col = next(c for c in updated.column_metadata if c.column_id == "output_col") + assert col.gen_config.model == setup.chat_model_id + assert col.gen_config.temperature == 0.7 + assert col.gen_config.system_prompt == "Updated" + assert col.gen_config.prompt == "Updated prompt" + + # Verify persistence after reload + table = await GenerativeTableCore.open_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + table_id=setup.table_id, + ) + reloaded_col = next(c for c in table.column_metadata if c.column_id == "output_col") + assert reloaded_col.gen_config == updated_config + + async def test_update_gen_config_invalid_column(self, setup: Setup): + """Test updating non-existent column""" + table = setup.table + config = LLMGenConfig( + model=setup.chat_model_id, + temperature=0.7, + system_prompt="Test", + prompt="Test prompt", + ) + with pytest.raises(ResourceNotFoundError): + await table.update_gen_config(update_mapping={"nonexistent_col": config}) + + async def test_update_gen_config_partial_changes(self, setup: Setup): + """Test updating only some config fields""" + table = setup.table + with assert_updated_time(table): + initial_config = LLMGenConfig( + model=setup.chat_model_id, + temperature=0.5, + system_prompt="Initial", + prompt="Initial prompt", + ) + + new_col = ColumnMetadata( + column_id="output_col", + table_id=table.table_id, + dtype=ColumnDtype.STR, + gen_config=initial_config, + ) + table = await table.add_column(new_col) + + # Only update temperature + updated_config = LLMGenConfig( + model=setup.chat_model_id, # Same model + temperature=0.8, # Updated + system_prompt="Initial", # Same + prompt="Initial prompt", # Same + ) + + updated = await table.update_gen_config(update_mapping={"output_col": updated_config}) + col = next(c for c in updated.column_metadata if c.column_id == "output_col") + + assert col.gen_config.model == setup.chat_model_id + assert col.gen_config.temperature == 0.8 + assert col.gen_config.system_prompt == "Initial" + assert col.gen_config.prompt == "Initial prompt" + + +class TestSearchOperations: + @pytest.fixture + def test_vectors(self): + return { + "valid_vector": np.random.rand(VECTOR_LEN), + "empty_vector": np.array([]), + "wrong_dim_vector": np.random.rand(VECTOR_LEN * 2), + "list_vector": np.random.rand(VECTOR_LEN).tolist(), + } + + async def test_vector_search_basic(self, setup: Setup, test_vectors): + """Test basic vector search functionality""" + table = setup.table + # Insert test vectors + test_data = [ + {"col (1)": "foo", "col (2)": 1, "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "bar", "col (2)": 2, "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "baz", "col (2)": 3, "vector_col": np.random.rand(VECTOR_LEN)}, + ] + await table.add_rows(test_data) + + # Test search with numpy array + results = await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: test_vectors["valid_vector"], + vector_column_names=["vector_col"], + ) + assert len(results) == 3 + assert "score" in results[0] + # Scores are distances (lower is better) + assert results[0]["score"] <= results[1]["score"] <= results[2]["score"] + + # Test search with list input + list_results = await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: test_vectors["list_vector"], + vector_column_names=["vector_col"], + ) + assert len(list_results) == 3 + + @pytest.mark.asyncio + async def test_multi_column_vector_search(self, setup: Setup, test_vectors): + """Test basic vector search functionality on multiple columns""" + table = setup.table + # Add vector column + table = await table.add_column( + ColumnMetadata( + column_id="vector_col2", + table_id="Table (test)", + dtype=ColumnDtype.FLOAT, + vlen=VECTOR_LEN, + gen_config=None, + column_order=4, + meta={}, + ) + ) + # Insert test vectors + test_data = [ + { + "col (1)": "foo", + "col (2)": 1, + "vector_col": test_vectors["valid_vector"], + "vector_col2": test_vectors["valid_vector"], + }, + { + "col (1)": "bar", + "col (2)": 2, + "vector_col": test_vectors["valid_vector"], + "vector_col2": test_vectors["valid_vector"], + }, + { + "col (1)": "baz", + "col (2)": 3, + "vector_col": np.random.rand(VECTOR_LEN), + "vector_col2": np.random.rand(VECTOR_LEN), + }, + ] + await table.add_rows(test_data) + + # Test search with numpy array + results = await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: test_vectors["valid_vector"], + vector_column_names=["vector_col"], + ) + # Scores are distances (lower is better) + assert results[0]["score"] <= results[1]["score"] <= results[2]["score"] + # Ensure not matching row is last + assert results[2]["col (1)"] == "baz" + + async def test_vector_search_errors(self, setup: Setup, test_vectors): + """Test vector search error handling""" + + table = setup.table + + # Test invalid input types + with pytest.raises(BadInputError): + await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: "invalid_type", + vector_column_names=["vector_col"], + ) + + # Test empty vector + with pytest.raises(BadInputError): + await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: test_vectors["empty_vector"], + vector_column_names=["vector_col"], + ) + + # Test dimension mismatch + with pytest.raises(BadInputError): + await table.vector_search( + "dummy_query", + embedding_fn=lambda _, __: test_vectors["wrong_dim_vector"], + vector_column_names=["vector_col"], + ) + + async def test_fts_search_basic(self, setup: Setup): + """Test basic full text search functionality""" + new_column = ColumnMetadata( + column_id="search_col", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + meta={}, + ) + + table = setup.table + table = await table.add_column(new_column) + + # Insert test data with searchable content + test_data = [ + {"col (1)": "foo", "col (2)": 1, "search_col": "quick brown fox"}, + {"col (1)": "bar", "col (2)": 2, "search_col": "lazy dog"}, + {"col (1)": "baz", "col (2)": 3, "search_col": "quick dog"}, + ] + await table.add_rows(test_data) + + # Test basic search + results = await table.fts_search("quick") + assert len(results) == 2 + assert {r["search_col"] for r in results} == {"quick brown fox", "quick dog"} + + async def test_fts_uses_index(self, setup: Setup): + """Verify FTS queries actually use the index""" + table = setup.table + rows_to_add = [{"col (1)": "test search term"}] + await table.add_rows(rows_to_add) + + # Test basic search + results = await table.fts_search("quick", explain=True) + + # Verify index scan is used + if not any(["Index Scan" in res["QUERY PLAN"] for res in results]): + # add more rows to force index scan + await table.add_rows(rows_to_add * 1000) + results = await table.fts_search("quick", force_use_index=True, explain=True) + assert any(["Index Scan" in res["QUERY PLAN"] for res in results]) + else: + assert True + + async def test_fts_search_pagination(self, setup: Setup): + """Test search with pagination""" + # Create table with text column + new_column = ColumnMetadata( + column_id="search_col", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + meta={}, + ) + + table = setup.table + table = await table.add_column(new_column) + + # Insert test data with searchable content + test_data = [ + {"col (1)": "foo", "col (2)": 1, "search_col": "quick brown fox"}, + {"col (1)": "bar", "col (2)": 2, "search_col": "lazy dog"}, + {"col (1)": "baz", "col (2)": 3, "search_col": "quick dog"}, + ] + await table.add_rows(test_data) + + # Test limit/offset + page1 = await table.fts_search("dog", limit=1) + assert len(page1) == 1 + + page2 = await table.fts_search("dog", limit=1, offset=1) + assert len(page2) == 1 + assert page1[0]["ID"] != page2[0]["ID"] + + async def test_fts_search_state_inclusion(self, setup: Setup): + """Test that state columns are included in search results""" + # Create table with text and state columns + new_column = ColumnMetadata( + column_id="search_col", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + + table = setup.table + table = await table.add_column(new_column) + + # Insert test data + await table.add_rows( + [ + { + "col (1)": "foo", + "col (2)": 1, + "search_col": "test value", + } + ] + ) + + # Verify that state columns appear in search results + results = await table.fts_search("value", remove_state_cols=False) + assert "search_col_" in results[0].keys() + + @pytest.mark.asyncio + async def test_multi_column_fts_search(self, setup: Setup): + """Test searches across multiple columns""" + table = setup.table + + # Add multiple text columns + table = await table.add_column( + ColumnMetadata( + column_id="text1", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + ) + table = await table.add_column( + ColumnMetadata( + column_id="text2", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=5, + ) + ) + + # Insert test data + await table.add_rows( + [ + {"text1": "first column text", "text2": "unrelated"}, + {"text1": "unrelated", "text2": "second column text"}, + ] + ) + + # Test FTS across multiple columns + results = await table.fts_search("text") + assert len(results) == 2 + assert {r["text1"] for r in results} == {"first column text", "unrelated"} + assert {r["text2"] for r in results} == {"unrelated", "second column text"} + + @pytest.mark.parametrize( + "text", + [ + "中文测试", # Chinese + "日本語テスト", # Japanese + "한국어 테스트", # Korean + ], + ) + @pytest.mark.asyncio + async def test_cjk_search(self, setup: Setup, text: str): + """Test CJK language support in FTS""" + table = setup.table + table = await table.add_column( + ColumnMetadata( + column_id="cjk_text", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + ) + + # Insert CJK text + await table.add_rows( + [ + {"cjk_text": text}, + ] + ) + + # Search for the text + results = await table.fts_search(text) + assert len(results) == 1 + assert results[0]["cjk_text"] == text + + @pytest.mark.asyncio + async def test_multi_term_fts_search(self, setup: Setup): + """Test FTS with multiple search terms""" + table = setup.table + table = await table.add_column( + ColumnMetadata( + column_id="multi_text", + table_id="Table (test)", + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + ) + + # Insert test data + await table.add_rows( + [ + {"multi_text": "quick brown fox"}, + {"multi_text": "lazy dog"}, + {"multi_text": "quick dog"}, + ] + ) + + # Test AND semantics + results = await table.fts_search("dog Quick") + assert len(results) == 1 + assert results[0]["multi_text"] == "quick dog" + + # Test OR semantics + results = await table.fts_search("quick OR lazy") + assert len(results) == 3 + + async def test_hybrid_search_basic(self, setup: Setup): + """Test basic hybrid search functionality""" + table = setup.table + + # Add text column for FTS + text_col = ColumnMetadata( + column_id="text_col", + table_id=table.table_id, + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + table = await table.add_column(text_col) + + # Insert test data with both text and vector content + same_vector = np.random.rand(VECTOR_LEN) + test_data = [ + {"col (1)": "foo", "text_col": "quick brown fox", "vector_col": same_vector}, + {"col (1)": "bar", "text_col": "lazy dog", "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "bay", "text_col": "slow hound", "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "baz", "text_col": "quick dog", "vector_col": same_vector}, + {"col (1)": "bax", "text_col": "tardy fox", "vector_col": np.random.rand(VECTOR_LEN)}, + ] + await table.add_rows(test_data) + + # Mock embedding function + async def mock_embed_fn(model: str, text: str): + return same_vector + + # Test hybrid search + results = await table.hybrid_search( + fts_query="quick", + vs_query="quick", + embedding_fn=mock_embed_fn, + vector_column_names=["vector_col"], + use_bm25_ranking=False, + ) + + assert len(results) > 0 + assert all("rrf_score" in r for r in results) # Check for rrf_score key + results = sorted(results, key=lambda x: x["rrf_score"], reverse=True) + # Verify the top results contain 'quick' from FTS and the matching vector + # The exact ranking depends on RRF scoring, but the relevant items should be highly ranked. + assert all(["quick" in r["text_col"] for r in results[:2]]) + + async def test_hybrid_search_with_bm25(self, setup: Setup): + """Test hybrid search with bm25 functionality""" + table = setup.table + + # Add text column for FTS + text_col = ColumnMetadata( + column_id="text_col", + table_id=table.table_id, + dtype=ColumnDtype.STR, + vlen=0, + gen_config=None, + column_order=4, + ) + table = await table.add_column(text_col) + + # Insert test data with both text and vector content + same_vector = np.random.rand(VECTOR_LEN) + test_data = [ + {"col (1)": "foo", "text_col": "quick brown fox", "vector_col": same_vector}, + {"col (1)": "bar", "text_col": "lazy dog", "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "bay", "text_col": "slow hound", "vector_col": np.random.rand(VECTOR_LEN)}, + {"col (1)": "baz", "text_col": "quick dog", "vector_col": same_vector}, + {"col (1)": "bax", "text_col": "tardy fox", "vector_col": np.random.rand(VECTOR_LEN)}, + ] + await table.add_rows(test_data) + + # Mock embedding function + async def mock_embed_fn(model: str, text: str): + return same_vector + + # Test hybrid search + results = await table.hybrid_search( + fts_query="quick", + vs_query="quick", + embedding_fn=mock_embed_fn, + vector_column_names=["vector_col"], + ) + + assert len(results) > 0 + assert all("rrf_score" in r for r in results) # Check for rrf_score key + results = sorted(results, key=lambda x: x["rrf_score"], reverse=True) + # VS scores should be the same, the difference should comes from FTS + # longer document reduced BM25 scores + assert results[0]["text_col"] == "quick dog" + assert results[1]["text_col"] == "quick brown fox" + + +class TestRowOperations: + async def test_add_rows(self, setup: Setup): + table = setup.table + with assert_updated_time(table): + # Insert data + row_data = [{"col (1)": "test value", "col (2)": 123, "version": "1"}] + await table.add_rows(row_data) + # Verify data inserted + async with GENTABLE_ENGINE.transaction() as conn: + result = await conn.fetchrow( + f'SELECT * FROM "{setup.schema_id}"."{setup.table_id}"' + ) + assert result["col (1)"] == "test value" + assert result["col (2)"] == 123 + + async def test_add_rows_batch(self, setup: Setup): + table = setup.table + with assert_updated_time(table): + # Insert data + row_data = [ + {"col (1)": "test value", "col (2)": 1, "version": "1"}, + {"col (1)": "test value 2", "col (2)": 2, "version": "1"}, + {"col (1)": "test value 3", "col (2)": 3, "version": "1"}, + ] + await table.add_rows(row_data) + # Verify data inserted + async with GENTABLE_ENGINE.transaction() as conn: + result = await conn.fetch(f'SELECT * FROM "{setup.schema_id}"."{setup.table_id}"') + assert result[0]["col (1)"] == "test value" + assert result[0]["col (2)"] == 1 + assert result[-1]["col (1)"] == "test value 3" + assert result[-1]["col (2)"] == 3 + + async def test_list_rows(self, setup: Setup): + table = setup.table + # Insert data + row_data = [ + {"col (1)": "llama", "col (2)": 1}, + {"col (1)": "lama", "col (2)": 2}, + {"col (1)": "DROP TABLE", "col (2)": 3}, + ] + await table.add_rows(row_data) + # List data + rows = (await table.list_rows()).items + rows_reversed = (await table.list_rows(order_ascending=False)).items + assert all(rr == r for rr, r in zip(rows_reversed[::-1], rows, strict=True)) + # Verify data inserted + assert rows[0]["col (1)"] == "llama" + assert rows[0]["col (2)"] == 1 + assert rows[-1]["col (1)"] == "DROP TABLE" + assert rows[-1]["col (2)"] == 3 + + async def test_list_rows_search_query(self, setup: Setup): + # Create table + table = setup.table + # Insert data + row_data = [ + {"col (1)": "llama", "col (2)": 1}, + {"col (1)": "lama", "col (2)": 2}, + {"col (1)": "1", "col (2)": 3}, + ] + await table.add_rows(row_data) + # Search + rows = (await table.list_rows(search_query="lama")).items + assert len(rows) == 2 + rows = (await table.list_rows(search_query="^lama")).items + assert len(rows) == 1 + assert rows[0]["col (1)"] == "lama" + rows = ( + await table.list_rows(search_query="1", search_columns=["col (1)", "col (2)"]) + ).items + assert len(rows) == 2 + rows = (await table.list_rows(search_query="1", search_columns=["col (2)"])).items + assert len(rows) == 1 + assert rows[0]["col (1)"] == "llama" + assert rows[0]["col (2)"] == 1 + + async def test_count_rows(self, setup: Setup): + """Verify count_rows() returns correct counts""" + table = setup.table + # Empty table + assert await table.count_rows() == 0 + # After insert + await table.add_rows([{"col (1)": "test"}]) + assert await table.count_rows() == 1 + # After delete + rows = (await table.list_rows()).items + await table.delete_rows(row_ids=[rows[0]["ID"]]) + assert await table.count_rows() == 0 + + async def test_update_rows(self, setup: Setup, tmp_path): + """Test updating rows including NULL values""" + table = setup.table + with assert_updated_time(table): + # Insert initial data + row_data = [{"col (1)": "initial value", "col (2)": 123}] + row_added = (await (await table.add_rows(row_data)).list_rows()).items + + # Update data + update_data = { + "col (1)": "updated value", + "col (2)": 456, + } + await table.update_rows({row_added[0]["ID"]: update_data}) + + # Verify data updated + retrieved_row = await table.get_row(row_added[0]["ID"]) + assert retrieved_row["col (1)"] == update_data["col (1)"] + assert retrieved_row["col (2)"] == update_data["col (2)"] + + # Test NULL value updates + # Case 1: Set existing value to NULL + await table.update_rows({row_added[0]["ID"]: {"col (1)": None}}) + updated = await table.get_row(row_added[0]["ID"]) + assert updated["col (1)"] is None + assert updated["col (2)"] == 456 + + # Case 2: Verify NULLs persist through export/import + export_path = tmp_path / "null_test.parquet" + await table.export_table(export_path) + new_table = await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="null_test_table", + ) + imported = await new_table.get_row(row_added[0]["ID"]) + assert imported["col (1)"] is None + + async def test_delete_rows_with_id(self, setup: Setup): + table = setup.table + with assert_updated_time(table): + # Insert data + row_data = [ + {"col (1)": "test value", "col (2)": 1}, + {"col (1)": "test value", "col (2)": 2}, + {"col (1)": "test value", "col (2)": 3}, + ] + new_rows = (await (await table.add_rows(row_data)).list_rows()).items + + # Delete data + await table.delete_rows(row_ids=[new_rows[0]["ID"], new_rows[2]["ID"]]) + + # Verify data deleted + with pytest.raises(ResourceNotFoundError, match="Row .+ not found in table"): + await table.get_row(new_rows[0]["ID"]) + with pytest.raises(ResourceNotFoundError, match="Row .+ not found in table"): + await table.get_row(new_rows[2]["ID"]) + + async def test_delete_rows_with_where(self, setup: Setup): + table = setup.table + with assert_updated_time(table): + # Insert data + row_data = [ + {"col (1)": "test value", "col (2)": 1}, + {"col (1)": "test value", "col (2)": 2}, + {"col (1)": "test value", "col (2)": 3}, + ] + new_rows = (await (await table.add_rows(row_data)).list_rows()).items + # Delete data + await table.delete_rows(where='"col (2)" > 1') + # Verify data deleted + with pytest.raises(ResourceNotFoundError, match="Row .+ not found in table"): + await table.get_row(new_rows[1]["ID"]) + with pytest.raises(ResourceNotFoundError, match="Row .+ not found in table"): + await table.get_row(new_rows[2]["ID"]) + + async def test_delete_rows_with_id_where(self, setup: Setup): + table = setup.table + with assert_updated_time(table): + # Insert data + row_data = [ + {"col (1)": "test value", "col (2)": 1}, + {"col (1)": "test value", "col (2)": 2}, + {"col (1)": "test value", "col (2)": 3}, + ] + new_rows = (await (await table.add_rows(row_data)).list_rows()).items + # Delete data + await table.delete_rows( + row_ids=[new_rows[1]["ID"], new_rows[2]["ID"]], where='"col (2)" > 2' + ) + # Verify data deleted + response = await table.get_row(new_rows[0]["ID"]) + assert isinstance(response, dict) + response = await table.get_row(new_rows[1]["ID"]) + assert isinstance(response, dict) + with pytest.raises(ResourceNotFoundError, match="Row .+ not found in table"): + await table.get_row(new_rows[2]["ID"]) + + +# --- Fixtures and Tests for Stateful Operations --- + + +async def setup_table_newly_created(table: GenerativeTableCore): + """Provides an async setup function for the newly created table.""" + # No op needed here, just return the table + return table + + +async def setup_table_with_added_column(table: GenerativeTableCore): + """Provides an async setup function for a table with an added column.""" + new_col = ColumnMetadata( + column_id="added_col_state_test", table_id=table.table_id, dtype=ColumnDtype.BOOL + ) + return await table.add_column(new_col) + + +async def setup_table_with_dropped_column(table: GenerativeTableCore): + """Provides an async setup function for a table with 'col (1)' dropped.""" + return await table.drop_columns(["col (1)"]) + + +async def setup_table_renamed(table: GenerativeTableCore): + """Provides an async setup function for a renamed table.""" + new_name = "renamed_state_test" + return await table.rename_table(new_name) + + +async def setup_table_duplicated(table: GenerativeTableCore, setup: Setup): + """Provides an async setup function for a duplicated table.""" + # Add data just before duplicating + await table.add_rows( + [{"col (1)": "data_for_dup", "col (2)": 111, "vector_col": np.random.rand(VECTOR_LEN)}] + ) + return await GenerativeTableCore.duplicate_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + table_id_src=table.table_id, + table_id_dst="duplicated_state_test", + ) + + +async def setup_table_imported(table: GenerativeTableCore, setup: Setup, tmp_path): + """Provides an async setup function for an imported table.""" + export_path = tmp_path / "state_test_export.parquet" + # Add data just before exporting + await table.add_rows( + [{"col (1)": "data_for_import", "col (2)": 222, "vector_col": np.random.rand(VECTOR_LEN)}] + ) + await table.export_table(export_path) + return await GenerativeTableCore.import_table( + project_id=setup.projects[0].id, + table_type=setup.table_type, + source=export_path, + table_id_dst="imported_state_test", + ) + + +class TestStatefulOperations: + # List of setup fixture names to parametrize over + SETUP_TABLE_STATE_FIXTURES = [ + "setup_table_newly_created", + "setup_table_with_added_column", + "setup_table_with_dropped_column", + "setup_table_renamed", + "setup_table_duplicated", + "setup_table_imported", # Use the new fixture name + ] + + def parametrized_setup(self, setup_fixture_name, setup: Setup, tmp_path): + if setup_fixture_name == "setup_table_newly_created": + return setup_table_newly_created(setup.table) + elif setup_fixture_name == "setup_table_with_added_column": + return setup_table_with_added_column(setup.table) + elif setup_fixture_name == "setup_table_with_dropped_column": + return setup_table_with_dropped_column(setup.table) + elif setup_fixture_name == "setup_table_renamed": + return setup_table_renamed(setup.table) + elif setup_fixture_name == "setup_table_duplicated": + return setup_table_duplicated(setup.table, setup) + elif setup_fixture_name == "setup_table_imported": + return setup_table_imported(setup.table, setup, tmp_path) + + @pytest.mark.parametrize("setup_fixture_name", SETUP_TABLE_STATE_FIXTURES) + @pytest.mark.asyncio + async def test_core_ops_on_various_tables(self, setup_fixture_name, setup: Setup, tmp_path): + """ + Tests core operations (add row, add col, drop col, search) + on tables in various states (new, col added, col dropped, renamed, duplicated, imported). + """ + # --- Setup --- + # Setup the table as per the parameter + table = await self.parametrized_setup(setup_fixture_name, setup, tmp_path) + + assert isinstance(table, GenerativeTableCore) # Ensure we have a table object + + # Store fixture name for easier debugging in asserts/fails + current_state_name = setup_fixture_name.replace("setup_", "") + + initial_row_count = await table.count_rows() + initial_col_count = len(table.column_metadata) + + # --- Test Add Row --- + row_data = {"col (2)": 999} # Use a column likely to exist across states + if "col (1)" in table.data_table_model.get_column_ids(exclude_state=True): + row_data["col (1)"] = f"state_test_{current_state_name}" + if "vector_col" in table.data_table_model.get_column_ids(exclude_state=True): + row_data["vector_col"] = np.random.rand(VECTOR_LEN) # Use correct dimension + if "added_col_state_test" in table.data_table_model.get_column_ids(exclude_state=True): + row_data["added_col_state_test"] = True + await table.add_rows([row_data]) + assert await table.count_rows() == initial_row_count + 1 + + # --- Test Add Column --- + temp_col_id = "temp_col_in_test" + temp_col_meta = ColumnMetadata( + column_id=temp_col_id, table_id=table.table_id, dtype=ColumnDtype.FLOAT + ) + table = await table.add_column( + temp_col_meta + ) # Reassign table as add_column returns updated instance + assert any(col.column_id == temp_col_id for col in table.column_metadata) + assert ( + len(table.column_metadata) == initial_col_count + 2 + ) # +1 for data col, +1 for state col + + # --- Test Drop Column --- + table = await table.drop_columns( + [temp_col_id] + ) # Reassign table as drop_columns returns updated instance + assert not any(col.column_id == temp_col_id for col in table.column_metadata) + assert len(table.column_metadata) == initial_col_count # Should be back to original count + + # --- Test Search (Index Check) --- + # FTS Search (ensure index exists and query runs) + # Use a column that exists in most states or adapt search term + search_term = ( + "state_test" + if "col (1)" in table.data_table_model.get_column_ids(exclude_state=True) + else "a" + ) # Generic search if col (1) dropped + try: + await table.fts_search(search_term, limit=1, explain=False) + except Exception as e: + pytest.fail(f"FTS search failed on {current_state_name} state: {e}") + + # Vector Search (ensure index exists and query runs) + if "vector_col" in table.vector_column_names: + + async def mock_embed_fn(model: str, text: str): + # Return a vector of the correct dimension for the column + vlen = next(c.vlen for c in table.column_metadata if c.column_id == "vector_col") + return np.random.rand(vlen) + + try: + await table.vector_search( + query="dummy", + embedding_fn=mock_embed_fn, + vector_column_names=["vector_col"], + limit=1, + ) + except Exception as e: + pytest.fail(f"Vector search failed on {current_state_name} state: {e}") + elif current_state_name not in [ + "table_with_dropped_column" + ]: # Expect vector col unless explicitly dropped + pytest.fail( + f"Vector column 'vector_col' missing unexpectedly in {current_state_name} state" + ) diff --git a/services/api/tests/gen_table_core/test_manipulation.py b/services/api/tests/gen_table_core/test_manipulation.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api/tests/routers/test_conversation.py b/services/api/tests/routers/test_conversation.py new file mode 100644 index 0000000..fe1c11b --- /dev/null +++ b/services/api/tests/routers/test_conversation.py @@ -0,0 +1,816 @@ +from dataclasses import dataclass +from os.path import dirname, join, realpath +from typing import Generator + +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + AgentMetaResponse, + CellCompletionResponse, + ChatTableSchemaCreate, + ColumnSchemaCreate, + ConversationCreateRequest, + ConversationMetaResponse, + LLMGenConfig, + MessageAddRequest, + MessagesRegenRequest, + MessageUpdateRequest, + OkResponse, + OrganizationCreate, + OrgMemberRead, + Page, + ProjectMemberRead, + Role, + TableType, +) +from owl.utils.exceptions import ResourceNotFoundError +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + GPT_4O_MINI_CONFIG, + GPT_4O_MINI_DEPLOYMENT, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, + get_file_map, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + + +@dataclass(slots=True) +class ConversationContext: + """Dataclass to hold context for conversation tests.""" + + superuser_id: str + user_id: str + org_id: str + project_id: str + template_table_id: str + real_template_table_id: str + multimodal_template_table_id: str + + +@pytest.fixture(scope="module") +def setup() -> Generator[ConversationContext, None, None]: + """ + Fixture to set up the necessary environment for conversation tests. + """ + with ( + create_user() as superuser, + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + create_organization( + body=OrganizationCreate(name="Convo Org"), + user_id=superuser.id, + ) as superorg, + create_project( + dict(name="Convo Project"), user_id=superuser.id, organization_id=superorg.id + ) as project, + ): + assert superuser.id == "0" + assert superorg.id == "0" + client = JamAI(user_id=superuser.id) + membership = client.organizations.join_organization( + user_id=user.id, organization_id=superorg.id, role=Role.MEMBER + ) + assert isinstance(membership, OrgMemberRead) + membership = client.projects.join_project( + user_id=user.id, project_id=project.id, role=Role.MEMBER + ) + assert isinstance(membership, ProjectMemberRead) + client = JamAI(user_id=superuser.id, project_id=project.id) + + with ( + create_model_config(GPT_4O_MINI_CONFIG) as llm_config, + create_model_config(ELLM_DESCRIBE_CONFIG) as llm_describe_config, + create_deployment(GPT_4O_MINI_DEPLOYMENT), + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + ): + # TODO: Don't call these templates since we have actual templates + # Standard Template + template_id = "chat-template-v2" + template_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig(model=llm_describe_config.id, multi_turn=True), + ), + ] + client.table.create_chat_table( + ChatTableSchemaCreate(id=template_id, cols=template_cols) + ) + + # Real Template - for regeneration tests + real_template_id = "real-chat-template-v2" + real_template_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig(model=llm_config.id, multi_turn=True, temperature=1.0), + ), + ] + client.table.create_chat_table( + ChatTableSchemaCreate(id=real_template_id, cols=real_template_cols) + ) + + # Multimodal Template + multimodal_template_id = "multimodal-chat-template-v2" + multimodal_cols = [ + ColumnSchemaCreate(id="User", dtype="str"), + ColumnSchemaCreate(id="Photo", dtype="image"), + ColumnSchemaCreate(id="Audio", dtype="audio"), + ColumnSchemaCreate(id="Doc", dtype="document"), + ColumnSchemaCreate( + id="AI", + dtype="str", + gen_config=LLMGenConfig( + model=llm_describe_config.id, + multi_turn=True, + prompt="Photo: ${Photo} \nAudio: ${Audio} \nDocument: ${Doc} \n\n${User}", + ), + ), + ] + client.table.create_chat_table( + ChatTableSchemaCreate(id=multimodal_template_id, cols=multimodal_cols) + ) + try: + yield ConversationContext( + superuser_id=superuser.id, + user_id=user.id, + org_id=superorg.id, + project_id=project.id, + template_table_id=template_id, + real_template_table_id=real_template_id, + multimodal_template_table_id=multimodal_template_id, + ) + finally: + client.table.delete_table(TableType.CHAT, template_id, missing_ok=True) + client.table.delete_table(TableType.CHAT, real_template_id, missing_ok=True) + client.table.delete_table(TableType.CHAT, multimodal_template_id, missing_ok=True) + + +def _create_conversation_and_get_id( + client: JamAI, + setup_context: ConversationContext, + initial_data: dict | None = None, + check_regen: bool = False, + multimodal: bool = False, +) -> str: + """Helper to create a conversation and extract its ID from the streamed metadata.""" + # TODO: This function should just take in table ID instead of the booleans + if check_regen: + template_id = setup_context.real_template_table_id + elif multimodal: + template_id = setup_context.multimodal_template_table_id + else: + template_id = setup_context.template_table_id + if initial_data is None: + initial_data = {"User": "First message"} + + create_req = ConversationCreateRequest(agent_id=template_id, data=initial_data) + response_stream = client.conversations.create_conversation(create_req) + responses = [r for r in response_stream] + + metadata = responses[0] + assert isinstance(metadata, ConversationMetaResponse), "Stream did not yield metadata first" + conv_id = metadata.conversation_id + assert conv_id is not None, "Metadata event did not contain conversation_id" + return conv_id + + +def test_create_conversation(setup: ConversationContext): + """ + Tests creating a new conversation and that a title is automatically generated. + - Creates a conversation with a specific user prompt. + - Verifies the first message is saved correctly. + - Verifies an AI-generated title is set on the conversation metadata. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 1. Create the conversation + user_prompt = "What is the theory of relativity?" + conv_id = _create_conversation_and_get_id(client, setup, initial_data={"User": user_prompt}) + assert isinstance(conv_id, str) + + # 2. Verify the conversation was created with the correct message + conv_details = client.conversations.list_messages(conv_id) + assert conv_details.total == 1 + assert conv_details.items[0]["User"] == user_prompt + + # 3. Verify that the title was auto-generated and saved + meta_after_creation = client.conversations.get_conversation(conv_id) + assert isinstance(meta_after_creation.title, str) + assert len(meta_after_creation.title) > 0, ( + "Title should have been auto-generated but is empty." + ) + assert "There is a text with" in meta_after_creation.title + + +def test_create_conversation_with_provided_title(setup: ConversationContext): + """ + Tests that providing a title during creation skips automatic generation. + - Creates a conversation and passes a custom `title` parameter. + - Verifies the conversation is created successfully. + - Asserts that the final conversation title matches the one provided. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 1. Create the conversation with provided title + provided_title = "Custom Test Title" + create_req = ConversationCreateRequest( + agent_id=setup.template_table_id, + title=provided_title, + data={"User": "This message should not be used for a title"}, + ) + response_stream = client.conversations.create_conversation(create_req) + responses = [r for r in response_stream] + metadata = responses[0] + conv_id = metadata.conversation_id + assert conv_id is not None + + # 2. Verify the conversation was created + conv_details = client.conversations.list_messages(conv_id) + assert conv_details.total == 1 + + # 3. Verify that the provided title was used + meta_after_creation = client.conversations.get_conversation(conv_id) + assert meta_after_creation.title == provided_title + + +def test_list_conversations(setup: ConversationContext): + """ + Tests listing conversations, ensuring only child chats are returned. + - Creates a new conversation. + - Calls the list endpoint. + - Verifies the new conversation is in the list. + - Verifies that parent agents/templates are not in the list. + - Verifies conversation title search. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + convos_page = client.conversations.list_conversations() + assert convos_page.total >= 1 + assert any(c.conversation_id == conv_id for c in convos_page.items) + # Verify that parent templates are NOT in the list + assert not any(c.conversation_id == setup.template_table_id for c in convos_page.items) + assert not any( + c.conversation_id == setup.multimodal_template_table_id for c in convos_page.items + ) + conv_id = _create_conversation_and_get_id(client, setup) + client.conversations.rename_conversation_title(conv_id, "text with [3600] tokens") + convos_page = client.conversations.list_conversations() + assert convos_page.total >= 2 + # Verify literal search + convos_page_search = client.conversations.list_conversations(search_query="[3600] tokens") + assert convos_page_search.total == 1 + # Verify regex search + convos_page_search = client.conversations.list_conversations(search_query="[0-9]{4}") + assert convos_page_search.total == 1 + convos_page_search = client.conversations.list_conversations(search_query="text with") + assert convos_page_search.total >= 2 + + +def test_list_agents(setup: ConversationContext): + """ + Tests listing agents, ensuring only parent templates are returned. + - Creates a new child conversation. + - Calls the list_agents endpoint. + - Verifies parent templates are in the list. + - Verifies the new child conversation is NOT in the list. + - Verifies agent id search. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + agents_page = client.conversations.list_agents() + assert agents_page.total == 3 + # Verify that parent templates ARE in the list + assert any(c.conversation_id == setup.template_table_id for c in agents_page.items) + assert any(c.conversation_id == setup.multimodal_template_table_id for c in agents_page.items) + # Verify that child conversations are NOT in the list + assert not any(c.conversation_id == conv_id for c in agents_page.items) + agents_page_search = client.conversations.list_agents(search_query="multimodal-") + assert agents_page_search.total == 1 + agents_page_search = client.conversations.list_agents(search_query="chat-template-v2") + assert agents_page_search.total == 3 + + +def test_get_conversation(setup: ConversationContext): + """ + Tests fetching the metadata for a single, specific conversation. + - Creates a conversation. + - Fetches it by its ID. + - Verifies the returned metadata matches the created conversation. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + convo_meta = client.conversations.get_conversation(conv_id) + assert isinstance(convo_meta, ConversationMetaResponse) + assert convo_meta.conversation_id == conv_id + assert convo_meta.parent_id == setup.template_table_id + assert convo_meta.created_by == setup.user_id + + +def test_get_agent(setup: ConversationContext): + """ + Tests fetching the metadata for a single, specific agent/template. + - Fetches a known agent by its ID. + - Verifies the returned metadata is correct. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + agent_meta = client.conversations.get_agent(setup.template_table_id) + assert isinstance(agent_meta, AgentMetaResponse) + assert agent_meta.agent_id == setup.template_table_id + assert agent_meta.created_by == setup.superuser_id + + +def test_generate_conversation_title(setup: ConversationContext): + """ + Tests explicitly generating a title for an existing conversation. + - Creates a conversation (which gets an auto-generated title). + - Calls the dedicated `generate_title` endpoint. + - Verifies the conversation's title is updated to the newly generated one. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + response = client.conversations.generate_title(conversation_id=conv_id) + assert isinstance(response, ConversationMetaResponse) + assert isinstance(response.title, str) + assert len(response.title) > 0 + + updated_table_meta = client.conversations.get_conversation(conv_id) + assert updated_table_meta.title == response.title + + +def test_rename_conversation_title(setup: ConversationContext): + """ + Tests renaming the title of an existing conversation. + - Creates a conversation. + - Calls the rename endpoint with a new title. + - Verifies the conversation metadata reflects the new title. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + new_title = "renamed-conversation-title" + table_meta = client.conversations.get_conversation(conv_id) + assert table_meta.title != new_title, "Title should not match the new title initially" + + rename_response = client.conversations.rename_conversation_title(conv_id, new_title) + assert isinstance(rename_response, ConversationMetaResponse) + assert rename_response.title == new_title + + updated_table_meta = client.conversations.get_conversation(conv_id) + assert updated_table_meta.title == new_title + + +def test_delete_conversation(setup: ConversationContext): + """ + Tests the permanent deletion of a conversation. + - Creates a conversation. + - Deletes it. + - Verifies that fetching the conversation by its ID now raises a ResourceNotFoundError. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + delete_response = client.conversations.delete_conversation(conv_id) + assert isinstance(delete_response, OkResponse) + with pytest.raises(ResourceNotFoundError): + client.conversations.list_messages(conv_id) + + +def test_send_message(setup: ConversationContext): + """ + Tests sending a follow-up message to an existing conversation. + - Creates a conversation with one message. + - Sends a second message to the same conversation. + - Verifies the conversation now contains two messages. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_table_id = _create_conversation_and_get_id(client, setup) + second_prompt = "And what is the capital of Germany?" + send_req = MessageAddRequest( + conversation_id=conv_table_id, + data={"User": second_prompt}, + ) + stream_gen = client.conversations.send_message(send_req) + ai_response_chunks = [c for c in stream_gen] + assert len(ai_response_chunks) > 0, "Send message stream was empty" + + conv_details = client.conversations.list_messages(conv_table_id) + assert conv_details.total == 2 + assert conv_details.items[1]["User"] == second_prompt + assert "text with [8] tokens" in conv_details.items[1]["AI"] + + +def test_list_messages(setup: ConversationContext): + """ + Tests fetching the full message history of a conversation. + - Creates a conversation with an initial message. + - Fetches the message list. + - Verifies the content of the first message. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + convo_details = client.conversations.list_messages(conv_id) + assert isinstance(convo_details, Page) + assert convo_details.total == 1 + first_turn = convo_details.items[0] + assert first_turn["User"] == "First message" + assert "text with [3] tokens" in first_turn["AI"] + # Threads + # TODO: Move this to its own test + response = client.conversations.get_threads(conv_id) + thread = response.threads["AI"].thread + assert len(thread) > 2 + assert thread[0].role == "system" + assert thread[1].role == "user" + assert thread[1].user_prompt == "First message" + assert thread[2].role == "assistant" + assert "text with [3] tokens" in thread[2].content + + +def test_regen_message(setup: ConversationContext): + """ + Tests regenerating the last AI response in a conversation. + - Creates a conversation. + - Stores the original AI response. + - Calls the regeneration endpoint. + - Verifies the new AI response is different from the original. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id( + client, setup, initial_data={"User": "Suggest a movie"}, check_regen=True + ) + # 1. Get the initial message + convo_details = client.conversations.list_messages(conv_id) + assert convo_details.total == 1 + message_row = convo_details.items[0] + row_id = message_row["ID"] + original_ai_content = message_row["AI"] + assert original_ai_content is not None + + # 2. Update the message (Optional) + new_content = "Suggest a movie before 1950." + update_req = MessageUpdateRequest( + conversation_id=conv_id, + row_id=row_id, + data={"User": new_content}, + ) + update_response = client.conversations.update_message(update_req) + assert isinstance(update_response, OkResponse) + + # 3. Regenerate the AI response + regen_req = MessagesRegenRequest( + conversation_id=conv_id, + row_id=row_id, + ) + stream_gen = client.conversations.regen_message(regen_req) + responses = list(stream_gen) + assert len(responses) > 0 + assert all(isinstance(r, CellCompletionResponse) for r in responses) + + # 3. Verify the regeneration + updated_details = client.conversations.list_messages(conv_id) + assert updated_details.total == 1 + updated_message_row = updated_details.items[0] + assert updated_message_row["AI"] != original_ai_content + + +def test_regen_messages(setup: ConversationContext): + """ + Tests regenerating from an earlier point in a multi-message conversation. + - Creates a conversation with three messages. + - Calls regenerate starting from the first message's ID. + - Verifies that all three AI responses have changed. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id( + client, setup, initial_data={"User": "Suggest a movie"}, check_regen=True + ) + # 1. Send messages + send_req = MessageAddRequest( + conversation_id=conv_id, + data={"User": "Suggest second movie"}, + ) + list(client.conversations.send_message(send_req)) # consume stream + send_req = MessageAddRequest( + conversation_id=conv_id, + data={"User": "Describe the movies"}, + ) + list(client.conversations.send_message(send_req)) # consume stream + + # 2. Get the conversation details + convo_details = client.conversations.list_messages(conv_id) + assert convo_details.total == 3 + first_row = convo_details.items[0] + second_row = convo_details.items[1] + third_row = convo_details.items[2] + assert first_row["User"] == "Suggest a movie" + assert second_row["User"] == "Suggest second movie" + assert third_row["User"] == "Describe the movies" + + # 3. Update the message (Optional) + new_content = "Suggest a movie before 1950." + update_req = MessageUpdateRequest( + conversation_id=conv_id, + row_id=first_row["ID"], + data={"User": new_content}, + ) + update_response = client.conversations.update_message(update_req) + assert isinstance(update_response, OkResponse) + + # 4. Regenerate both messages + regen_req = MessagesRegenRequest( + conversation_id=conv_id, + row_id=first_row["ID"], + ) + stream_gen = client.conversations.regen_message(regen_req) + responses = list(stream_gen) + assert len(responses) > 0 + assert all(isinstance(r, CellCompletionResponse) for r in responses) + + # 5. Verify the regeneration + updated_details = client.conversations.list_messages(conv_id) + assert updated_details.total == 3 + updated_first_row = updated_details.items[0] + updated_second_row = updated_details.items[1] + updated_third_row = updated_details.items[2] + assert updated_first_row["User"] != first_row["User"] + assert updated_second_row["User"] == second_row["User"] + assert updated_third_row["User"] == third_row["User"] + assert updated_first_row["AI"] != first_row["AI"] + assert updated_second_row["AI"] != second_row["AI"] + assert updated_third_row["AI"] != third_row["AI"] + + +def test_update_message(setup: ConversationContext): + """ + Tests editing the content of a specific message. + - Creates a conversation. + - Updates the 'User' content of the first message. + - Verifies the change while ensuring the 'AI' content is untouched. + - Updates the 'AI' content and verifies the change. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id = _create_conversation_and_get_id(client, setup) + # 1. Get the initial message to find its row_id + convo_details = client.conversations.list_messages(conv_id) + assert convo_details.total == 1 + message_row = convo_details.items[0] + row_id = message_row["ID"] + assert message_row["User"] == "First message" + + # 2. Update the message + new_content = "This is the edited first message." + update_req = MessageUpdateRequest( + conversation_id=conv_id, + row_id=row_id, + data={"User": new_content}, + ) + update_response = client.conversations.update_message(update_req) + assert isinstance(update_response, OkResponse) + + # 3. Verify the update + updated_details = client.conversations.list_messages(conv_id) + assert updated_details.total == 1 + updated_message_row = updated_details.items[0] + assert updated_message_row["User"] == new_content + assert updated_message_row["AI"] == message_row["AI"] # AI part should be unchanged + + # 2. Update the message + new_ai_content = "AI Response" + update_req = MessageUpdateRequest( + conversation_id=conv_id, + row_id=row_id, + data={"AI": new_ai_content}, + ) + update_response = client.conversations.update_message(update_req) + assert isinstance(update_response, OkResponse) + + # 3. Verify the update + updated_details = client.conversations.list_messages(conv_id) + assert updated_details.total == 1 + updated_message_row = updated_details.items[0] + assert updated_message_row["User"] == new_content + assert updated_message_row["AI"] == new_ai_content + + +def test_conversation_with_image(setup: ConversationContext): + """ + Tests starting a conversation with a multimodal (image) input. + - Uploads an image to get a file URI. + - Creates a conversation using a multimodal agent, passing the image URI. + - Verifies the AI response correctly identifies the image content. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + photo_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + initial_data = {"User": "What animal is in this image?", "Photo": photo_uri} + conv_id = _create_conversation_and_get_id( + client, setup, initial_data=initial_data, multimodal=True + ) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 1 + assert "[image/jpeg], shape [(1200, 1600, 3)]" in messages.items[0]["AI"].lower() + + +def test_conversation_with_audio(setup: ConversationContext): + """ + Tests starting a conversation with a multimodal (audio) input. + - Uploads an audio file to get a file URI. + - Creates a conversation using a multimodal agent, passing the audio URI. + - Verifies the AI response indicates successful processing of the audio. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + audio_uri = upload_file(client, FILES["turning-a4-size-magazine.mp3"]).uri + initial_data = {"User": "What does the audio say?", "Audio": audio_uri} + conv_id = _create_conversation_and_get_id( + client, setup, initial_data=initial_data, multimodal=True + ) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 1 + assert "[audio/mpeg]" in messages.items[0]["AI"].lower() + + +def test_conversation_with_document(setup: ConversationContext): + """ + Tests starting a conversation with a multimodal (document) input. + - Uploads a document to get a file URI. + - Creates a conversation using a multimodal agent, passing the document URI. + - Verifies the AI response correctly processes the document content. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + doc_uri = upload_file(client, FILES["creative-story.md"]).uri + initial_data = {"User": "Summarize this document in one sentence.", "Doc": doc_uri} + conv_id = _create_conversation_and_get_id( + client, setup, initial_data=initial_data, multimodal=True + ) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 1 + assert "text with [398] tokens" in messages.items[0]["AI"].lower() + + +def test_full_lifecycle(setup: ConversationContext): + """ + Tests the complete sequence of user actions from creation to deletion. + - Creates a conversation, which auto-generates a title. + - Sends a follow-up message. + - Regenerates the last message. + - Renames the conversation title. + - Deletes the conversation and verifies it's gone. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 1. Create + conv_id = _create_conversation_and_get_id( + client, setup, initial_data={"User": "Suggest a movie"}, check_regen=True + ) + assert client.conversations.list_messages(conv_id).total == 1 + meta = client.conversations.get_conversation(conv_id) + assert len(meta.title) > 0 + + # 2. Send Message + send_req = MessageAddRequest( + conversation_id=conv_id, + data={"User": "Suggest second movie"}, + ) + list(client.conversations.send_message(send_req)) # consume stream + messages_after_send = client.conversations.list_messages(conv_id) + assert messages_after_send.total == 2 + + # 3. Regenerate Message + last_message = messages_after_send.items[-1] + last_row_id = last_message["ID"] + original_ai_content = last_message["AI"] + regen_req = MessagesRegenRequest(conversation_id=conv_id, row_id=last_row_id) + list(client.conversations.regen_message(regen_req)) # consume stream + messages_after_regen = client.conversations.list_messages(conv_id) + assert messages_after_regen.total == 2 + regenerated_message = messages_after_regen.items[-1] + assert regenerated_message["User"] == "Suggest second movie" + assert regenerated_message["AI"] != original_ai_content + + # 4. Rename + new_title = "Best Movie Agent" + client.conversations.rename_conversation_title(conv_id, new_title) + updated_table_meta = client.conversations.get_conversation(conv_id) + assert updated_table_meta.title == new_title + + # 5. Delete + client.conversations.delete_conversation(conv_id) + with pytest.raises(ResourceNotFoundError): + client.conversations.list_messages(conv_id) + + +def test_full_lifecycle_with_files(setup: ConversationContext): + """ + Tests a complete lifecycle using multimodal inputs. + - Creates a conversation with an image. + - Sends a follow-up with audio. + - Updates and regenerates the first (image) message. + - Sends a final follow-up with a document. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 1. Create with image + photo_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + initial_data = {"User": "What is this animal?", "Photo": photo_uri} + conv_id = _create_conversation_and_get_id(client, setup, initial_data, multimodal=True) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 1 + response_text = messages.items[0]["AI"].lower() + assert ( + "[image/jpeg], shape [(1200, 1600, 3)]" in response_text + and "text with [10] tokens" in response_text + ) + first_row_id = messages.items[0]["ID"] + + # 2. Send a follow-up with audio + audio_uri = upload_file(client, FILES["turning-a4-size-magazine.mp3"]).uri + send_req = MessageAddRequest( + conversation_id=conv_id, + data={"User": "What sound is this?", "Audio": audio_uri}, + ) + list(client.conversations.send_message(send_req)) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 2 + assert "[audio/mpeg]" in messages.items[1]["AI"].lower() + + # 3. Update and Regenerate the first message (the one with the image) + new_content = "What is this animal? Why is it so popular?" + update_req = MessageUpdateRequest( + conversation_id=conv_id, + row_id=first_row_id, + data={"User": new_content}, + ) + update_response = client.conversations.update_message(update_req) + assert isinstance(update_response, OkResponse) + regen_req = MessagesRegenRequest(conversation_id=conv_id, row_id=first_row_id) + list(client.conversations.regen_message(regen_req)) + messages_after_regen = client.conversations.list_messages(conv_id) + assert messages_after_regen.total == 2 + response_text = messages_after_regen.items[0]["AI"].lower() + assert ( + "[image/jpeg], shape [(1200, 1600, 3)]" in response_text + and "text with [15] tokens" in response_text + ) + + # 4. Send a follow-up with a document + doc_uri = upload_file(client, FILES["creative-story.md"]).uri + send_req = MessageAddRequest( + conversation_id=conv_id, + data={"User": "Summarize this document in one sentence.", "Doc": doc_uri}, + ) + list(client.conversations.send_message(send_req)) + messages = client.conversations.list_messages(conv_id) + assert messages.total == 3 + assert "text with [398] tokens" in messages.items[2]["AI"].lower() + + +def test_conversation_permissions(setup: ConversationContext): + """ + Tests that users cannot access conversations they do not own. + - User1 creates a conversation. + - User2 (in the same project) tries to access User1's conversation. + - Asserts that all access attempts by User2 fail with ResourceNotFoundError. + """ + client1 = JamAI(user_id=setup.user_id, project_id=setup.project_id) + conv_id1 = _create_conversation_and_get_id(client1, setup) + + with create_user({"email": "user2@example.com", "name": "user2"}) as user2: + su_client = JamAI(user_id=setup.superuser_id) + su_client.organizations.join_organization( + user_id=user2.id, organization_id=setup.org_id, role=Role.GUEST + ) + su_client.projects.join_project( + user_id=user2.id, project_id=setup.project_id, role=Role.GUEST + ) + client2 = JamAI(user_id=user2.id, project_id=setup.project_id) + + assert client2.conversations.list_conversations().total == 0 + + with pytest.raises(ResourceNotFoundError): + client2.conversations.list_messages(conv_id1) + + +def test_invalid_operations(setup: ConversationContext): + """ + Tests various invalid API calls to ensure they fail with the correct errors. + - Tries to get/rename a non-existent conversation. + - Tries to create a conversation from a non-existent agent template. + """ + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + non_existent_id = "non-existent-conversation-id" + + with pytest.raises(ResourceNotFoundError): + client.conversations.list_messages(non_existent_id) + with pytest.raises(ResourceNotFoundError): + client.conversations.rename_conversation_title(non_existent_id, "new-title") + + with pytest.raises(ResourceNotFoundError): + create_req = ConversationCreateRequest( + agent_id="non-existent-template", + data={"User": "test"}, + ) + list(client.conversations.create_conversation(create_req)) diff --git a/services/api/tests/routers/test_models.py b/services/api/tests/routers/test_models.py new file mode 100644 index 0000000..0a20e13 --- /dev/null +++ b/services/api/tests/routers/test_models.py @@ -0,0 +1,204 @@ +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + DeploymentRead, + DeploymentUpdate, + ModelConfigRead, +) +from owl.types import CloudProvider, DeploymentCreate, OkResponse, Page +from owl.utils.exceptions import ( + BadInputError, + ForbiddenError, + ResourceExistsError, + ResourceNotFoundError, +) +from owl.utils.test import ( + GPT_4O_MINI_CONFIG, + GPT_4O_MINI_DEPLOYMENT, + SMOL_LM2_CONFIG, + create_deployment, + create_model_config, + setup_organizations, +) + + +def test_create_model_config(): + with setup_organizations(): + with create_model_config(SMOL_LM2_CONFIG) as model: + assert isinstance(model, ModelConfigRead) + assert model.id == SMOL_LM2_CONFIG.id + assert model.type == SMOL_LM2_CONFIG.type + assert model.name == SMOL_LM2_CONFIG.name + assert model.context_length == SMOL_LM2_CONFIG.context_length + assert model.capabilities == SMOL_LM2_CONFIG.capabilities + + +def test_create_existing_model_config(): + with setup_organizations(): + with create_model_config(SMOL_LM2_CONFIG) as model: + assert model.id == SMOL_LM2_CONFIG.id + with pytest.raises(ResourceExistsError): + with create_model_config(SMOL_LM2_CONFIG): + pass + + +def test_list_system_model_configs(): + with setup_organizations() as ctx: + with create_model_config(SMOL_LM2_CONFIG): + # OK + models = JamAI(user_id=ctx.superuser.id).models.list_model_configs() + assert isinstance(models, Page) + assert len(models.items) == 1 + assert models.total == 1 + + +@pytest.mark.cloud +def test_list_system_model_configs_permission(): + with setup_organizations() as ctx: + with create_model_config(SMOL_LM2_CONFIG): + # No permission + with pytest.raises(ForbiddenError): + JamAI(user_id=ctx.user.id).models.list_model_configs() + + +def test_get_model_config(): + with setup_organizations() as ctx: + with create_model_config(SMOL_LM2_CONFIG) as model: + client = JamAI(user_id=ctx.superuser.id) + # Fetch + response = client.models.get_model_config(model.id) + assert isinstance(response, ModelConfigRead) + assert response.model_dump() == model.model_dump() + + +def test_get_nonexistent_model_config(): + with setup_organizations() as ctx: + client = JamAI(user_id=ctx.superuser.id) + with pytest.raises(ResourceNotFoundError): + client.models.get_model_config("nonexistent-model") + + +def test_update_model_config(): + """ + Test updating a model config. + - Update name + - Update ID and ensure foreign keys of deployments are updated + - `owned_by` and `id` must match for ELLM models + """ + with setup_organizations() as ctx: + with ( + create_model_config(GPT_4O_MINI_CONFIG) as model, + create_deployment(GPT_4O_MINI_DEPLOYMENT) as deployment, + ): + assert isinstance(model, ModelConfigRead) + client = JamAI(user_id=ctx.superuser.id) + # Update name + new_name = "NEW MODEL" + model = client.models.update_model_config(model.id, dict(name=new_name)) + assert isinstance(model, ModelConfigRead) + assert model.id == model.id + assert model.name == new_name + # Update meta + meta = dict(icon="openai") + model = client.models.update_model_config(model.id, dict(meta=meta)) + assert isinstance(model, ModelConfigRead) + assert model.id == model.id + assert model.meta == meta + # `owned_by` and `id` must match for ELLM models + new_owned_by = "ellm" + new_id = "ellm/biglm2:135m" + with pytest.raises(BadInputError, match="ELLM models must have `owned_by"): + client.models.update_model_config(model.id, dict(owned_by=new_owned_by)) + with pytest.raises(BadInputError, match="ELLM models must have `owned_by"): + client.models.update_model_config(model.id, dict(id=new_id)) + # Update ID and `owned_by` + model = client.models.update_model_config( + model.id, dict(id=new_id, owned_by=new_owned_by) + ) + assert isinstance(model, ModelConfigRead) + assert model.id == new_id + assert model.name == new_name + assert model.meta == meta + assert model.owned_by == new_owned_by + # Fetch again + model = client.models.get_model_config(model.id) + assert isinstance(model, ModelConfigRead) + assert model.id == new_id + assert model.name == new_name + assert model.meta == meta + assert model.owned_by == new_owned_by + # Fetch deployment to ensure foreign key is updated + response = client.models.get_deployment(deployment.id) + assert isinstance(response, DeploymentRead) + assert response.model.id == new_id + + +def test_delete_model_config(): + with setup_organizations() as ctx: + with create_model_config(SMOL_LM2_CONFIG) as model: + client = JamAI(user_id=ctx.superuser.id) + response = client.models.delete_model_config(model.id) + assert isinstance(response, OkResponse) + with pytest.raises(ResourceNotFoundError): + client.models.get_model_config(model.id) + + +def test_create_cloud_deployment(): + with setup_organizations() as ctx: + with ( + create_model_config(GPT_4O_MINI_CONFIG) as model, + create_deployment(GPT_4O_MINI_DEPLOYMENT) as deployment, + ): + assert deployment.model_id == model.id + assert deployment.name == GPT_4O_MINI_DEPLOYMENT.name + assert deployment.provider == CloudProvider.OPENAI + assert deployment.routing_id == GPT_4O_MINI_DEPLOYMENT.routing_id + + model = JamAI(user_id=ctx.superuser.id).models.get_model_config(model.id) + assert isinstance(model, ModelConfigRead) + + +def test_get_deployment(): + with setup_organizations() as ctx: + with ( + create_model_config(GPT_4O_MINI_CONFIG) as model, + create_deployment( + DeploymentCreate( + model_id=model.id, + name="Test Deployment", + provider=CloudProvider.OPENAI, + routing_id="openai/gpt-4o-mini", + ) + ) as deployment, + ): + client = JamAI(user_id=ctx.superuser.id) + # Fetch + response = client.models.get_deployment(deployment.id) + assert isinstance(response, DeploymentRead) + assert response.model_dump() == deployment.model_dump() + + +def test_update_deployment(): + with setup_organizations() as ctx: + with ( + create_model_config(GPT_4O_MINI_CONFIG), + create_deployment(GPT_4O_MINI_DEPLOYMENT) as deployment, + ): + assert deployment.name == GPT_4O_MINI_DEPLOYMENT.name + client = JamAI(user_id=ctx.superuser.id) + # Update + new_name = "NEW DEPLOYMENT" + deployment = client.models.update_deployment( + deployment.id, DeploymentUpdate(name=new_name) + ) + assert isinstance(deployment, DeploymentRead) + assert deployment.name == new_name + # Fetch again + deployment = client.models.get_deployment(deployment.id) + assert isinstance(deployment, DeploymentRead) + assert deployment.name == new_name + + +if __name__ == "__main__": + test_create_model_config() diff --git a/services/api/tests/routers/test_organizations.py b/services/api/tests/routers/test_organizations.py new file mode 100644 index 0000000..ac1989e --- /dev/null +++ b/services/api/tests/routers/test_organizations.py @@ -0,0 +1,380 @@ +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + OrganizationRead, + OrganizationUpdate, + OrgMemberRead, + Page, +) +from owl.configs import ENV_CONFIG +from owl.db import TEMPLATE_ORG_ID, sync_session +from owl.db.models import Organization +from owl.types import ChatCompletionResponse, ChatRequest, Role, StripePaymentInfo +from owl.utils.exceptions import ( + BadInputError, + ForbiddenError, + ResourceNotFoundError, + UpgradeTierError, +) +from owl.utils.test import ( + BASE_PLAN_ID, + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + GPT_4O_MINI_CONFIG, + GPT_4O_MINI_DEPLOYMENT, + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + create_deployment, + create_model_config, + create_organization, + create_user, + setup_organizations, + setup_projects, +) + + +def test_create_superorg(): + """ + Test creating organizations + - Assert that superorg and template org are created + - Assert that external keys are persisted correctly + """ + with ( + create_user() as superuser, + create_organization( + dict(name="Clubhouse", external_keys={"key": "value", "openai": "openai"}), + user_id=superuser.id, + ) as superorg, + ): + with create_organization( + dict(name="Clubhouse", external_keys={"key": "value", "openai": "openai"}), + user_id=superuser.id, + ) as org: + assert superorg.id == "0" + assert superorg.name == "Clubhouse" + assert superorg.external_keys == {"key": "value", "openai": "openai"} + assert org.id != "0" + assert org.name == "Clubhouse" + assert org.external_keys == {"key": "value", "openai": "openai"} + # Check org memberships + user = JamAI(user_id=superuser.id).users.get_user(superuser.id) + assert len(user.org_memberships) == 3 # Superorg + Template + Org + org_memberships = {m.organization_id: m for m in user.org_memberships} + assert org_memberships["0"].role == Role.ADMIN + assert org_memberships[TEMPLATE_ORG_ID].role == Role.ADMIN + assert org_memberships[org.id].role == Role.ADMIN + + # Assert template org and sys org still exist + with sync_session() as session: + assert session.get(Organization, TEMPLATE_ORG_ID) is not None + assert session.get(Organization, "0") is not None + # Assert template org and sys org are deleted + with sync_session() as session: + assert session.get(Organization, TEMPLATE_ORG_ID) is None + assert session.get(Organization, "0") is None + + +# @pytest.mark.cloud +# def test_create_superorg_permission(): +# with create_user(), create_user(dict(email="russell@up.com", name="Russell")) as user: +# with pytest.raises(ForbiddenError): +# with create_organization(user_id=user.id): +# pass + + +@pytest.mark.cloud +def test_create_organization_base_tier_limit(): + """ + A user can only have one organization with a base tier plan. + """ + with ( + create_user() as superuser, + create_user(dict(name="user", email="russell@up.com")) as user, + # Internal org "0" is not counted against the limit + create_organization(dict(name="Admin org"), user_id=superuser.id) as superorg, + # First base tier org + create_organization(dict(name="Org 1"), user_id=superuser.id) as o1, + ): + assert superorg.id == "0" + assert o1.id != "0" + # Auto-subscribed to base tier plan + assert o1.price_plan_id == BASE_PLAN_ID + # Second base tier org + with pytest.raises( + (ForbiddenError, UpgradeTierError), + match="can have only one organization with Free Plan", + ): + with create_organization(dict(name="Org 2"), user_id=superuser.id): + pass + # Create another org with a different plan + super_client = JamAI(user_id=superuser.id) + client = JamAI(user_id=user.id) + with ( + create_organization( + dict(name="Org 2"), user_id=superuser.id, subscribe_plan=False + ) as o2, + create_organization(dict(name="Org X"), user_id=user.id, subscribe_plan=False) as ox, + ): + assert o2.price_plan_id is None + assert o2.active is False + plans = super_client.prices.list_price_plans().items + plan = next((p for p in plans if p.id != BASE_PLAN_ID), None) + assert plan is not None + invoice = super_client.organizations.subscribe_plan( + organization_id=o2.id, price_plan_id=plan.id + ) + assert isinstance(invoice, StripePaymentInfo) + assert invoice.amount_due == 0 # Stripe not enabled + # Second base tier org + with pytest.raises( + (ForbiddenError, UpgradeTierError), + match="can have only one organization with Free Plan", + ): + with create_organization(dict(name="Org 3"), user_id=superuser.id): + pass + # Cannot subscribe to base tier plan + with pytest.raises( + (ForbiddenError, UpgradeTierError), + match="can have only one organization with Free Plan", + ): + super_client.organizations.subscribe_plan( + organization_id=o2.id, price_plan_id=BASE_PLAN_ID + ) + # Auto-subscribed to base tier plan + assert ox.price_plan_id == BASE_PLAN_ID + with pytest.raises(BadInputError, match="already subscribed to .+ plan"): + client.organizations.subscribe_plan( + organization_id=ox.id, price_plan_id=BASE_PLAN_ID + ) + + +def test_list_organizations(): + with setup_organizations() as ctx: + orgs = JamAI(user_id=ctx.superuser.id).organizations.list_organizations() + assert isinstance(orgs, Page) + assert len(orgs.items) == 3 # 2 orgs + template + assert orgs.total == 3 + + +@pytest.mark.cloud +def test_list_organizations_permission(): + with setup_organizations() as ctx: + with pytest.raises(ForbiddenError): + JamAI(user_id=ctx.user.id).organizations.list_organizations() + + +def test_get_org(): + """ + Test fetch organization. + - Admin can view API keys + - Member cannot view API keys + - System user can fetch org but not API keys + """ + with setup_organizations() as ctx: + super_client = JamAI(user_id=ctx.superuser.id) + client = JamAI(user_id=ctx.user.id) + # Add API key + super_client.organizations.update_organization( + ctx.superorg.id, OrganizationUpdate(external_keys=dict(x="x")) + ) + client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(external_keys=dict(x="x")) + ) + # Join organization as member + membership = super_client.organizations.join_organization( + ctx.user.id, + organization_id=ctx.superorg.id, + role=Role.MEMBER, + ) + assert isinstance(membership, OrgMemberRead) + # Admin can view API keys + org = super_client.organizations.get_organization(ctx.superorg.id) + assert isinstance(org, OrganizationRead) + assert org.id == ctx.superorg.id + assert org.external_keys["x"] == "x" + # Member cannot view API keys (cloud only) + org = client.organizations.get_organization(ctx.superorg.id) + assert isinstance(org, OrganizationRead) + assert org.id == ctx.superorg.id + assert org.external_keys["x"] == "x" if ENV_CONFIG.is_oss else "***" + # System user can fetch org but not API keys (cloud only) + user = super_client.users.get_user() + assert ctx.org.id not in {m.organization_id for m in user.org_memberships} + org = super_client.organizations.get_organization(ctx.org.id) + assert isinstance(org, OrganizationRead) + assert org.id == ctx.org.id + assert org.external_keys["x"] == "x" if ENV_CONFIG.is_oss else "***" + + +def test_update_org(): + """ + Test update organization. + - Partial update org + - Partial update external keys + """ + with setup_organizations() as ctx: + client = JamAI(user_id=ctx.user.id) + # Partial update org + org = client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(name="Updated Name") + ) + assert isinstance(org, OrganizationRead) + assert org.name == "Updated Name" + assert org.timezone is None + org = client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(timezone="Asia/Kuala_Lumpur") + ) + assert isinstance(org, OrganizationRead) + assert org.name == "Updated Name" + assert org.timezone == "Asia/Kuala_Lumpur" + with pytest.raises(BadInputError, match="currency"): + # Only USD is accepted for now + client.organizations.update_organization(ctx.org.id, dict(currency="EUR")) + with pytest.raises(BadInputError, match="timezone"): + # Strict timezone validation + client.organizations.update_organization( + ctx.org.id, dict(timezone="asia/kuala_lumpur") + ) + # Update external keys + org = client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(external_keys=dict(x="x")) + ) + assert isinstance(org, OrganizationRead) + assert org.external_keys == dict(x="x") + org = client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(external_keys=dict(y="y")) + ) + assert isinstance(org, OrganizationRead) + assert org.external_keys == dict(y="y") + + +@pytest.mark.cloud +def test_update_org_permission(): + """ + Test update organization. + - Only admin can update org + """ + with setup_organizations() as ctx: + super_client = JamAI(user_id=ctx.superuser.id) + client = JamAI(user_id=ctx.user.id) + # Test update permission + membership = client.organizations.join_organization( + ctx.superuser.id, + organization_id=ctx.org.id, + role=Role.MEMBER, + ) + assert isinstance(membership, OrgMemberRead) + # Member fail + with pytest.raises(ForbiddenError): + super_client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(name="New Name") + ) + # Admin OK + org = client.organizations.update_organization( + ctx.org.id, OrganizationUpdate(name="New Name") + ) + assert isinstance(org, OrganizationRead) + assert org.name == "New Name" + + +def test_delete_org(): + with setup_organizations() as ctx: + ok_response = JamAI(user_id=ctx.user.id).organizations.delete_organization( + ctx.org.id, missing_ok=False + ) + assert ok_response.ok is True + client = JamAI(user_id=ctx.superuser.id) + with pytest.raises(ResourceNotFoundError): + client.organizations.get_organization(ctx.org.id) + # Assert users are not deleted + users = client.users.list_users() + assert isinstance(users, Page) + assert len(users.items) == 2 + + +@pytest.mark.cloud +def test_delete_org_permission(): + with setup_organizations() as ctx: + client = JamAI(user_id=ctx.user.id) + with pytest.raises(ForbiddenError): + client.organizations.delete_organization(ctx.superorg.id, missing_ok=False) + + +def test_organisation_model_catalogue(): + """ + Test listing model configs: + - System level + - Organization level + - Private models via allow list and block list + - Run chat completion + """ + with setup_projects() as ctx: + with ( + # Common models + create_model_config(GPT_4O_MINI_CONFIG) as m0, + # Private models (allow list) + create_model_config( + dict( + **ELLM_DESCRIBE_CONFIG.model_dump(exclude_unset=True), + allowed_orgs=[ctx.org.id], + ) + ) as m1, + # Private models (block list) + create_model_config( + dict( + **GPT_41_NANO_CONFIG.model_dump(exclude_unset=True), + allowed_orgs=[ctx.org.id, ctx.superorg.id], + blocked_orgs=[ctx.org.id], + ) + ) as m2, + create_deployment(GPT_4O_MINI_DEPLOYMENT), + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(GPT_41_NANO_DEPLOYMENT), + ): + assert m0.is_private is False + assert m1.is_private is True + assert m2.is_private is True + super_client = JamAI(user_id=ctx.superuser.id, project_id=ctx.projects[0].id) + client = JamAI(user_id=ctx.user.id, project_id=ctx.projects[1].id) + # System-level + models = super_client.models.list_model_configs() + assert isinstance(models, Page) + assert len(models.items) == 3 + assert models.total == 3 + # Organisation-level + models = super_client.organizations.model_catalogue(organization_id=ctx.superorg.id) + assert isinstance(models, Page) + assert len(models.items) == 2 + assert models.total == 2 + model_ids = {m.id for m in models.items} + assert GPT_4O_MINI_CONFIG.id in model_ids + assert ELLM_DESCRIBE_CONFIG.id not in model_ids + assert GPT_41_NANO_CONFIG.id in model_ids + # Organisation-level + models = client.organizations.model_catalogue(organization_id=ctx.org.id) + assert isinstance(models, Page) + assert len(models.items) == 2 + assert models.total == 2 + model_ids = {m.id for m in models.items} + assert GPT_4O_MINI_CONFIG.id in model_ids + assert ELLM_DESCRIBE_CONFIG.id in model_ids + assert GPT_41_NANO_CONFIG.id not in model_ids + # Run chat completion + req = ChatRequest( + model=ELLM_DESCRIBE_CONFIG.id, + messages=[{"role": "user", "content": "Hi there"}], + max_tokens=4, + stream=False, + ) + response = client.generate_chat_completions(req) + assert isinstance(response, ChatCompletionResponse) + assert len(response.content) > 0 + assert response.prompt_tokens == 2 + assert response.completion_tokens > 0 + with pytest.raises(ResourceNotFoundError): + super_client.generate_chat_completions(req) + + +if __name__ == "__main__": + test_list_organizations() diff --git a/services/api/tests/routers/test_projects.py b/services/api/tests/routers/test_projects.py new file mode 100644 index 0000000..77fab85 --- /dev/null +++ b/services/api/tests/routers/test_projects.py @@ -0,0 +1,577 @@ +import re +from dataclasses import dataclass +from os.path import dirname, join, realpath +from tempfile import TemporaryDirectory + +import httpx +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + GetURLResponse, + LLMGenConfig, + OrganizationCreate, + OrgMemberRead, + Page, + ProjectCreate, + ProjectMemberRead, + ProjectRead, + ProjectUpdate, + RAGParams, + TableImportRequest, +) +from jamaibase.utils.exceptions import ( + AuthorizationError, + ForbiddenError, + ResourceExistsError, + ResourceNotFoundError, +) +from owl.db import TEMPLATE_ORG_ID +from owl.types import GEN_CONFIG_VAR_PATTERN, ColumnDtype, Role, TableType +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + TEXT_EMBEDDING_3_SMALL_CONFIG, + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, + get_file_map, + list_table_rows, + setup_organizations, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + +FILE_COLUMNS = ["image", "audio", "document"] + + +def test_create_project(): + with setup_organizations() as ctx: + # Standard creation + with ( + create_project(user_id=ctx.superuser.id), + create_project( + dict(name="Mickey 17"), user_id=ctx.user.id, organization_id=ctx.org.id + ), + ): + pass + + +@pytest.mark.cloud +def test_create_project_auth(): + from owl.db import sync_session + from owl.db.models.cloud import APIKey + + with ( + setup_organizations() as ctx, + create_project(user_id=ctx.user.id, organization_id=ctx.org.id) as p0, + ): + ### --- Test Project Key auth --- ### + # Project-linked PAT + pat = JamAI(user_id=ctx.user.id).users.create_pat(dict(name="pat", project_id=p0.id)) + assert pat.id.startswith("jamai_pat_") + client = JamAI(user_id=ctx.user.id, token=pat.id) + name = "Mickey 18" + p1 = client.projects.create_project(dict(name=name, organization_id=ctx.org.id)) + assert p1.name == name + with pytest.raises(AuthorizationError, match="invalid authorization token"): + JamAI(user_id=ctx.user.id, token=f"{pat.id}xx").projects.create_project( + dict(name=name, organization_id=ctx.org.id) + ) + # No project link + pat = JamAI(user_id=ctx.user.id).users.create_pat(dict(name="pat", project_id=None)) + assert pat.id.startswith("jamai_pat_") + client = JamAI(user_id=ctx.user.id, token=pat.id) + name = "Mickey 19" + p1 = client.projects.create_project(dict(name=name, organization_id=ctx.org.id)) + assert p1.name == name + with pytest.raises(AuthorizationError, match="invalid authorization token"): + JamAI(user_id=ctx.user.id, token=f"{pat.id}xx").projects.create_project( + dict(name=name, organization_id=ctx.org.id) + ) + + ### --- Test Legacy Organization Key auth --- ### + with sync_session() as session: + key = APIKey(id="jamai_sk_legacy", organization_id=ctx.org.id) + session.add(key) + session.commit() + session.refresh(key) + client = JamAI(user_id=ctx.user.id, token=key.id) + name = "Mickey 20" + p1 = client.projects.create_project(dict(name=name, organization_id=ctx.org.id)) + assert p1.name == name + with pytest.raises(AuthorizationError, match="invalid authorization token"): + JamAI(user_id=ctx.user.id, token=f"{key.id}xx").projects.create_project( + dict(name=name, organization_id=ctx.org.id) + ) + + # List projects + projects = client.projects.list_projects(ctx.org.id) + assert isinstance(projects, Page) + assert len(projects.items) == 4 + assert projects.total == 4 + + +@pytest.mark.cloud +def test_create_project_permission(): + with setup_organizations() as ctx: + assert ctx.user.id != "0" + with pytest.raises(ForbiddenError): + with create_project( + dict(name="My First Project", organization_id=ctx.superorg.id), + user_id=ctx.user.id, + ): + pass + + +# def test_create_existing_project(): +# with setup_organizations() as ctx: +# with create_project(user_id=ctx.superuser.id) as project: +# with pytest.raises(ResourceExistsError): +# with create_project( +# dict(id=project.id, name="Mickey 1"), user_id=ctx.superuser.id +# ): +# pass + + +def test_create_project_duplicate_name(): + with setup_organizations() as ctx, create_project(user_id=ctx.superuser.id) as p0: + with ( + create_project(dict(name=p0.name), user_id=ctx.superuser.id) as p1, + create_project(dict(name=p0.name), user_id=ctx.superuser.id) as p2, + ): + assert isinstance(p1, ProjectRead) + assert p1.name == f"{p0.name} (1)" + assert isinstance(p2, ProjectRead) + assert p2.name == f"{p0.name} (2)" + assert len({p0.id, p1.id, p2.id}) == 3 + + +def test_create_project_missing_org(): + with setup_organizations() as ctx: + with pytest.raises((ForbiddenError, ResourceNotFoundError)): + with create_project( + dict(name="My First Project"), + user_id=ctx.superuser.id, + organization_id="nonexistent", + ): + pass + + +def test_list_projects(): + with setup_organizations() as ctx: + with ( + create_project(user_id=ctx.superuser.id), + create_project(dict(name="Mickey 1"), user_id=ctx.superuser.id), + ): + projects = JamAI(user_id=ctx.superuser.id).projects.list_projects(ctx.superorg.id) + assert isinstance(projects, Page) + assert len(projects.items) == 2 + + +@pytest.mark.cloud +def test_list_projects_permission(): + """ + Test project list permission. + - ADMIN and MEMBER can list all projects. + - Non-members cannot list projects at all. + - GUEST can only list projects that they are a member of. + """ + with ( + setup_organizations() as ctx, + create_project(user_id=ctx.superuser.id), + create_project(user_id=ctx.superuser.id) as p1, + create_project(user_id=ctx.user.id, organization_id=ctx.org.id), + ): + super_client = JamAI(user_id=ctx.superuser.id) + client = JamAI(user_id=ctx.user.id) + ### --- Admin can list all projects --- ### + projects = super_client.projects.list_projects(ctx.superorg.id) + assert isinstance(projects, Page) + assert len(projects.items) == 2 + ### --- Non-member fail --- ### + with pytest.raises(ForbiddenError): + client.projects.list_projects(ctx.superorg.id) + ### --- Guest can list projects that they are a member of --- ### + # Join organization as guest and project + membership = super_client.organizations.join_organization( + ctx.user.id, + organization_id=ctx.superorg.id, + role=Role.GUEST, + ) + assert isinstance(membership, OrgMemberRead) + membership = super_client.projects.join_project( + ctx.user.id, + project_id=p1.id, + role=Role.MEMBER, + ) + assert isinstance(membership, ProjectMemberRead) + projects = client.projects.list_projects(ctx.superorg.id) + assert isinstance(projects, Page) + assert len(projects.items) == 1 + # Project role doesn't matter + membership = super_client.projects.update_member_role( + user_id=ctx.user.id, + project_id=p1.id, + role=Role.GUEST, + ) + assert isinstance(membership, ProjectMemberRead) + assert membership.role == Role.GUEST + projects = client.projects.list_projects(ctx.superorg.id) + assert isinstance(projects, Page) + assert len(projects.items) == 1 + ### --- Member can list all projects --- ### + # Update org role to MEMBER + membership = super_client.organizations.update_member_role( + user_id=ctx.user.id, + organization_id=ctx.superorg.id, + role=Role.MEMBER, + ) + assert isinstance(membership, OrgMemberRead) + assert membership.role == Role.MEMBER + projects = client.projects.list_projects(ctx.superorg.id) + assert isinstance(projects, Page) + assert len(projects.items) == 2 + + +@pytest.mark.cloud +def test_update_project_permission(): + with ( + setup_organizations() as ctx, + create_project(user_id=ctx.user.id, organization_id=ctx.org.id) as project, + ): + client = JamAI(user_id=ctx.user.id) + # Join organization and project as member + membership = client.organizations.join_organization( + ctx.superuser.id, + organization_id=ctx.org.id, + role=Role.MEMBER, + ) + assert isinstance(membership, OrgMemberRead) + membership = client.projects.join_project( + ctx.superuser.id, + project_id=project.id, + role=Role.MEMBER, + ) + assert isinstance(membership, ProjectMemberRead) + # Admin OK + updated_proj = client.projects.update_project(project.id, ProjectUpdate(name="New Name")) + assert isinstance(updated_proj, ProjectRead) + # Member fail + with pytest.raises(ForbiddenError): + JamAI(user_id=ctx.superuser.id).projects.update_project( + project.id, ProjectUpdate(name="Another Name") + ) + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + superorg_id: str + project_id: str + embedding_size: int + image_uri: str + audio_uri: str + document_uri: str + chat_model_id: str + embed_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + create_user() as superuser, + create_organization( + body=OrganizationCreate(name="Superorg"), user_id=superuser.id + ) as superorg, + create_project( + dict(name="Superorg Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superuser.id == "0" + assert superorg.id == "0" + + bge = "ellm/BAAI/bge-m3" + with ( + # Create models + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config(TEXT_EMBEDDING_3_SMALL_CONFIG) as embed_config, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + create_model_config( + TEXT_EMBEDDING_3_SMALL_CONFIG.model_copy(update=dict(id=bge, owned_by="ellm")) + ), + # Create deployments + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(TEXT_EMBEDDING_3_SMALL_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + create_deployment( + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT.model_copy(update=dict(model_id=bge)) + ), + ): + client = JamAI(user_id=superuser.id, project_id=p0.id) + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + audio_uri = upload_file(client, FILES["gutter.mp3"]).uri + document_uri = upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri + yield ServingContext( + superuser_id=superuser.id, + superorg_id=superorg.id, + project_id=p0.id, + embedding_size=embed_config.final_embedding_size, + image_uri=image_uri, + audio_uri=audio_uri, + document_uri=document_uri, + chat_model_id=desc_llm_config.id, + embed_model_id=embed_config.id, + rerank_model_id=rerank_config.id, + ) + + +def _check_tables(user_id: str, project_id: str): + client = JamAI(user_id=user_id, project_id=project_id) + for table_type in TableType: + tables = client.table.list_tables(table_type, parent_id="_agent_") + assert tables.total == 1 + rows = list_table_rows(client, table_type, tables.items[0].id) + assert rows.total == 1 + if table_type == TableType.ACTION: + # Check image content + urls = client.file.get_raw_urls([rows.values[0]["image"]]) + assert isinstance(urls, GetURLResponse) + image = httpx.get(urls.urls[0]).content + with open(FILES["cifar10-deer.jpg"], "rb") as f: + assert image == f.read() + + +def test_project_import_export( + setup: ServingContext, +): + """ + Test project import and export. + + Args: + setup (ServingContext): Setup. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + tables = [] + try: + # Create the tables + for table_type in TableType: + if table_type == TableType.CHAT: + parquet_filepath = FILES["export-v0.4-chat-agent.parquet"] + else: + parquet_filepath = FILES[f"export-v0.4-{table_type}.parquet"] + table = client.table.import_table( + table_type, + TableImportRequest(file_path=parquet_filepath, table_id_dst=None), + ) + tables.append(table) + # Export + with TemporaryDirectory() as tmp_dir: + file_path = join(tmp_dir, f"{setup.project_id}.parquet") + with open(file_path, "wb") as f: + f.write(client.projects.export_project(setup.project_id)) + # Import as new project + imported_project = client.projects.import_project( + file_path, + project_id="", + organization_id=setup.superorg_id, + ) + assert isinstance(imported_project, ProjectRead) + assert imported_project.id != setup.project_id + _check_tables(setup.superuser_id, imported_project.id) + # Import into existing project + with create_project( + dict(name="Superorg Project 1"), + user_id=setup.superuser_id, + organization_id=setup.superorg_id, + ) as p1: + imported_project = client.projects.import_project( + file_path, + project_id=p1.id, + organization_id="", + ) + assert isinstance(imported_project, ProjectRead) + assert imported_project.id == p1.id + _check_tables(setup.superuser_id, imported_project.id) + # Should not change existing metadata + project = client.projects.get_project(p1.id) + assert project.name == "Superorg Project 1" + # Should fail if tables already exist + with pytest.raises(ResourceExistsError): + client.projects.import_project( + file_path, + project_id=p1.id, + organization_id="", + ) + finally: + for table in tables: + client.table.delete_table(table_type, table.id) + + +@pytest.mark.parametrize("version", ["v0.4"]) +def test_project_import_parquet( + setup: ServingContext, + version: str, +): + """ + Test project import from an existing Parquet file. + - Import as new project from v0.4 file + - Import into existing parquet from v0.4 file + - Import v0.4 file with table and column names that are too long (test truncation) + + Args: + setup (ServingContext): Setup. + """ + client = JamAI(user_id=setup.superuser_id, project_id=setup.project_id) + ### --- Import as new project --- ### + imported_project = client.projects.import_project( + FILES[f"export-{version}-project.parquet"], + project_id="", + organization_id=setup.superorg_id, + ) + assert imported_project.id != setup.project_id + assert imported_project.name == "Test Project æ–°ã—ã„" + _check_tables(setup.superuser_id, imported_project.id) + ### --- Import into existing project --- ### + with create_project( + dict(name="Superorg Project 2"), + user_id=setup.superuser_id, + organization_id=setup.superorg_id, + ) as p1: + imported_project = client.projects.import_project( + FILES[f"export-{version}-project.parquet"], + project_id=p1.id, + organization_id="", + ) + assert imported_project.id == p1.id + assert imported_project.name == p1.name + assert imported_project.name != "Test Project æ–°ã—ã„" + _check_tables(setup.superuser_id, imported_project.id) + ### --- Import table and column names that are too long --- ### + imported_project = client.projects.import_project( + FILES[f"export-{version}-project-long-name.parquet"], + project_id="", + organization_id=setup.superorg_id, + ) + assert imported_project.id != setup.project_id + client = JamAI(user_id=setup.superuser_id, project_id=imported_project.id) + # Check tables + tables = client.table.list_tables(TableType.KNOWLEDGE) + assert len(tables.items) == 1 + assert tables.total == 1 + kt = tables.items[0] + assert len(kt.id) == 100 + tables = client.table.list_tables(TableType.ACTION) + assert len(tables.items) == 1 + assert tables.total == 1 + at = tables.items[0] + assert len(at.id) == 100 + assert len(at.cols) == 4 + for col in at.cols[2:]: + assert len(col.id) == 100 + assert at.cols[2].dtype == ColumnDtype.IMAGE + assert at.cols[3].dtype == ColumnDtype.STR + cfg = at.cols[3].gen_config + assert isinstance(cfg, LLMGenConfig) + ref_ids = re.findall(GEN_CONFIG_VAR_PATTERN, cfg.prompt) + assert len(ref_ids) == 1 + assert ref_ids[0] == at.cols[2].id + assert isinstance(cfg.rag_params, RAGParams) + assert cfg.rag_params.table_id == kt.id + tables = client.table.list_tables(TableType.CHAT) + assert len(tables.items) == 2 + assert tables.total == 2 + tables = client.table.list_tables(TableType.CHAT, parent_id="_agent_") + assert len(tables.items) == 1 + assert tables.total == 1 + agent = tables.items[0] + assert len(agent.id) == 100 + tables = client.table.list_tables(TableType.CHAT, parent_id="_chat_") + assert len(tables.items) == 1 + assert tables.total == 1 + ct = tables.items[0] + assert len(ct.id) == 100 + assert agent.parent_id is None + assert ct.parent_id == agent.id + + +def test_template_import_export( + setup: ServingContext, +): + """ + Test template import. + + Args: + setup (ServingContext): Setup. + """ + # Create template + template = JamAI(user_id=setup.superuser_id).projects.create_project( + ProjectCreate(organization_id=TEMPLATE_ORG_ID, name="Template") + ) + client = JamAI(user_id=setup.superuser_id, project_id=template.id) + tables = [] + try: + # Create the tables + for table_type in TableType: + if table_type == TableType.CHAT: + parquet_filepath = FILES["export-v0.4-chat-agent.parquet"] + else: + parquet_filepath = FILES[f"export-v0.4-{table_type}.parquet"] + table = client.table.import_table( + table_type, + TableImportRequest(file_path=parquet_filepath, table_id_dst=None), + ) + tables.append(table) + # Import as new project + imported_project = client.projects.import_template( + template.id, + project_id="", + organization_id=setup.superorg_id, + ) + assert isinstance(imported_project, ProjectRead) + assert imported_project.id != setup.project_id + _check_tables(setup.superuser_id, imported_project.id) + # Import into existing project + with create_project( + dict(name="Superorg Project 2"), + user_id=setup.superuser_id, + organization_id=setup.superorg_id, + ) as p1: + imported_project = client.projects.import_template( + template.id, + project_id=p1.id, + organization_id="", + ) + assert isinstance(imported_project, ProjectRead) + assert imported_project.id == p1.id + _check_tables(setup.superuser_id, imported_project.id) + # Should not change existing metadata + project = client.projects.get_project(p1.id) + assert project.name == "Superorg Project 2" + # Should fail if tables already exist + with pytest.raises(ResourceExistsError): + client.projects.import_template( + template.id, + project_id=p1.id, + organization_id="", + ) + finally: + for table in tables: + client.table.delete_table(table_type, table.id) + + +if __name__ == "__main__": + test_list_projects() diff --git a/services/api/tests/routers/test_serving.py b/services/api/tests/routers/test_serving.py new file mode 100644 index 0000000..729081c --- /dev/null +++ b/services/api/tests/routers/test_serving.py @@ -0,0 +1,1201 @@ +import base64 +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from time import sleep +from typing import Generator + +import numpy as np +import pytest +from flaky import flaky + +from jamaibase import JamAI, JamAIAsync +from jamaibase.types import ( + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, + ChatEntry, + ChatRequest, + DeploymentCreate, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingUsage, + ModelInfoListResponse, + OkResponse, + OrganizationCreate, + RAGParams, + References, + RerankingRequest, + StripePaymentInfo, + TextContent, +) +from jamaibase.utils.exceptions import BadInputError, ForbiddenError, ResourceNotFoundError +from owl.configs import ENV_CONFIG +from owl.types import ( + CloudProvider, + ModelCapability, + ModelType, + Role, + TableType, +) +from owl.utils import uuid7_str +from owl.utils.test import ( + DS_PARAMS, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + STREAM_PARAMS, + TEXT_EMBEDDING_3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + create_deployment, + create_model_config, + create_organization, + create_project, + create_table, + create_user, +) + +METER_RETRY = 50 +METER_RETRY_DELAY = 1 +# Together AI sometimes take a long time +CHAT_TIMEOUT = 30 +RERANK_TIMEOUT = 60 +EMBED_TIMEOUT = 30 + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + user_id: str + superorg_id: str + org_id: str + project_ids: list[str] + chat_model_id: str + short_chat_model_id: str + embedding_model_id: str + rerank_model_id: str + chat_deployment_id: str + embedding_deployment_id: str + rerank_deployment_id: str + llm_input_costs: float + llm_output_costs: float + embed_costs: float + rerank_costs: float + chat_request: ChatRequest + chat_request_text_array: ChatRequest + chat_request_short: ChatRequest + embedding_request: EmbeddingRequest + reranking_request: RerankingRequest + + +def _metrics_match_llm_token_counts(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data.get("data", []): + if entry["groupBy"].get("model", "") == serving_info["model"]: + if ( + entry["groupBy"]["type"] == "input" + and entry["value"] == serving_info["prompt_tokens"] + ): + count_true += 1 + if ( + entry["groupBy"]["type"] == "output" + and entry["value"] == serving_info["completion_tokens"] + ): + count_true += 1 + return count_true == 2 + + +def _metrics_match_llm_spent(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data["data"]: + if ( + entry["groupBy"].get("model", "") == serving_info["model"] + and entry["groupBy"].get("category", "") == "llm_tokens" + ): + if ( + entry["groupBy"]["type"] == "input" + and round(entry["value"], 8) == serving_info["prompt_costs"] + ): + count_true += 1 + if ( + entry["groupBy"]["type"] == "output" + and round(entry["value"], 8) == serving_info["completion_costs"] + ): + count_true += 1 + return count_true == 2 + + +def _metrics_match_embed_token_counts(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data["data"]: + if entry["groupBy"].get("model", "") == serving_info["model"]: + if entry["value"] == serving_info["tokens"]: + count_true += 1 + return count_true == 1 + + +def _metrics_match_embed_spent(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data["data"]: + if ( + entry["groupBy"].get("model", "") == serving_info["model"] + and entry["groupBy"].get("category", "") == "embedding_tokens" + ): + if round(entry["value"], 8) == serving_info["costs"]: + count_true += 1 + return count_true == 1 + + +def _metrics_match_rerank_search_counts(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data["data"]: + if entry["groupBy"].get("model", "") == serving_info["model"]: + if entry["value"] == serving_info["documents"]: + count_true += 1 + return count_true == 1 + + +def _metrics_match_rerank_spent(metrics_data, serving_info): + count_true = 0 + for entry in metrics_data["data"]: + if ( + entry["groupBy"].get("model", "") == serving_info["model"] + and entry["groupBy"].get("category", "") == "reranker_searches" + ): + if round(entry["value"], 8) == serving_info["costs"]: + count_true += 1 + return count_true == 1 + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization, models, deployments, and projects for serving tests. + """ + with ( + # Create superuser + create_user() as superuser, + # Create user + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + # Create organization + create_organization(body=OrganizationCreate(name="TSP"), user_id=superuser.id) as superorg, + create_organization(body=OrganizationCreate(name="Org"), user_id=user.id) as org, + # Create project + create_project(dict(name="P0"), user_id=superuser.id, organization_id=superorg.id) as p0, + create_project(dict(name="P1"), user_id=superuser.id, organization_id=superorg.id) as p1, + create_project(dict(name="P2"), user_id=user.id, organization_id=org.id) as p2, + ): + assert superuser.id == "0" + assert superorg.id == "0" + projects = [p0, p1, p2] + client = JamAI(user_id=superuser.id) + # Join organization and project + client.organizations.join_organization( + user_id=user.id, organization_id=superorg.id, role=Role.ADMIN + ) + client.projects.join_project(user_id=user.id, project_id=p0.id, role=Role.ADMIN) + client.projects.join_project(user_id=user.id, project_id=p1.id, role=Role.ADMIN) + # Create models + with ( + create_model_config(GPT_41_NANO_CONFIG) as llm_config, + create_model_config( + dict( + # Max context length = 5 + id=f"ellm/lorem-context-5/{uuid7_str()}", + type=ModelType.LLM, + name="Short-Context Chat Model", + capabilities=[ModelCapability.CHAT], + context_length=5, + languages=["en"], + owned_by="ellm", + ) + ) as short_llm_config, + create_model_config(TEXT_EMBEDDING_3_SMALL_CONFIG) as embed_config, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + ): + # Create deployments + with ( + create_deployment(GPT_41_NANO_DEPLOYMENT) as chat_deployment, + create_deployment( + DeploymentCreate( + model_id=short_llm_config.id, + name="Short chat Deployment", + provider="custom", + routing_id=short_llm_config.id, + api_base=ENV_CONFIG.test_llm_api_base, + ) + ), + create_deployment( + ELLM_EMBEDDING_DEPLOYMENT.model_copy(update=dict(model_id=embed_config.id)) + ) as embedding_deployment, + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT) as rerank_deployment, + ): + # Yield the setup data for use in tests + yield ServingContext( + superuser_id=superuser.id, + user_id=user.id, + superorg_id=superorg.id, + org_id=org.id, + project_ids=[project.id for project in projects], + chat_model_id=llm_config.id, + short_chat_model_id=short_llm_config.id, + embedding_model_id=embed_config.id, + rerank_model_id=rerank_config.id, + chat_deployment_id=chat_deployment.id, + embedding_deployment_id=embedding_deployment.id, + rerank_deployment_id=rerank_deployment.id, + llm_input_costs=llm_config.llm_input_cost_per_mtoken, + llm_output_costs=llm_config.llm_output_cost_per_mtoken, + embed_costs=embed_config.embedding_cost_per_mtoken, + rerank_costs=rerank_config.reranking_cost_per_ksearch, + chat_request=ChatRequest( + model=llm_config.id, + # Test malformed input + messages=[ChatEntry.system(content=""), ChatEntry.user(content="Hi")], + max_tokens=3, + stream=False, + ), + # TODO: Test image and audio input + chat_request_text_array=ChatRequest( + model=llm_config.id, + messages=[ + ChatEntry.user( + content=[ + TextContent(text="Hi "), + TextContent(text="there"), + ] + ) + ], + max_tokens=3, + stream=False, + ), + chat_request_short=ChatRequest( + model=short_llm_config.id, + messages=[{"role": "user", "content": "Hi there how is your day going?"}], + max_tokens=4, + stream=False, + ), + embedding_request=EmbeddingRequest( + model=embed_config.id, + input="This is a test input.", + # encoding_format="base64", + ), + reranking_request=RerankingRequest( + model=rerank_config.id, + query="What is the capital of France?", + documents=["London", "Berlin", "Paris"], + ), + ) + + +@pytest.mark.cloud +def test_model_prices(setup: ServingContext): + del setup + client = JamAI() + prices = client.prices.list_model_prices() + assert len(prices.llm_models) == 2 + assert len(prices.embed_models) == 1 + assert len(prices.rerank_models) == 1 + + +def test_model_info(setup: ServingContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + chat_model_id = setup.chat_model_id + + response = client.model_info() + assert isinstance(response, ModelInfoListResponse) + assert len(response.data) == 4 + + response = client.model_info(model=chat_model_id) + assert len(response.data) == 1 + assert response.data[0].id == chat_model_id + assert response.data[0].capabilities == ["chat", "image", "tool"] + + response = client.model_info(capabilities=["chat"]) + assert len(response.data) > 1 + assert all("chat" in m.capabilities for m in response.data) + + response = client.model_info(model="non-existent-model") + assert len(response.data) == 0 # Ensure no data is returned for a non-existent model + + +def test_model_ids(setup: ServingContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + embedding_model_id = setup.embedding_model_id + + response = client.model_ids() + assert isinstance(response, list) + assert len(response) == 4 + + response = client.model_ids(prefer=embedding_model_id) + assert isinstance(response, list) + assert len(response) == 4 + assert embedding_model_id == response[0] + + +@pytest.mark.cloud +def test_chat_completion_without_credit(setup: ServingContext): + # Only Cloud enforces quota and credits + super_client = JamAI(user_id=setup.superuser_id) + # Set zero credit + response = super_client.organizations.set_credit_grant(setup.org_id, amount=0) + assert isinstance(response, OkResponse) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[2]) + with pytest.raises(ForbiddenError, match="Insufficient .+ credits"): + client.generate_chat_completions(setup.chat_request) + + +def _test_chat_completion_stream( + setup: ServingContext, request: ChatRequest +) -> list[ChatCompletionChunkResponse | References]: + request.stream = True + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + _responses = client.generate_chat_completions(request) + responses: list[ChatCompletionChunkResponse | References] = [item for item in _responses] + assert len(responses) > 0 + assert all(isinstance(r, (ChatCompletionChunkResponse, References)) for r in responses) + _chat_chunks = [r for r in responses if isinstance(r, ChatCompletionChunkResponse)] + assert all(isinstance(r.content, str) for r in _chat_chunks) + assert len("".join(r.content for r in _chat_chunks)) > 1 + response = responses[-1] + assert isinstance(response.usage, ChatCompletionUsage) + assert isinstance(response.usage.prompt_tokens, int) + assert isinstance(response.usage.completion_tokens, int) + assert isinstance(response.usage.total_tokens, int) + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.total_tokens == response.prompt_tokens + response.completion_tokens + return responses + + +def _compile_and_check_responses( + response: (Generator[ChatCompletionChunkResponse, None, None] | ChatCompletionResponse), + stream: bool, +): + if stream: + responses: list[ChatCompletionChunkResponse] = [item for item in response] + for r in responses: + assert isinstance(r, ChatCompletionChunkResponse) + assert r.object == "chat.completion.chunk" + assert r.usage is None or isinstance(r.usage, ChatCompletionUsage) + content = "".join(getattr(r.choices[0].delta, "content", "") or "" for r in responses) + reasoning_content = "".join( + getattr(r.choices[0].delta, "reasoning_content", "") or "" for r in responses + ) + usage = responses[-1].usage + assert isinstance(usage, ChatCompletionUsage) + + choice = responses[0].choices[0] + assert isinstance(choice, ChatCompletionChoice) + + message = ChatCompletionMessage(content=content) + assert isinstance(message, ChatCompletionMessage) + + if reasoning_content: + message.reasoning_content = reasoning_content + assert isinstance(message.reasoning_content, str) + assert len(message.reasoning_content) > 0 + + choice.delta = None + choice.message = message + + response = ChatCompletionResponse( + id=responses[0].id, + object="chat.completion", + created=responses[0].created, + model=responses[0].model, + choices=[choice], + usage=usage, + service_tier=responses[0].service_tier, + system_fingerprint=responses[0].system_fingerprint, + ) + + assert isinstance(response, ChatCompletionResponse) + assert isinstance(response.id, str) + assert response.object == "chat.completion" + assert isinstance(response.created, int) + assert isinstance(response.model, str) + assert isinstance(response.choices[0], ChatCompletionChoice) + assert isinstance(response.choices[0].message, ChatCompletionMessage) + assert isinstance(response.choices[0].message.content, str) + assert len(response.choices[0].message.content) > 1 + assert isinstance(response.usage, ChatCompletionUsage) + 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 + + return response + + +@pytest.mark.cloud +def test_serving_credit(setup: ServingContext): + setup = deepcopy(setup) + super_client = JamAI(user_id=setup.superuser_id, project_id=setup.project_ids[0]) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[2]) + # Assert credit grant is consumed first + response = super_client.organizations.set_credit_grant(setup.org_id, amount=0.01) + assert isinstance(response, OkResponse) + client.generate_chat_completions(setup.chat_request, timeout=CHAT_TIMEOUT) + sleep(1.0) + org = client.organizations.get_organization(setup.org_id) + assert org.credit == 0 + assert org.credit_grant < 0.01 + # Set credit to zero + super_client.organizations.set_credit_grant(setup.org_id, amount=0) + # Chat completion + for stream in [True, False]: + setup.chat_request.stream = stream + with pytest.raises(ForbiddenError, match="Insufficient quota or credits"): + client.generate_chat_completions(setup.chat_request, timeout=CHAT_TIMEOUT) + # Embedding + with pytest.raises(ForbiddenError, match="Insufficient quota or credits"): + client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + # Reranking + with pytest.raises(ForbiddenError, match="Insufficient quota or credits"): + client.rerank(setup.reranking_request, timeout=RERANK_TIMEOUT) + # Assert credit is consumed if there is no credit grant + response = client.organizations.purchase_credits(setup.org_id, amount=1) + assert isinstance(response, StripePaymentInfo) + super_client.organizations.set_credit_grant(setup.org_id, amount=0) + client.generate_chat_completions(setup.chat_request, timeout=CHAT_TIMEOUT) + sleep(1.0) + org = client.organizations.get_organization(setup.org_id) + assert org.credit < 1 + assert org.credit_grant == 0 + + +def _test_chat_completion( + client: JamAI, + request: ChatRequest, + stream: bool, + timeout: int = 60, +): + request.stream = stream + response = client.generate_chat_completions(request, timeout=timeout) + return _compile_and_check_responses(response, stream) + + +def _test_chat_completion_no_stream( + setup: ServingContext, request: ChatRequest +) -> ChatCompletionResponse: + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.generate_chat_completions(request) + assert isinstance(response, ChatCompletionResponse) + assert isinstance(response.content, str) + assert len(response.content) > 1 + assert isinstance(response.usage, ChatCompletionUsage) + 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 + return response + + +def test_chat_completion_auto_model(setup: ServingContext): + setup = deepcopy(setup) + setup.chat_request = ChatRequest( + **setup.chat_request.model_dump( + exclude={"model"}, exclude_unset=True, exclude_defaults=True + ) + ) + _test_chat_completion_no_stream(setup, setup.chat_request) + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_completion(setup: ServingContext, stream: bool): + setup = deepcopy(setup) + if stream: + _test_chat_completion_stream(setup, setup.chat_request) + else: + _test_chat_completion_no_stream(setup, setup.chat_request) + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_completion_text_array(setup: ServingContext, stream: bool): + setup = deepcopy(setup) + if stream: + _test_chat_completion_stream(setup, setup.chat_request_text_array) + else: + _test_chat_completion_no_stream(setup, setup.chat_request_text_array) + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_completion_rag(setup: ServingContext, stream: bool): + """ + Chat completion with RAG. + - RAG on empty table: stream and non-stream + - RAG on non-empty table: stream and non-stream + + Args: + setup (ServingContext): Setup. + stream (bool): Stream (SSE) or not. + """ + setup = deepcopy(setup) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + with create_table(client, TableType.KNOWLEDGE, cols=[]) as kt: + setup.chat_request.rag_params = RAGParams( + reranking_model=None, + table_id=kt.id, + search_query="", + k=2, + ) + ### --- RAG on empty table --- ### + if stream: + responses = _test_chat_completion_stream(setup, setup.chat_request) + assert isinstance(responses[0], References) + else: + response = _test_chat_completion_no_stream(setup, setup.chat_request) + assert isinstance(response.references, References) + ### --- Add data into Knowledge Table --- ### + data = [dict(Title="Pet", Text="My pet's name is Latte.")] + response = add_table_rows(client, TableType.KNOWLEDGE, kt.id, data, stream=False) + assert len(response.rows) == len(data) + if stream: + responses = _test_chat_completion_stream(setup, setup.chat_request) + assert isinstance(responses[0], References) + else: + response = _test_chat_completion_no_stream(setup, setup.chat_request) + assert isinstance(response.references, References) + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +async def test_chat_completion_error_cases(setup: ServingContext, stream: bool): + """ + Test chat completion error cases. + - Sync and async + - Exceed context length + - Model not found + + Args: + setup (ServingContext): Setup. + stream (bool): Stream (SSE) or not. + """ + setup = deepcopy(setup) + model_id = setup.chat_request_short.model + setup.chat_request_short.stream = stream + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + aclient = JamAIAsync(user_id=setup.user_id, project_id=setup.project_ids[0]) + # Prompt too long, max tokens too large + with pytest.raises(BadInputError, match="maximum context length"): + client.generate_chat_completions(setup.chat_request_short) + with pytest.raises(BadInputError, match="maximum context length"): + await aclient.generate_chat_completions(setup.chat_request_short) + # Max tokens is too large + setup.chat_request_short.messages[0].content = "Hi there" + with pytest.raises(BadInputError, match="maximum context length"): + client.generate_chat_completions(setup.chat_request_short) + with pytest.raises(BadInputError, match="maximum context length"): + await aclient.generate_chat_completions(setup.chat_request_short) + # Unknown model + setup.chat_request_short.model = "unknown" + with pytest.raises(ResourceNotFoundError, match="Model .+ is not found"): + client.generate_chat_completions(setup.chat_request_short) + with pytest.raises(ResourceNotFoundError, match="Model .+ is not found"): + await aclient.generate_chat_completions(setup.chat_request_short) + # OK + setup.chat_request_short.model = model_id + setup.chat_request_short.max_tokens = 1 + if stream: + responses = list(client.generate_chat_completions(setup.chat_request_short)) + assert len(responses) > 0 + assert all(isinstance(r, ChatCompletionChunkResponse) for r in responses) + assert all(isinstance(r.content, str) for r in responses) + assert len("".join(r.content for r in responses)) > 1 + response = responses[-1] + else: + response = client.generate_chat_completions(setup.chat_request_short) + assert isinstance(response.usage, ChatCompletionUsage) + assert isinstance(response.usage.prompt_tokens, int) + assert isinstance(response.usage.completion_tokens, int) + assert isinstance(response.usage.total_tokens, int) + assert response.prompt_tokens > 0 + assert response.completion_tokens > 0 + assert response.total_tokens == response.prompt_tokens + response.completion_tokens + + +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_llm_usage_metrics(setup: ServingContext, stream: bool, data_source: str): + setup = deepcopy(setup) + setup.chat_request.stream = stream + start_dt = datetime.now(tz=timezone.utc) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + if stream: + responses = list(client.generate_chat_completions(setup.chat_request)) + response = responses[-1] + else: + response = client.generate_chat_completions(setup.chat_request) + serving_info = { + "model": setup.chat_model_id, + "prompt_tokens": response.prompt_tokens, + "completion_tokens": response.completion_tokens, + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_usage_metrics( + type="llm", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + proj_ids=[setup.project_ids[0]], + group_by=["type", "model"], + data_source=data_source, + ) + if _metrics_match_llm_token_counts(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="llm", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + proj_ids=[setup.project_ids[0]], + group_by=["type", "model"], + data_source=data_source, + ) + assert _metrics_match_llm_token_counts(response.model_dump(), serving_info) + + +@pytest.mark.cloud +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_llm_billing_metrics(setup: ServingContext, stream: bool, data_source: str): + setup = deepcopy(setup) + start_dt = datetime.now(tz=timezone.utc) + setup.chat_request.stream = stream + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + if stream: + responses = list(client.generate_chat_completions(setup.chat_request)) + response = responses[-1] + else: + response = client.generate_chat_completions(setup.chat_request) + serving_info = { + "model": setup.chat_model_id, + "prompt_costs": round(response.prompt_tokens * 1e-6 * setup.llm_input_costs, 8), + "completion_costs": round(response.completion_tokens * 1e-6 * setup.llm_output_costs, 8), + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_billing_metrics( + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + proj_ids=[setup.project_ids[0]], + group_by=["type", "model", "category"], + data_source=data_source, + ) + if _metrics_match_llm_spent(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="spent", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + proj_ids=[setup.project_ids[0]], + group_by=["type", "model", "category"], + data_source=data_source, + ) + assert _metrics_match_llm_spent(response.model_dump(), serving_info) + + +def _test_chat_reasoning_cloud( + setup: ServingContext, + provider: CloudProvider, + routing_id: str, + stream: bool, + max_tokens: int, + timeout: int = 60, + prompt: str = "How many R is in Red?", + reasoning_effort: str | None = None, + thinking_budget: int | None = None, +): + model_id = setup.chat_model_id + super_client = JamAI(user_id=setup.superuser_id) + super_client.models.update_deployment( + setup.chat_deployment_id, + dict(provider=provider, routing_id=routing_id), + ) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + chat_request = ChatRequest( + model=model_id, + messages=[ChatEntry.user(content=prompt)], + max_tokens=max_tokens, + stream=stream, + reasoning_effort=reasoning_effort, + thinking_budget=thinking_budget, + temperature=0, + top_p=0.6, + ) + + response = _test_chat_completion(client, chat_request, stream, timeout) + assert response.model == model_id + return response + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_reasoning_openai(setup: ServingContext, stream: bool): + kwargs = dict( + setup=setup, + provider=CloudProvider.OPENAI, + stream=stream, + max_tokens=1000, + ) + # Test default params + response = _test_chat_reasoning_cloud( + routing_id="gpt-5-mini", + **kwargs, + ) + assert len(response.content) > 0 + # Test disabling reasoning + response = _test_chat_reasoning_cloud( + routing_id="gpt-5-mini", + reasoning_effort="disable", + **kwargs, + ) + assert len(response.content) > 0 + assert response.reasoning_tokens < 300 + # Test reasoning effort + med_response = _test_chat_reasoning_cloud( + routing_id="gpt-5-mini", + thinking_budget=512, + **kwargs, + ) + assert len(med_response.content) > 0 + assert med_response.usage.reasoning_tokens > 0 + + +@flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_chat_reasoning_anthropic(setup: ServingContext, stream: bool): + kwargs = dict( + setup=setup, + provider=CloudProvider.ANTHROPIC, + routing_id="claude-sonnet-4-0", + stream=stream, + max_tokens=2200, + ) + # Test default params + response = _test_chat_reasoning_cloud(**kwargs) + assert len(response.content) > 0 + kwargs["routing_id"] = "claude-3-7-sonnet-latest" + response = _test_chat_reasoning_cloud(**kwargs) + assert len(response.content) > 0 + # Test disabling reasoning + response = _test_chat_reasoning_cloud( + reasoning_effort="disable", + **kwargs, + ) + # Test reasoning effort + kwargs["max_tokens"] = 5000 + med_response = _test_chat_reasoning_cloud( + reasoning_effort="medium", + **kwargs, + ) + assert len(med_response.content) > 0 + assert med_response.usage.reasoning_tokens > 0 + + +# @flaky(max_runs=3, min_passes=1) +# @pytest.mark.parametrize("stream", **STREAM_PARAMS) +# def test_chat_reasoning_gemini(setup: ServingContext, stream: bool): +# kwargs = dict( +# setup=setup, +# provider=CloudProvider.GEMINI, +# stream=stream, +# max_tokens=1024, +# ) +# # Test default params +# response = _test_chat_reasoning_cloud( +# routing_id="gemini-2.5-flash-lite", +# **kwargs, +# ) +# assert len(response.content) > 0 +# # Test disabling reasoning +# response = _test_chat_reasoning_cloud( +# reasoning_effort="disable", +# routing_id="gemini-2.5-pro", +# **kwargs, +# ) +# assert len(response.content) > 0 +# # Test reasoning effort +# high_response = _test_chat_reasoning_cloud( +# thinking_budget=512, +# routing_id="gemini-2.5-flash", +# **kwargs, +# ) +# assert len(high_response.content) > 0 +# assert high_response.reasoning_tokens > 0 + + +@flaky(max_runs=5, min_passes=1) +def test_generate_embeddings_auto_model(setup: ServingContext): + setup = deepcopy(setup) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + setup.embedding_request.model = "" + response = client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + assert len(response.data) > 0 + embedding = response.data[0].embedding + assert isinstance(embedding, list) + assert len(embedding) > 1 + assert all(isinstance(x, float) for x in embedding) + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize( + "texts", + ["What is a llama?", ["What is a llama?", "What is an alpaca?"]], + ids=["str", "list[str]"], +) +def test_generate_embeddings(setup: ServingContext, texts: str | list[str]): + setup = deepcopy(setup) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + setup.embedding_request.input = texts + # Float embeddings + response = client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + assert isinstance(response, EmbeddingResponse) + assert isinstance(response.model, str) + assert isinstance(response.usage, EmbeddingUsage) + assert isinstance(response.data, list) + if isinstance(texts, str): + assert len(response.data) == 1 + else: + assert len(response.data) == len(texts) + for d in response.data: + assert isinstance(d.embedding, list) + assert len(d.embedding) > 1 + assert all(isinstance(x, float) for x in d.embedding) + embed_float = np.asarray(response.data[0].embedding, dtype=np.float32) + + # Base64 embeddings + setup.embedding_request.encoding_format = "base64" + response = client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + assert isinstance(response, EmbeddingResponse) + assert isinstance(response.model, str) + assert isinstance(response.usage, EmbeddingUsage) + assert isinstance(response.data, list) + if isinstance(texts, str): + assert len(response.data) == 1 + else: + assert len(response.data) == len(texts) + for d in response.data: + assert isinstance(d.embedding, str) + assert len(d.embedding) > 1 + embed_base64 = np.frombuffer(base64.b64decode(response.data[0].embedding), dtype=np.float32) + assert len(embed_float) == len(embed_base64) + assert np.allclose(embed_float, embed_base64, atol=0.01, rtol=0.05) + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_embed_usage_metrics(setup: ServingContext, data_source: str): + start_dt = datetime.now(tz=timezone.utc) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + serving_info = { + "model": setup.embedding_model_id, + "tokens": response.usage.total_tokens, + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_usage_metrics( + type="embedding", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + group_by=["model"], + data_source=data_source, + ) + if _metrics_match_embed_token_counts(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="embedding", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + group_by=["model"], + data_source=data_source, + ) + + assert _metrics_match_embed_token_counts(response.model_dump(), serving_info) + + +# response = client.projects.get_usage_metrics( +# type="embedding", +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[0], +# group_by=["model"], +# ) +# assert _metrics_match_embed_token_counts(response.json(), serving_info) + +# response = client.projects.get_usage_metrics( +# type="embedding", +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[1], +# group_by=["model"], +# ) +# assert not _metrics_match_embed_token_counts(response.json(), serving_info) + + +@pytest.mark.cloud +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_embed_billing_metrics(setup: ServingContext, data_source: str): + start_dt = datetime.now(tz=timezone.utc) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.generate_embeddings(setup.embedding_request, timeout=EMBED_TIMEOUT) + serving_info = { + "model": setup.embedding_model_id, + "costs": round(response.usage.total_tokens * 1e-6 * setup.embed_costs, 8), + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_billing_metrics( + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + group_by=["model", "category"], + data_source=data_source, + ) + if _metrics_match_embed_spent(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="spent", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + group_by=["model", "category"], + data_source=data_source, + ) + assert _metrics_match_embed_spent(response.model_dump(), serving_info) + + +# response = client.projects.get_billing_metrics( +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[0], +# group_by=["model", "category"], +# ) +# assert _metrics_match_embed_spent(response.json(), serving_info) + +# response = client.projects.get_billing_metrics( +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[1], +# group_by=["model", "category"], +# ) +# assert not _metrics_match_embed_spent(response.json(), serving_info) + + +@flaky(max_runs=5, min_passes=1) +def test_rerank_auto_model(setup: ServingContext): + setup = deepcopy(setup) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + setup.reranking_request.model = "" + response = client.rerank(setup.reranking_request, timeout=RERANK_TIMEOUT) + assert response.results[0].index == 2, f"Reranking results are unsorted: {response.results}" + relevance_scores = [x.relevance_score for x in response.results] + assert len(relevance_scores) == 3 + assert relevance_scores[0] > relevance_scores[1] + + +@flaky(max_runs=5, min_passes=1) +def test_rerank(setup: ServingContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.rerank(setup.reranking_request, timeout=RERANK_TIMEOUT) + assert response.results[0].index == 2, f"Reranking results are unsorted: {response.results}" + relevance_scores = [x.relevance_score for x in response.results] + assert len(relevance_scores) == 3 + assert relevance_scores[0] > relevance_scores[1] + + +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_rerank_usage_metrics(setup: ServingContext, data_source: str): + start_dt = datetime.now(tz=timezone.utc) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.rerank(setup.reranking_request, timeout=RERANK_TIMEOUT) + serving_info = { + "model": setup.rerank_model_id, + "documents": len(response.results), + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_usage_metrics( + type="reranking", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + group_by=["model"], + data_source=data_source, + ) + if _metrics_match_rerank_search_counts(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="reranking", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + group_by=["model"], + data_source=data_source, + ) + + assert _metrics_match_rerank_search_counts(response.model_dump(), serving_info) + + +# response = client.projects.get_usage_metrics( +# type="reranking", +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[0], +# group_by=["model"], +# ) +# assert _metrics_match_rerank_search_counts(response.json(), serving_info) + +# response = client.projects.get_usage_metrics( +# type="reranking", +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[1], +# group_by=["model"], +# ) +# assert not _metrics_match_rerank_search_counts(response.json(), serving_info) + + +@pytest.mark.cloud +@flaky(max_runs=5, min_passes=1) +@pytest.mark.parametrize("data_source", **DS_PARAMS) +def test_get_rerank_billing_metrics(setup: ServingContext, data_source: str): + start_dt = datetime.now(tz=timezone.utc) + client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) + response = client.rerank(setup.reranking_request, timeout=RERANK_TIMEOUT) + serving_info = { + "model": setup.rerank_model_id, + "costs": round(len(response.results) * 1e-3 * setup.rerank_costs, 8), + } + response_match = False + for _ in range(METER_RETRY): + response = client.meters.get_billing_metrics( + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + group_by=["model", "category"], + data_source=data_source, + ) + if _metrics_match_rerank_spent(response.model_dump(), serving_info): + response_match = True + break + sleep(METER_RETRY_DELAY) + + assert response_match + + response = client.organizations.get_organization_metrics( + metric_id="spent", + from_=start_dt, + to=start_dt + timedelta(minutes=2), + window_size="10s", + org_id=setup.superorg_id, + group_by=["model", "category"], + data_source=data_source, + ) + assert _metrics_match_rerank_spent(response.model_dump(), serving_info) + + +# response = client.projects.get_billing_metrics( +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[0], +# group_by=["model", "category"], +# ) +# assert _metrics_match_rerank_spent(response.json(), serving_info) + +# response = client.projects.get_billing_metrics( +# from_=start_dt, +# to=start_dt + timedelta(minutes=2), +# window_size="10s", +# proj_id=setup.project_ids[1], +# group_by=["model", "category"], +# ) +# assert not _metrics_match_rerank_spent(response.json(), serving_info) + + +# @flaky(max_runs=5, min_passes=1) +# def test_chat_arbitrary_provider(setup: ServingContext): +# setup = deepcopy(setup) +# client = JamAI(user_id=setup.superuser_id) +# model_id = uuid7_str("llm-model/") +# with create_model_config( +# { +# "id": model_id, +# "type": "llm", +# "name": "Chat Model", +# "capabilities": ["chat"], +# "context_length": 1024, +# "languages": ["en"], +# } +# ): +# with create_deployment( +# DeploymentCreate( +# model_id=model_id, +# name="Chat Deployment", +# provider="abc", +# routing_id="openai/gpt-4o-mini", +# api_base="", +# ) +# ): +# client.organizations.update_organization( +# OrganizationUpdate( +# id=setup.org_id, +# external_keys=dict(abc=ENV_CONFIG.openai_api_key_plain), +# ) +# ) +# client = JamAI(user_id=setup.user_id, project_id=setup.project_ids[0]) +# setup.chat_request.model = model_id +# response = client.generate_chat_completions(setup.chat_request, timeout=CHAT_TIMEOUT) +# assert response.model == model_id +# assert isinstance(response.content, str) +# assert len(response.content) > 1 diff --git a/services/api/tests/routers/test_templates.py b/services/api/tests/routers/test_templates.py new file mode 100644 index 0000000..f2e5e1e --- /dev/null +++ b/services/api/tests/routers/test_templates.py @@ -0,0 +1,253 @@ +from dataclasses import dataclass +from os.path import dirname, join, realpath + +import pytest + +from jamaibase import JamAI +from jamaibase.types import ( + OrganizationCreate, + Page, + ProjectCreate, + ProjectRead, + TableImportRequest, + TableMetaResponse, +) +from owl.db import TEMPLATE_ORG_ID +from owl.types import Role, TableType +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + TEXT_EMBEDDING_3_SMALL_CONFIG, + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + create_deployment, + create_model_config, + create_organization, + create_project, + create_user, + get_file_map, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + + +def _create_template(client: JamAI, name: str = "Template") -> ProjectRead: + return client.projects.create_project( + ProjectCreate(organization_id=TEMPLATE_ORG_ID, name=name) + ) + + +# We test template creation just as sanity check +# Template creation, update, deletion operations are the same as projects +def test_create_template(): + with create_user() as superuser, create_organization(user_id=superuser.id) as superorg: + assert superorg.id == "0" + client = JamAI(user_id=superuser.id) + template = _create_template(client, "Template 1") + try: + assert isinstance(template, ProjectRead) + assert template.created_by == superuser.id, f"{template.created_by=}, {superuser.id=}" + assert template.name == "Template 1" + assert template.organization_id == TEMPLATE_ORG_ID + # Check memberships + user = client.users.get_user(superuser.id) + assert len(user.org_memberships) == 2 # Superorg + Template + org_memberships = {m.organization_id: m for m in user.org_memberships} + assert "0" in org_memberships + assert org_memberships["0"].role == Role.ADMIN + assert TEMPLATE_ORG_ID in org_memberships + assert org_memberships[TEMPLATE_ORG_ID].role == Role.ADMIN + proj_memberships = {m.project_id: m for m in user.proj_memberships} + assert proj_memberships[template.id].role == Role.ADMIN + finally: + client.projects.delete_project(template.id) + + +@dataclass(slots=True) +class ServingContext: + superuser_id: str + superorg_id: str + project_id: str + embedding_size: int + image_uri: str + audio_uri: str + document_uri: str + chat_model_id: str + embed_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + create_user(dict(email="admin@up.com", name="System Admin")) as superuser, + create_organization( + body=OrganizationCreate(name="Superorg"), user_id=superuser.id + ) as superorg, + create_project( + dict(name="Superorg Project"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + ): + assert superuser.id == "0" + assert superorg.id == "0" + + bge = "ellm/BAAI/bge-m3" + with ( + # Create models + create_model_config(ELLM_DESCRIBE_CONFIG) as desc_llm_config, + create_model_config(TEXT_EMBEDDING_3_SMALL_CONFIG) as embed_config, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_config, + create_model_config( + TEXT_EMBEDDING_3_SMALL_CONFIG.model_copy(update=dict(id=bge, owned_by="ellm")) + ), + # Create deployments + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(TEXT_EMBEDDING_3_SMALL_DEPLOYMENT), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + create_deployment( + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT.model_copy(update=dict(model_id=bge)) + ), + ): + client = JamAI(user_id=superuser.id, project_id=p0.id) + image_uri = upload_file(client, FILES["rabbit.jpeg"]).uri + audio_uri = upload_file(client, FILES["gutter.mp3"]).uri + document_uri = upload_file( + client, FILES["LLMs as Optimizers [DeepMind ; 2023].pdf"] + ).uri + yield ServingContext( + superuser_id=superuser.id, + superorg_id=superorg.id, + project_id=p0.id, + embedding_size=embed_config.final_embedding_size, + image_uri=image_uri, + audio_uri=audio_uri, + document_uri=document_uri, + chat_model_id=desc_llm_config.id, + embed_model_id=embed_config.id, + rerank_model_id=rerank_config.id, + ) + + +def test_get_list_templates(setup: ServingContext): + super_client = JamAI(user_id=setup.superuser_id) + public_client = JamAI() + # List projects + response = super_client.projects.list_projects(organization_id=setup.superorg_id) + assert isinstance(response, Page) + assert len(response.items) == 1 + assert response.total == 1 + # List templates + response = super_client.templates.list_templates() + assert isinstance(response, Page) + assert len(response.items) == 0 + assert response.total == 0 + assert public_client.templates.list_templates().total == 0 + # Create templates + templates = [] + try: + templates = [_create_template(super_client) for _ in range(2)] + # There are now two templates + response = super_client.templates.list_templates() + assert len(response.items) == 2 + assert response.total == 2 + assert all(t.name.startswith("Template") for t in templates) + assert public_client.templates.list_templates().total == 2 + # There is still just one project + assert super_client.projects.list_projects(organization_id=setup.superorg_id).total == 1 + # Get a template + template = super_client.templates.get_template(templates[0].id) + assert template.id == templates[0].id + assert template.name == templates[0].name + finally: + for template in templates: + super_client.projects.delete_project(template.id) + + +def test_get_list_template_tables_rows(setup: ServingContext): + # Create template + template = _create_template(JamAI(user_id=setup.superuser_id)) + super_client = JamAI(user_id=setup.superuser_id, project_id=template.id) + public_client = JamAI() + tables: list[TableMetaResponse] = [] + try: + # Create the tables + for table_type in TableType: + if table_type == TableType.CHAT: + parquet_filepath = FILES["export-v0.4-chat-agent.parquet"] + else: + parquet_filepath = FILES[f"export-v0.4-{table_type}.parquet"] + table = super_client.table.import_table( + table_type, + TableImportRequest(file_path=parquet_filepath, table_id_dst=None), + ) + assert isinstance(table, TableMetaResponse) + tables.append(table) + # Get and list tables + # Get and list table rows + for i, table_type in enumerate(TableType): + table_id = tables[i].id + # List tables + response = super_client.templates.list_tables(template.id, table_type) + assert isinstance(response, Page) + assert all(isinstance(r, TableMetaResponse) for r in response.items) + assert len(response.items) == 1 + assert response.total == 1 + assert public_client.templates.list_tables(template.id, table_type).total == 1 + # Get table + table = super_client.templates.get_table(template.id, table_type, table_id) + assert isinstance(table, TableMetaResponse) + assert table.id == response.items[0].id + table = public_client.templates.get_table(template.id, table_type, table_id) + assert table.id == response.items[0].id + # List rows + rows = super_client.templates.list_table_rows(template.id, table_type, table_id) + assert isinstance(rows, Page) + assert all(isinstance(r, dict) for r in rows.items) + assert len(rows.items) == 1 + assert rows.total == 1 + rows = public_client.templates.list_table_rows(template.id, table_type, table_id) + assert rows.total == 1 + # Get row + row = super_client.templates.get_table_row( + template.id, table_type, table_id, rows.items[0]["ID"] + ) + assert isinstance(row, dict) + assert row["ID"] == rows.items[0]["ID"] + row = public_client.templates.get_table_row( + template.id, table_type, table_id, rows.items[0]["ID"] + ) + assert row["ID"] == rows.items[0]["ID"] + # Try generation + if table_type == TableType.ACTION: + response = add_table_rows( + super_client, table_type, table_id, [{"question": "Why"}], stream=False + ) + assert len(response.rows) == 1 + assert "There is a text" in response.rows[0].columns["answer"].content + elif table_type == TableType.KNOWLEDGE: + response = add_table_rows(super_client, table_type, table_id, [{}], stream=False) + assert len(response.rows) == 1 + else: + response = add_table_rows( + super_client, table_type, table_id, [{"User": "Hi"}], stream=False + ) + assert len(response.rows) == 1 + assert "There is a text" in response.rows[0].columns["AI"].content + # List rows again + rows = super_client.templates.list_table_rows(template.id, table_type, table_id) + assert isinstance(rows, Page) + assert all(isinstance(r, dict) for r in rows.items) + assert len(rows.items) == 2 + assert rows.total == 2 + rows = public_client.templates.list_table_rows(template.id, table_type, table_id) + assert rows.total == 2 + finally: + for table in tables: + super_client.table.delete_table(table_type, table.id) diff --git a/services/api/tests/routers/test_users.py b/services/api/tests/routers/test_users.py new file mode 100644 index 0000000..21a66e0 --- /dev/null +++ b/services/api/tests/routers/test_users.py @@ -0,0 +1,341 @@ +import httpx +import pytest +from pwdlib import PasswordHash + +from jamaibase import JamAI +from jamaibase.types import ( + OkResponse, + Page, + PasswordChangeRequest, + PasswordLoginRequest, + UserRead, +) +from jamaibase.utils.exceptions import ( + AuthorizationError, + BadInputError, + ForbiddenError, + ResourceExistsError, + ResourceNotFoundError, +) +from owl.utils.test import ( + EMAIL, + create_organization, + create_user, + register_password, + setup_organizations, + setup_projects, +) + +# --- Auth --- # + +PASSWORD = "test_password" + + +def test_register_password(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + pass + + +def test_login_password(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + + +def test_login_password_wrong_pw(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + # Wrong password should fail + with pytest.raises(AuthorizationError): + JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password="PASSWORD")) + + +def test_login_password_hash(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + # Password hash should fail + hasher = PasswordHash.recommended() + password_hash = hasher.hash(PASSWORD) + with pytest.raises((AuthorizationError, BadInputError)): + JamAI().auth.login_password(dict(email=EMAIL, password=password_hash)) + + +def test_change_password(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + # Existing password OK + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + # Change password + user = JamAI(user_id=user.id).auth.change_password( + PasswordChangeRequest(email=EMAIL, password=PASSWORD, new_password=PASSWORD * 2) + ) + assert isinstance(user, UserRead) + # Old password should fail + with pytest.raises(AuthorizationError): + JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + # New password OK + user = JamAI().auth.login_password( + PasswordLoginRequest(email=EMAIL, password=PASSWORD * 2) + ) + assert isinstance(user, UserRead) + + +@pytest.mark.cloud +def test_change_password_wrong_user(): + with ( + register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)) as u0, + register_password(dict(email="russell@up.com", name="Russell", password="test")) as u1, + ): + # Existing password OK + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert user.id == u0.id + # Wrong user should fail + with pytest.raises(ForbiddenError): + JamAI(user_id=u1.id).auth.change_password( + PasswordChangeRequest(email=EMAIL, password="PASSWORD", new_password=PASSWORD * 2) + ) + # Old password OK + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert user.id == u0.id + # New password should fail + with pytest.raises(AuthorizationError): + JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD * 2)) + + +def test_change_password_wrong_old_pw(): + with register_password(dict(email=EMAIL, name="Carl", password=PASSWORD)): + # Existing password OK + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + # Wrong old password should fail + with pytest.raises(AuthorizationError): + JamAI(user_id=user.id).auth.change_password( + PasswordChangeRequest(email=EMAIL, password="PASSWORD", new_password=PASSWORD * 2) + ) + # Old password OK + user = JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD)) + assert isinstance(user, UserRead) + # New password should fail + with pytest.raises(AuthorizationError): + JamAI().auth.login_password(PasswordLoginRequest(email=EMAIL, password=PASSWORD * 2)) + + +# --- Users --- # + + +def test_create_superuser(): + with create_user(dict(email=EMAIL, name="Carl", password="test_password")) as user: + assert user.id == "0" + assert user.email == EMAIL + assert isinstance(user.password_hash, str) + assert user.password_hash == "***" + + +def test_create_user(): + with ( + create_user(), + create_user( + dict(email="russell@up.com", name="Russell", password="test_password") + ) as user, + ): + assert user.id != "0" + assert isinstance(user.password_hash, str) + assert user.password_hash == "***" + + +def test_create_user_existing_id(): + with create_user(), create_user(dict(email="russell@up.com", name="Russell")) as user: + with pytest.raises(ResourceExistsError): + with create_user(dict(id=user.id, email="random@up.com", name="Random")): + pass + + +def test_create_user_existing_email(): + with ( + create_user(dict(email=EMAIL, name="Carl", password=PASSWORD)) as user, + ): + with pytest.raises(ResourceExistsError, match="email"): + with create_user(dict(email=user.email, name="Random")): + pass + + +def test_get_list_users(): + relations = {"org_memberships", "proj_memberships", "organizations", "projects"} + dump_kwargs = dict(warnings="error", exclude=relations) + with ( + # Create users with name ordering opposite of creation order + # Also test case sensitivity + create_user(dict(email="russell@up.com", name="Russell")) as superuser, + create_user( + dict(email="carl@up.com", name="carl", google_id="1234", github_id="22") + ) as u1, + create_user(dict(email="aaron@up.com", name="Aaron")) as u2, + create_organization(user_id=superuser.id), + ): + super_client = JamAI(user_id=superuser.id) + ### --- List users --- ### + num_users = 3 + users = super_client.users.list_users() + assert isinstance(users, Page) + assert len(users.items) == num_users + assert users.total == num_users + assert all(isinstance(m, UserRead) for m in users.items) + assert users.items[0].id == superuser.id + assert users.items[1].id == u1.id + + ### --- Get user --- ### + for u in users.items: + _user = super_client.users.get_user(u.id) + assert isinstance(_user, UserRead) + u = u.model_dump(**dump_kwargs) + _user = _user.model_dump(**dump_kwargs) + assert _user == u, f"Data mismatch: {_user=}, {u=}" + # Fetch using Google ID + _user = super_client.users.get_user(f"google-oauth2|{u1.google_id}") + assert isinstance(_user, UserRead) + u = u1.model_dump(**dump_kwargs) + _user = _user.model_dump(**dump_kwargs) + assert _user == u, f"Data mismatch: {_user=}, {u=}" + # Fetch using GitHub ID + _user = super_client.users.get_user(f"github|{u1.github_id}") + assert isinstance(_user, UserRead) + u = u1.model_dump(**dump_kwargs) + _user = _user.model_dump(**dump_kwargs) + assert _user == u, f"Data mismatch: {_user=}, {u=}" + + ### --- List users (offset and limit) --- ### + _users = super_client.users.list_users(offset=0, limit=1) + assert len(_users.items) == 1 + assert _users.total == num_users + assert _users.items[0].id == users.items[0].id, f"{_users.items=}" + _users = super_client.users.list_users(offset=1, limit=1) + assert len(_users.items) == 1 + assert _users.total == num_users + assert _users.items[0].id == users.items[1].id, f"{_users.items=}" + # Offset >= num rows + _users = super_client.users.list_users(offset=num_users, limit=1) + assert len(_users.items) == 0 + assert _users.total == num_users + _users = super_client.users.list_users(offset=num_users + 1, limit=1) + assert len(_users.items) == 0 + assert _users.total == num_users + # Invalid offset and limit + with pytest.raises(BadInputError): + super_client.users.list_users(offset=0, limit=0) + with pytest.raises(BadInputError): + super_client.users.list_users(offset=-1, limit=1) + + ### --- List users (order_by and order_ascending) --- ### + _users = super_client.users.list_users(order_ascending=False) + assert len(users.items) == num_users + assert _users.total == num_users + assert [t.id for t in _users.items[::-1]] == [t.id for t in users.items] + _users = super_client.users.list_users(order_by="name") + assert len(users.items) == num_users + assert _users.total == num_users + assert [t.id for t in _users.items[::-1]] == [t.id for t in users.items] + assert [t.name for t in _users.items] == [u2.name, u1.name, superuser.name] + _users = super_client.users.list_users(order_by="name", order_ascending=False) + assert len(users.items) == num_users + assert _users.total == num_users + assert [t.id for t in _users.items] == [t.id for t in users.items] + + ### --- List users (search_query and search_columns) --- ### + _users = super_client.users.list_users(search_query="rus") + assert len(_users.items) == 1 + assert _users.total == 1 + assert _users.total != num_users + assert _users.items[0].id == superuser.id + _users = super_client.users.list_users(search_query="rus", offset=1) + assert len(_users.items) == 0 + assert _users.total == 1 + + +@pytest.mark.cloud +def test_list_users_permission(): + with create_user(), create_user(dict(email="russell@up.com", name="Russell")) as user: + with pytest.raises(ForbiddenError): + JamAI(user_id=user.id).users.list_users() + + +def test_get_nonexistent_user(): + with setup_organizations() as ctx: + client = JamAI(user_id=ctx.superuser.id) + response = client.users.get_user(ctx.user.id) + assert isinstance(response, UserRead) + with pytest.raises(ResourceNotFoundError): + client.users.get_user("fake") + + +def test_update_user(): + with create_user() as user: + client = JamAI(user_id=user.id) + new_name = f"{user.name} {user.name}" + response = client.users.update_user(dict(name=new_name)) + assert isinstance(response, UserRead) + assert response.name == new_name + assert response.model_dump( + exclude={"updated_at", "name", "preferred_name"} + ) == user.model_dump(exclude={"updated_at", "name", "preferred_name"}) + assert response.updated_at > user.updated_at + + +def test_delete_user(): + with ( + create_user() as superuser, + create_user(dict(email="russell@up.com", name="Russell")) as user, + create_organization(user_id=superuser.id), + ): + client = JamAI(user_id=superuser.id) + # Fetch + response = client.users.get_user(user.id) + assert isinstance(response, UserRead) + # Delete + response = JamAI(user_id=user.id).users.delete_user(missing_ok=False) + assert isinstance(response, OkResponse) + assert response.ok is True + # Fetch again + with pytest.raises(ResourceNotFoundError): + client.users.get_user(user.id) + + +def test_cors(): + 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" + + with setup_projects() as ctx: + client = JamAI(user_id=ctx.superuser.id) + + headers = { + "Origin": "http://example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type", + } + + # Preflight + response = httpx.options(client.api_base, headers=headers) + _assert_cors(response) + print(response.headers) + + endpoint = f"{client.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={ + "Authorization": "Bearer PAT_KEY", + **headers, + }, + ) + assert response.status_code == 401 + 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" diff --git a/services/api/tests/test_db.py b/services/api/tests/test_db.py new file mode 100644 index 0000000..6275c52 --- /dev/null +++ b/services/api/tests/test_db.py @@ -0,0 +1,24 @@ +from sqlmodel import select + +from owl.db import async_session, sync_session +from owl.db.models import User +from owl.types import UserAuth +from owl.utils.test import create_user + + +async def test_async_session(): + with create_user() as user: + assert user.id == "0" + async with async_session() as session: + users = (await session.exec(select(User))).all() + users = [UserAuth.model_validate(user) for user in users] + assert len(users) == 1 + + +async def test_sync_session(): + with create_user() as user: + assert user.id == "0" + with sync_session() as session: + users = (session.exec(select(User))).all() + users = [UserAuth.model_validate(user) for user in users] + assert len(users) == 1 diff --git a/services/api/tests/test_docparse.py b/services/api/tests/test_docparse.py new file mode 100644 index 0000000..01fbb57 --- /dev/null +++ b/services/api/tests/test_docparse.py @@ -0,0 +1,84 @@ +import hashlib +import json +import os +from os.path import basename, dirname, join, realpath + +import pytest + +from owl.docparse import DoclingLoader +from owl.utils.test import get_file_map + +TEST_FILE_DIR = join(dirname(realpath(__file__)), "files") +FILES = get_file_map(TEST_FILE_DIR) + +GT_FILE_DIR = join(dirname(realpath(__file__)), "docling_ground_truth") +GT_FILES = get_file_map(GT_FILE_DIR) + + +def get_canonical_json_hash(data: dict) -> str: + """ + Calculates a SHA256 hash of a dictionary after canonical JSON serialization. + Ensures keys are sorted and spacing is compact for consistent hashing. + """ + if not isinstance(data, dict): + # If data is not a dict (e.g., an error string or None from .get("document", {})), + # we still need a consistent way to hash it. Converting to string is a simple way. + # However, for this test, 'document' should ideally always be a dict or an empty dict. + # If it can be None or other types, this part might need more specific handling + # based on expected behavior. + stable_representation = str(data) + else: + # sort_keys=True: Essential for canonical form. + # separators=(',', ':'): Creates the most compact JSON, removing unnecessary whitespace. + stable_representation = json.dumps(data, sort_keys=True, separators=(",", ":")) + + json_bytes = stable_representation.encode("utf-8") + return hashlib.sha256(json_bytes).hexdigest() + + +@pytest.mark.timeout(180) +@pytest.mark.parametrize( + "doc_path", + [ + FILES["Swire_AR22_e_230406_sample.pdf"], + FILES["GitHub è¡¨å•æž¶æž„语法 - GitHub 文档.pdf"], + ], + ids=lambda x: basename(x), +) +async def test_convert_pdf_document_to_markdown(doc_path: str): + """ + Test the conversion of various document types to markdown. + """ + loader = DoclingLoader( + request_id="test_request", + docling_serve_url="http://localhost:5001", + ) + with open(doc_path, "rb") as f: + doc_content_bytes = f.read() + + api_response_data = await loader.retrieve_document_content( + basename(doc_path), doc_content_bytes + ) + + api_document_content = api_response_data.get("document", {}) + + # Sanity check on md_content from the API response + md_content_from_api = api_document_content.get("md_content", "") + assert isinstance(md_content_from_api, str) + + # --- Ground Truth Comparison --- + gt_file_path = GT_FILES[f"{os.path.splitext(basename(doc_path))[0]}.json"] + + with open(gt_file_path, "r", encoding="utf-8") as f_gt: + expected_document_content = json.load(f_gt).get("document", {}) + + api_content_hash = get_canonical_json_hash(api_document_content) + gt_content_hash = get_canonical_json_hash(expected_document_content) + + assert api_content_hash == gt_content_hash, ( + f"Hash mismatch for the 'document' content of '{basename(doc_path)}'.\n" + f"API Hash: {api_content_hash}\n" + f"GT Hash : {gt_content_hash}\n" + f"API 'document' part:\n{json.dumps(api_document_content, sort_keys=True, indent=2, ensure_ascii=False)}\n" + f"Expected 'document' part (from {basename(gt_file_path)}):\n{json.dumps(expected_document_content, sort_keys=True, indent=2, ensure_ascii=False)}" + ) diff --git a/services/api/tests/test_lance.py b/services/api/tests/test_lance.py deleted file mode 100644 index a766ec9..0000000 --- a/services/api/tests/test_lance.py +++ /dev/null @@ -1,31 +0,0 @@ -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_protocol.py b/services/api/tests/test_protocol.py new file mode 100644 index 0000000..1c84955 --- /dev/null +++ b/services/api/tests/test_protocol.py @@ -0,0 +1,209 @@ +import pytest +from pydantic import ValidationError + +from owl.types import ( + ChatCompletionChoice, + ChatCompletionChunkResponse, + ChatCompletionDelta, + ChatCompletionMessage, + ChatCompletionResponse, + ChatCompletionUsage, + MultiRowAddRequestWithLimit, + MultiRowUpdateRequestWithLimit, +) + +REQUEST_ID = "chatcmpl-AtBWW4Kf8NoM4WDBaNSBLR8fD0fc6" +MODEL = "gpt-3.5-turbo" +CONTENT = "Hello" +SERVICE_TIER = "default" +SYSTEM_FINGERPRINT = "fp_2f141ce944" + + +@pytest.mark.parametrize( + "body", + [ + # Role chunk + ChatCompletionChunkResponse( + id=REQUEST_ID, + model=MODEL, + choices=[ + ChatCompletionChoice( + index=0, + delta=ChatCompletionDelta(role="assistant", content="", refusal=None), + logprobs=None, + finish_reason=None, + ) + ], + ), + # Content chunks + ChatCompletionChunkResponse( + id=REQUEST_ID, + model=MODEL, + choices=[ + ChatCompletionChoice( + index=0, + delta=ChatCompletionDelta(content=CONTENT), + logprobs=None, + finish_reason=None, + ) + ], + ), + # Finish reason chunk + ChatCompletionChunkResponse( + id=REQUEST_ID, + model=MODEL, + choices=[ + ChatCompletionChoice( + index=0, + logprobs=None, + finish_reason="length", + ) + ], + ), + # Usage chunk + ChatCompletionChunkResponse( + id=REQUEST_ID, + model=MODEL, + choices=[], + usage=ChatCompletionUsage( + prompt_tokens=10, + completion_tokens=5, + total_tokens=10 + 5, + ), + ), + ], +) +def test_chat_completion_chunk(body: ChatCompletionChunkResponse): + if len(body.choices) > 0: + if body.message is None: + assert body.delta is None + assert body.content == "" + else: + assert isinstance(body.message, ChatCompletionDelta) + assert isinstance(body.delta, ChatCompletionDelta) + assert isinstance(body.content, str) + else: + assert body.message is None + assert body.delta is None + assert body.content == "" + assert body.finish_reason is None or isinstance(body.finish_reason, str) + assert isinstance(body.prompt_tokens, int) + assert isinstance(body.completion_tokens, int) + assert isinstance(body.total_tokens, int) + if body.usage is not None: + assert body.prompt_tokens == body.usage.prompt_tokens + assert body.completion_tokens == body.usage.completion_tokens + assert body.total_tokens == body.usage.total_tokens + assert body.total_tokens == body.prompt_tokens + body.completion_tokens + + +@pytest.mark.parametrize( + "body", + [ + # Non-stream + ChatCompletionResponse( + id=REQUEST_ID, + model=MODEL, + choices=[ + ChatCompletionChoice( + index=0, + message=ChatCompletionMessage(content=CONTENT), + logprobs=None, + finish_reason="length", + ) + ], + usage=ChatCompletionUsage( + prompt_tokens=10, + completion_tokens=5, + total_tokens=10 + 5, + ), + ) + ], +) +def test_chat_completion(body: ChatCompletionResponse): + if len(body.choices) > 0: + assert isinstance(body.message, ChatCompletionMessage) + assert isinstance(body.content, str) + else: + assert body.message is None + assert body.content is None + assert body.finish_reason is None or isinstance(body.finish_reason, str) + assert isinstance(body.prompt_tokens, int) + assert isinstance(body.completion_tokens, int) + assert isinstance(body.total_tokens, int) + assert body.prompt_tokens == body.usage.prompt_tokens + assert body.completion_tokens == body.usage.completion_tokens + assert body.total_tokens == body.usage.total_tokens + assert body.total_tokens == body.prompt_tokens + body.completion_tokens + + +def test_multirow_add(): + # AAC files not accepted + with pytest.raises(ValidationError, match="Unsupported file type"): + MultiRowAddRequestWithLimit( + table_id="x", + data=[{"col1": "s3://val1.aac", "col2": "val2"}], + ) + body = MultiRowAddRequestWithLimit( + table_id="x", + data=[{"col1": "s3://val1.mp3", "col2": "val2"}], + ) + assert body.data == [{"col1": "s3://val1.mp3", "col2": "val2"}] + # Max 100 rows + with pytest.raises(ValidationError): + MultiRowAddRequestWithLimit( + table_id="x", + data=[{"col1": "val1"} for _ in range(101)], + ) + MultiRowAddRequestWithLimit( + table_id="x", + data=[{"col1": "val1"} for _ in range(100)], + ) + # Min 1 row + with pytest.raises(ValidationError): + MultiRowAddRequestWithLimit( + table_id="x", + data=[], + ) + body = MultiRowAddRequestWithLimit( + table_id="x", + data=[{"col1": "val1", "col2": "val2"}], + ) + assert body.table_id == "x" + assert body.data == [{"col1": "val1", "col2": "val2"}] + + +def test_multirow_update(): + # AAC files not accepted + with pytest.raises(ValidationError, match="Unsupported file type"): + MultiRowUpdateRequestWithLimit( + table_id="x", + data={"row1": {"col1": "s3://val1.aac", "col2": "val2"}}, + ) + body = MultiRowUpdateRequestWithLimit( + table_id="x", + data={"row1": {"col1": "s3://val1.mp3", "col2": "val2"}}, + ) + assert body.data == {"row1": {"col1": "s3://val1.mp3", "col2": "val2"}} + # Max 100 rows + with pytest.raises(ValidationError): + MultiRowUpdateRequestWithLimit( + table_id="x", + data={str(i): {"col1": "val1"} for i in range(101)}, + ) + MultiRowUpdateRequestWithLimit( + table_id="x", + data={str(i): {"col1": "val1"} for i in range(100)}, + ) + # Min 1 row + with pytest.raises(ValidationError): + MultiRowUpdateRequestWithLimit( + table_id="x", + data={}, + ) + body = MultiRowUpdateRequestWithLimit( + table_id="x", + data={"row1": {"col1": "val1", "col2": "val2"}}, + ) + assert body.table_id == "x" + assert body.data == {"row1": {"col1": "val1", "col2": "val2"}} 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 deleted file mode 100644 index 7a54f317d086a1d4d1a7767ab4b449acc778b693..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index 651a4ad92a979d1df6e596fce1b76e63277a28d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index fe902202788378d2cb2451296bf7a7984968f359..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1326 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 Do&pO` 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 deleted file mode 100644 index 651a4ad92a979d1df6e596fce1b76e63277a28d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/services/api/tests/test_table.lance/_versions/56.manifest b/services/api/tests/test_table.lance/_versions/56.manifest deleted file mode 100644 index 15e3105890b4d089101c68825c02c26dd43a7a42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4417 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 diff --git a/services/api/tests/test_table.lance/_versions/58.manifest b/services/api/tests/test_table.lance/_versions/58.manifest deleted file mode 100644 index cfed58282c6003b76e65b5eaee6d18920cb12243..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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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 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 deleted file mode 100644 index d0158c727341826bae1821aed8f3d96f267c0599..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12344 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 DX)g}r 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 deleted file mode 100644 index bfb0fa667373b8d06072b6aeee495ecd0883df17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12854 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 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 deleted file mode 100644 index 23692ca08beef252a585dc19754a0d42ff679b61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 490087 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

    s- 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 XmBZ~u*!&sHUG$XF%^>iF;7R`j{M8|g 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 deleted file mode 100644 index d3b06fe97761ac16d9e0788779f0cc064d00baf0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12867 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 M>0%c#C2HFL0ac^{ssI20 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 deleted file mode 100644 index 76a9af89d6ca8c8c408936fc5b10537c77d72bff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11998 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 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 deleted file mode 100644 index 907d6d7b2b8b7e107be71a7e306fa99034d85fe7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12837 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 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 deleted file mode 100644 index 10ab98bdec0b7498eb3c883ab1298f11958629e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12337 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 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 deleted file mode 100644 index b3ddef7cde719f958acde3022269cffc6cd79564..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12859 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 I8aDiY00Waz5&!@I 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 deleted file mode 100644 index 5bc2b0a7b97a072ed3d0f8c60a1257fac92a2242..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12158 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 c-hKKyX`gX^RYdkjf8P7K@B182WOVd! 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 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 deleted file mode 100644 index 3e8ed4ba079bde8da96e522f807863eb2239e6a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12845 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 XVvx;X>x#e@dkv*@(Fzz7H12-@uh0_o 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 deleted file mode 100644 index e5f2eabfcf0a511bbd2bd503e46232dfaf0f9664..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12517 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 At^fc4 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 deleted file mode 100644 index 8770168a7067fefc582ce500ee5727d0555418ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12499 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 D%4|X} 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 deleted file mode 100644 index 9f4d45649fd56634e7e2f16a1078c38dffb8e4e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12292 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 L7=(@uANT(Nsz^i5 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 deleted file mode 100644 index 5f21958dfbcd2861f1f6de333975ec9fec51f589..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12842 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 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 deleted file mode 100644 index 3d76233fb50764bd176e6f3e12b531371f726979..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12521 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 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 deleted file mode 100644 index def46271fdd2c4c5959c90a7731158518398b4c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12348 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 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 deleted file mode 100644 index 59bbb414687ba19f40259b4d584731405ebb96fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12827 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 Mx^@Yj8b1C10eiz2J^%m! 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 deleted file mode 100644 index 2b38d51979d204df821db9570dba53f536d16c9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12259 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 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 deleted file mode 100644 index b92018f3b205d386c10dc08aca3cbf925bbe86c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12485 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 ap1oAP`?$M(J#L4;C8e}(6*)3m^Zx*Bbv~g0 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 deleted file mode 100644 index 0fbfe62dc2042098a99ecb65f8c29e0f667d8e83..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12867 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 YWB&mIgZ?=wDAFBL+IXao%pCpy06I}+`Tzg` 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 deleted file mode 100644 index 89587e8ffa6c775d4c0375df4f5fc3072fa545f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12801 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 Y6~l&)kbgbz9Y>v{bnoUn*>CFq1A|cv^Z)<= 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 deleted file mode 100644 index 02201c29ce424b3eba53e28fbd576e1285db471a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12339 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 Iqb7{`AB4|DLjV8( 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 deleted file mode 100644 index 1726ba9dcf58d196dc69b8e5185772ad329d7f3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12829 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 SX8z)0kd!W+rj897|Nj6Vs7ucP 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 deleted file mode 100644 index 1fced8d95160035dc9e8c59b21ea8cdf4b4489ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12828 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 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 deleted file mode 100644 index 1ab40b9c8307c2404e61becb751e4b70ac2ce93e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12867 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 A^8f$< 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 deleted file mode 100644 index cb2ba9005b3e020d1cb2f1569714915531ef105e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12799 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 EAAsYte*gdg 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 deleted file mode 100644 index 25d913424e9de02970884c4107b79e7247bb6d63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12590 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 RV--PGN@-y;`o(DN{{c;Tfh+(3 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 deleted file mode 100644 index 29aaf879121828da6b62ce538125fd5dda776cbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12807 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%0DZpK8K*ooeRXyqrG`KQkvg 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 Vr#d)rYs%N*E=p0jB@a#2{SV%cl3V}) 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 deleted file mode 100644 index 884f186727df7e670b962b366bf8214807ed614c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12102 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 LU7MiMA!Gjs8FwN# 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 deleted file mode 100644 index 4c4d5b40e5f08af84dfd8ecdf0376d945084ebf8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12809 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�s4pe*$nW`D$ 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 OzeQ54t;Uaz)cik7UIk$Q 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 deleted file mode 100644 index c19c9a28ce7041a88589adef07a56074cbf0cc52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12005 zcmbta2Y8cJ+inx+9#B@v{``K|^?6--dag5`Gw$cU@AI1I!GnjU z4H`Nia!BfcS&>7A3>+FcG&(vuvj4!;A%^IIsk4R-8S3KrrOv?pUb%dAV}es!bJW!z`* zM#OmL8Q)&IUpo@MyKZ2s%Ddt6liob2rUSq0GgXRP6^}HQ-{@-uniChD3YUsQR!JSI zKbI%_&4s2jKO?J)lW!M~KVSdg_EI(o#Oo~W=U$TFZG}#T((2Bm8Uki|ZEfHUeE0$)*cj0rY z5~X)bQlP;18=O*Bz%OV2441~ZvfEb_yy;3lj_|30<7I20Hf}BV$Q;FXH0M~LuoE9x zdzkgl`T}2^3+(fZop8ce&lOoy@yn35Z2GBk$cxHC=Ks7@Iqn(xg}ORCc+yXv;o-^V z=iHO7G!Bqm%Gu#Es4XRSM6YN53J*w zg_eNv3vux2CzzBtRH`a|3_{~O@*`CrLRFj}b17dZ*32d*l*1Q|2iWg>7r>dyIri*gIl6cVzmVP*{ma`kqhB_E;%b4wJ=+uhu3UTVBX~AqtaRA-5_Xu=o4Zwi zCXLzmJpZ(!KigmXE-buS1RJmVuwwr|u-~;47}2;JX86wJZNqx=Vdb6p=#%dF(b$ji zw-X9DSM@R06sk~HJ5-F92-9qOxJCnon>dY=eG&(;yr}DR)|K+6vrAZRnHShjuCdIG ze3Gs4Nn);NzUQ9%eiiosZQVOrER63eC0&aL$|Lyggpmn6ts8qrYSpj~U9OE}=~q4Zw9HfT{8|a!n_~Er zku%{);(JFYqvqucoFud`!T}0 zJZ#)_w(_bsJDjmY@=Tg_ROGqujpy)ASPah&tBhTgeI2j)e2Q~!ya*Tjy0RI*UtZhp|&&!1&*R)+SQt`{Ft4jh;!Iagd_rf?hInfzCS(MH;L_BwwbJbCGa<6D zBeW_|N^~wD&Of!R5Of9ZxWW4v(A{ud?GYpl@Q{i=+;-BR6;_=_TDSChb}pxN%J0;h~kz~%-ssw)o(4Pyf5b!UE*Z#LP8FT>#1j8fSz zq?P=l|9!L;Me~@P-yzU@u)qR)tl#sL2T6E3y$$p!>VxwtCDt=;Ieb)Y2azL!Z`L++ z6B>Z4Yr0`}zk8BL=z7wwEl6jGb3BR!FN53I_wjtqPNcl!F?05y;NkbpGvK%MR3B|oWAL8_kEhu>BQdwIuKMOm%i1n&HgrBYYn7l$~ zOb?e}X~S!%Ikg8CUVjqbh&T&$&+K+wJHF`bJ<{5Hu|Ho=MdEiZbV2xqN0uS>GS+7q>z_+SrfoYq2yvqchOlQdQh)>E;+Cqder6x(U*&)!)Ptcaxsi zN-ugv;Iz|U$--AuhwTI3!u6JS3)V1$UnUlus=`B8{U|s3@FiJK0Z;cAcdwat(gT;& zbd}eKc84dfddU7>z4@_({dmB02YeTA##!F7M7hJYC12w9tcxgc-sNg5K5zUzi_pqx zCkyaejanuy6gtmuUompZMfrX~I~-KDf;gq;(dUw~IN=ud7x?kr*ONKrM=bSY-+HPk z{|Gy;5WQZpZz5X~)iYtKl@K{_`z73~kCh)GOdUJuJZP$``-1RS!G*z;ne8qA*ZXJA4 z7{tl5p-Y^Sxa|;rp8XT7kIDhkB#cX%gXDj>cgFgbeGD$zLzpXGb6(fCeB(tkMZ zGf5^r=SLEUGojt@Tz>|7jys2%j7=7P=B7+uO#U%6n2DTBD&Gv`ZRM+xsZ5(t2E)Tb z*mqak@>S!WW&3^A z@Pj{wmO_F@0oxkl%M6}d<(}mmaQF3#@cvbQ)+PK9k{5x&MSX=w8`3xP1PEO?)1cz| zh+4Yi1(LXzZ~cD8uZy)XvAIzeJW3owzW0O~_h$TNHzN>{4Udlknn z`q{ghh7_myqb(~csx*Q(h`TuXIQDwjUKBbcw3oO=($`v~ zqQ)w`SkM-GUi$z-Ds}^Dn8*j5S-3^;6v%OFa7y(I`1;CHAYUQ)Q`#0+g1Ob_;ppm{ z(qnNerHSM3VB@t<O>IcJXI!VHJlV0H~1*<4OZ&04* ziaSJofoF@q99ZB6%JIKKw;KyJ6MPc**pLFa)MS;2=kRy07!Y~Z=GrLsa#)q{HZpMp z+6E)_FXiWzbOGu>~iK*s}#*ytwYPrMV;(=nUaSuPS)X8>OK1)>6;Cv*r7J zP0;1)GTiK&%s#C+AUuP}K^9%pk&i75B!89_^xw{zYS8X}R_ z@}k0S*!SdenY0H;3+0vJ91m1KBlHDkBx>Z#9s%r|5`%^I0&f@XBai(rB%Tm{QNG}w zFB9KMn>~E_$T?-i{}UzQeMp1(tdoUurPsd0qeBBBIjjPHsu)PPc@t!>{aAS8WuSL4 zJL2I_8uO$?=SBD7L3`hbB9mE%qc{#ax|Nb1JPnnyR8+FdH#@x1luK919F*zF6JIY?_wHRt_3vcN1_>%@b zJ8VA$sp=pM(yxK>`mQWb`zEFvzK34gHgddu3vN^9%7F@BmQk7pleF7FX*+eaqtORc zhDoqlIbZ(5_?UFXbty#a+reeE4zH%TNJ~rS9_?iI!IkO?X^EmNc9*?B#MPgdwwr#! z4T>n7Tz?V6%&%~$pN{PnJ``&$u2w|wvs%Vx=zFlO+I{#hy#_YuZptqQH$k4dx8-O1 zXcTLET@lUeY)kmt{)=b&2vuZ4@72 z?ZkBY&DdUdR;o5FWNEfkmTfMT8!x#ajfH28y?C?seQ0O)W-+$M@oD1@sM6g+tzxq{ z3uv(0j;vNkuyxuzxK3X!A5jd!Vp}$H{SipBeFwi>GRYmZKJ15lBV6B^54X%?P*HjcWy2lK zdgV=tV!(3cb$m+QlMS-2$36AGz+l4{Xx1NqxxusGO~WrJ?&0Q61^?x^lG7REc6~HX z)4q;7lzG_IT7tp$HJV@4Cy?;Lr<%TnDRyCR=-Q4{O2E6zv=G zGFv8IG1}lq?P%7@?yfm*jD@h^skqEo4j1h2$u~wQ@YaY{>{tB>XbQ@QXZ5{URPZ#W zF-(_71b;2BRnFsC+ES!5#6R*^pk1w%RvT_$mhG}M+H@X2uxoIwxk~C{E|E6X-)uSC zTthlrX1g}DR*jZ%imu$4^8FERe4gnF+;er4Ue-0qhm3=|1e|q7%NT-2-{0SK)2l?~ti3r5s!evhD@B zjiOk3U->fM7rcl~RdkoqOsOnM-Ie7lGvx`v7jdO2i-)UUg#)HV>>d3fkhB|t=8_L+ z2eD}FIw;q^4Q_f5_WPw)xY@cFzETgzul4Uq5!zQF-x|WlXgjlNZ47Q!{s^=;)^L2O z{E~SOWSTC6$&?B2lrF};_O*=mAkLZJvW}4mM?krv(Hg$SXAA~>&-5bOSb7u7)dTqw zbqH(J{|m+#J0DBcrSehM%{VsrX+A3WJUpq>!;e8q{#fY@oT1%LnB5QGnvY{-{ZvkC zf>PrF)F?V)lIdyKXu64STi=m4s-vW5>u2*d+A0`s?u73uV%al>dMvbt@kHBG@VvDT zBiu^2Ml9oW=S=WPi9K8@3C@u~-;uqijb+o-+ogTx7}hGd7JAzKn7`SJozndQKk7%b zp23s&-Q%q_e#Rn5R@URKlvXUv+*Tq^;@>NDQl@E^B+iT5Uf{c|{UvX6A?2MBiT8kV z7VZbF;OEW5S)6qX7Hcc{(E2B^tL`H_VR)JHYomNhw?w{^vK-aQ$uQp7l|N?s3RK!P zK=~(qqdtCgp`igs8_vTo$4&h1&ekoQK=;Hc|L{}&FnCh21J>yyIkR@e$IadOXvG$J zg84N~sPPqiIe6}{N!o5~Q~k5>zFEUX?nUXQq1H5$6URfcc?TAn!}u26eC}OeC*>%1 zp{zK9JB-oXQ(cG$6@#&}z64I{WwzRMLqqv4`KrCxY-=H5t{13JGJTrW27mOr+0%`n3ZP+R@^4=D=V zY>tw;saGR$5$+0Jz#FwA*+fG!5D!2rvpc(BeGe+l19@-TC-9ZxJ?O6WM^Nc(NUd4z2$(EgBk3Gy1;z@Q zxRZGAOBiSGA`ySV9_s+9pQ0V7^$K2RMTTbLlmsSl7*L-CqO ztnCUeEp3wXtz}4gA-$mJ%P5|ObOg6+qj{P>2&d`06W4fRoe|K@R12>e`>~SX8$j5E zW>*E_{sXxw$dyyh$8InEiT2u+6Bgu~mlS-hIYK(7d=U)xH-NMgE*g*HTgG>AeEriH zqhF6BbTfhYkc&NhX?=_z*T>4;wX4y^x&mwpi-!1=&8bh5iMvF}+LoS?2-}dWyNa%g zm3*NggXxVbcFa5!ciR^5NaL%7ueSVZ{!%1A!bVs(p~z7;TP;vOk!uiW_z<43c1M%C zT($<^gWmO@NMlORGdEKrkE?$YeRQW$p(`7 z*I6VTYW_VkzHz#iZf8@KhM4h_d$aCUH- zRHP1k{k+u(?R{AY|Z?{S#bYJ65#b`F! zybU^QOTc0s!ls)pK{wMxu$T|SC~KgG@QRdIe4~0Vs?`3HhrIxYn>T4ZbYmH<0Xo)? z$2%#lvDWa1z=<^7kb;!oJj)o&Kh!hQ#aosI`ZtmADbfCspDt6aT{euGC5SA(D2=!oT&jt_Gn8os<*UcUCLDq?{{t z)lHOcr7UBl-MCY6Kn|!sBN09%!lqQFcw7ET{T>p($*3MGhuPb5`j^o?(|u0>;#26W zXv-&?y8-DqjIeLQuN1@CdUG`j&J!BcxpacG-+mD1n|HNnG;uEJZY2JkuVkcwDEiXb zVy=A`{$L%&lS;p_Na~KlGviMwO19td8;HA~sNTki$B7GlVYxMwbbJdNU~ebRGS=JZ z$tbU7;voKvLdEIM;P!~t#8ty_q%n}^nbMKgrnzH|L#)I=3Z=xVQ#F@ zhGKoVM7b&bk>bY5JA$vah}CIe>p3lQ?N1 zke(eQ-%mN}#=o~~v9+NVb9A%e4|_b;>$O4?B$11EtZ_&_pC_qbgBNX$cs1WeCM*%& zhRDLR2%k-yBH!J)oQW93@37WbWpS^s#e3R#t}rFgy;S2;eVJrc-jmO1-?flG;KKJ4 ze>2ixiS{c~Zfj;L?MOUJI{z$^=YZc7C*g|ThI{n~r3lk!QcvB-!UuuKSz0S!tej(^ z{Sa0?S};(pJqTOuQ8MMDzy%N0w}tkGv)Ib&E{#;4m+5RJfdTTQTxiqX`ZF20TKZqGDa7G74q?b=$rE!bjXAK`IOc>E9b9IgZ{m4qh?RQrO` z=nn6dz6eY?gP*cXOyKu`={KY|l)#O6bJ4rdh4--`wzi8y0M{K0;8 zT?WEO-!{fcgB2TE;*B+LmFX=5If`CLJ`x0WXfLq^#xN{YHUoKCC{kCl?zV}1igjDd zc)E8^?^P7}PFSV0`3h&%eBLjn3{}YR&15N*-ma<*0TDJEpvgiN>u$J0RZPnUgO;%31Df7}xSP6na`2yby$+ zAYU##uRPIk5mVHv7M?n2S}gb#{`P@jSJ0nd4&HH;(SQDkqkp7+kRdj5NNj4%pFiS= zF{J3z3`6w;V`mM0-_f_hQT}bx-yIEJ>OUuU{@TO!&#Fs|qjc7tKh|*8oxja+)(<_@ z-}!0A+3x)O;jBBqb~x+19A7`2b?0WCb>~K$b?3UBbw_v?$Km|Hzi_cT_Usn?=cxZ= zOh-pwjiYqN`)_|0Gs!XEtB&%&oNu1lF^~PBb^Z+iw;dh49XJ2C4qY7$5l(10w*0q_ zBOM*4K5C{kN5gXu&Gg@Xwp;C(?~J4TFXv1B#xYNi-pge#wqh{LaXn5?r?}G4~yP)qVcq3^a}oiH`DbXE4*zkonL| z&UwFbWT7*Io!RKDJ2TB$caC$G2@U`|+npKYtUEKnS$B?emOnl;-kAu_{!ZLG>&|h` z5*%o8wmaePtUJ-}tUJd!%M}leccR$Y--$wJ-8s%#MmcWE+3p0Rv+l&6v+f+{EEhdA z-U&fxe<$Lcb>}!|X?au&0$m^M)+Ud>=jZ07WX;no$kj}pqN4$> zUaElBQ!?^0(*hL__I+^5pa8d)9?5~qM|IHzc(-&>jmpxer3SWouwR#18H>_VlXEkk zPfO0EUrcIdW#**hY5GSGh-q0s-vFNn#wG{0esJ)E>*yKKYD(Ioyg;`{br4&j4#|P; zkLnQ{;3axEmg(_ew|`n@AKK!-hb9MlJ~;NlRjLErM`zIgg^3>-==G>R!2uqQKFNXJ zkLVQWI>+Cub!JMoAuYfyI;#JGsQ#T=xwv@X!~lhhYv9(AVMQKZ9RpmWA0EHJkmuDY zz)Sp?G}(}|Ae&GS);c3QZ*UCVcIN=ktc>jB=Tb5k(2f5EIie|Lk)uiZm!`k~*8zXM zRIy_bsPkU}wN|zI-%wp24CO{G51x`}{C6<-hZ-r|z`(y;&i@_GQ|0l9a9-5%$Z*~d zHBz`ifjpTb z{~WG^s{JFvb)=R@hU@fDBZZ3%4E*PComGL42-k&L9vQCdLyesS6#b(EyZv*#?y7E& zh}VN!9vQFaLyaO{|3KA0#|u)a9uZGXEsu;B{MW{SUM{W*WvkY1?jD|A-afv5{%zU@ lv}@m?W2eB*UAlJb-lL}~NF5w_cdRAMDJcq0i$2tw2tG6KR#6$Y>+u}cvJr6g)J#zbj$ zN}P9}G_O$vY^Z1wlbB)zY*?d-$-fWI+-uC|pS#v|E!W}9>3cu>+0QvMZazL^V!g+B zxTxlOd%O7hL`S=fadUHXnd|1`>*nni>+S2~+eP=q6sJD;Ez_r$;4cBqk11&Q&F*$G%ytYuKqPKXllNfu=M0I!6~? zxhEB(>fgnbcw?}f@&I}o6+w>SS{!J&3RL-DL&h;0TSG3{EEZa&23W9B$K zwN2LA$Xm^hJbyOE-W99!<$P{-VpF5r>` zVCU7_;i%~x-Zg1DUbMGhGme*o#x)69pG8t-$Q$zF#zw3@W+u-xG-7EfkELJQJmfCr zeVJ3$QA}8O4MsJ!$dk+MprCaNwCq~Sj$B>O`(2N~iwR#yAC#FgKc|oJ+t#7XA)rLE zbtnem3sqnRCYATcmR()=($)dYqt!CN&qFLu zoD4~O5@AuB6J9agfEA5XxPO2h8*IE&+xNy??Cs>ut&Obt*~#gwZ+--ylkz*>nS4+F zz-Ta=zJ4)ePQEU;T?>PK&Zl5iVhEpDn$F@DjwATka$Ek&v0ivO_zeDfv@5h!ox%DX1&(hR zBgRXFX*R=2sf3(TPUB>gAaAB|y-v2SluJ*(&C<(^;qbBb+60%^*m{%6%;3b&+-UE8 z;RiTSX2w4}X3FVr_UKwTyyfW3FHc&=KB{m9s&k4>Z16XSy@8|Iirts+aAGL;Zuey_ zIVBj`TgjGX=SgP#>wBbmL^}ac|kHR%kGVt6;8tE(63?yC!5NH+CIXNJ#7MGQf|%`xK(D! zKQHTp=a27~9^|~hOkEDWHfp1 zAra@MH#6}!M=zf2Sn0PQ`8r-RIg9gdE`jex8L*kAU!%2AF%x?bxVlvNG7^Ug+e(A% zd%*AU8+nDj882Bo8S@%LP~dQ6yAOZ6ezQz-!=?B_KI}%E^hNGItVsA2ho1NaA}1#> zIsTvby7=$8O3Qgj>o7v99%f`Hj|L>{f6%cuaW+ zv@bf(EakPN7>sFGax?ptkYdsZ$J*Rv;$gfwsw?~6OaorKU&Rlbz7sr#1CD=$Fu?6AM)JeQ`mmg;CZv5!=abVp?Hkuuat!J78~%`cKx(R2!1_wY zDSntw(*+C<4#XR+AK4v=a|JI$&)_5YP5pMHxZ_^)cA?;naSTQ9{?vuErn(I zmw<4Vd`pN}HGqj%4e?q2^1&aLUf z2HCH|uz3!+$K({wP``_UcP^J%i20f0$pvh9LoJ?Ldxo?^Ka6*hU}@`SR36_2S=V2~ z_nl7y`I$ZP@5>jQd`!9avESY6F-ZK*MP3kk;d3JeS~-7-p;H#Kq&?S^#0wZ^){c^4 zFBqB>$#w>8)ou&i0}HEyp{Z9FuRrHivvo<|Lb}mrJX&=c?_S-2#1s6tq@NFs_FpX@ zY8y@dS}4s>_XC<+s`6habxc~$C=R){ajLXo_qTq;-IUK8q$S4A7}4~VEOfh zQ>6Vgdp(ObOT_HsRakq~jACOXe>-V5@c2H$_sWQ4hWK{<0J+Fv5WITTQ0`+qf`1aY z5BD2wgP#IEz`4C;iDHMpoPQCwCVhtj=l!pC=c!X3YelY%IF^mq>W9h1g(A=MN53rR z6pQj7*?rNwY&CJpkVBc1lQ`iP_GO##o!6r{#fKmDW8WJMqxh?Z!-=OMBe9V%&KThX zJz7fyj@ZJgMl1<<50c6^0p)96bj*ZNJb`BYn_ALX(A@iF?fdyr;9|d;3A@KtRCH<{ z;z_yc+DZA(-LXKWG$QyY5^ut#>6CE;XG5NN`3rxi1^j7pKlV2hyzzoH(--pl(02|sw-VFv^nX0uK9rYzcM zlRTun7ebB54uu$sHv$n(wH@qagCaiB<)l?#&X9y(zBjvzU+29H zp*PxO!K1_>q5M)QNE{G+aYz0 z@5YDt_h4m)W}j@U{6zZAv0A1aBNtb=@G}nEW!g7TjAD|%F(Zw|#aboTU*|hqQ$d{j zbEEg=i;dHS&5-z87I}*DBD;Oel93N&!U7X^s7vmUDW0&cd>Egf|C-jMYNF5&68RYj z*Vt#z6(AmGeQ#WmuT(XQJc?TT^%!-cUAmC7opeo;w02K2<;NhXGgX1eE5z$;q-#9P zYAeUD%D3Z#HqS#M2Syw92PebT+@@@$Oc<4QcIWwd3~buH`H0;uQoVfv-7WIqgW!{L zVFTm0$~IuI%U7f&mhtO>o=Ex)-pT`JZT1V2#%Gi-V5I9+j6eCivi0aPSW%XTrY`64 z59f2l-$UeqRj+7QS5zqlZxDBJ(sArP$2}pG+t{ntpLQ+~s?Yt3>; z?m;cx2Y~J+D0IqxqaiZ!uSEUerFvUQ=x)kaSeU(*;`1iOX}a(s&dE0F)R&&wJ)zr_ z`!MikmU5~|AP=_BhRf|=NW^pa(AWz^oS9#n$lh|S652*4j-c2Z!NVF3$&{Z(j00(P zG)z1q?nI*W(0$cM-q3xFFy|SLJ@DIWjO2eNG7c%rzCxY-y}sV zDehRtv0OGk+nDDyHfe9<#{k(7mKaw-W^a^+#`lnh?1_{A7_|cWUtNjsm`1U)75jx| z5HZNy>TP&%jveW`!+vXOJ{+DxqC@t{|kvHgkF>{^;#wq-%0Nnn(_(r%836% zC82#N2lKhda^y+iR*g=$I1>C9dB;0%ivhhC5x%n2*y_kJ$*oSh=YZBRu{9#CE zFTp7%+9cDq;fy##=wqNe3j5mwL9O|dmw}_Ya+ZhZY{fKJ#SB%FDn{k32zOP4#U`lZ zGNR+v&WahXiYW;h3#rXn5lEdf;#5iWu_`%6;qR&l%1DZJR{XGZ%Q)lYl+4&f`dpQ` zFkPLlNQ%|Or^JZI4o!SoYq^UHqiqy1}xHMG~O&O;@=G>Gt-R7U{ zee$@;6BH9CPMtO-Bwg&?_#gJ};V1U)>E^6(_w(}>d-tBL81JeWuS!f*$Eng%oE76- z6(Oo*dN+#Ih@FXQN=z&@KiM@MG(08kNu72rtdpFQm=dRsRwZ_BKPEO^9ha<#P7!mc z)XAE3#lLs`H-oEVJGU)NJ~b^R+EwA17GoSRapFWpK(dCmm6ANyRlHP9OpJ}zq@^UQ zqtl%gKJE04n_H)I+}y@E8`BAW#0h;mPZ*MtyhxowGbW{~ z;*x15XGJhA5U*Bc(y^1tCn23?P|YC}J*iEKSI4AiG-_vKG6HoDRz<7SI-8I)!&24B z|FH{oiBPB0si}XbJ2R4FRN@^@m8eKmC($?;V~v`OBCx25k5y!;V>IzkaQh!u$Hpik z!WA+4XFKXIdi?u|&bSk$dZTsPDLjpw<@#>}DJH~bs-wxOA_!a+-o84+`1-gD&(q5x zxillr)!4($-A!1>clML`BE*rmgD46$=@fgNjt?Si&LLaSVS`lj)K7ww!gfk}=f@PX zVXAahT#9pxF2oO5Jqh{0alse5hGt#)qs;~^Z$5%48b3VX{Shn=jmHHY@fdl26Xccl z!iwq|SX<^eiu=)&6WbBZP>iWp) zx*RBe&=aoQ8_Kpt_JoRRZ??IlM4E9{EB}%4KJMO^0gpGFQodVj!<047QjB{W)|qGE zqBF0`>gE9aKHNjPe^0>^4hBeTi+5?2HC33OwFVMO-j&P>Q$Tq-QTDdtXi_qS@364} z<(b#8+NuT{_y36Rhu6Z_rOUNd#UDZLnMme)CzuZ^8p}smjpbtB7i%q{e~l}vz3YWL zEw13BYB%|?2p3lvFxljzBJt#Z--u{`M-nTi}t~VbHl*4#f%@h-&gjrNaCgE zOW?bR4H#khHAJ@L!kF`0Sa%x*9~TbNN^5&=cy6?Iz`@DTSmY_%alda5mU!SIgoXvd zu9j73o)*QgXBqI1&wU7{S?l5BvtImP*2DRv^P7R}!S6lj!o56ZV;{|@5R%nf%!@5| zUS>|G-h?ol&CFYx}r1wq1F&O@APt!nJ)J$`w-=u-6Oc!CfnNx!>8b{I`RjVK=*_Jl1Uydws)K(qY?` zps_B80o7yKk5g|;*NTT?q3;?Le%l*C3EuK`Ja6%%yz9I*=9SpW+kI@LhqdNh@j&+5s3Jc^kH}ke)7;S3|LsS3oYi<4BGy;)_`fziRbG>+P^UeG&>kPTue}zP7&& zGA&y16HT>5u>sQ`7;D3=oq#aR))tTAtJRw;m}T_>QTWVClUV33s2odV8k4weX7H4_+RO_JGTL>~O(3dp=sj z(XJzz6L+ECS%AXSES|W(RXHs*LE2l8fxq1O9CB`DA^A$0z417>W)9*<&u?UZMijtG zj|(vCU?6Yy2m_VRCOBwg&3fM3D;=t};$u1zK{~Yrz1)-F70cJO6tC>l$dwT7r34y_ zk2k)=1{Uw}8&dEG_KEDnM%`(G`C$Qw#lvM~?G^~$pMbIV`>{bAPsks7MBui_)mZ3L zitpaKF9n@ijKTZcF{k+`VYU}%SthLa!6|g^0N%f558B=SR>T3nwDDzue||l)5bt<| zLd}DfY|J@lo_jh@TVA({pYeWyd0D%#i*?H(-7^5}I&R24tX^V8?s52C+8NpY{Cn6| zy`Q(3-@%%`M`XoxSKO-c;UnDM;``?ROmTb|9%q{KH~0MtJZt6H7d1${QUytw^gvXj z;Ka{uDYTKp^#k3#%82R0|PO`1C0NgABtiWi%|P#!zmiCJjMTEX7R zu*Vg)*%;$7lV2;|j;@8*rSe*9?%$DsUmtu~zC3N3eDT(8^3Oe-n)bDPIqVBqTxuxx zL;e~EXWf64q6!}22cdK2gi<4J-*FwhJT9TY#iW)zzBj@e$Zl-pJr{1WKLvN#_T&qj z{kid(?_klH3pi)mO!=d}uP_g97rt?77Q8=yF08h^fZs)S75gGC9LP?m-T^v~98{V| zHg^T}*+a0f&QR`2++O*uLN4opU4EY2R)l7sz=frO;Nh9YezSTjg$T0LCaE+f55S3 zH++{i4ZpnW2|=a3Ir$z_(`K;p+F~TUqeF8D5H6&Ho=4<+mZ8dw`|rq~`&ePf#s-OA zzGaFlT$iy6_GEm-$-mtG_H0J@5cyZw28Q~Y5nj#NPRj_0?R#7je;1}cfcN&@$H1CO z{PNxoB)-BGHg=3Kk1rN1fr9CyxP0I&tq}y{d`)0Wkq;1$Vg9}>Y4gEX1P)=M6#(rM zDUJna_z54K3O^`|`QpqUNrvb4;%^6EVuLrl4vVVCF!HaIVdck1-rJ3-(;YZ*Gg3Z- zuTIZlS1f*zuNCcwBC9$ezJ}jQ3h-)&3i^7U1mbyIaCQtEyWyKd{^y*4_Q}&d>#@Uk z8JcZ~Vv6Z|l#ijV0RdbwDu(&f{-*xMN{7mA7=2zshj-TOjY8nRITFmR~RsiLBAlu4srys-1 zskdQJ);j#Q@CTSS?Q7|mmSr&BW*{Rwp-WaiHXLY%HRns=)s8l3+II)u%*sZCF z%B9_z{rN#Sb$TTZE=*=0Jy<1+94WNX+M+tjNxAYKjW>3LeJ)QjU!skQ%++@Hc%w7F z%)M7G3tm!0Y-Pj+6i@xxuxa1Q!Umgac8VB?~s@bQCYjlpN2@hJJPHKycf8nI+2cwrhbjq0rMf(F(zB`fkX0V93*^%>c?FkW}PH z{>$;IIFNvHj-@9*4 zu`w09dA&sOaSccdp_TP$5d2M=g@t4e_=Ge$dOuj(nft~36eDtIxQriokHj}K-vQ!Y zZg+mG*3x@4@n4RTG$`IoAIFLF;861fnfL%nW6EStpg8Ny!91%wqZlFWJ(>lcP5`0v zo3gs`G;aqO=QEHA?eU|HCCxjSk-u2&z2QjQB;tXvQhX61s8tEh+=%!DQ;UBwdjt3j+Yv2U=2p4Xp7Z%JfWm+=QhZ5m}(VpaQiVomm zn-^g^&L%QoA-Gdn2L-?m@;X-rJ9cYQv(;^ib_eV?i z9ZB*>_pSMbvmU&!*Ng0AS{O8KXoiGpKTbM@Jv=j2`}&5N{CM?2!ADZUsf7^k-j4Ib z>cu^Zx%=kBVb6c@rj3V@Vgl)|#QJDngq*rwyr<>&(y}mrY}%KO7wfw6m#4iZkMmtC z?XX>qlkPN#I|gcNK9oMucu68>Qw$5uAa5(k{QG0!kGdZ+-PM(DPksaC?lQ+a+Rw$; zFUIT1Z=k%Q>FFVQ%p6a@xnsJIc=Fm{uPgud>M%yv5TGmdKbA7k{bb2YSL*Alo~i49 z+N5u$^}_>eu^Ue^geeoA0!){2%8_y{wyO=qUXTpOk+CfS;~IpsxH|hnc#D zgnyc8g|0)<^Je-`*HH7!O#kbvcav^DQ~M`p{2%9=J4iRrY+b3h;NMJ>q3f_(SN^R- ziLPPSKh5;1uEVM4&Gfylq2rmE{u>f7a(HqA7hU<^jA6RIDqZ1XVxL#jT4_0g!W>myBH*N@YeInRvON07e1J_7W0{WyK;u0xN$T^|Vgx*qrX zx_+F#T>Q*%pk6>#?V=>&NNK zx@X4gA*k=KN1VQ{AEz%3bbi&h>rtk!>k*`{>&NNK`Ol2k14-Xsj~jjc5&dC2z_M$= z1jV2Fe+G^{#jkji;jEl&*yazN1+Z; zcD|s7j8Gwe0EU>h9s{Zri;}7sKBoExUFxu)7%Om}_WkV`<>_?D&jm zjj^qzvG^@^RCG#4GNHh+2Yt`x<3--?XK6&=7e-~O5;Mr-e?g9DQZ3Lmb^A+`ouz@t zU!5vWEEekbmxX#Ly8maX0Z%X0lUkm(l4$(*V!fVeq~$#A{^FefdpRS8;d7QVrk3X} z*ZY}9TF%?f077Fv{EN4kA&t0zXGmW&I zubtIDFK4Z=dd_k-)biZrY@cbQ<^1gI{&~563cKel*PmLRyWD_h8v9vxb$7EH_|NMN zQVe{~dV{Iux$6yirctctZm0O?^@b`G&slF6wLEvd7yjC4IlPNO*KXZ=^z3D5WZc`t r)U1!Wg=JqWYa3g;e*Fgw95i@{V(73J?BuYAF8Y+xwM+20$>aYY`ivgn 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 deleted file mode 100644 index 82fa57779cd00b226192ae61c5dc1c73e9023798..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12309 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 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 deleted file mode 100644 index a55e3caa152ba798c14935ce68ce0d25810e4bbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11766 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 diff --git a/services/api/tests/test_types.py b/services/api/tests/test_types.py new file mode 100644 index 0000000..26b77de --- /dev/null +++ b/services/api/tests/test_types.py @@ -0,0 +1,108 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import BaseModel, Field, ValidationError + +from owl.types import DatetimeUTC, LanguageCodeList, SanitisedNonEmptyStr +from owl.utils.dates import now_iso +from owl.utils.test import TEXTS + + +class IdTest(BaseModel): + id: SanitisedNonEmptyStr = Field() + + +GOOD_IDS = [ + pytest.param("Hello", id="Simple word"), + pytest.param("Hello World", id="Words with space"), + pytest.param(" Hello", id="Leading space"), + pytest.param("Hello ", id="Trailing space"), + pytest.param(" Hello ", id="Leading and trailing space"), + pytest.param("\nHello", id="Leading newline"), + pytest.param("Hello\n", id="Trailing newline"), + pytest.param("\nHello\n", id="Leading and trailing newline"), + # \u00A0 is NBSP + pytest.param("\u00a0NBSP at start", id="Leading Non-Breaking Space"), + pytest.param("NBSP at end\u00a0", id="Trailing Non-Breaking Space"), + pytest.param("H", id="Single character"), + pytest.param("1", id="Single number"), + pytest.param("?", id="Single symbol"), + pytest.param("123", id="Numbers"), + pytest.param("!@#$", id="Symbols"), + pytest.param("😊", id="Single emoji"), + pytest.param("你好", id="CJK characters"), + pytest.param("مرحبا", id="Arabic characters"), + pytest.param("Привет", id="Cyrillic/Russian characters"), + pytest.param("Hello 😊 World", id="Text with emoji"), + pytest.param("Text with multiple spaces", id="Internal multiple spaces"), + pytest.param("Test-123_ABC", id="Text with symbols"), + pytest.param("a-b_c=d+e*f/g\\h|i[j]k{l}m;n:o'p\"q,r.su?v", id="Complex symbols"), +] + [pytest.param(text, id=lang) for lang, text in TEXTS.items()] + +BAD_IDS = [ + pytest.param("", id="Empty string"), + pytest.param(" ", id="Single space"), + pytest.param(" ", id="Multiple spaces"), + pytest.param("\t", id="Tab only"), + pytest.param("\n", id="Newline only"), + pytest.param("Text\tnewlines", id="Internal tab"), + pytest.param("Text\nwith\nnewlines", id="Internal newlines"), + pytest.param(" Hello\nWorld ", id="Leading space, trailing space, internal newline"), + # \u00A0 is NBSP + pytest.param("Okay\u00a0NBSP\u00a0Okay", id="Internal Non-Breaking Space"), + pytest.param("â–ˆ â–„ â–€", id="Block elements"), + pytest.param("─ │ ┌ â”", id="Box drawing"), + pytest.param("⠲⠳⠴⠵", id="Braille"), +] + + +@pytest.mark.parametrize("value", GOOD_IDS) +def test_id_string_good(value: str): + item = IdTest(id=value) + assert item.id == value.strip() + + +@pytest.mark.parametrize("value", BAD_IDS) +def test_id_string_bad(value: str): + with pytest.raises(ValidationError): + IdTest(id=value) + + +def test_string_normalisation(): + # --- + # + # Zalgo text + assert IdTest(id="H̵̛͕̞̦̰̜Ḭ̥̟́͆Ì͂̌͑ͅä̷͔̟͓̬̯̟Í̭͉͈̮͙̣̯̬͚̞̭Ì̀̾͠m̴̡̧̛Ì̯̹̗̹̤̲̺̟̥̈Ì͊̔̑Ì͆̌̀̚ÍÍb̴̢̢̫Ì̠̗̼̬̻̮̺̭͔̘͑̆̎̚ư̵̧̡̥̙̭̿̈̀̒ÌÌŠÍ’Í‘r̷̡̡̲̼̖͎̫̮̜͇̬͌͘g̷̹Í͎̬͕͓͕Ì̃̈Ì̓̆̚Íẻ̵̡̼̬̥̹͇̭͔̯̉͛̈ÌÌ•r̸̮̖̻̮̣̗͚͖Ì̂͌̾̓̀̿̔̀͋̈Ì͌̈Ì̋͜").id == "Hämbưrgẻr" + # + # + # --- + # Arabic + assert IdTest(id="مَرْحَبًا بÙÙƒÙمْ").id == "مرحبا بكم" + # --- + # Thai + assert IdTest(id="สวัสดีครับ คามุย อิอิ").id == "สวสดครบ คามย ออ" + + +def test_datetime_utc(): + class DatetimeTest(BaseModel): + dt: DatetimeUTC = Field() + + now = now_iso("Asia/Kuala_Lumpur") + d = DatetimeTest(dt=now) + assert isinstance(d.dt, datetime) + assert d.dt.tzinfo is timezone.utc + assert datetime.fromisoformat(now) == d.dt + + +def test_language_list(): + class TestModel(BaseModel): + lang: LanguageCodeList + + model = TestModel(lang=["en", "FR", "zh-cn", "ZH-sg"]) + assert set(model.lang) == {"en", "fr", "zh-CN", "zh-SG"} + + model = TestModel(lang=["en", "mul"]) + assert set(model.lang) == {"en", "fr", "es", "zh", "ko", "ja", "it"} + + with pytest.raises(ValidationError): + TestModel(lang=["xx"]) diff --git a/services/api/tests/utils/test_auth.py b/services/api/tests/utils/test_auth.py new file mode 100644 index 0000000..5335e42 --- /dev/null +++ b/services/api/tests/utils/test_auth.py @@ -0,0 +1,126 @@ +import pytest + +from owl.types import OrgMember_, ProjectMember_, Role, UserRead +from owl.utils.auth import has_permissions +from owl.utils.dates import now +from owl.utils.exceptions import ForbiddenError + +USER_ID = "user_id" +ORG_ID = "0" +PROJ_ID = "project_id" +USER_KWARGS = dict( + id=USER_ID, + name="name", + email="email@example.com", + organizations=[], + projects=[], + created_at=now(), + updated_at=now(), + email_verified=True, + password_hash="***", # Password is not used in this test +) +ORG_MEMBER_KWARGS = dict( + user_id=USER_ID, + organization_id=ORG_ID, + created_at=now(), + updated_at=now(), +) +PROJ_MEMBER_KWARGS = dict( + user_id=USER_ID, + project_id=PROJ_ID, + created_at=now(), + updated_at=now(), +) + + +@pytest.mark.cloud +def test_has_permissions(): + ### --- ADMIN permissions --- ### + sys_user = UserRead( + org_memberships=[OrgMember_(role=Role.ADMIN, **ORG_MEMBER_KWARGS)], + proj_memberships=[ProjectMember_(role=Role.ADMIN, **PROJ_MEMBER_KWARGS)], + **USER_KWARGS, + ) + # Must pass in org ID or proj ID + with pytest.raises(ValueError): + has_permissions(sys_user, ["organization"]) + with pytest.raises(ValueError): + has_permissions(sys_user, ["organization.admin"]) + with pytest.raises(ValueError): + has_permissions(sys_user, ["project"]) + with pytest.raises(ValueError): + has_permissions(sys_user, ["project.admin"]) + with pytest.raises(ValueError): + has_permissions(sys_user, ["organization", "project"], project_id=PROJ_ID) + with pytest.raises(ValueError): + has_permissions(sys_user, ["organization", "project"], organization_id=ORG_ID) + # Membership checks + assert has_permissions(sys_user, ["system"]) is True + assert has_permissions(sys_user, ["organization"], organization_id=ORG_ID) is True + assert has_permissions(sys_user, ["project"], project_id=PROJ_ID) is True + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["organization"], organization_id="ORG_ID") + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["project"], project_id="PROJ_ID") + assert has_permissions(sys_user, ["organization"], organization_id="ORG_ID", raise_error=False) is False # fmt: off + assert has_permissions(sys_user, ["project"], project_id="PROJ_ID", raise_error=False) is False + # Permission checks + assert has_permissions(sys_user, ["system.admin"]) is True + assert has_permissions(sys_user, ["system.member"]) is True + assert has_permissions(sys_user, ["organization.admin"], organization_id=ORG_ID) is True + assert has_permissions(sys_user, ["project.admin"], project_id=PROJ_ID) is True + + ### --- MEMBER permissions --- ### + sys_user = UserRead( + org_memberships=[OrgMember_(role=Role.MEMBER, **ORG_MEMBER_KWARGS)], + proj_memberships=[ProjectMember_(role=Role.MEMBER, **PROJ_MEMBER_KWARGS)], + **USER_KWARGS, + ) + # Membership checks + assert has_permissions(sys_user, ["system"]) is True + assert has_permissions(sys_user, ["organization"], organization_id=ORG_ID) is True + assert has_permissions(sys_user, ["project"], project_id=PROJ_ID) is True + # Permission checks + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["system.admin"]) + assert has_permissions(sys_user, ["system.member"]) is True + assert has_permissions(sys_user, ["system.guest"]) is True + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["organization.admin"], organization_id=ORG_ID) + assert has_permissions(sys_user, ["organization.member"], organization_id=ORG_ID) is True + assert has_permissions(sys_user, ["organization.guest"], organization_id=ORG_ID) is True + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["project.admin"], project_id=PROJ_ID) + assert has_permissions(sys_user, ["project.member"], project_id=PROJ_ID) is True + assert has_permissions(sys_user, ["project.guest"], project_id=PROJ_ID) is True + + ### --- Update membership --- ### + user = sys_user.model_copy(deep=True) + user.org_memberships[0].organization_id = "1" + assert has_permissions(sys_user, ["system"]) is True + with pytest.raises(ForbiddenError): + has_permissions(user, ["system"]) + assert has_permissions(user, ["system", "organization"], organization_id="1") is True + assert ( + has_permissions( + user, + ["system", "organization", "project"], + organization_id="1", + project_id="PROJ_ID", + ) + is True + ) + with pytest.raises(ForbiddenError): + has_permissions( + user, + ["system", "organization", "project"], + organization_id="ORG_ID", + project_id="PROJ_ID", + ) + + ### --- Update permission --- ### + assert has_permissions(sys_user, ["project.member"], project_id=PROJ_ID) is True + sys_user.proj_memberships[0].role = Role.GUEST + with pytest.raises(ForbiddenError): + has_permissions(sys_user, ["project.member"], project_id=PROJ_ID) + assert has_permissions(sys_user, ["project.guest"], project_id=PROJ_ID) is True diff --git a/services/api/tests/utils/test_billing_event.py b/services/api/tests/utils/test_billing_event.py new file mode 100644 index 0000000..3dc4687 --- /dev/null +++ b/services/api/tests/utils/test_billing_event.py @@ -0,0 +1,608 @@ +""" +Tests for the BillingManager's event creation and processing for all usage types. + +This module verifies that different API endpoints and periodic tasks trigger the +correct billing events, leading to accurate updates in an organization's usage +and credit records in the database. + +It covers: +- LLM, Embedding, and Reranker token/search usage and costs. +- Egress (bandwidth) usage for streaming responses. +- Database and File Storage usage calculated by the periodic Celery task. +""" + +from contextlib import contextmanager +from dataclasses import dataclass +from os.path import dirname, join, realpath +from time import sleep + +import pytest +from loguru import logger + +from jamaibase import JamAI +from jamaibase import types as t +from owl.types import ( + ChatEntry, + ChatRequest, + ColumnSchemaCreate, + EmbeddingRequest, + LLMGenConfig, + OrganizationRead, + PaymentState, + PricePlan_, + PriceTier, + Product, + Products, + ProjectRead, + RAGParams, + RerankingRequest, + TableType, + UserRead, +) +from owl.utils.dates import now +from owl.utils.test import ( + ELLM_DESCRIBE_CONFIG, + ELLM_DESCRIBE_DEPLOYMENT, + ELLM_EMBEDDING_CONFIG, + ELLM_EMBEDDING_DEPLOYMENT, + GPT_41_NANO_CONFIG, + GPT_41_NANO_DEPLOYMENT, + STREAM_PARAMS, + TEXT_EMBEDDING_3_SMALL_CONFIG, + TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, + RERANK_ENGLISH_v3_SMALL_CONFIG, + RERANK_ENGLISH_v3_SMALL_DEPLOYMENT, + add_table_rows, + create_deployment, + create_model_config, + create_project, + create_table, + get_file_map, + setup_organizations, +) + +USAGE_RETRY = 30 +USAGE_RETRY_DELAY = 1.0 +MODEL_PROVIDER_PARAMS = dict(argvalues=[True, False], ids=["ellm", "other"]) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) + + +@dataclass(slots=True) +class BillingContext: + user: UserRead + org: OrganizationRead + project: ProjectRead + ellm_chat_model_id: str + chat_model_id: str + ellm_embedding_model_id: str + embedding_model_id: str + ellm_rerank_model_id: str + rerank_model_id: str + + +@pytest.fixture(scope="module") +def setup(): + """ + Sets up a test environment with an organization, project, and both internal (ELLM) + and external models configured for billing tests. + """ + with setup_organizations() as ctx: + with create_project(user_id=ctx.user.id, organization_id=ctx.org.id) as project: + # Create ELLM and External models for all types (Chat, Embed, Rerank) + with ( + # --- Chat Models --- + create_model_config(ELLM_DESCRIBE_CONFIG) as ellm_chat_model, + create_model_config(GPT_41_NANO_CONFIG) as chat_model, + # --- Embedding Models --- + create_model_config(ELLM_EMBEDDING_CONFIG) as ellm_embed_model, + create_model_config(TEXT_EMBEDDING_3_SMALL_CONFIG) as embed_model, + # --- Reranking Models --- + create_model_config( + dict( + id=f"ellm/{RERANK_ENGLISH_v3_SMALL_CONFIG.id}", + name=f"ELLM {RERANK_ENGLISH_v3_SMALL_CONFIG.name}", + owned_by="ellm", + **RERANK_ENGLISH_v3_SMALL_CONFIG.model_dump( + exclude={"id", "name", "owned_by"} + ), + ) + ) as ellm_rerank_model, + create_model_config(RERANK_ENGLISH_v3_SMALL_CONFIG) as rerank_model, + # --- Deployments --- + create_deployment(ELLM_DESCRIBE_DEPLOYMENT), + create_deployment(GPT_41_NANO_DEPLOYMENT), + create_deployment(ELLM_EMBEDDING_DEPLOYMENT), + create_deployment(TEXT_EMBEDDING_3_SMALL_DEPLOYMENT), + create_deployment( + dict( + model_id=f"ellm/{RERANK_ENGLISH_v3_SMALL_DEPLOYMENT.model_id}", + name=f"ELLM {RERANK_ENGLISH_v3_SMALL_DEPLOYMENT.name}", + **RERANK_ENGLISH_v3_SMALL_DEPLOYMENT.model_dump( + exclude={"model_id", "name"}, mode="json" + ), + ) + ), + create_deployment(RERANK_ENGLISH_v3_SMALL_DEPLOYMENT), + ): + yield BillingContext( + user=ctx.user, + org=ctx.org, + project=project, + ellm_chat_model_id=ellm_chat_model.id, + chat_model_id=chat_model.id, + ellm_embedding_model_id=ellm_embed_model.id, + embedding_model_id=embed_model.id, + ellm_rerank_model_id=ellm_rerank_model.id, + rerank_model_id=rerank_model.id, + ) + + +def _cmp(new_org: OrganizationRead, org: OrganizationRead, attr: str, op: str) -> bool: + return getattr(getattr(new_org, attr), op)(getattr(org, attr)) + + +@contextmanager +def _test_usage_event( + client: JamAI, + org_id: str, + is_ellm: bool, + usage_attr: str, + quota_attr: str, +): + """ + Helper function to test billing events for a specific usage type. + + Args: + client: The JamAI client instance. + org_id: The ID of the organization to test. + is_ellm: Boolean indicating if an ELLM model is being tested. + usage_attr: The attribute name for usage on the OrganizationRead object. + quota_attr: The attribute name for quota on the OrganizationRead object. + """ + org = client.organizations.get_organization(org_id) + assert isinstance(org, t.OrganizationRead) + yield + for i in range(USAGE_RETRY): + sleep(USAGE_RETRY_DELAY) + logger.info(f"{usage_attr}: Attempt {i}") + new_org = client.organizations.get_organization(org_id) + checks = { + "credit": _cmp(new_org, org, "credit", "__eq__"), + "credit_grant": _cmp(new_org, org, "credit_grant", "__eq__" if is_ellm else "__lt__"), + quota_attr: _cmp(new_org, org, quota_attr, "__eq__"), + usage_attr: _cmp(new_org, org, usage_attr, "__gt__" if is_ellm else "__eq__"), + "egress_quota_gib": _cmp(new_org, org, "egress_quota_gib", "__eq__"), + "egress_usage_gib": _cmp(new_org, org, "egress_usage_gib", "__gt__"), + } + if all(checks.values()): + break + else: + org = {k: getattr(org, k) for k in checks} + new_org = {k: getattr(new_org, k) for k in checks} + raise AssertionError(f"Usage failed to update: {checks=} {new_org=} {org=}") + + +@pytest.mark.cloud +@pytest.mark.parametrize("is_ellm", **MODEL_PROVIDER_PARAMS) +@pytest.mark.parametrize("stream", **STREAM_PARAMS) +def test_create_llm_events(setup: BillingContext, is_ellm: bool, stream: bool): + """Verifies that LLM usage events correctly update organization metrics.""" + client = JamAI(user_id=setup.user.id, project_id=setup.project.id) + request = ChatRequest( + model=setup.ellm_chat_model_id if is_ellm else setup.chat_model_id, + messages=[ChatEntry.user(content="Tell me a very short joke.")], + max_tokens=10, + stream=stream, + ) + + with _test_usage_event( + client=client, + org_id=setup.org.id, + is_ellm=is_ellm, + usage_attr="llm_tokens_usage_mtok", + quota_attr="llm_tokens_quota_mtok", + ): + if stream: + list(client.generate_chat_completions(request)) + else: + client.generate_chat_completions(request) + + +@pytest.mark.cloud +@pytest.mark.parametrize("is_ellm", **MODEL_PROVIDER_PARAMS) +def test_create_embedding_events(setup: BillingContext, is_ellm: bool): + """Verifies that embedding usage events correctly update organization metrics.""" + client = JamAI(user_id=setup.user.id, project_id=setup.project.id) + request = EmbeddingRequest( + model=setup.ellm_embedding_model_id if is_ellm else setup.embedding_model_id, + input="This is a test for embedding billing.", + ) + + with _test_usage_event( + client=client, + org_id=setup.org.id, + is_ellm=is_ellm, + usage_attr="embedding_tokens_usage_mtok", + quota_attr="embedding_tokens_quota_mtok", + ): + client.generate_embeddings(request) + + +@pytest.mark.cloud +@pytest.mark.parametrize("is_ellm", **MODEL_PROVIDER_PARAMS) +def test_create_reranker_events(setup: BillingContext, is_ellm: bool): + """Verifies that reranker usage events correctly update organization metrics.""" + client = JamAI(user_id=setup.user.id, project_id=setup.project.id) + documents = [ + "Paris is the capital of France.", + "The Eiffel Tower is in Paris.", + "Berlin is the capital of Germany.", + ] + request = RerankingRequest( + model=setup.ellm_rerank_model_id if is_ellm else setup.rerank_model_id, + query="What is the capital of France?", + documents=documents, + ) + + with _test_usage_event( + client=client, + org_id=setup.org.id, + is_ellm=is_ellm, + usage_attr="reranker_usage_ksearch", + quota_attr="reranker_quota_ksearch", + ): + client.rerank(request) + + +def _retry(func): + for i in range(USAGE_RETRY): + sleep(USAGE_RETRY_DELAY) + logger.info(f"{func.__name__}: Attempt {i}") + try: + return func() + except Exception: + if i == USAGE_RETRY - 1: + raise + + +def _check_quotas(org: OrganizationRead, new_org: OrganizationRead): + # Credits + assert new_org.credit == org.credit + # LLM + assert new_org.llm_tokens_quota_mtok == org.llm_tokens_quota_mtok + # Embed + assert new_org.embedding_tokens_quota_mtok == org.embedding_tokens_quota_mtok + # Rerank (no usage yet) + assert new_org.reranker_quota_ksearch == org.reranker_quota_ksearch + # Egress + assert new_org.egress_quota_gib == org.egress_quota_gib + # DB storage + assert new_org.db_quota_gib == org.db_quota_gib + # File storage + assert new_org.file_quota_gib == org.file_quota_gib + + +@pytest.mark.cloud +@pytest.mark.timeout(180) +def test_gen_table_billing(setup: BillingContext): + client = JamAI(user_id=setup.user.id, project_id=setup.project.id) + org = client.organizations.get_organization(setup.org.id) + with ( + create_table( + client, TableType.KNOWLEDGE, embedding_model=setup.embedding_model_id, cols=[] + ) as kt, + create_table( + client, TableType.KNOWLEDGE, embedding_model=setup.ellm_embedding_model_id, cols=[] + ) as ellm_kt, + ): + ### --- Perform RAG --- ### + system_prompt = "Be concise." + gen_config_kwargs = dict( + system_prompt=system_prompt, + prompt="", + max_tokens=20, + temperature=0.001, + top_p=0.001, + ) + rag_kwargs = dict(search_query="", k=2) + cols = [ + ColumnSchemaCreate(id="question", dtype="str"), + ColumnSchemaCreate(id="image", dtype="image"), + ColumnSchemaCreate( + id="ellm", + dtype="str", + gen_config=LLMGenConfig( + model=setup.ellm_chat_model_id, + multi_turn=False, + rag_params=RAGParams( + reranking_model=setup.ellm_rerank_model_id, + table_id=ellm_kt.id, + **rag_kwargs, + ), + **gen_config_kwargs, + ), + ), + ColumnSchemaCreate( + id="non_ellm", + dtype="str", + gen_config=LLMGenConfig( + model=setup.chat_model_id, + multi_turn=False, + rag_params=RAGParams( + reranking_model=setup.rerank_model_id, + table_id=kt.id, + **rag_kwargs, + ), + **gen_config_kwargs, + ), + ), + ] + + ### --- Embed file --- ### + client.table.embed_file(file_path=FILES["weather.txt"], table_id=kt.id) + client.table.embed_file(file_path=FILES["weather.txt"], table_id=ellm_kt.id) + + # Check the billing data + def _check_embed(): + new_org = client.organizations.get_organization(setup.org.id) + # fmt: off + assert new_org.credit_grant < org.credit_grant, ( + f"{new_org.credit_grant=}, {org.credit_grant=}" + ) + assert new_org.llm_tokens_usage_mtok > org.llm_tokens_usage_mtok, ( + f"{new_org.llm_tokens_usage_mtok=}, {org.llm_tokens_usage_mtok=}" + ) + assert new_org.embedding_tokens_usage_mtok > org.embedding_tokens_usage_mtok, ( + f"{new_org.embedding_tokens_usage_mtok=}, {org.embedding_tokens_usage_mtok=}" + ) + # No usage yet + assert new_org.reranker_usage_ksearch == org.reranker_usage_ksearch, ( + f"{new_org.reranker_usage_ksearch=}, {org.reranker_usage_ksearch=}" + ) + assert new_org.egress_usage_gib > org.egress_usage_gib, ( + f"{new_org.egress_usage_gib=}, {org.egress_usage_gib=}" + ) + assert new_org.db_usage_gib > org.db_usage_gib, ( + f"{new_org.db_usage_gib=}, {org.db_usage_gib=}" + ) + assert new_org.file_usage_gib > org.file_usage_gib, ( + f"{new_org.file_usage_gib=}, {org.file_usage_gib=}" + ) + # fmt: on + _check_quotas(org, new_org) + return new_org + + org = _retry(_check_embed) + + ### --- RAG --- ### + image_uri = client.file.upload_file(FILES["rabbit.jpeg"]).uri + table_type = TableType.ACTION + with create_table(client, table_type, cols=cols) as table: + ### Stream + data = [dict(question="What is it?", image=image_uri)] + response = add_table_rows(client, table_type, table.id, data, stream=True) + assert len(response.rows) == len(data) + + # Check the billing data + def _check_rag_stream(): + new_org = client.organizations.get_organization(setup.org.id) + # fmt: off + assert new_org.credit_grant < org.credit_grant, ( + f"{new_org.credit_grant=}, {org.credit_grant=}" + ) + assert new_org.llm_tokens_usage_mtok > org.llm_tokens_usage_mtok, ( + f"{new_org.llm_tokens_usage_mtok=}, {org.llm_tokens_usage_mtok=}" + ) + assert new_org.embedding_tokens_usage_mtok > org.embedding_tokens_usage_mtok, ( + f"{new_org.embedding_tokens_usage_mtok=}, {org.embedding_tokens_usage_mtok=}" + ) + assert new_org.reranker_usage_ksearch > org.reranker_usage_ksearch, ( + f"{new_org.reranker_usage_ksearch=}, {org.reranker_usage_ksearch=}" + ) + assert new_org.egress_usage_gib > org.egress_usage_gib, ( + f"{new_org.egress_usage_gib=}, {org.egress_usage_gib=}" + ) + assert new_org.db_usage_gib > org.db_usage_gib, ( + f"{new_org.db_usage_gib=}, {org.db_usage_gib=}" + ) + assert new_org.file_usage_gib > org.file_usage_gib, ( + f"{new_org.file_usage_gib=}, {org.file_usage_gib=}" + ) + # fmt: on + _check_quotas(org, new_org) + return new_org + + org = _retry(_check_rag_stream) + + ### Non-stream + data = [dict(question="What is it?", image=image_uri)] + response = add_table_rows(client, table_type, table.id, data, stream=False) + assert len(response.rows) == len(data) + + # Check the billing data + def _check_rag_non_stream(): + new_org = client.organizations.get_organization(setup.org.id) + # fmt: off + assert new_org.credit_grant < org.credit_grant, ( + f"{new_org.credit_grant=}, {org.credit_grant=}" + ) + assert new_org.llm_tokens_usage_mtok > org.llm_tokens_usage_mtok, ( + f"{new_org.llm_tokens_usage_mtok=}, {org.llm_tokens_usage_mtok=}" + ) + assert new_org.embedding_tokens_usage_mtok > org.embedding_tokens_usage_mtok, ( + f"{new_org.embedding_tokens_usage_mtok=}, {org.embedding_tokens_usage_mtok=}" + ) + assert new_org.reranker_usage_ksearch > org.reranker_usage_ksearch, ( + f"{new_org.reranker_usage_ksearch=}, {org.reranker_usage_ksearch=}" + ) + assert new_org.egress_usage_gib > org.egress_usage_gib, ( + f"{new_org.egress_usage_gib=}, {org.egress_usage_gib=}" + ) + # No new page allocated + assert new_org.db_usage_gib == org.db_usage_gib, ( + f"{new_org.db_usage_gib=}, {org.db_usage_gib=}" + ) + # No new file uploaded + assert new_org.file_usage_gib == org.file_usage_gib, ( + f"{new_org.file_usage_gib=}, {org.file_usage_gib=}" + ) + # fmt: on + _check_quotas(org, new_org) + return new_org + + org = _retry(_check_rag_non_stream) + + ### --- Tables deleted --- ### + # Check the billing data + def _check_delete(): + new_org = client.organizations.get_organization(setup.org.id) + # fmt: off + assert new_org.credit_grant == org.credit_grant, ( + f"{new_org.credit_grant=}, {org.credit_grant=}" + ) + assert new_org.llm_tokens_usage_mtok == org.llm_tokens_usage_mtok, ( + f"{new_org.llm_tokens_usage_mtok=}, {org.llm_tokens_usage_mtok=}" + ) + assert new_org.embedding_tokens_usage_mtok == org.embedding_tokens_usage_mtok, ( + f"{new_org.embedding_tokens_usage_mtok=}, {org.embedding_tokens_usage_mtok=}" + ) + assert new_org.reranker_usage_ksearch == org.reranker_usage_ksearch, ( + f"{new_org.reranker_usage_ksearch=}, {org.reranker_usage_ksearch=}" + ) + assert new_org.egress_usage_gib > org.egress_usage_gib, ( + f"{new_org.egress_usage_gib=}, {org.egress_usage_gib=}" + ) + assert new_org.db_usage_gib < org.db_usage_gib, ( + f"{new_org.db_usage_gib=}, {org.db_usage_gib=}" + ) + assert new_org.file_usage_gib == org.file_usage_gib, ( + f"{new_org.file_usage_gib=}, {org.file_usage_gib=}" + ) + # fmt: on + _check_quotas(org, new_org) + return new_org + + org = _retry(_check_delete) + + +@pytest.mark.cloud +def test_tiered_billing(): + from owl.utils.billing.cloud import BillingManager + + base_kwargs = dict(created_at=now(), updated_at=now()) + price_plan = PricePlan_( + id="free", + name="Free plan", + stripe_price_id_live="stripe_price_id_live", + stripe_price_id_test="stripe_price_id_test", + flat_cost=0.0, + credit_grant=0.0, + max_users=2, # For ease of testing + products=Products( + llm_tokens=Product( + name="ELLM tokens", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Million Tokens", + ), + embedding_tokens=Product( + name="Embedding tokens", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Million Tokens", + ), + reranker_searches=Product( + name="Reranker searches", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="Thousand Searches", + ), + db_storage=Product( + name="Database storage", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="GiB", + ), + file_storage=Product( + name="File storage", + included=PriceTier(unit_cost=0.5, up_to=0.75), + tiers=[], + unit="GiB", + ), + egress=Product( + name="Egress bandwidth", + included=PriceTier(unit_cost=0.5, up_to=0.5), + tiers=[ + PriceTier(unit_cost=1.0, up_to=1.0), + PriceTier(unit_cost=2.0, up_to=None), + ], + unit="GiB", + ), + ), + is_private=False, + stripe_price_id="stripe_price_id", + **base_kwargs, + ) + assert price_plan.products.egress.included.unit_cost == 0 + org = OrganizationRead( + id="test_org", + name="test_org", + created_by="", + owner="", + stripe_id="stripe_id", + external_keys={}, + price_plan_id=price_plan.id, + payment_state=PaymentState.SUCCESS, + last_subscription_payment_at=now(), + quota_reset_at=now(), + credit=0.0, + credit_grant=0.0, + llm_tokens_quota_mtok=price_plan.products.llm_tokens.included.up_to, + llm_tokens_usage_mtok=0.0, + embedding_tokens_quota_mtok=price_plan.products.embedding_tokens.included.up_to, + embedding_tokens_usage_mtok=0.0, + reranker_quota_ksearch=price_plan.products.reranker_searches.included.up_to, + reranker_usage_ksearch=0.0, + db_quota_gib=price_plan.products.db_storage.included.up_to, + db_usage_gib=0.0, + db_usage_updated_at=now(), + file_quota_gib=price_plan.products.file_storage.included.up_to, + file_usage_gib=0.0, + file_usage_updated_at=now(), + egress_quota_gib=price_plan.products.egress.included.up_to, + egress_usage_gib=0.0, + quotas={}, + active=True, + price_plan=price_plan, + **base_kwargs, + ) + # Test single charge + billing = BillingManager( + organization=org.model_copy(), + project_id="test_project", + user_id="test_user", + ) + usage = 2.0 + billing.create_egress_events(usage) + billing.org.egress_usage_gib += usage + assert round(billing.cost, 2) == 2.0 + # Test multiple charge + billing = BillingManager( + organization=org.model_copy(), + project_id="test_project", + user_id="test_user", + ) + usage = 0.4 + billing.create_egress_events(usage) + billing.org.egress_usage_gib += usage + assert billing.cost == 0.0 + usage = 0.2 # 0.1 * 0.0 + 0.1 * 1.0 + billing.create_egress_events(usage) + billing.org.egress_usage_gib += usage + assert round(billing.cost, 2) == 0.1 + usage = 1.2 # 0.9 * 1.0 + 0.3 * 2.0 + billing.create_egress_events(usage) + billing.org.egress_usage_gib += usage + assert round(billing.cost, 2) == 1.6 diff --git a/services/api/tests/utils/test_crypt.py b/services/api/tests/utils/test_crypt.py new file mode 100644 index 0000000..a92928b --- /dev/null +++ b/services/api/tests/utils/test_crypt.py @@ -0,0 +1,112 @@ +import io + +import pytest + +from owl.utils.crypt import ( + blake2b_hash_file, + decrypt, + encrypt_deterministic, + encrypt_random, + generate_key, + hash_string_blake2b, +) + + +def test_encrypt_random(): + message = "Hello, World!" + password = "secret" + encrypted = encrypt_random(message, password) + decrypted = decrypt(encrypted, password) + assert message == decrypted + + +def test_encrypt_deterministic(): + message = "Hello, World!" + password = "secret" + encrypted1 = encrypt_deterministic(message, password) + encrypted2 = encrypt_deterministic(message, password) + assert encrypted1 == encrypted2 + decrypted = decrypt(encrypted1, password) + assert message == decrypted + + +def test_decrypt_invalid_parts(): + with pytest.raises(ValueError): + decrypt("invalid*format*with*three*parts", "password") + + +def test_decrypt_wrong_password(): + message = "Hello, World!" + password = "correct_password" + wrong_password = "wrong_password" + encrypted = encrypt_random(message, password) + with pytest.raises(ValueError): + decrypt(encrypted, wrong_password) + + +def test_empty_message(): + message = "" + password = "secret" + encrypted = encrypt_random(message, password) + decrypted = decrypt(encrypted, password) + assert message == decrypted + + +def test_long_message(): + message = "A" * 1000000 # 1 million characters + password = "secret" + encrypted = encrypt_random(message, password) + decrypted = decrypt(encrypted, password) + assert message == decrypted + + +def test_hash_string_blake2b(): + string = "Hello, World!" + hashed = hash_string_blake2b(string) + assert len(hashed) == 8 + + +def test_hash_string_blake2b_custom_size(): + string = "Hello, World!" + hashed = hash_string_blake2b(string, key_length=16) + assert len(hashed) == 16 + + +def test_blake2b_hash_file(): + file_content = b"Hello, World!" + file = io.BytesIO(file_content) + hashed = blake2b_hash_file(file) + assert len(hashed) == 128 # Default blake2b digest size is 64 bytes + + +def test_blake2b_hash_file_custom_blocksize(): + file_content = b"Hello, World!" * 1000 + file = io.BytesIO(file_content) + hashed = blake2b_hash_file(file, blocksize=1024) + assert len(hashed) == 128 + + +def test_generate_key_default(): + key = generate_key() + assert len(key) == 48 + + +def test_generate_key_custom_length(): + key = generate_key(key_length=32) + assert len(key) == 32 + + +def test_generate_key_with_prefix(): + key = generate_key(prefix="test_") + assert key.startswith("test_") + assert len(key) == 53 # 48 + 5 (prefix length) + + +def test_generate_key_invalid_length(): + with pytest.raises(ValueError): + generate_key(key_length=15) + + +def test_generate_key_odd_length(): + with pytest.raises(ValueError): + generate_key(key_length=33) diff --git a/services/api/tests/utils/test_dates.py b/services/api/tests/utils/test_dates.py new file mode 100644 index 0000000..7010d6d --- /dev/null +++ b/services/api/tests/utils/test_dates.py @@ -0,0 +1,109 @@ +import unittest +from datetime import date, datetime, timezone +from zoneinfo import ZoneInfo + +from freezegun import freeze_time + +from owl.utils.dates import ( + date_to_utc, + date_to_utc_iso, + ensure_utc_timezone, + now, + now_iso, + utc_iso_from_datetime, + utc_iso_from_string, + utc_iso_from_uuid7, + utc_iso_from_uuid7_draft2, +) + + +class TestDateTimeFunctions(unittest.TestCase): + @freeze_time("2023-05-01 12:00:00+00:00") + def test_now_iso(self): + self.assertEqual(now_iso(), "2023-05-01T12:00:00+00:00") + self.assertEqual(now_iso("America/New_York"), "2023-05-01T08:00:00-04:00") + + @freeze_time("2023-05-01 12:00:00+00:00") + def test_now(self): + self.assertEqual(now(), datetime(2023, 5, 1, 12, 0, 0, tzinfo=timezone.utc)) + expected_ny_time = datetime(2023, 5, 1, 12, 0, 0, tzinfo=timezone.utc).astimezone( + ZoneInfo("America/New_York") + ) + self.assertEqual( + now("America/New_York"), + expected_ny_time, + ) + + def test_utc_iso_from_string(self): + self.assertEqual( + utc_iso_from_string("2023-05-01T12:00:00+02:00"), "2023-05-01T10:00:00+00:00" + ) + with self.assertRaises(ValueError): + utc_iso_from_string("2023-05-01T12:00:00") # No timezone + + def test_utc_iso_from_datetime(self): + dt = datetime(2023, 5, 1, 12, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")) + self.assertEqual(utc_iso_from_datetime(dt), "2023-05-01T10:00:00+00:00") + + def test_utc_iso_from_uuid7(self): + uuid7_str = "018859e1-6a62-7f60-b6e1-f6e4b8ec6b66" + result = utc_iso_from_uuid7(uuid7_str) + self.assertTrue(result.startswith("2023-")) # Check if it's at least from 2023 + + # def test_utc_iso_from_uuid7_draft2(self): + # uuid7_str = "018859e1-6a62-7f60-b6e1-f6e4b8ec6b66" + # result = utc_iso_from_uuid7_draft2(uuid7_str) + # self.assertTrue(result.startswith("2023-")) # Check if it's at least from 2023 + + def test_date_to_utc_iso(self): + d = date(2023, 5, 1) + self.assertEqual(date_to_utc_iso(d), "2023-05-01T00:00:00+00:00") + self.assertEqual(date_to_utc_iso(d, "America/New_York"), "2023-05-01T04:00:00+00:00") + + def test_date_to_utc(self): + d = date(2023, 5, 1) + self.assertEqual(date_to_utc(d), datetime(2023, 5, 1, 0, 0, 0, tzinfo=timezone.utc)) + self.assertEqual( + date_to_utc(d, "America/New_York"), + datetime(2023, 5, 1, 0, 0, 0, tzinfo=ZoneInfo("America/New_York")), + ) + + def test_ensure_utc_timezone(self): + self.assertEqual( + ensure_utc_timezone("2023-05-01T12:00:00+00:00"), "2023-05-01T12:00:00+00:00" + ) + with self.assertRaises(ValueError): + ensure_utc_timezone("2023-05-01T12:00:00+02:00") + + # Edge cases + def test_utc_iso_from_string_edge_cases(self): + with self.assertRaises(ValueError): + utc_iso_from_string("invalid_datetime") + with self.assertRaises(ValueError): + utc_iso_from_string("2023-05-01") # No time + + def test_utc_iso_from_datetime_edge_cases(self): + with self.assertRaises(ValueError): + utc_iso_from_datetime(datetime(2023, 5, 1)) # No timezone + + def test_utc_iso_from_uuid7_edge_cases(self): + with self.assertRaises(ValueError): + utc_iso_from_uuid7("invalid_uuid") + + def test_utc_iso_from_uuid7_draft2_edge_cases(self): + with self.assertRaises(ValueError): + utc_iso_from_uuid7_draft2("invalid_uuid") + + def test_date_to_utc_iso_edge_cases(self): + with self.assertRaises(ValueError): + date_to_utc_iso(date(2023, 5, 1), "Invalid/Timezone") + + def test_ensure_utc_timezone_edge_cases(self): + with self.assertRaises(ValueError): + ensure_utc_timezone("invalid_datetime") + with self.assertRaises(ValueError): + ensure_utc_timezone("2023-05-01T12:00:00") # No timezone + + +if __name__ == "__main__": + unittest.main() diff --git a/services/api/tests/utils/test_file.py b/services/api/tests/utils/test_file.py new file mode 100644 index 0000000..f562d99 --- /dev/null +++ b/services/api/tests/utils/test_file.py @@ -0,0 +1,268 @@ +import os +import re +import tempfile +from dataclasses import dataclass +from io import BytesIO +from os.path import basename, dirname, join, realpath +from urllib.parse import urlparse + +import httpx +import numpy as np +import pytest +from PIL import Image + +from jamaibase import JamAI +from jamaibase.types import ( + FileUploadResponse, + GetURLResponse, + OrganizationCreate, +) +from jamaibase.utils.exceptions import BadInputError +from owl.types import Role +from owl.utils.test import ( + create_organization, + create_project, + create_user, + get_file_map, + upload_file, +) + +TEST_FILE_DIR = join(dirname(dirname(realpath(__file__))), "files") +FILES = get_file_map(TEST_FILE_DIR) +# Define the paths to your test image and audio files +IMAGE_FILES = [ + FILES["cifar10-deer.jpg"], + FILES["rabbit.png"], + FILES["rabbit_cifar10-deer.gif"], + FILES["rabbit_cifar10-deer.webp"], +] +AUDIO_FILES = [ + FILES["gutter.wav"], + FILES["gutter.mp3"], +] +DOC_FILES = [ + FILES["1970_PSS_ThAT_mechanism.pdf"], + FILES["Claims Form.xlsx"], +] +ALL_FILES = IMAGE_FILES + AUDIO_FILES + DOC_FILES + + +@dataclass(slots=True) +class FileContext: + superuser_id: str + user_id: str + org_id: str + project_id: str + + +def _read_file_content(file_path): + with open(file_path, "rb") as f: + return f.read() + + +@pytest.fixture(scope="module") +def setup(): + """ + Fixture to set up the necessary organization and projects for file tests. + """ + with ( + # Create superuser + create_user() as superuser, + # Create user + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + # Create organization + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as org, + # Create project + create_project(dict(name="Bucket A"), user_id=superuser.id, organization_id=org.id) as p0, + ): + assert superuser.id == "0" + assert org.id == "0" + client = JamAI(user_id=superuser.id) + # Join organization and project + client.organizations.join_organization( + user_id=user.id, organization_id=org.id, role=Role.ADMIN + ) + client.projects.join_project(user_id=user.id, project_id=p0.id, role=Role.ADMIN) + + yield FileContext( + superuser_id=superuser.id, user_id=user.id, org_id=org.id, project_id=p0.id + ) + + +@pytest.mark.parametrize("image_file", IMAGE_FILES) +def test_upload_image(setup: FileContext, image_file: str): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # 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 = upload_file(client, 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( + rf"(file|s3)://[^/]+/raw/{setup.org_id}/{setup.project_id}/[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/{setup.org_id}/{setup.project_id}/{{UUID}}/{filename}" + ) + + +@pytest.mark.parametrize("audio_file", AUDIO_FILES) +def test_upload_audio(setup: FileContext, audio_file: str): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # Ensure the audio file exists + assert os.path.exists(audio_file), f"Test audio file does not exist: {audio_file}" + # Upload the file + upload_response = upload_file(client, audio_file) + assert isinstance(upload_response, FileUploadResponse) + assert upload_response.uri.startswith(("file://", "s3://")), ( + f"Returned URI '{upload_response.uri}' does not start with 'file://' or 's3://'" + ) + + filename = os.path.basename(audio_file) + expected_uri_pattern = re.compile( + rf"(file|s3)://[^/]+/raw/{setup.org_id}/{setup.project_id}/[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/{setup.org_id}/{setup.project_id}/{{UUID}}/{filename}" + ) + + +@pytest.mark.parametrize("doc_file", DOC_FILES) +def test_upload_doc(setup: FileContext, doc_file: str): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # Ensure the doc file exists + assert os.path.exists(doc_file), f"Test doc file does not exist: {doc_file}" + # Upload the file + upload_response = upload_file(client, doc_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(doc_file) + expected_uri_pattern = re.compile( + rf"(file|s3)://[^/]+/raw/{setup.org_id}/{setup.project_id}/[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/{setup.org_id}/{setup.project_id}/{{UUID}}/{filename}" + ) + + +def test_upload_large_image_file(setup: FileContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # 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(BadInputError, match=pattern): + upload_file(client, file_path) + + +def test_get_raw_urls(setup: FileContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + # Upload files first + uploaded_uris = [] + for f in ALL_FILES: + response = upload_file(client, f) + uploaded_uris.append(response.uri) + + # Now test get_raw_urls + response = client.file.get_raw_urls(uploaded_uris) + assert isinstance(response, GetURLResponse) + assert len(response.urls) == len(ALL_FILES) + for original_file, url in zip(ALL_FILES, response.urls, strict=True): + downloaded_content = httpx.get(url).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}") + + +def test_get_thumbnail_urls(setup: FileContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + + # Upload files first + uploaded_uris = [upload_file(client, f).uri for f in ALL_FILES] + + # Test get_thumbnail_urls + response = client.file.get_thumbnail_urls(uploaded_uris) + assert isinstance(response, GetURLResponse) + assert len(response.urls) == len(ALL_FILES) + thumb_url_map = {basename(f): url for f, url in zip(ALL_FILES, response.urls, strict=True)} + + # Compare thumbnails + for file_path in ALL_FILES: + thumb_url = thumb_url_map[basename(file_path)] + if file_path in IMAGE_FILES + DOC_FILES: + expected_thumb = _read_file_content(f"{file_path}.thumb.webp") + elif file_path in AUDIO_FILES: + expected_thumb = _read_file_content(f"{file_path}.thumb.mp3") + else: + raise ValueError(f"Unexpected file: {file_path}") + if thumb_url.startswith(("http://", "https://")): + downloaded_thumb = httpx.get(thumb_url).content + else: + downloaded_thumb = _read_file_content(thumb_url) + # Compare the contents + if file_path in AUDIO_FILES: + # We could find a way to strip out ID3 tags but it's easier to just compare parts of it + expected_thumb = expected_thumb[-round(len(expected_thumb) * 0.9) :] + downloaded_thumb = downloaded_thumb[-round(len(downloaded_thumb) * 0.9) :] + assert expected_thumb == downloaded_thumb, f"Thumbnail mismatch for file: {file_path}" + + +def test_thumbnail_transparency(setup: FileContext): + client = JamAI(user_id=setup.user_id, project_id=setup.project_id) + response = upload_file(client, FILES["github-mark-white.png"]) + response = client.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).content + else: + downloaded_thumbnail = _read_file_content(thumb_url) + + image = Image.open(BytesIO(downloaded_thumbnail)) + assert image.mode == "RGBA" diff --git a/services/api/tests/utils/test_io.py b/services/api/tests/utils/test_io.py new file mode 100644 index 0000000..b8f54dd --- /dev/null +++ b/services/api/tests/utils/test_io.py @@ -0,0 +1,140 @@ +import pickle +import unittest +from unittest.mock import MagicMock, mock_open, patch + +import numpy as np +import pandas as pd +from PIL import ExifTags, Image + +from owl.utils.io import ( + csv_to_df, + df_to_csv, + dump_json, + dump_pickle, + dump_toml, + dump_yaml, + json_dumps, + json_loads, + load_pickle, + read_image, + read_json, + read_toml, + read_yaml, +) + + +class TestFileOperations(unittest.TestCase): + def test_load_pickle(self): + mock_data = {"key": "value"} + with patch("builtins.open", mock_open(read_data=pickle.dumps(mock_data))): + result = load_pickle("dummy_path") + self.assertEqual(result, mock_data) + + def test_dump_pickle(self): + mock_data = {"key": "value"} + mock_file = mock_open() + with patch("builtins.open", mock_file): + dump_pickle("dummy_path", mock_data) + mock_file().write.assert_called() + + def test_read_json(self): + mock_data = '{"key": "value"}' + with patch("builtins.open", mock_open(read_data=mock_data)): + result = read_json("dummy_path") + self.assertEqual(result, {"key": "value"}) + + def test_dump_json(self): + mock_data = {"key": "value"} + mock_file = mock_open() + with patch("builtins.open", mock_file): + result = dump_json(mock_data, "dummy_path") + self.assertEqual(result, "dummy_path") + mock_file().write.assert_called() + + def test_json_loads(self): + mock_data = '{"key": "value"}' + result = json_loads(mock_data) + self.assertEqual(result, {"key": "value"}) + + def test_json_dumps(self): + mock_data = {"key": "value"} + result = json_dumps(mock_data) + self.assertEqual(result, '{"key":"value"}') + + def test_read_yaml(self): + mock_data = "key: value" + with patch("builtins.open", mock_open(read_data=mock_data)): + result = read_yaml("dummy_path") + self.assertEqual(result, {"key": "value"}) + + def test_dump_yaml(self): + mock_data = {"key": "value"} + mock_file = mock_open() + with patch("builtins.open", mock_file): + result = dump_yaml(mock_data, "dummy_path") + self.assertEqual(result, "dummy_path") + mock_file().write.assert_called() + + def test_read_toml(self): + mock_data = 'key = "value"' + with patch("builtins.open", mock_open(read_data=mock_data)): + result = read_toml("dummy_path") + self.assertEqual(result, {"key": "value"}) + + def test_dump_toml(self): + mock_data = {"key": "value"} + mock_file = mock_open() + with patch("builtins.open", mock_file): + result = dump_toml(mock_data, "dummy_path") + self.assertEqual(result, "dummy_path") + mock_file().write.assert_called() + + def test_csv_to_df(self): + mock_data = "col1,col2\n1,2\n3,4" + result = csv_to_df(mock_data) + expected = pd.DataFrame({"col1": [1, 3], "col2": [2, 4]}) + pd.testing.assert_frame_equal(result, expected) + + def test_csv_to_df_with_column_names(self): + mock_data = "1,2\n3,4" + result = csv_to_df(mock_data, column_names=["A", "B"]) + expected = pd.DataFrame({"A": [1, 3], "B": [2, 4]}) + pd.testing.assert_frame_equal(result, expected) + + @patch("pandas.DataFrame.to_csv") + def test_df_to_csv(self, mock_to_csv): + df = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) + df_to_csv(df, "dummy_path") + mock_to_csv.assert_called_once() + + @patch("PIL.Image.open") + def test_read_image_rotated(self, mock_open): + mock_image = Image.new("RGB", (100, 100)) + mock_exif = {} + for key, value in ExifTags.TAGS.items(): + if value == "Orientation": + mock_exif[key] = 3 # 3 is the code for 180 degree rotation + break + mock_image.getexif = MagicMock(return_value=mock_exif) + mock_open.return_value.__enter__.return_value = mock_image + + result, is_rotated = read_image("dummy_path") + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (100, 100, 3)) + self.assertTrue(is_rotated) + + @patch("PIL.Image.open") + def test_read_image_not_rotated(self, mock_open): + mock_image = Image.new("RGB", (100, 100)) + mock_exif = {} # Empty EXIF data (no orientation) + mock_image.getexif = MagicMock(return_value=mock_exif) + mock_open.return_value.__enter__.return_value = mock_image + + result, is_rotated = read_image("dummy_path") + self.assertIsInstance(result, np.ndarray) + self.assertEqual(result.shape, (100, 100, 3)) + self.assertFalse(is_rotated) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/api/tests/utils/test_jwt.py b/services/api/tests/utils/test_jwt.py new file mode 100644 index 0000000..d8b9c9f --- /dev/null +++ b/services/api/tests/utils/test_jwt.py @@ -0,0 +1,44 @@ +from datetime import datetime, timedelta, timezone +from time import sleep + +import pytest + +from owl.utils.exceptions import AuthorizationError +from owl.utils.jwt import decode_jwt, encode_jwt + + +def test_jwt_round_trip(): + data = {"user_id": 123, "role": "admin"} + expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + token = encode_jwt(data, expiry) + decoded = decode_jwt(token, "expired", "invalid") + # Should contain original data plus 'iat' and 'exp' + assert decoded["user_id"] == 123 + assert decoded["role"] == "admin" + assert "iat" in decoded + assert "exp" in decoded + + +def test_jwt_expired(): + expiry = datetime.now(timezone.utc) - timedelta(seconds=1) + token = encode_jwt({"user_id": 456}, expiry) + sleep(2) + with pytest.raises(AuthorizationError, match="expired"): + decode_jwt(token, "expired", "invalid") + + +def test_jwt_invalid_signature(): + data = {"user_id": 789} + expiry = datetime.now(timezone.utc) + timedelta(minutes=1) + token = encode_jwt(data, expiry) + # Tamper with the token + bad_token = token + "abc" + with pytest.raises(AuthorizationError, match="invalid"): + decode_jwt(bad_token, "expired", "invalid") + + +def test_jwt_invalid_token_format(): + # Not even a JWT + bad_token = "not.a.jwt" + with pytest.raises(AuthorizationError, match="invalid"): + decode_jwt(bad_token, "expired", "invalid") diff --git a/services/api/tests/utils/test_mcp.py b/services/api/tests/utils/test_mcp.py new file mode 100644 index 0000000..657295d --- /dev/null +++ b/services/api/tests/utils/test_mcp.py @@ -0,0 +1,227 @@ +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from mcp.types import ( + CallToolResult, + ClientNotification, + EmptyResult, + InitializedNotification, + ListToolsResult, +) + +from jamaibase import JamAIAsync +from jamaibase.types import ( + OrganizationCreate, + Page, + ProjectRead, + Role, +) +from owl.configs import ENV_CONFIG +from owl.utils.test import ( + create_organization, + create_project, + create_user, +) + + +@dataclass(slots=True) +class SetupContext: + superorg_id: str + org_id: str + superproject_id: str + project_id: str + superuser_id: str + user_id: str + guestuser_id: str + + +@pytest.fixture(scope="module") +async def setup(): + with ( + # Create superuser + create_user() as superuser, + # Create user + create_user({"email": "testuser@example.com", "name": "Test User"}) as user, + # Create guestuser + create_user({"email": "guest@example.com", "name": "Test Guest User"}) as guestuser, + # Create super organization + create_organization( + body=OrganizationCreate(name="Clubhouse"), user_id=superuser.id + ) as superorg, + # Create organization + create_organization(body=OrganizationCreate(name="CommonOrg"), user_id=user.id) as org, + # Create project + create_project( + dict(name="projA"), user_id=superuser.id, organization_id=superorg.id + ) as p0, + create_project(dict(name="projA"), user_id=user.id, organization_id=org.id) as p1, + ): + client = JamAIAsync(user_id=user.id) + # guest user join organization but not project + await client.organizations.join_organization( + user_id=guestuser.id, organization_id=org.id, role=Role.MEMBER + ) + yield SetupContext( + superorg_id=superorg.id, + org_id=org.id, + superproject_id=p0.id, + project_id=p1.id, + superuser_id=superuser.id, + user_id=user.id, + guestuser_id=guestuser.id, + ) + + +@asynccontextmanager +async def mcp_session(user_id: str, project_id: str | None = None): + # Connect to a streamable HTTP server + headers = { + "X-USER-ID": user_id, + "X-PROJECT-ID": project_id if project_id else "", + } + if ENV_CONFIG.is_cloud: + headers["Authorization"] = f"Bearer {ENV_CONFIG.service_key_plain}" + async with streamablehttp_client( + url=f"http://localhost:{ENV_CONFIG.port}/api/v1/mcp/http", + headers=headers, + ) as (read_stream, write_stream, _): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + yield session + + +async def test_send_ping(setup: SetupContext): + async with mcp_session(setup.superuser_id) as session: + response = await session.send_ping() + assert isinstance(response, EmptyResult) + + +async def test_send_notification(setup: SetupContext): + async with mcp_session(setup.superuser_id) as session: + response = await session.send_notification( + ClientNotification(InitializedNotification(method="notifications/initialized")) + ) + assert response is None + + +async def test_list_tools(setup: SetupContext): + async with mcp_session(setup.superuser_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + # Should have all tools available + assert ( + "list_organizations_api_v2_organizations_list_get" in tools + ) # need system membership + assert "model_info_api_v1_models_get" in tools # need project membership + assert "create_project_api_v2_projects_post" in tools # needs organization.admin + assert ( + "create_action_table_api_v2_gen_tables_action_post" in tools + ) # needs organization or project member permission + assert ( + "create_conversation_api_v2_conversations_post" in tools + ) # needs project member permission + assert ( + "list_projects_api_v2_projects_list_get" in tools + ) # need system or organization guest permission + + async with mcp_session(setup.user_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + assert "model_info_api_v1_models_get" in tools # need project membership + assert "create_project_api_v2_projects_post" in tools # needs organization.admin + assert ( + "create_action_table_api_v2_gen_tables_action_post" in tools + ) # needs organization or project member permission + assert ( + "create_conversation_api_v2_conversations_post" in tools + ) # needs project member permission + assert ( + "list_projects_api_v2_projects_list_get" in tools + ) # need system or organization guest permission + + async with mcp_session(setup.guestuser_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + assert "model_info_api_v1_models_get" not in tools # need project membership + assert "create_project_api_v2_projects_post" not in tools # needs organization.admin + assert ( + "create_action_table_api_v2_gen_tables_action_post" in tools + ) # needs organization or project member permission + assert ( + "create_conversation_api_v2_conversations_post" not in tools + ) # needs project member permission + assert ( + "list_projects_api_v2_projects_list_get" in tools + ) # need system or organization guest permission + + +@pytest.mark.cloud +async def test_list_tools_system_membership(setup: SetupContext): + async with mcp_session(setup.superuser_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + # Should have all tools available + assert ( + "list_organizations_api_v2_organizations_list_get" in tools + ) # need system membership + + async with mcp_session(setup.user_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + assert ( + "list_organizations_api_v2_organizations_list_get" not in tools + ) # need system membership + + async with mcp_session(setup.guestuser_id) as session: + tool_list = await session.list_tools() + assert isinstance(tool_list, ListToolsResult) + tools = {tool.name: tool for tool in tool_list.tools} + assert ( + "list_organizations_api_v2_organizations_list_get" not in tools + ) # need system membership + + +async def test_call_tool(setup: SetupContext): + async with mcp_session(setup.superuser_id) as session: + # List projects + tool_result = await session.call_tool( + "list_projects_api_v2_projects_list_get", + dict( + organization_id=setup.superorg_id, + limit=2, + order_by="created_at", + order_ascending=False, + ), + ) + assert isinstance(tool_result, CallToolResult) + assert not tool_result.isError + assert isinstance(tool_result.content[0].text, str) + projects = Page[ProjectRead].model_validate_json(tool_result.content[0].text) + assert projects.total == 1 + assert projects.items[0].id == setup.superproject_id + # Create Proj + new_proj_name = "MCP proj" + tool_result = await session.call_tool( + "create_project_api_v2_projects_post", + dict(organization_id=setup.superorg_id, name=new_proj_name), + ) + assert isinstance(tool_result, CallToolResult) + assert isinstance(tool_result.content[0].text, str) + proj = ProjectRead.model_validate_json(tool_result.content[0].text) + assert proj.organization.id == setup.superorg_id + assert proj.name == new_proj_name + # Fetch the updated organization + client = JamAIAsync(user_id=setup.superuser_id) + p = await client.projects.get_project(proj.id) + assert isinstance(p, ProjectRead) + assert p.name == new_proj_name diff --git a/services/api/tests/utils/test_utils.py b/services/api/tests/utils/test_utils.py new file mode 100644 index 0000000..fdf37b5 --- /dev/null +++ b/services/api/tests/utils/test_utils.py @@ -0,0 +1,166 @@ +import numpy as np +import pytest + +from owl.utils import mask_content, mask_dict, merge_dict, validate_where_expr + + +def test_mask_content(): + # mask_content(x: str | list | dict | np.ndarray | Any) -> str | list | dict | None + x = "str" + assert mask_content(x) == "*** (str_len=3)" + x = "long-string" + assert mask_content(x) == "lo***ng (str_len=11)" + x = 0 + assert mask_content(x) is None + x = False + assert mask_content(x) is None + x = np.ones(3) + assert mask_content(x) == "array(shape=(3,), dtype=float64)" + x = ["long-string", np.ones(3), 0] + assert mask_content(x) == ["lo***ng (str_len=11)", "array(shape=(3,), dtype=float64)", None] + x = dict(x=["long-string", np.ones(3), 0], y=0, z=dict(a="str")) + assert mask_content(x) == dict( + x=["lo***ng (str_len=11)", "array(shape=(3,), dtype=float64)", None], + y=None, + z=dict(a="*** (str_len=3)"), + ) + + +def test_mask_dict(): + x = dict(a=0, b=1, c="", d="d") + assert mask_dict(x) == dict(a=0, b="***", c="", d="***") + + +def test_merge_dict(): + x = dict(a=1, b=dict(p=2, q=3)) + y = dict(b=dict(p=30)) + assert merge_dict(x, y) == dict(a=1, b=dict(p=30, q=3)) + + x = dict(a=1, b=dict(p=2, q=3)) + y = dict(b=dict(p=[])) + assert merge_dict(x, y) == dict(a=1, b=dict(p=[], q=3)) + + x = dict(a=1, b=[dict(p=2, q=3)]) + y = dict(b=[dict(p=30)]) + assert merge_dict(x, y) == dict(a=1, b=[dict(p=30)]) + + x = dict(a=1, b=dict(p=dict(r=3, t=None), q=3)) + y = dict(b=dict(p=30)) + assert merge_dict(x, y) == dict(a=1, b=dict(p=30, q=3)) + + x = dict(a=1, b=dict(p=dict(r=3, t=None), q=3)) + y = dict(b=dict(p=dict(t=True))) + assert merge_dict(x, y) == dict(a=1, b=dict(p=dict(r=3, t=True), q=3)) + + x = dict(a=1, b=dict(p=dict(r=3, t=None), q=3)) + y = dict(b=dict(p=dict(t={}))) + assert merge_dict(x, y) == dict(a=1, b=dict(p=dict(r=3, t={}), q=3)) + + x = dict(a=1, b=None) + y = dict(b=dict(p=3)) + assert merge_dict(x, y) == dict(a=1, b=dict(p=3)) + + x = dict(a=1, b=dict(p=2)) + y = dict(b=None) + assert merge_dict(x, y) == dict(a=1, b=None) + + x = dict(a=1, b=dict(p=2, q=3)) + y = dict(b=dict(p=30), c=True) + assert merge_dict(x, y) == dict(a=1, b=dict(p=30, q=3), c=True) + + x = dict(a=1, b=dict(p=2, q=3)) + y = dict(a="yes", b=dict(p=30), c=True) + assert merge_dict(x, y) == dict(a="yes", b=dict(p=30, q=3), c=True) + + +def test_validate_where_expr(): + # Basic cases + sql = validate_where_expr("WHERE a = 1") + assert sql == "a = 1" + sql = validate_where_expr("WHERE a =\n1") + assert sql == "a = 1" + sql = validate_where_expr("WHERE a = 'x'") + assert sql == "a = 'x'" + sql = validate_where_expr("WHERE (a = 'x')") + assert sql == "(a = 'x')" + sql = validate_where_expr("a = 1") + assert sql == "a = 1" + sql = validate_where_expr(""""a" = 'x'""") + assert sql == """"a" = 'x'""" + # Nested comparisons + sql = validate_where_expr( + """WHERE a = 1 OR ((b = NULL AND c = 9) OR ("b (1)" = TRUE) AND c = '9')""" + ) + assert sql == """a = 1 OR ((b = NULL AND c = 9) OR ("b (1)" = TRUE) AND c = '9')""" + # Comparison with a column + sql = validate_where_expr('WHERE (("ID" = 1 AND "Updated at" = 9) AND "Updated at" = "M")') + assert sql == '(("ID" = 1 AND "Updated at" = 9) AND "Updated at" = "M")' + # Wildcard + sql = validate_where_expr('"222 two three" ~* 3;') + assert sql == '"222 two three" ~* 3' + # Column name with parenthesis + sql = validate_where_expr('"text (en)" ~* 3;') + assert sql == '"text (en)" ~* 3' + sql = validate_where_expr(""""text (en)" ~* 'yes (no)';""") + assert sql == """"text (en)" ~* 'yes (no)'""" + + # ID mapping + sql = validate_where_expr("WHERE a = 'x'", id_map={"a": "b"}) + assert sql == """"b" = 'x'""" + sql = validate_where_expr(""""a" = 'x'""", id_map={"a": "b"}) + assert sql == """"b" = 'x'""" + sql = validate_where_expr( + """WHERE a = 1 OR ((b = NULL AND c = 9) OR ("b" = TRUE) AND c = '9')""", + id_map={"a": "b"}, + ) + assert sql == """"b" = 1 OR (("b" = NULL AND "c" = 9) OR ("b" = TRUE) AND "c" = '9')""" + sql = validate_where_expr( + 'WHERE (("ID" = 1 AND "Updated at" = 9) AND "Updated at" = "M")', + id_map={"ID": "a", "Updated at": "b", "M": "c"}, + ) + assert sql == '(("a" = 1 AND "b" = 9) AND "b" = "c")' + sql = validate_where_expr('"222 two three" ~* 3;', id_map={"222 two three": "a"}) + assert sql == '"a" ~* 3' + + # Illegal SQL + for stmt in [ + # Classic drop table + "DROP TABLE users; --", + # Update data for all users + "UPDATE users SET is_admin = 1", + # Insert a new admin user + "INSERT INTO users (username, is_admin) VALUES ('attacker', 1);", + # Comment + "email = 'a@a.com' --", + "email = 'a@a.com' /*", + # Shutdown the database (in some systems like SQL Server) + "SHUTDOWN", + # Attempt to alter a table + "name = 'x' OR 1 = (ALTER TABLE users ADD COLUMN hacked VARCHAR(100))", + "name = 'x' OR 1 = \n(ALTER TABLE users ADD COLUMN hacked VARCHAR(100))", + "name = 'x' OR 1 = \r(ALTER TABLE users ADD COLUMN hacked VARCHAR(100))", + # Keywords used directly + "id > 0 OR UPDATE users SET is_admin = 1", + "id = 1 OR MERGE INTO users", + # Attempt to drop a column + "ALTER TABLE users DROP COLUMN password_hash;", + # Truncate a table + "TRUNCATE TABLE logs;", + # Functions + "1=1 AND pg_sleep(10)", + "1=1 AND pg_sleep (10)", + "1=1 AND set_config(10)", + "1=1 AND BENCHMARK(50000000, ENCODE('key', 'val'))", + # Exec + "EXEC master.dbo.xp_cmdshell 'dir c:';", + # Using comments to break up keywords + "DR/**/OP TABLE users;", + # Using different character encodings or functions + "EXEC(CHAR(100) + CHAR(114) + CHAR(111) + CHAR(112) + ' TABLE users')", # SQL Server 'drop' + ]: + with pytest.raises(ValueError): + validate_where_expr(stmt) + with pytest.raises(ValueError): + validate_where_expr(f"{stmt}; id = 1") + sql = validate_where_expr(f"id = 1; {stmt}") + assert sql == "id = 1" diff --git a/services/app/.env.example b/services/app/.env.example old mode 100644 new mode 100755 index 0f2257e..8d84dec --- a/services/app/.env.example +++ b/services/app/.env.example @@ -1,27 +1,33 @@ # Sveltekit config BODY_SIZE_LIMIT="Infinity" -# Services URLs -JAMAI_URL="http://localhost:6969" +# Services +OWL_URL="http://localhost:6969" PUBLIC_JAMAI_URL="" +PUBLIC_ADMIN_ORGANIZATION_ID="0" -# Playwright test user -TEST_ACC_EMAIL="" -TEST_ACC_PW="" -TEST_ACC_USERID="" +# Auth config +AUTH_SECRET="changeme" # Set to false only if you have the secrets -PUBLIC_IS_LOCAL="true" PUBLIC_IS_SPA="false" # Generate as a single-page application, only works if running locally -BASE_URL="" -JAMAI_SERVICE_KEY="" +OWL_SERVICE_KEY="" +OWL_STRIPE_API_KEY="" +OWL_STRIPE_PUBLISHABLE_KEY_LIVE="" +OWL_STRIPE_PUBLISHABLE_KEY_TEST="" AUTH0_ISSUER_BASE_URL="" AUTH0_CLIENT_ID="" AUTH0_CLIENT_SECRET="" AUTH0_MGMTAPI_CLIENT_ID="" AUTH0_MGMTAPI_CLIENT_SECRET="" AUTH0_SECRET="" -PUBLIC_STRIPE_PUBLISHABLE_KEY="" -STRIPE_SECRET_KEY="" -STRIPE_WEBHOOK_SECRET="" -RESEND_API_KEY="" \ No newline at end of file +RESEND_API_KEY="" + +# Test config +CI_TEST_MODE="false" +TEST_USER_ID="" +TEST_USER_USERNAME="" +TEST_USER_PASSWORD="" +TEST_FREE_PLAN_ID="" +TEST_PRO_PLAN_ID="" +TEST_TEAM_PLAN_ID="" \ No newline at end of file diff --git a/services/app/.eslintignore b/services/app/.eslintignore old mode 100644 new mode 100755 diff --git a/services/app/.eslintrc.cjs b/services/app/.eslintrc.cjs old mode 100644 new mode 100755 diff --git a/services/app/.gitignore b/services/app/.gitignore old mode 100644 new mode 100755 index 9c46f28..4f34e08 --- a/services/app/.gitignore +++ b/services/app/.gitignore @@ -10,4 +10,5 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* *.db -playwright \ No newline at end of file +playwright +.vscode \ No newline at end of file diff --git a/services/app/.npmrc b/services/app/.npmrc old mode 100644 new mode 100755 diff --git a/services/app/.prettierignore b/services/app/.prettierignore old mode 100644 new mode 100755 diff --git a/services/app/.prettierrc b/services/app/.prettierrc old mode 100644 new mode 100755 index 9573023..664a09d --- a/services/app/.prettierrc +++ b/services/app/.prettierrc @@ -3,6 +3,11 @@ "singleQuote": true, "trailingComma": "none", "printWidth": 100, - "plugins": ["prettier-plugin-svelte"], + "tabWidth": 2, + "plugins": [ + "prettier-plugin-svelte", + "prettier-plugin-tailwindcss", + "prettier-plugin-organize-imports" + ], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } diff --git a/services/app/README.md b/services/app/README.md old mode 100644 new mode 100755 diff --git a/services/app/build.bat b/services/app/build.bat old mode 100644 new mode 100755 index 1f9db98..498eaac --- a/services/app/build.bat +++ b/services/app/build.bat @@ -24,7 +24,7 @@ powershell -Command "Get-Content .env | ForEach-Object { $line = $_ -replace '#. echo %BASE_URL% echo %PUBLIC_JAMAI_URL% -echo %JAMAI_URL% +echo %OWL_URL% echo %PUBLIC_IS_SPA% rem Set the flag variable diff --git a/services/app/components.json b/services/app/components.json old mode 100644 new mode 100755 index 865be21..9c615d6 --- a/services/app/components.json +++ b/services/app/components.json @@ -1,14 +1,16 @@ { - "$schema": "https://shadcn-svelte.com/schema.json", - "style": "default", - "tailwind": { - "config": "tailwind.config.js", - "css": "src/app.css", - "baseColor": "neutral" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils" - }, - "typescript": true -} \ No newline at end of file + "$schema": "https://next.shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://tw3.shadcn-svelte.com/registry/default" +} diff --git a/services/app/electron/icons/icon.icns b/services/app/electron/icons/icon.icns old mode 100644 new mode 100755 diff --git a/services/app/electron/icons/icon.ico b/services/app/electron/icons/icon.ico old mode 100644 new mode 100755 diff --git a/services/app/electron/icons/icon.png b/services/app/electron/icons/icon.png old mode 100644 new mode 100755 diff --git a/services/app/electron/main.js b/services/app/electron/main.js old mode 100644 new mode 100755 diff --git a/services/app/forge.config.cjs b/services/app/forge.config.cjs old mode 100644 new mode 100755 diff --git a/services/app/messages/en.json b/services/app/messages/en.json new file mode 100644 index 0000000..0a59ad6 --- /dev/null +++ b/services/app/messages/en.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "sortable_field_name": "Name", + "sortable_field_created_at": "Date created", + "sortable_field_updated_at": "Date modified", + "left_dock": { + "home": "Home", + "project": "Project", + "organization": "Organization", + "docs": "Docs", + "logout": "Log Out", + "upgrade": "Ready for more?
    Upgrade to our premium plan", + "upgrade_btn": "Upgrade Plan", + "show_hide_btn": "Show/hide side navigation bar", + "analytics": "Analytics" + }, + "breadcrumbs": { + "org_btn": "Switch organizations", + "org_placeholder": "Unknown", + "org_create_btn": "Create organization", + "org_default": "Default Organization", + "org_join_btn": "Join organization" + }, + "organization": { + "navigation": { + "general": "General", + "team": "Team", + "secrets": "Secrets", + "billing": "Billing", + "usage": "Usage", + "heading": "Organization" + }, + "team_page": { + "subheading": "Organization Members", + "invite_btn": "Invite people", + "idx": "No.", + "uid": "User ID", + "email": "Email", + "role": "Role", + "created": "Created at", + "useredit_heading": "Edit user role", + "useredit_field_role": "User role", + "useredit_field_role_title": "Select user role" + } + }, + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "confirm_message": "Are you sure?", + "project": { + "heading": "Projects", + "search_placeholder": "Search Project", + "create_btn": "New Project", + "import_btn": "Import Project", + "subheading": "All Projects", + "settings_rename": "Rename project", + "settings_export": "Export project", + "settings_delete": "Delete project", + "updated_at": "Last updated", + "settings_btn": "Project settings", + "edit": { + "heading": "Edit project name", + "field_name": "Project name" + }, + "create": { + "heading": "New project", + "field_name": "Project name" + }, + "delete": { + "heading": "Delete project", + "text_content": "Do you really want to delete project `{project_name}` ? This process cannot be undone.", + "text_confirm": "Enter project {confirm_text} to confirm", + "field_confirm": "Project {confirm_text}" + } + }, + "sortable": { + "name": "Name", + "created_at": "Date created", + "updated_at": "Date modified", + "direction_asc": "Ascending", + "direction_desc": "Descending" + }, + "field_required": "Required", + "add": "Add", + "create": "Create", + "delete": "Delete", + "project_export_confirm": "Export project `{project_name}`?", + "project_export_fail": "Failed to export project" +} diff --git a/services/app/package-lock.json b/services/app/package-lock.json old mode 100644 new mode 100755 index 3db7d75..09262a5 --- a/services/app/package-lock.json +++ b/services/app/package-lock.json @@ -1,24 +1,27 @@ { "name": "jamaibase-app", - "version": "0.2.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jamaibase-app", - "version": "0.2.0", + "version": "0.5.0", "dependencies": { + "@auth/sveltekit": "^1.9.2", "@fontsource-variable/roboto-flex": "^5.0.15", "@formkit/auto-animate": "^0.8.1", - "@stripe/stripe-js": "^3.4.0", + "@monaco-editor/loader": "^1.5.0", + "@stripe/stripe-js": "^3.5.0", "@tailwindcss/container-queries": "^0.1.1", "auth0": "^4.4.0", "axios": "^1.6.8", - "bits-ui": "^0.20.1", "chart.js": "^4.4.3", "chartjs-adapter-moment": "^1.0.1", "clsx": "^2.1.0", "cors": "^2.8.5", + "csvtojson": "^2.0.10", + "date-fns": "^4.1.0", "dexie": "^4.0.10", "dotenv": "^16.4.5", "electron-serve": "^2.0.0", @@ -29,22 +32,30 @@ "lodash": "^4.17.21", "lucide-svelte": "^0.359.0", "minio": "^7.1.3", - "mode-watcher": "^0.3.0", + "minisearch": "^7.1.2", + "monaco-editor": "^0.52.2", "node-cache": "^5.1.2", "nprogress": "^0.2.0", "overlayscrollbars-svelte": "^0.5.1", "papaparse": "^5.4.1", + "pdfjs-dist": "^4.10.38", + "pdfobject": "^2.3.1", "pretty-bytes": "^6.1.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.3", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.41.0", "showdown": "^2.1.0", "showdown-htmlescape": "^0.1.9", - "stripe": "^15.5.0", + "stripe": "^15.12.0", "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" + "zod": "^3.25.67" }, "devDependencies": { "@electron-forge/cli": "^7.4.0", @@ -53,10 +64,13 @@ "@electron-forge/maker-squirrel": "^7.4.0", "@electron-forge/maker-zip": "^7.4.0", "@faker-js/faker": "^8.4.1", + "@inlang/cli": "^3.0.0", + "@inlang/paraglide-js": "2.0.13", + "@lucide/svelte": "^0.482.0", "@playwright/test": "^1.28.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.2", - "@sveltejs/kit": "^2.5.27", + "@sveltejs/kit": "^2.15.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@types/cors": "^2.8.17", "@types/eslint": "^8.56.0", @@ -64,28 +78,38 @@ "@types/lodash": "^4.17.0", "@types/nprogress": "^0.2.3", "@types/papaparse": "^5.3.14", + "@types/pdfobject": "^2.2.5", "@types/showdown": "^2.0.6", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.18", + "bits-ui": "^1.8.0", "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.45.1", + "mode-watcher": "^1.0.7", + "paneforge": "^1.0.0-next.5", "postcss": "^8.4.37", "prettier": "^3.1.1", + "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-svelte": "^3.2.6", + "prettier-plugin-tailwindcss": "^0.6.12", "run-script-os": "^1.1.6", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", + "sveltekit-superforms": "^2.27.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "tslib": "^2.4.1", "typescript": "^5.5.0", + "vaul-svelte": "^1.0.0-next.7", "vite": "^5.4.4", + "vite-plugin-devtools-json": "^0.4.1", "vitest": "^1.2.0" } }, @@ -121,13 +145,96 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", - "integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", + "node_modules/@ark/schema": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.46.0.tgz", + "integrity": "sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/util": "0.46.0" + } + }, + "node_modules/@ark/util": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.46.0.tgz", + "integrity": "sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@auth/core": { + "version": "0.39.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.39.1.tgz", + "integrity": "sha512-McD8slui0oOA1pjR5sPjLPl5Zm//nLP/8T3kr8hxIsvNLvsiudYvPHhDFPjh1KcZ2nFxCkZmP6bRxaaPd/AnLA==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/sveltekit": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@auth/sveltekit/-/sveltekit-1.9.2.tgz", + "integrity": "sha512-EqwLS6kFyhtDQ4HfLEeIROIK/rIRhdSosnofI6XT4woXKtctBt4UeKTcoBKumXwR7u04/lnB6rHoXuG1nnD52A==", + "license": "ISC", "dependencies": { - "regenerator-runtime": "^0.14.0" + "@auth/core": "0.39.1", + "set-cookie-parser": "^2.7.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.3", + "@sveltejs/kit": "^1.0.0 || ^2.0.0", + "nodemailer": "^6.6.5", + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-0" }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -1752,7 +1859,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -1768,7 +1874,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -1784,7 +1889,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -1800,7 +1904,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -1816,7 +1919,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -1832,7 +1934,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -1848,7 +1949,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -1864,7 +1964,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -1880,7 +1979,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1896,7 +1994,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1912,7 +2009,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1928,7 +2024,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1944,7 +2039,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1960,7 +2054,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1976,7 +2069,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1992,7 +2084,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2008,7 +2099,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2024,7 +2114,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -2040,7 +2129,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -2056,7 +2144,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -2072,7 +2159,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2088,7 +2174,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2104,7 +2189,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2191,6 +2275,14 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@faker-js/faker": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", @@ -2208,26 +2300,32 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", - "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", + "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", + "dev": true, + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.8" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.12", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", - "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", + "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.8" + "@floating-ui/core": "^1.7.1", + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "dev": true, + "license": "MIT" }, "node_modules/@fontsource-variable/roboto-flex": { "version": "5.0.15", @@ -2246,15 +2344,50 @@ "dev": true, "license": "MIT" }, + "node_modules/@gcornut/valibot-json-schema": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@gcornut/valibot-json-schema/-/valibot-json-schema-0.42.0.tgz", + "integrity": "sha512-4Et4AN6wmqeA0PfU5Clkv/IS27wiefsWf6TemAZrb75uzkClYEFavim7SboeKwbll9Nbsn2Iv0LT/HS5H7orZg==", + "dev": true, + "optional": true, + "dependencies": { + "valibot": "~0.42.0" + }, + "bin": { + "valibot-json-schema": "bin/index.js" + }, + "optionalDependencies": { + "@types/json-schema": ">= 7.0.14", + "esbuild-runner": ">= 2.2.2" + } + }, + "node_modules/@gcornut/valibot-json-schema/node_modules/valibot": { + "version": "0.42.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz", + "integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -2314,10 +2447,106 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@inlang/cli": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inlang/cli/-/cli-3.0.11.tgz", + "integrity": "sha512-JGyDrB7Jy0GRT6Z3QdenoJdxq+2Hob4pm4+wjrUa/bhXCTWAG+vbL+irP6OOS4EO+X8upn94NC39hbC0+72cHg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inlang/sdk": "2.4.8", + "esbuild-wasm": "^0.19.2" + }, + "bin": { + "inlang": "bin/run.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@inlang/paraglide-js": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.0.13.tgz", + "integrity": "sha512-8tccsLzGa9uw0rufFqbHSM6GDF8+X1BgfBOyjG7PweBF2zGhN5fMu/nVNbsZiVKpXyR7lcfMxajIBwKhZ/zGKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inlang/recommend-sherlock": "0.2.1", + "@inlang/sdk": "2.4.8", + "commander": "11.1.0", + "consola": "3.4.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0" + }, + "bin": { + "paraglide-js": "bin/run.js" + } + }, + "node_modules/@inlang/paraglide-js/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.8.tgz", + "integrity": "sha512-tyXNe/5+1Vn/eDt3mVklVjZh5qxFwqdF9+hdB6wRUCexVRw6w/w854TIRFrHuaAwFq/0N/ij/yXzll9oScAB+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.4.7", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@inlang/sdk/node_modules/@sinclair/typebox": { + "version": "0.31.28", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", + "integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inlang/sdk/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@internationalized/date": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.4.tgz", - "integrity": "sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.1.tgz", + "integrity": "sha512-PgVE6B6eIZtzf9Gu5HvJxRK3ufUFz9DhspELuhW/N0GuMGMTLvPQNRkHP2hTuP9lblOk+f+1xi96sPiPXANXAA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2368,6 +2597,7 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, + "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2423,6 +2653,56 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" }, + "node_modules/@lix-js/sdk": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", + "integrity": "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@lix-js/server-protocol-schema": "0.1.1", + "dedent": "1.5.1", + "human-id": "^4.1.1", + "js-sha256": "^0.11.0", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@lix-js/sdk/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@lix-js/server-protocol-schema": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", + "integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@lucide/svelte": { + "version": "0.482.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.482.0.tgz", + "integrity": "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "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", @@ -2447,65 +2727,259 @@ "node": ">= 10" } }, - "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==", + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", "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==", - "engines": { - "node": ">= 8" + "state-local": "^1.0.6" } }, - "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==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz", + "integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], "engines": { - "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" + "node": ">= 10" }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.73", + "@napi-rs/canvas-darwin-arm64": "0.1.73", + "@napi-rs/canvas-darwin-x64": "0.1.73", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.73", + "@napi-rs/canvas-linux-arm64-musl": "0.1.73", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.73", + "@napi-rs/canvas-linux-x64-gnu": "0.1.73", + "@napi-rs/canvas-linux-x64-musl": "0.1.73", + "@napi-rs/canvas-win32-x64-msvc": "0.1.73" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz", + "integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">= 10" } }, - "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, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz", + "integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz", + "integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz", + "integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz", + "integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz", + "integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz", + "integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz", + "integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz", + "integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz", + "integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "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==", + "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==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "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": { @@ -2525,10 +2999,20 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", + "license": "MIT", "engines": { "node": ">=10.13.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2554,24 +3038,36 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.25", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", - "dev": true + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@poppinss/macroable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.4.tgz", + "integrity": "sha512-ct43jurbe7lsUX5eIrj4ijO3j/6zIPp7CDnFWXDs7UPAbw1Pu1iH3oAmFdP4jcskKJBURH5M9oTtyeiUXyHX8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.16.0" + } }, "node_modules/@rollup/plugin-commonjs": { - "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==", + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", + "integrity": "sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==", "dev": true, + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", - "fdir": "^6.1.1", + "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=16.0.0 || 14 >= 14.17" @@ -2585,33 +3081,16 @@ } } }, - "node_modules/@rollup/plugin-commonjs/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/@rollup/plugin-commonjs/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, + "license": "MIT", "dependencies": { "@types/estree": "*" } }, - "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, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", @@ -2633,10 +3112,11 @@ } }, "node_modules/@rollup/plugin-node-resolve": { - "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==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", + "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", "dev": true, + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", @@ -2678,12 +3158,6 @@ } } }, - "node_modules/@rollup/pluginutils/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/@rollup/pluginutils/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2703,7 +3177,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -2716,7 +3189,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2729,7 +3201,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2742,7 +3213,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2755,7 +3225,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2768,7 +3237,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2781,7 +3249,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2794,7 +3261,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2807,7 +3273,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2820,7 +3285,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2833,7 +3297,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2846,7 +3309,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2859,7 +3321,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2872,7 +3333,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2885,7 +3345,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2898,7 +3357,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2908,6 +3366,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" } @@ -2915,18 +3374,21 @@ "node_modules/@sideway/formula": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" }, "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 + "dev": true, + "license": "MIT" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", @@ -2939,23 +3401,52 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.48.0-build4", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz", + "integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "sqlite-wasm": "bin/index.js" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@stripe/stripe-js": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.4.0.tgz", - "integrity": "sha512-a2kUP7OrsV0SSIk3UxWa+cnrW+PPIyuCbWIBH8vxfHIqmyeQN/d0lsplZJ2h7MlLsU/sB3EyhNBkhLLT+zHwKw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz", + "integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==", + "license": "MIT", "engines": { "node": ">=12.16" } }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@sveltejs/adapter-node": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.5.tgz", - "integrity": "sha512-FVeysFqeIlKFpDF1Oj38gby34f6uA9FuXnV330Z0RHmSyOR9JzJs70/nFKy1Ue3fWtf7S0RemOrP66Vr9Jcmew==", + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.12.tgz", + "integrity": "sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==", "dev": true, + "license": "MIT", "dependencies": { - "@rollup/plugin-commonjs": "^28.0.0", + "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { @@ -2963,33 +3454,34 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", - "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", + "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", "dev": true, + "license": "MIT", "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/kit": { - "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, + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.21.3.tgz", + "integrity": "sha512-Bd05srNOaqP05qnytjg/KkWNlkcwEpE76s0xGSlgzL4I8pLyrK3c9+a7zMCquoiEEIZF2ecGTn6Fj/lELjaa8A==", + "license": "MIT", "dependencies": { + "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", - "esm-env": "^1.0.0", - "import-meta-resolve": "^4.1.0", + "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", - "tiny-glob": "^0.2.9" + "sirv": "^3.0.0", + "vitefu": "^1.0.6" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -2998,16 +3490,15 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" + "vite": "^5.0.3 || ^6.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", - "dev": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", @@ -3028,7 +3519,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", - "dev": true, "dependencies": { "debug": "^4.3.7" }, @@ -3042,11 +3532,13 @@ } }, "node_modules/@swc/helpers": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.11.tgz", - "integrity": "sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@szmarczak/http-timer": { @@ -3111,8 +3603,7 @@ "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, "node_modules/@types/cors": { "version": "2.8.17", @@ -3254,6 +3745,13 @@ "@types/node": "*" } }, + "node_modules/@types/pdfobject": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/pdfobject/-/pdfobject-2.2.5.tgz", + "integrity": "sha512-7gD5tqc/RUDq0PyoLemL0vEHxBYi+zY0WVaFAx/Y0jBsXFgot1vB9No1GhDZGwRGJMCIZbgAb74QG9MTyTNU/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -3270,7 +3768,8 @@ "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/responselike": { "version": "1.0.3", @@ -3319,6 +3818,14 @@ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true }, + "node_modules/@types/validator": { + "version": "13.15.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", + "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3330,6 +3837,41 @@ "@types/node": "*" } }, + "node_modules/@typeschema/class-validator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", + "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@typeschema/core": "0.14.0" + }, + "peerDependencies": { + "class-validator": "^0.14.1" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, + "node_modules/@typeschema/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", + "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + }, + "peerDependenciesMeta": { + "@types/json-schema": { + "optional": true + } + } + }, "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", @@ -3526,14 +4068,75 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", + "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", + "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@types/validator": "^13.12.2", + "@vinejs/compiler": "^3.0.0", + "camelcase": "^8.0.0", + "dayjs": "^1.11.13", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, + "node_modules/@vinejs/vine/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, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vinejs/vine/node_modules/normalize-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", + "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@vitest/expect": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz", - "integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", "chai": "^4.3.10" }, "funding": { @@ -3541,12 +4144,13 @@ } }, "node_modules/@vitest/runner": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz", - "integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "1.4.0", + "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -3559,6 +4163,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^1.0.0" }, @@ -3570,10 +4175,11 @@ } }, "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.20" }, @@ -3582,10 +4188,11 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz", - "integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", "dev": true, + "license": "MIT", "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", @@ -3596,10 +4203,11 @@ } }, "node_modules/@vitest/spy": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz", - "integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^2.2.0" }, @@ -3608,10 +4216,11 @@ } }, "node_modules/@vitest/utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", "dev": true, + "license": "MIT", "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", @@ -3622,6 +4231,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -3658,9 +4277,10 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3677,14 +4297,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-typescript": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", - "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", - "peerDependencies": { - "acorn": ">=8.9.0" - } - }, "node_modules/acorn-walk": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", @@ -3866,9 +4478,9 @@ "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==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "optional": true, @@ -4052,11 +4664,30 @@ "node": ">= 0.4" } }, + "node_modules/arktype": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.20.tgz", + "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.46.0", + "@ark/util": "0.46.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -4132,6 +4763,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true, + "license": "MIT", "engines": { "node": "*" } @@ -4157,26 +4789,24 @@ } }, "node_modules/auth0": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.4.0.tgz", - "integrity": "sha512-umlAogwQDUYvL1Pd4RnViyps7lkvntZ3+VVDW+/4ML7/GzkJcq6VGJ20Nb60eYosQkxa7up1n9lrA4Of+BZsUg==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.27.0.tgz", + "integrity": "sha512-4FGgjzKCH/f7rQLQVR5dM30asjOObeW3PyHa8bQrS4rKkuv22JoNxox26fb1FZ3hI4zEgbVbPm9x7pHrljZzrw==", "license": "MIT", "dependencies": { "jose": "^4.13.2", + "undici-types": "^6.15.0", "uuid": "^9.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/auth0/node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } + "node_modules/auth0/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" }, "node_modules/author-regex": { "version": "1.0.0", @@ -4240,9 +4870,10 @@ } }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -4298,6 +4929,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -4314,49 +4946,30 @@ } }, "node_modules/bits-ui": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.20.1.tgz", - "integrity": "sha512-P0JRuWn+XpFYsAbGnPlyPVKab88v2S8Q57cUI3LZdh0nulO7fgxbXgBHgEAmmgNk63XxyvhmfYz44kZFRrHtLA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.8.0.tgz", + "integrity": "sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg==", + "dev": true, + "license": "MIT", "dependencies": { - "@internationalized/date": "^3.5.1", - "@melt-ui/svelte": "0.76.2", - "nanoid": "^5.0.5" + "@floating-ui/core": "^1.6.4", + "@floating-ui/dom": "^1.6.7", + "@internationalized/date": "^3.5.6", + "css.escape": "^1.5.1", + "esm-env": "^1.1.2", + "runed": "^0.23.2", + "svelte-toolbelt": "^0.7.1", + "tabbable": "^6.2.0" }, - "peerDependencies": { - "svelte": "^4.0.0" - } - }, - "node_modules/bits-ui/node_modules/@melt-ui/svelte": { - "version": "0.76.2", - "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", - "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==", - "dependencies": { - "@floating-ui/core": "^1.3.1", - "@floating-ui/dom": "^1.4.5", - "@internationalized/date": "^3.5.0", - "dequal": "^2.0.3", - "focus-trap": "^7.5.2", - "nanoid": "^5.0.4" + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" }, - "peerDependencies": { - "svelte": ">=3 <5" - } - }, - "node_modules/bits-ui/node_modules/nanoid": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz", - "integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.js" + "funding": { + "url": "https://github.com/sponsors/huntabyte" }, - "engines": { - "node": "^18 || >=20" + "peerDependencies": { + "svelte": "^5.11.0" } }, "node_modules/bl": { @@ -4383,7 +4996,6 @@ "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": { @@ -4550,6 +5162,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4757,10 +5370,11 @@ ] }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -4768,7 +5382,7 @@ "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" @@ -4815,6 +5429,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", "dev": true, + "license": "MIT", "dependencies": { "get-func-name": "^2.0.2" }, @@ -4884,6 +5499,19 @@ "license": "MIT", "optional": true }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -5092,11 +5720,29 @@ "node": ">= 6" } }, + "node_modules/comment-json": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">= 6" + } + }, "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 + "dev": true, + "license": "MIT" }, "node_modules/compare-version": { "version": "0.1.2", @@ -5155,6 +5801,23 @@ "node": ">=12" } }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/concurrently/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5243,6 +5906,16 @@ "node": ">=12" } }, + "node_modules/consola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -5273,7 +5946,6 @@ "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" } @@ -5283,6 +5955,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "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, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -5321,9 +6000,10 @@ } }, "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==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5357,6 +6037,13 @@ "node": ">=12.10" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5368,22 +6055,53 @@ "node": ">=4" } }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, + "node_modules/csvtojson": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/csvtojson/-/csvtojson-2.0.10.tgz", + "integrity": "sha512-lUWFxGKyhraKCW8Qghz6Z0f2l/PqB1W3AO0HKJzGIQ5JRSlR651ekJDiGJbBT4sRNNv5ddnSGVEnsxP9XRCVpQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.21.0" + "bluebird": "^3.5.1", + "lodash": "^4.17.3", + "strip-bom": "^2.0.0" + }, + "bin": { + "csvtojson": "bin/csvtojson" }, "engines": { - "node": ">=0.11" + "node": ">=4.0.0" + } + }, + "node_modules/csvtojson/node_modules/strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.0" }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -5441,11 +6159,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, + "license": "MIT", "dependencies": { "type-detect": "^4.0.0" }, @@ -5463,7 +6197,6 @@ "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" } @@ -5557,14 +6290,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5595,8 +6320,7 @@ "node_modules/devalue": { "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 + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" }, "node_modules/dexie": { "version": "4.0.10", @@ -5614,6 +6338,7 @@ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -5681,6 +6406,18 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/effect": { + "version": "3.16.9", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.9.tgz", + "integrity": "sha512-onKn21L/Us3G/x4BeUxiE4B/jNiJ09uRcYEfSYVPJE10dTUM3aDdO3g15PW6ccF1BJuOtQt1cxx4/1lACwX/bA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron": { "version": "31.0.1", "resolved": "https://registry.npmjs.org/electron/-/electron-31.0.1.tgz", @@ -6127,7 +6864,6 @@ "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": { "esbuild": "bin/esbuild" @@ -6161,6 +6897,45 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "license": "Apache License 2.0", + "optional": true, + "dependencies": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "bin": { + "esr": "bin/esr.js" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/esbuild-runner/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/esbuild-wasm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.19.12.tgz", + "integrity": "sha512-Zmc4hk6FibJZBcTx5/8K/4jT3/oG1vkGTEeKJUQFCUQKimD6Q7+adp/bdVQyYJFolMKaXkQnVZdV4O5ZaTYmyQ==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -6354,9 +7129,10 @@ } }, "node_modules/esm-env": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", - "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" }, "node_modules/espree": { "version": "9.6.1", @@ -6375,6 +7151,20 @@ "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, + "license": "BSD-2-Clause", + "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", @@ -6388,9 +7178,10 @@ } }, "node_modules/esrap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.3.2.tgz", - "integrity": "sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", + "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -6417,13 +7208,11 @@ } }, "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "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, - "dependencies": { - "@types/estree": "^1.0.0" - } + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", @@ -6493,9 +7282,10 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6516,7 +7306,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -6531,21 +7321,26 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-openid-connect": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/express-openid-connect/-/express-openid-connect-2.17.1.tgz", - "integrity": "sha512-5pVK6PNV09x6UN29R9Mer0XF3hwQq2HxiFsjZvLuIQ9ezeTUGbqrefzBOpzciz1S/1WWVaVPDIcj4EBpD8WB3Q==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/express-openid-connect/-/express-openid-connect-2.18.1.tgz", + "integrity": "sha512-trHqgwXxWF0n/XrDsRzsvQtnBNbU03iCNXbKR/sHwBqXlvCgup341bW7B8t6nr3L/CMoDpK+9gsTnx3qLCqdjQ==", + "license": "MIT", "dependencies": { "base64url": "^3.0.1", "clone": "^2.1.2", - "cookie": "^0.5.0", + "cookie": "^0.7.1", "debug": "^4.3.4", "futoin-hkdf": "^1.5.1", "http-errors": "^1.8.1", "joi": "^17.7.0", - "jose": "^2.0.6", + "jose": "^2.0.7", "on-headers": "^1.0.2", "openid-client": "^4.9.1", "url-join": "^4.0.1" @@ -6558,9 +7353,10 @@ } }, "node_modules/express-openid-connect/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6569,6 +7365,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6577,6 +7374,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", @@ -6588,10 +7386,26 @@ "node": ">= 0.6" } }, + "node_modules/express-openid-connect/node_modules/jose": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", + "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", + "license": "MIT", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/express-openid-connect/node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6662,6 +7476,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.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", @@ -6955,14 +7793,6 @@ "imul": "^1.0.0" } }, - "node_modules/focus-trap": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", - "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", - "dependencies": { - "tabbable": "^6.2.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -7153,6 +7983,7 @@ "version": "1.5.3", "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz", "integrity": "sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==", + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -7319,6 +8150,7 @@ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } @@ -7546,12 +8378,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -7572,12 +8398,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -7625,11 +8445,21 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "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==", + "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/has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7781,6 +8611,16 @@ "node": ">= 6" } }, + "node_modules/human-id": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz", + "integrity": "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -7871,16 +8711,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-meta-resolve": { - "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", @@ -7938,6 +8768,13 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -8087,7 +8924,8 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/is-my-ip-valid": { "version": "1.0.1", @@ -8184,6 +9022,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "license": "MIT" + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -8225,9 +9069,10 @@ } }, "node_modules/joi": { - "version": "17.13.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", - "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -8237,19 +9082,21 @@ } }, "node_modules/jose": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", - "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" - }, + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", @@ -8280,6 +9127,21 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8305,6 +9167,19 @@ "license": "ISC", "optional": 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, + "license": "MIT", + "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", @@ -8354,7 +9229,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, "engines": { "node": ">=6" } @@ -8365,6 +9239,16 @@ "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", "dev": true }, + "node_modules/kysely": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8378,6 +9262,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz", + "integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -8653,6 +9545,7 @@ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", "dev": true, + "license": "MIT", "dependencies": { "get-func-name": "^2.0.1" } @@ -8669,6 +9562,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -8710,7 +9604,8 @@ "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==" + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" }, "node_modules/make-fetch-happen": { "version": "10.2.1", @@ -8823,6 +9718,13 @@ "node": ">=6" } }, + "node_modules/memoize-weak": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", + "integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -9117,6 +10019,12 @@ "node": ">=8" } }, + "node_modules/minisearch": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.2.tgz", + "integrity": "sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA==", + "license": "MIT" + }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", @@ -9170,11 +10078,33 @@ } }, "node_modules/mode-watcher": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-0.3.0.tgz", - "integrity": "sha512-k8jjuTx94HaaRKWO6JDf8wL761hFatrTIHJKl+E+3JWcnv+GnMBH062zcLsy0lbCI3n7RZxxHaWi66auFnUO4g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.0.7.tgz", + "integrity": "sha512-ZGA7ZGdOvBJeTQkzdBOnXSgTkO6U6iIFWJoyGCTt6oHNg9XP9NBvS26De+V4W2aqI+B0yYXUskFG2VnEo3zyMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.27.0" + } + }, + "node_modules/mode-watcher/node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, "peerDependencies": { - "svelte": "^4.0.0" + "svelte": "^5.7.0" } }, "node_modules/moment": { @@ -9186,20 +10116,25 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, "engines": { "node": ">=4" } }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", "engines": { "node": ">=10" } @@ -9241,15 +10176,16 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -9488,6 +10424,15 @@ "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==" }, + "node_modules/oauth4webapi": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.2.tgz", + "integrity": "sha512-VYz5BaP3izIrUc1GAVzIoz4JnljiW0YAUFObMBwsqDnfHxz2sjLu3W7/8vE8Ms9IbMewN9+1kcvhY3tMscAeGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9524,9 +10469,10 @@ } }, "node_modules/oidc-token-hash": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" } @@ -9546,6 +10492,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -9577,6 +10524,7 @@ "version": "4.9.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.9.1.tgz", "integrity": "sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==", + "license": "MIT", "dependencies": { "aggregate-error": "^3.1.0", "got": "^11.8.0", @@ -9593,10 +10541,26 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", + "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", + "license": "MIT", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/openid-client/node_modules/object-hash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", "engines": { "node": ">= 6" } @@ -9702,6 +10666,12 @@ "dev": true, "license": "ISC" }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/overlayscrollbars": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.6.1.tgz", @@ -9809,6 +10779,20 @@ "node": ">=6" } }, + "node_modules/paneforge": { + "version": "1.0.0-next.5", + "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-1.0.0-next.5.tgz", + "integrity": "sha512-1ArDM+GMEO+o6pixEAFobhTkWkyxUDdHyw2bKruvQIXBStJmdRP7HoV4jNBZ/2i9UHDzmczxJzA3D2tKa91phw==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.23.4", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.20.0" + } + }, "node_modules/papaparse": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", @@ -9944,9 +10928,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -9968,10 +10953,29 @@ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, + "node_modules/pdfjs-dist": { + "version": "4.10.38", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", + "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.65" + } + }, + "node_modules/pdfobject": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pdfobject/-/pdfobject-2.3.1.tgz", + "integrity": "sha512-vluuGiSDmMGpOvWFGiUY4trNB8aGKLDVxIXuuGHjX0kK3bMxCANUVtLivctE7uejLBScWCnbVarKatFVvdwXaQ==", + "license": "MIT" + }, "node_modules/pe-library": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", @@ -10004,8 +11008,6 @@ "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": ">=12" }, @@ -10365,6 +11367,25 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10389,6 +11410,23 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, "node_modules/prettier-plugin-svelte": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.3.2.tgz", @@ -10399,6 +11437,85 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.12.tgz", + "integrity": "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -10415,6 +11532,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -10429,6 +11547,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -10467,6 +11586,87 @@ "node": ">=10" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-history": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", + "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz", + "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", + "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.0.tgz", + "integrity": "sha512-FatMIIl0vRHMcNc3sPy3cMw5MMyWuO1nWQxqvYpJvXAruucGvmQ2tyyjT2/Lbok77T9a/qZqBVCq4sj43V2ihw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10510,6 +11710,24 @@ "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" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -10606,10 +11824,11 @@ } }, "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 + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, "node_modules/read-binary-file-arch": { "version": "1.0.6", @@ -10795,18 +12014,12 @@ "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" } @@ -11025,7 +12238,6 @@ "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.6" }, @@ -11056,6 +12268,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11088,6 +12306,22 @@ "run-script-os": "index.js" } }, + "node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -11101,7 +12335,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, "dependencies": { "mri": "^1.1.0" }, @@ -11253,10 +12486,10 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -11390,17 +12623,17 @@ } }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" + "node": ">=18" } }, "node_modules/slash": { @@ -11539,6 +12772,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlite-wasm-kysely": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz", + "integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==", + "dev": true, + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.48.0-build2" + }, + "peerDependencies": { + "kysely": "*" + } + }, "node_modules/ssri": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", @@ -11571,6 +12816,12 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -11775,9 +13026,10 @@ } }, "node_modules/stripe": { - "version": "15.5.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.5.0.tgz", - "integrity": "sha512-c04ToET4ZUzoeSh2rWarXCPNa2+6YzkwNAcWaT4axYRlN/u1XMkz9+inouNsXWjeT6ttBrp1twz10x/sCbWLpQ==", + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.12.0.tgz", + "integrity": "sha512-slTbYS1WhRJXVB8YXU8fgHizkUrM9KJyrw4Dd8pLEwzKHYyQTIE46EePC2MVbSDZdE24o1GdNtzmJV4PrPpmJA==", + "license": "MIT", "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" @@ -11791,6 +13043,16 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -11853,6 +13115,17 @@ "node": ">= 8.0" } }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11877,20 +13150,21 @@ } }, "node_modules/svelte": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.16.5.tgz", - "integrity": "sha512-zTG45crJUGjNYQgmQ0YDxFJ7ge1O6ZwevPxGgGOxuMOXOQhcH9LC9GEx2JS9/BlkhxdsO8ETofQ76ouFwDVpCQ==", + "version": "5.33.14", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.14.tgz", + "integrity": "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA==", + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", - "acorn-typescript": "^1.4.13", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", - "esrap": "^1.3.2", + "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -11990,17 +13264,147 @@ } }, "node_modules/svelte-sonner": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.24.tgz", - "integrity": "sha512-txuL0JBUs0v6qGrr0PGCsbXmKHuthdrAkfISYi8umuveF7+gINb6EXl6VmKY9aHhyxCqvVgqd6yophQNrnor4w==", + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", + "integrity": "sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/sveltekit-superforms": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.27.0.tgz", + "integrity": "sha512-FXIdUg4VRVZeAdVH/zB7JtHvuoC6RmHDw032meEasqB5v+i1ud4pwU/Big+6eJ2SysqrzCahBbCvLN2qzRPVUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ciscoheat" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/ciscoheat" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" + } + ], + "license": "MIT", + "dependencies": { + "devalue": "^5.1.1", + "memoize-weak": "^1.0.2", + "ts-deepmerge": "^7.0.3" + }, + "optionalDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@gcornut/valibot-json-schema": "^0.42.0", + "@sinclair/typebox": "^0.34.35", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^3.0.1", + "arktype": "^2.1.20", + "class-validator": "^0.14.2", + "effect": "^3.16.7", + "joi": "^17.13.3", + "json-schema-to-ts": "^3.1.1", + "superstruct": "^2.0.2", + "valibot": "^1.1.0", + "yup": "^1.6.1", + "zod": "^3.25.64", + "zod-to-json-schema": "^3.24.5" + }, "peerDependencies": { - "svelte": ">=3 <5" + "@exodus/schemasafe": "^1.3.0", + "@sinclair/typebox": "^0.34.28", + "@sveltejs/kit": "1.x || 2.x", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", + "arktype": ">=2.0.0-rc.23", + "class-validator": "^0.14.1", + "effect": "^3.13.7", + "joi": "^17.13.1", + "superstruct": "^2.0.2", + "svelte": "3.x || 4.x || >=5.0.0-next.51", + "valibot": "^1.0.0", + "yup": "^1.4.0", + "zod": "^3.25.0" + }, + "peerDependenciesMeta": { + "@exodus/schemasafe": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "@typeschema/class-validator": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "arktype": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "effect": { + "optional": true + }, + "joi": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } } }, + "node_modules/sveltekit-superforms/node_modules/@sinclair/typebox": { + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true, + "license": "MIT" }, "node_modules/tailwind-merge": { "version": "2.2.2", @@ -12070,6 +13474,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", "dev": true, + "license": "MIT", "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } @@ -12234,6 +13639,14 @@ "readable-stream": "3" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tiny-each-async": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", @@ -12242,16 +13655,6 @@ "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", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tinybench": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", @@ -12259,10 +13662,11 @@ "dev": true }, "node_modules/tinypool": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", - "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -12272,6 +13676,7 @@ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -12339,11 +13744,19 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -12387,6 +13800,14 @@ "node": ">=0.8.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -12399,15 +13820,27 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-deepmerge": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz", + "integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -12422,10 +13855,11 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -12474,9 +13908,10 @@ "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==", + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", "engines": { "node": ">=18.17" } @@ -12541,6 +13976,21 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz", + "integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.1", + "picomatch": "^4.0.2", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.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", @@ -12583,7 +14033,15 @@ "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" }, "node_modules/username": { "version": "5.1.0", @@ -12600,9 +14058,9 @@ } }, "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==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { @@ -12771,6 +14229,22 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "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", @@ -12782,6 +14256,17 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12790,11 +14275,29 @@ "node": ">= 0.8" } }, - "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "node_modules/vaul-svelte": { + "version": "1.0.0-next.7", + "resolved": "https://registry.npmjs.org/vaul-svelte/-/vaul-svelte-1.0.0-next.7.tgz", + "integrity": "sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg==", "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.23.2", + "svelte-toolbelt": "^0.7.1" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12850,10 +14353,11 @@ } }, "node_modules/vite-node": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz", - "integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", @@ -12871,11 +14375,37 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-devtools-json": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-0.4.1.tgz", + "integrity": "sha512-pN+QJL+NwZUV+Via8w/Sh6X2pDrVClIMDAXdl7+EteXKB6mcHhsFGGclmxrPx6ZPGKSK5ez5ns64oRpjE5wFCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "uuid": "^11.1.0" + }, + "peerDependencies": { + "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vite-plugin-devtools-json/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite/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": [ @@ -12886,10 +14416,10 @@ } }, "node_modules/vitefu": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.5.tgz", - "integrity": "sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA==", - "dev": true, + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", + "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "license": "MIT", "workspaces": [ "tests/deps/*", "tests/projects/*" @@ -12904,16 +14434,17 @@ } }, "node_modules/vitest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz", - "integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/expect": "1.4.0", - "@vitest/runner": "1.4.0", - "@vitest/snapshot": "1.4.0", - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -12925,9 +14456,9 @@ "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.2", + "tinypool": "^0.8.3", "vite": "^5.0.0", - "vite-node": "1.4.0", + "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "bin": { @@ -12942,8 +14473,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, @@ -12968,6 +14499,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -12996,6 +14533,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -13397,9 +14941,9 @@ } }, "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==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "license": "MIT", "dependencies": { @@ -13492,18 +15036,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==" }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "dev": true, + "license": "ISC", + "optional": true, + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/services/app/package.json b/services/app/package.json old mode 100644 new mode 100755 index 9312519..a885fc2 --- a/services/app/package.json +++ b/services/app/package.json @@ -1,6 +1,6 @@ { "name": "jamaibase-app", - "version": "0.2.0", + "version": "0.5.0", "private": true, "main": "electron/main.js", "author": "EmbeddedLLM", @@ -15,7 +15,7 @@ "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", + "devstart": "cross-env ORIGIN=http://localhost:4173 HOST=localhost FRONTEND_PORT=4173 NODE_ENV=development node server", "test": "npm run test:integration && npm run test:unit", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -23,7 +23,8 @@ "format": "prettier --write .", "test:integration": "playwright test", "test:unit": "vitest", - "start:debug_electron": "electron-forge start --inspect-electron" + "start:debug_electron": "electron-forge start --inspect-electron", + "machine-translate": "inlang machine translate --project project.inlang" }, "devDependencies": { "@electron-forge/cli": "^7.4.0", @@ -32,10 +33,13 @@ "@electron-forge/maker-squirrel": "^7.4.0", "@electron-forge/maker-zip": "^7.4.0", "@faker-js/faker": "^8.4.1", + "@inlang/cli": "^3.0.0", + "@inlang/paraglide-js": "2.0.13", + "@lucide/svelte": "^0.482.0", "@playwright/test": "^1.28.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.2", - "@sveltejs/kit": "^2.5.27", + "@sveltejs/kit": "^2.15.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@types/cors": "^2.8.17", "@types/eslint": "^8.56.0", @@ -43,43 +47,56 @@ "@types/lodash": "^4.17.0", "@types/nprogress": "^0.2.3", "@types/papaparse": "^5.3.14", + "@types/pdfobject": "^2.2.5", "@types/showdown": "^2.0.6", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.18", + "bits-ui": "^1.8.0", "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.45.1", + "mode-watcher": "^1.0.7", + "paneforge": "^1.0.0-next.5", "postcss": "^8.4.37", "prettier": "^3.1.1", + "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-svelte": "^3.2.6", + "prettier-plugin-tailwindcss": "^0.6.12", "run-script-os": "^1.1.6", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "svelte-sonner": "^0.3.28", + "sveltekit-superforms": "^2.27.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "tslib": "^2.4.1", "typescript": "^5.5.0", + "vaul-svelte": "^1.0.0-next.7", "vite": "^5.4.4", + "vite-plugin-devtools-json": "^0.4.1", "vitest": "^1.2.0" }, "type": "module", "dependencies": { + "@auth/sveltekit": "^1.9.2", "@fontsource-variable/roboto-flex": "^5.0.15", "@formkit/auto-animate": "^0.8.1", - "@stripe/stripe-js": "^3.4.0", + "@monaco-editor/loader": "^1.5.0", + "@stripe/stripe-js": "^3.5.0", "@tailwindcss/container-queries": "^0.1.1", "auth0": "^4.4.0", "axios": "^1.6.8", - "bits-ui": "^0.20.1", "chart.js": "^4.4.3", "chartjs-adapter-moment": "^1.0.1", "clsx": "^2.1.0", "cors": "^2.8.5", + "csvtojson": "^2.0.10", + "date-fns": "^4.1.0", "dexie": "^4.0.10", "dotenv": "^16.4.5", "electron-serve": "^2.0.0", @@ -90,21 +107,29 @@ "lodash": "^4.17.21", "lucide-svelte": "^0.359.0", "minio": "^7.1.3", - "mode-watcher": "^0.3.0", + "minisearch": "^7.1.2", + "monaco-editor": "^0.52.2", "node-cache": "^5.1.2", "nprogress": "^0.2.0", "overlayscrollbars-svelte": "^0.5.1", "papaparse": "^5.4.1", + "pdfjs-dist": "^4.10.38", + "pdfobject": "^2.3.1", "pretty-bytes": "^6.1.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.3", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.41.0", "showdown": "^2.1.0", "showdown-htmlescape": "^0.1.9", - "stripe": "^15.5.0", + "stripe": "^15.12.0", "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" + "zod": "^3.25.67" } } diff --git a/services/app/playwright.config.ts b/services/app/playwright.config.ts old mode 100644 new mode 100755 diff --git a/services/app/postcss.config.js b/services/app/postcss.config.js old mode 100644 new mode 100755 diff --git a/services/app/project.inlang/.gitignore b/services/app/project.inlang/.gitignore new file mode 100644 index 0000000..5e46596 --- /dev/null +++ b/services/app/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/services/app/project.inlang/project_id b/services/app/project.inlang/project_id new file mode 100644 index 0000000..47e2976 --- /dev/null +++ b/services/app/project.inlang/project_id @@ -0,0 +1 @@ +7kdRYkHy8FwuNcDFad \ No newline at end of file diff --git a/services/app/project.inlang/settings.json b/services/app/project.inlang/settings.json new file mode 100644 index 0000000..67d14e4 --- /dev/null +++ b/services/app/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/services/app/server/index.js b/services/app/server/index.js old mode 100644 new mode 100755 index f927ec2..5dd0671 --- a/services/app/server/index.js +++ b/services/app/server/index.js @@ -1,16 +1,16 @@ +import cors from 'cors'; import 'dotenv/config'; -import { handler } from '../build/handler.js'; import express from 'express'; -import cors from 'cors'; import expressOpenIdConnect from 'express-openid-connect'; +import { handler } from '../build/handler.js'; -const { NODE_ENV, BASE_URL } = process.env; +const { NODE_ENV, ORIGIN } = process.env; const FRONTEND_PORT = process.env.FRONTEND_PORT || 4000; const app = express(); app.use(cors()); -if (process.env.PUBLIC_IS_LOCAL === 'false') { +if (!!process.env.OWL_SERVICE_KEY && !!process.env.AUTH0_CLIENT_SECRET) { // The `auth` router attaches /login, /logout and /callback routes to the baseURL app.use( expressOpenIdConnect.auth({ @@ -20,7 +20,7 @@ if (process.env.PUBLIC_IS_LOCAL === 'false') { }, authRequired: false, auth0Logout: true, - baseURL: NODE_ENV === 'production' ? BASE_URL : `http://localhost:${FRONTEND_PORT}`, + baseURL: NODE_ENV === 'production' ? ORIGIN : `http://localhost:${FRONTEND_PORT}`, clientID: process.env.AUTH0_CLIENT_ID, clientSecret: process.env.AUTH0_CLIENT_SECRET, issuerBaseURL: process.env.AUTH0_ISSUER_BASE_URL, diff --git a/services/app/src/app.css b/services/app/src/app.css old mode 100644 new mode 100755 diff --git a/services/app/src/app.d.ts b/services/app/src/app.d.ts old mode 100644 new mode 100755 index e337c8d..8618f54 --- a/services/app/src/app.d.ts +++ b/services/app/src/app.d.ts @@ -1,30 +1,23 @@ /* eslint-disable @typescript-eslint/ban-types */ // See https://kit.svelte.dev/docs/types#app + +import type { Auth0User, User } from '$lib/types'; + // for information about these interfaces declare global { namespace App { // interface Error {} interface Locals { - user?: User; + ossMode: boolean; + auth0Mode: boolean; + user?: Partial & User; } - interface PageData { - user?: User; + // interface PageData {} + interface PageState { + page?: number; } // interface Platform {} } } -type User = { - sid: string; - given_name?: string; - nickname: string; - name: string; - picture: string; - locale?: string; - updated_at: '2024-05-06T17:16:18.952Z'; - email: string; - email_verified: boolean; - sub: string; -}; - export {}; diff --git a/services/app/src/app.html b/services/app/src/app.html old mode 100644 new mode 100755 index 9b1b3b8..c0e63a6 --- a/services/app/src/app.html +++ b/services/app/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/services/app/src/globalStore.ts b/services/app/src/globalStore.ts old mode 100644 new mode 100755 index 1ca4e21..b5b04de --- a/services/app/src/globalStore.ts +++ b/services/app/src/globalStore.ts @@ -1,4 +1,4 @@ -import type { AvailableModel, Organization, Project, UploadQueue } from '$lib/types'; +import type { ModelConfig, OrganizationReadRes, Project, UploadQueue } from '$lib/types'; import { serializer } from '$lib/utils'; import { persisted } from 'svelte-persisted-store'; import { writable } from 'svelte/store'; @@ -14,6 +14,11 @@ type SortOptions = { orderBy: string; order: 'asc' | 'desc'; }; +export const modelConfigSort = persisted( + 'modelConfigSort', + { orderBy: 'created_at', order: 'desc', filter: 'all' }, + { serializer } +); export const projectSort = persisted( 'projectSort', { orderBy: 'updated_at', order: 'desc' }, @@ -35,7 +40,7 @@ export const cTableSort = persisted( { serializer } ); -export const modelsAvailable = writable([]); +export const modelsAvailable = writable([]); export const uploadQueue = writable({ activeFile: null, @@ -45,7 +50,23 @@ export const uploadQueue = writable({ export const uploadController = writable(null); //* Non-local -export const activeOrganization = writable(null); +function createActiveOrgStore() { + const { subscribe, set, update } = writable(null); + + return { + subscribe, + set, + update, + setOrgCookie: (id: string | null) => { + if (id) { + document.cookie = `activeOrganizationId=${id}; path=/; max-age=604800; samesite=strict`; + } else { + document.cookie = `activeOrganizationId=; path=/; max-age=604800; samesite=strict`; + } + } + }; +} +export const activeOrganization = createActiveOrgStore(); export const activeProject = writable(null); export const loadingProjectData = writable<{ loading: boolean; error?: string }>({ loading: true, diff --git a/services/app/src/hljs-theme.css b/services/app/src/hljs-theme.css old mode 100644 new mode 100755 diff --git a/services/app/src/hooks.server.ts b/services/app/src/hooks.server.ts old mode 100644 new mode 100755 index ca9c53f..2d2dff5 --- a/services/app/src/hooks.server.ts +++ b/services/app/src/hooks.server.ts @@ -1,65 +1,87 @@ -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, redirect, type Handle } from '@sveltejs/kit'; -import { Agent } from 'undici'; -import { getPrices } from '$lib/server/nodeCache'; +import { env } from '$env/dynamic/private'; +import { handle as authenticationHandle } from '$lib/auth'; import logger from '$lib/logger'; +import { paraglideMiddleware } from '$lib/paraglide/server'; +import { getPrices } from '$lib/server/nodeCache'; +import type { Auth0User, User } from '$lib/types'; +import type { Session } from '@auth/sveltekit'; +import { error, redirect, type Handle } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; +import { Agent } from 'undici'; + +const { AUTH0_CLIENT_SECRET, OWL_SERVICE_KEY, OWL_URL } = env; +const ossMode = !OWL_SERVICE_KEY; +const auth0Mode = !!OWL_SERVICE_KEY && !!AUTH0_CLIENT_SECRET; -const PROXY_PATHS: { path: string; target: string }[] = [ +const PROXY_PATHS: { path: string; exclude?: string[]; target: string }[] = [ + { + path: '/api/owl/organizations', + exclude: ['/api/owl/organizations/webhooks/stripe'], + target: `${OWL_URL}/api/v2/organizations` + }, + { + path: '/api/owl/projects', + // exclude: ['/api/owl/projects/export', '/api/owl/projects/import'], + target: `${OWL_URL}/api/v2/projects` + }, { - path: '/api/v1/gen_tables', - target: JAMAI_URL + path: '/api/owl/gen_tables', + target: `${OWL_URL}/api/v2/gen_tables` }, { - path: '/api/v1/models', - target: JAMAI_URL + path: '/api/owl/models', + target: `${OWL_URL}/api/v2/models` }, { - path: '/api/v1/model_names', - target: JAMAI_URL + path: '/api/owl/model_names', + target: `${OWL_URL}/api/v2/model_names` }, { - path: '/api/v1/chat/completions', - target: JAMAI_URL + path: '/api/owl/chat/completions', + target: `${OWL_URL}/api/v2/chat/completions` }, { - path: '/api/v1/files', - target: JAMAI_URL + path: '/api/owl/conversations', + target: `${OWL_URL}/api/v2/conversations` + }, + { + path: '/api/owl/files', + target: `${OWL_URL}/api/v2/files` }, { path: '/api/file', - target: JAMAI_URL + target: `${OWL_URL}/api/v2/file` }, { - path: '/api/public/v1/templates', - target: JAMAI_URL + path: '/api/owl/templates', + target: `${OWL_URL}/api/v2/templates` } ]; const handleApiProxy: Handle = async ({ event }) => { const proxyPath = PROXY_PATHS.find((p) => event.url.pathname.startsWith(p.path))!; - const urlPath = `${proxyPath!.target}${event.url.pathname}${event.url.search}`; + const urlPath = `${proxyPath.target}${event.url.pathname.replace(proxyPath.path, '')}${event.url.search}`; const proxiedUrl = new URL(urlPath); event.request.headers.delete('connection'); - if (PUBLIC_IS_LOCAL === 'false') { - if (event.locals.user) { - event.request.headers.append('Authorization', `Bearer ${JAMAI_SERVICE_KEY}`); - event.request.headers.append('x-user-id', event.locals.user.sub); + if (event.locals.user) { + if (!ossMode) { + event.request.headers.append('Authorization', `Bearer ${OWL_SERVICE_KEY}`); } + event.request.headers.append('x-user-id', event.locals.user.id); } - 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.cookies.get('activeProjectId')) { + event.request.headers.append('x-project-id', event.cookies.get('activeProjectId')!); } - if (!event.request.headers.get('x-project-id')) { - event.request.headers.append('x-project-id', projectId); - } + // const projectId = + // event.request.headers.get('x-project-id') || event.cookies.get('activeProjectId'); + // if (!projectId) { + // return json({ message: 'Missing project ID' }, { status: 400 }); + // } return fetch(proxiedUrl.toString(), { body: event.request.body, @@ -81,11 +103,16 @@ const handleApiProxy: Handle = async ({ event }) => { }); }; -export const handle: Handle = async ({ event, resolve }) => { +export const mainHandle: Handle = async ({ event, resolve }) => { const { cookies, locals, request, url } = event; - if (dev && !request.url.includes('/api/v1/files')) console.log('Connecting', request.url); + if (dev && !request.url.includes('/api/owl/files')) console.log('Connecting', request.url); + + locals.ossMode = ossMode; + locals.auth0Mode = auth0Mode; - if (PUBLIC_IS_LOCAL === 'false') { + let auth0UserData: Auth0User; + let session: Session | null; + if (auth0Mode) { //? Workaround for event.platform unavailable in development if (dev) { const user = await ( @@ -93,38 +120,149 @@ export const handle: Handle = async ({ event, resolve }) => { headers: { cookie: `appSession=${cookies.get('appSession')}` } }) ).json(); - locals.user = Object.keys(user).length ? user : undefined; + auth0UserData = Object.keys(user).length ? user : undefined; } else { // @ts-expect-error missing type - locals.user = event.platform?.req?.res?.locals?.user; + auth0UserData = event.platform?.req?.res?.locals?.user; } + } else { + session = await locals.auth(); } - if (PUBLIC_IS_LOCAL === 'false' && !url.pathname.startsWith('/api')) { + //@ts-expect-error asd + if (auth0UserData || session) { + //@ts-expect-error asd + let userApiData = await getUserApiData(auth0UserData?.sub ?? session?.user?.id); + if (!userApiData.data) { + if (auth0Mode && userApiData.status === 404) { + const userUpsertRes = await fetch(`${OWL_URL}/api/v2/users`, { + method: 'POST', + headers: { + Authorization: `Bearer ${OWL_SERVICE_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: auth0UserData!.sub, + name: + auth0UserData!.email === auth0UserData!.name + ? auth0UserData!.nickname + : auth0UserData!.name, + email: auth0UserData!.email, + email_verified: true + }) + }); + const userUpsertBody = (await userUpsertRes.json()) as User; + + if (!userUpsertRes.ok) { + logger.error('APP_USER_UPSERT', userUpsertBody); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw error(userUpsertRes.status, userUpsertBody as any); + } else { + userApiData = { status: 200, data: userUpsertBody }; + } + } else { + // logger.error('APP_USER_GET', `User not found: ${session.user.id}`); + if (!url.pathname.startsWith('/login') && !url.pathname.startsWith('/register')) { + throw redirect(302, '/login'); + } + } + } + locals.user = { + ...(auth0UserData! ?? {}), + ...userApiData.data!, + email_verified: (auth0UserData! ?? {}).email_verified ?? userApiData.data!.email_verified + }; + } + + //? Bandaid fix for email verification - REMOVE LATER + /* if (auth0Mode && locals.user) { + await fetch( + `${OWL_URL}/api/v2/users/verify/email/code?${new URLSearchParams([ + ['user_email', locals.user.email], + ['valid_days', '7'] + ])}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${OWL_SERVICE_KEY}`, 'x-user-id': locals.user.sub! } + } + ) + .then((r) => r.json()) + .then((emailCode) => + fetch( + `${OWL_URL}/api/v2/users/verify/email?${new URLSearchParams([['verification_code', emailCode.id]])}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${OWL_SERVICE_KEY}`, 'x-user-id': locals.user!.sub! } + } + ) + ); + } */ + + if ( + !url.pathname.startsWith('/api') && + !url.pathname.startsWith('/login') && + !url.pathname.startsWith('/register') + ) { if (!locals.user) { const originalUrl = url.pathname + (url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ''); - throw redirect(302, `/login${originalUrl ? `?returnTo=${originalUrl}` : ''}`); - } else { - if (!locals.user.email_verified && !url.pathname.startsWith('/verify-email')) { - throw redirect( - 302, - `/verify-email${url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ''}` - ); - } + throw redirect( + 302, + `/login${originalUrl ? `?returnTo=${encodeURIComponent(originalUrl)}` : ''}` + ); } } - if (PROXY_PATHS.some((p) => url.pathname.startsWith(p.path))) { + if ( + PROXY_PATHS.some( + (p) => + url.pathname.startsWith(p.path) && + (!p.exclude || !p.exclude.some((ex) => url.pathname.startsWith(ex))) + ) + ) { return await handleApiProxy({ event, resolve }); } return await resolve(event); }; +const paraglideHandle: Handle = ({ event, resolve }) => + paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { + event.request = localizedRequest; + return resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace('%lang%', locale); + } + }); + }); + +export const handle: Handle = sequence(authenticationHandle, mainHandle, paraglideHandle); + //* Server startup script -if (PUBLIC_IS_LOCAL === 'false') { - (async function () { - await getPrices(); - })(); +(async function () { + await getPrices(); +})(); + +async function getUserApiData(userId: string) { + const userApiRes = await fetch( + `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', userId]])}`, + { + headers: { + Authorization: `Bearer ${OWL_SERVICE_KEY}`, + 'x-user-id': userId + } + } + ); + + const userApiBody = await userApiRes.json(); + if (userApiRes.ok) { + return { status: 200, data: userApiBody as User }; + } else { + if (!/User "([^"]*)" is not found\./.test(userApiBody.message)) { + logger.error('APP_USER_GET', userApiBody); + return { status: userApiRes.status, data: undefined }; + } else { + return { status: 404, data: undefined }; + } + } } diff --git a/services/app/src/hooks.ts b/services/app/src/hooks.ts new file mode 100644 index 0000000..fd4a845 --- /dev/null +++ b/services/app/src/hooks.ts @@ -0,0 +1,6 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; +import type { Reroute } from '@sveltejs/kit'; + +export const reroute: Reroute = (request) => { + return deLocalizeUrl(request.url).pathname; +}; diff --git a/services/app/src/lib/assets/Black-Long-Main.svg b/services/app/src/lib/assets/Black-Long-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/Black-Long.svg b/services/app/src/lib/assets/Black-Long.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/Black-Main.svg b/services/app/src/lib/assets/Black-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/Black.svg b/services/app/src/lib/assets/Black.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/Jamai-Long-Black-Main.svg b/services/app/src/lib/assets/Jamai-Long-Black-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/Jamai-Long-White-Main.svg b/services/app/src/lib/assets/Jamai-Long-White-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/White-Long-Main.svg b/services/app/src/lib/assets/White-Long-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/White-Long.svg b/services/app/src/lib/assets/White-Long.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/White-Main.svg b/services/app/src/lib/assets/White-Main.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/White.svg b/services/app/src/lib/assets/White.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/dark-mode.svg b/services/app/src/lib/assets/dark-mode.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/jamai-onboarding-bg.svg b/services/app/src/lib/assets/jamai-onboarding-bg.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/light-mode.svg b/services/app/src/lib/assets/light-mode.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/assets/model-icons/allenai.png b/services/app/src/lib/assets/model-icons/allenai.png new file mode 100644 index 0000000000000000000000000000000000000000..60976e2fcdd44e52fca002f5a45a411e9b6d15a7 GIT binary patch literal 149373 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

    WonEwAp4Pa{o}M{UR5qwT?o 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%0AqP5~ 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Μ)eat7KYlz1kL+rP%4Sk?9yakNKCt7`MhfzagN0&~SKboB$An2{>I{MWLJsZ&Hf+J%8%`Tq1f`b(-%Q#EhZTtJC zOhLDbsd#cU^2k0QO$^fr?Ac4qBr=EK4hYrN4i97{>)chGJnOiqX^208@c(C?*MGeH zK4-}HUf%VH1@#&e9FK7ySSi(x+a8n7bRNzF#lCS%9nZ2S|Komn8}0yMiM1}@<*W22 zQB1@mkZ`$v`8)l3{3vw9e$vZG9GZ90xX*ue-j{Orm3#ek)zAu%%0Yfma;CH2y^WvD z__(^1ajWDPCQ|Cx^(I81+fv{N7SkW0|^#d)Rk zEsziw+1=hfqX8pz*?6Xgepd#8GtXO%iLoJnxY}-(ll#FL6N7H{7eu9(`;sfm!4G2$ zon(ZUDlUn#Hy3*IcZFsB;B@3#vv1TcZTN9Ku z)57%ndkDL``h?4z?Oy^bE4EmIp4L(KX6vaVZ#dx^tZ69&>cr*3um|0&Ea;CpM3`R6#mYZ#g4yUmjQU@Imen6cOhsJwCVv4uvq#QC&c8$Q&oek>$? zK)|5C$md!4aa!C)6gMxOHOlLgORvpe_P}l->*UE&#Wg!i)Qltac5M~;T7dB|aM>`_ z$Mv+o_pajHA?yUm?9bwKz-he$G0n^=nJ)@(<1PQJw>Wbgl|LAmUja;oachgvr-JjS=Z^ju` z<5bl6S*INM^%%O^t~vf#Vry>6pl80a0ldbC?S;{1|b2xj)hiE?g%BD5Z`Ec|umEy;8 zd%Z7*H=+ad0+g9C%Ji_Mb56DmJ^2hESpwx-m%NBDw86*;?1b-VTV60?1~ zpdA(Ek^x&r!JfAmy4}`57$Jd)sU`fV*Kul(l7HW(*bn) z_$pz*_y7eN5CUHTaUTT&`7MsdH|}Zlv`LFp*pB}^ty7~e5sf%NP(_u@8~Q@$C_yiYt2q28#MAO*t)6lTj_0*qhf~2 z@NnG0z3mP9ZHxY;F8PuHVkc($od4;_twg6`UJRA0+ccD_^}1hu`7=?Q!<--CNl+sb zO5ZV;0d>mB)tz8^QvqCL6r!Rr>AlviqSp_*A`Hl3mhs&-v2joM0DDmi^Mii{M~#{`RX32lq&7z)T#9M8wTj>F`f(1}z#A%~Py~ zexGKJQoP|xWeXeit?6{JIq7L|oi`aLlht5t1bvwKrC|OjrvDtc{?GsTgkn@TZrI5X zk_+O1trYR8&?Z}*+#4ekh?~xbRcsZ%9jU?AA|&QEF5YS;%(Tn1;h$|S#)wBVl10Rl zSds>c>9&P+9!h=3wso$TCj@LkZw7{$CG-7*ehb~DP(%d2`2}3vBFptYoasFg*P4-> zcuCZE6$uRVMf?$?eu4T9`4&5pofUB463Ad9Z`Ndr668Y}lXo)Q9tvtDr$H@Hmur`6 z#)e-YEdR4s7r?+^@PWTZ10wJo0WqLG=leOZDEXY{4&gNyJz(TiA6WBf?^5N@`|3mn zWaAU$J?XdB&r!YK(ewEqI=H_85e@J(!O36ws~3%Fc1Q;ckXvtm=YWH){Yq~55&*i5CLMwxEy$58j+LD+oviqMjgXIwM9N5Gd}e%=$b*=R2g6 zJ+HJygjag6m!2JVe043DyZ=Ef0#DNF;ILltqX6-H4wK|8eSKrZwQTn&H`Q}P_H&{7 zvxd0|oZQ-U6L8^gB1PX64olOCsT7+n^#DTOVCyOwYpbT6uQMW}JDIWq6qB`{5XcBK z;x|eZ;^H|x)$p$bZgv5TPJb^9lB@$c1_&ou+H1TU(icZoO$j6g3|XV6`3ijUF-oU8 z$GeXiNf#a?ts)3Z9S+CST1#bq$J%$Tf%sWN8^o>up1Ki9HGb1S+V19k5dA?mSi;+_ zu(k{2&nsYhWw)^GT>mR)?^ViiCiM~avU#RTCsQj$x;gj>KK^%8?^ekD? zz(AlO)16)tt`8lt#Fp*+obYDGCM8ayMS~Y7F@Y2pt$7HwYt#V_Lq^zMqIzcPQb)$7JG}59kU?d<^6B<^g0Nn*x&O?S_m} zONAtw#1JWNq_dQsR!%Nygufad+8-ciyDLqR^r0#XzBjrXPl4GontzIWAd~gmV5-i1^>9$m4nxAm6iaE8U_^^Sxa1m*j$^74Ef9a+V5I@ebsFd*h zZ`2&cDmO1;-w^N*f681It2DT*sXpzBxT+#O0`{;VVO2kQU*lu-uA!&Ry%^>yMWG(M z?E9Y>;M=YuET${DWLH>>I_Y;EazmFGfIU9Ke-sj5>=h&;Q8JQKPsz&BRDQ)7mgbuvnHe2zym)ncyBFYp zZORO20RnD!doLbH_7BL7Vr(_?fx?nLLdN##jsMk&PE1X76|25hLOlcwSdETUv`OS| zeBL)|R`!xoTaKDlBWm%VKoNyob`IQ3SuJMVYvG>Sk`B_GhaP}Phiu0PKX%k2C0vk^ zhZ^O{o-oB%1Xy%_>m`hk3;p`&cP5rMN}w0usnSTDu!;@?iRS-!>@u(gD)o9U+MLpu zy;=qWa1}Xdbx)$1DRE18MX_TR3DO1H!R=d#)Q_(bLzZ&!5%!#MdX1Wj7c`?8S4Q z>mIab?bnyzMBG7qQQ}e0hq3)W3$?fN&lq%QoJg7PfEMn~W{xr+yjq9lNxvi0wKGoK z;m5zcL`9}P=L9kB%>~p&REUNON-ADTDU^cEwe8Qh1s!+se|8A2vTRlyg+m)oJ{=F+ zi0OOk<+R?pXldHhtD3g6T5SHwT;4s|Lco3Yo5G|$h|mEq4Ymi0nsi_{LkViCu}eX> zp7*=Q7Q&x^-bWbKeRk`SWEPmI#XB*`oO2k%A~jLb!8JHVo*ic;-&&dG-){ z&|RZr6#=b3hla>11CLl|VztR%lcst}z7C=;J>yy>m93Wk!L+z8VKbG!+Y9ZLtIU~W zXU#I$9=}h!YHMxh+zu`L9Tm3JkiVa7COd3)#o`@b06|fVQy$LoA=7FV{@XrPHbS$g z<9mn_wc08|e?!2-!=>J@3~p^?>DG-VW!<@Y)Xr*i+&r_(6%xNS zN-*9Fy6@}P)`;# z1;>5Qdo=AbWCd9I1lt12G-#`xQ>?g;_3hJVMb&5NO;9~T+WYq~RVGw2@dITb!`9aR zE9+GSSaJ&0(nhaS2Z>VZYAXLZ*C@auWIe3O|H^ERX2&&u`t>0nZw1$D zUasb1*hp2MOazomH}hwn{n9nG){mbKnu_tL%6Jt^)4l~R zQLip>>CDw5Niqk}1g$ujrkUu+3ce=&l zvQz*-l`Al^i^lVAiUqvO>AG-XVv-ofM*cmWh|t+^bn2~c3>nhyaYnW+!lZeGanbW% zX=YV`Rat{!MqSEUhe46xI_rHBQ-qgMoaIz`%3@xn$6)G3d6E{;zfcIf&t+CSzfAUZ zt}2pLEeomqd}I3gpPD;_)q=37+`>o0QNLbosEeNAI2g_sQ+KDg6au_+1vZW{3ED?j z^Y3es6N*Yk{-mVm%-;0VN?WNhMdh0?({q6*E&UHw?-%4RjEkZ)?Eec>q_}XSOx%(% zj;DhLiaUd~U%sJLuNe!f9~}B|p3_>nS1qy1 zP;X16$~V5mJE(o62B7WCiU^1W_e`ooBvsSk^MHl@x(peTTzqQeyIpOED zyqN+vy1FC+monm{k)((O*oeAEa4Q|NGwkh6)Z^cXzErY%bD}q-b(B(@QNGI7&P}#= zHeW3KzWw_dp(3C9dY~a&G*gFZzty!OQDnIUGG^-XGyxeKRRbT3~X`nwV??@~B z3o(soeDgf(5#$wt{=PR8!yQf*dDd;Tc3^{6&jFI}unFe>X_n zNbw=hdEj>%Ea>PXF1IyPEFq=eCNZa)oOIvxyZzmFB+XEmw(Htnw#>swHo|<9Jo#{O z>L!Bk`8eU;RTK$~p+={ZM>eqw>6^pm*#q9^7Ro(6w7=|XAfSfu{ptl|<;It`fXry} zU&7WmI6voO_S34@C9*Pa^v;3KGji~9JF~g^Il8$+rfrr4)gzT?mjK9I{1d<7x|e&( ze9pP)oUeK}4>BcvUHw;Jdc`hVi$7wB)Vx>4s)`^vH=!933StJxFUb7R8n5$bDx-CI0E${Zn4$VWF42%?9Ws zXT(gXH^nB>oxP5@nws&-A9$x@{Ro2&m#`(@<9>S8MgMgev4HY_0LP0Pbd!t3C`IFQ zgn9edoE0uJV3W5~orx6zGsKstBNk`3?72B;uvI`%QBOx~k? z!=qTKs~!nEHnmpwF4r$bX)Zasf6Wp}&_f%ul)t7|(r3thMvR2mEneG$h>^Hc-Ut^D zX8o3nUxhyn47_Er@xOM;G5I-*kTuOGD}yI*I|$hKkVI3QkI2F#A$Zx88lmKKY;qra zt-gbqIayL{OQmlDI%5HX$;_FS zL8VWy7#N|_k9ob>Eo=fVwiRHq(|(2*GosO}WfwY;cn;|c62H-+1zmJ)h^5*NNwqnz z^xk*%4$tJ9Xz?BG-PTWof&w45b}!E+j3v)VgEIbAB{=c zXlFniDr2fdV`n@oKOdp?@FcxArB5YhMnE%hJohUr+udGSEj|Zuc_*e#zpcL411Oj|8!NVYp(}U@CtAs@Lm^Vf1K?S{AI9jpu`I!jE~c5NK(gP$|_`*D0Jv}3CCl#$L;3e-l* z4U0Ra1R*-PKoL^Z{>sh9mdxR`L~Y-!q37zQmz?(Wz3$MEVEQ=&ktXlsMfk&sh$ER& zN=X70)KaBLlFhX9P<(d)_b+q;?k_gmK~-M~LR^zBKAwGc_onh^;YI~E06Zh$3pjaM z)CrVVKT_29qJbEal!>mzYM77hixq=BN$-4W+APk~h0N3&K?~M^)v;q0tIU=c9)&I~ zgCu(qm)y{LDZi7&In||mLwmH-6rp;kSE*_#!>q70g>h{1( zvadv67iJI)KGHR_k|=+NbEl#8t;TeNJ5juch{W!nN}pvN!Ga}#0^D4Qe?bD)?J*+;AT7lHUUuO%jSAJ##J!&x&f8`F;R(TPj^Yf8jrIu;E^Br!hv0nkR-%r4Aw0DY z>{->;BGs_&xwsSzhmDSj(YF?ce&1EeYB3!XT~RG!hx*3)=oJXbv7Y%ZWS&%9l$p8? z`D*=5lCWB>A1+556Go)@LtpDFB`qu|DIYlKteo@gEKK}f8L%EM z7-zz}!By+L1*BnPIlE+q$D*0qv@bz+KLq>|@7F|>AXDX^K6|V74803?7_=kg7YeZ{ z^AB6#FilVt^VltT;l0eGM!ZdDjjb7k>%P;^KSlpMAv8TctRpySyKwT;Hzr9PiBq}? z1u@Zk;Kh7)Mg|l7Zl%kQ5WQwzjOLd{+tPEjoh!EcI0HL@cj)zCOHK zIJt0?e^LAlQzvqIjO&KCQ(Jg#pk{RS%r7;wk!(}nxLgm3XAib0*1+lD4@(-fzbSfA zZeLE7N7ML;oxprX|C^X6VuH{P2d~!Du!=l$=Mya2lIJ0AXZ+L39JEMg@Yf@T{Uk!! z8?Gp*&XyM#%{q?B`gQ1~UWPH_m4LQ zN&e!eH%k)FbgJO#VEP$@ z#tSLQtNra))-Xtw9a$Mwx8U#q->1sRDyQo$6jR|^M$+;4$EMCtz=eSzlf;0te1~rH z-Pw8=g~=f(9~vNRof9UfOY@}&G&t>1Hd!U7&#fIHG}0bn=*r2VQhDe`IO6=ax=9`C z7Zpc&ul?gy3DXMYeMtOC^ucY&$Fl45IeZOfv zML87N2kEX;C$3RmuvARObu8bZ$hRWkW#BP&v6s*cB&rL7{d}^hvU~W$t=9gjcv{4t z6~O~x5=6dmsO#DA3-N{1931q?3XsJgCNFc(>Vx~YJ;st~u|>zYr1XQ}6Im{}_T>A+ zOPUWj>}3_2zl0p}iY1s1?iw65qlHKi1=p8J%L!A~aULQMo7uLaKgGNorn{O`* zfj1U!d2I%W%;}=}LtK%KX5`tA1zr5iuaW)j3wG~0D<+Jy;C{!Cf_#0HD)^kokG6^t zC;#+{H)C`YsK2yFP~avBD`LHB)|9OOF2Sg2s!ZXRB=Hk!wpi}y|WVCFmRBf z|7!NEv#CUXzhzfr|32+o)47wxHNy%GR?oFVf(PuN2(W!565fHO$rG~rN|;W!ymmO} zAZB^`=Bvwe{!&lBC~^D1$;Y;TH_>`wf3>khSMq%Ef@v;u-4u3Qgm3$PSqjIRUbV8( z@nx(Z93L8g$qQ8wqi+hA5cKPNX^e_Ye&I_tR@~H9R}=vQM;*(Bme7iBJnc{ulcpKn zi?Y%^@Mf!XvGY!5(3f3Q$0Z|7H|M8=7+a0!G=1#=y=6iceX?9q4QT$s|S_WyoJ+^ zy%Q>4rt7QGy|0(%E}14mg@WL>vYyD3KT4f*SqPu%{%E8T{9quF_Syu`uI)^_UVuxC z>rxif`P5ufKMPP?Nzaaj_2}2fchaAsbq)!B=Nb1G`wM>VTLU&%@|AA?le9pigWIm0 zoWvhJm6~^+0l}92qz_LAUk-#PqS2@}l{hH*p6`!9OdIov^H7khgpIV17GS=o@l>-n zOXNZ`^_niwz)13{D$!=H`RY!-fME{V3O0H(<^JF&+PyA07*naRCr$Oy$76KWwk#1yt|z;B_Z^HND*lw2ni@Ch#e^c5{mRP0V$%QUJ2g& z@!bo->jeuUB}4BhL_k2W0D{sIL`14e5k!iFBr|i)+4cSZp0)Nq=S)aQVKOs;Irn0c znSFM7_ge32&w7@@*k`${!2P|`R`+4ngSG2en6+W-L<_7Am{kny1OVdzY_x|2DuYu;wKL`y!0_M-2PyhkK^KvV`XvJ8UUdz%0eFV}~we1s*x~l+_Y9`Vb60 zXkj)3>?Q!V8KK2`5HL>|*vDY;XDQNAhdk4Ad5kdyS;(?JIOC)p9bGF z`w`yN3i3ejch+-ldH@WL2h3`3do|xtC$O)=;>W3RuiUoh)MwvOkG2#K^R85oSu;-B z-57H?fG;e?TD_YOz0q2G(d4P8-}G)8R=ddM7^LJ5E6!zW9{!X%hbjV88uNd|{*anap>QCEuNN z)c?*Kz4y5EclM4_kcWDweKU8Zh;thI4F3wuHNf<(d#3;2@LziQT=5;DAP@DPysw4* z|K0^fz2zeXLx0vMjKwKiPo4hLp+EKTo^gd%kOyb{=Vp#cPa8|u4zJKG#no=itb6h{ zQ~&3&rTEUMI&_6qklDS{{u97|kE+!iyO~9;z<*8Znf^a3;_|F;3PMOo2J?LbOj;2a z{M~xb7MSh8etkuQu&huDGP`#gEe0*e*k{FEf&ZD*GyT6-l-pk+6y%}aX&(e|fdwY7 zC<{AwpNn1r1F$FM$gO*(|9P~p)2LUFhk8$*YGKbG?F%_}e@njt2Ii=(drq6N^xqu5 zZ;WaMnbkY(d;?4!zU3IZ(r~YU1!hd{nSRu8Uw28bH!2lGy7(M(F@O&*>4S_Nw?ePL z{RYnAqp`bj)G5fU-jlyzU@y&CKUV0sK6djqIb?FrX*Vv_x0dt4qe?*@>OJ|Z z7WN0r`I*N~KZ;ktzi9!AFsSm27uJ<|^w>AM+G1!0%pyh`^>z>+;=>@&6k3#@>-wa@dv zxYJ>$G)8J^ModBOz2KAyfsKC!*sVwE;m5AKBCLRUC^G(Mwmb5aIU{kmBcdQ|gY{r; z8B>mtc;zeNHO)f_>@FkG-aG6HGNv3WqJWNudmb4j8CC@ubE`&!psmRFF#PV;uqnu$ z7oSq@O5)#-xm7E&fR2WHHxG@g&)=TQwSFY=yrEbL*s@({>q- z#~2m`8LM*)$E$pIU1PZFB+Fev#wz6RF2Nn% zj>^5^UHHz%9(kBoz{2dYyimZ)ML{0w{mv#9*?nV>^%^D+;e%tSI>;%hU1xT1Zxo7&@qN{yd3Nki=U^%?iu~QE93cRh+3(Kq^ z`S6;*4E572?fGo}1jbtVYU_av^e6gL`MW88()tbHz``JdgUq!GdY64H%@>kbMVYd zuj0ImuS6|#(HEw0gEEpVg)0g`fll+Y@|*e}l^`>ET|WifT!2zzkT?dZ0g^01N6AOo zX86W2J$TQAE(B=W8W_KXfha}h1i1K$Yw*ZZe@E<9 zks24S>mW^HdA7j!5yvq+*GHDMl(0}({zMkc^4|G$K;{@2n_$hZ3ch{pQJCOc_=u4? z8Ms;*JQz4|GhGkx{$&lRoXadm!zDg9PU@My$Ff{(nV#&S8PoQ$2G=gj1&8uGi&l_g zm!|-$HPBkd?aoOci%S)GjF>S^KA*S;_%5bK7bD!*D>D7Fy%8J$FAFNg`|HD+#pvV_%LwcWc*H=D+t$v3c~+J_s78O zw{_2HOD(g@q#(0#R$?6?^|;feC>z> z(P?6Icqy`|0k~dnky>tDu4bYnd;(gBFp2@MgwzR;7$2{`F&{s<=t?|4FGS{5kfge! z`@Sd2VT{AQAQ#?tcT<(4u)S1narQh9Hi=QqV!VIN3HZ91S#zT@Bc%BPnR1l3F^t**(xVdHGPHf>>wIn=*imBE`cVF(f6(@rq z*KqU!dt>X3)`Oqa;E8=v7f54^!d)ruF?Vh}4~;MqWlD{YICJ0xWnA&|oAK)hACvp0 z^+;_d_nagtJkLiQC2$=Ic6(%^!BVkV@Sb_UjAcxgp<*nmHpDll9)u0nTnTR0kY_LM zjeqKDw%pVez{L{rGr+f&c0~13Dah>e|6?2+_c;LLmzTi5z4Nwq#@2a?M{Mn)8aro* zO@O&sfQzr4i9bF5G~8+z`oc_neNbQ|_*U0SMZMVD9ay6ZqCcl{v?U4JtXIaL+Z1IV zu=V-aaqG=-=-#`-3;R$qbo2DQ8iy1LA`!F3OKAe1F+~!?aebs#9X_vI!DG+7i2u9b zYSiJwaXrLo0z)NAGXy~iVIzX)GInTvH2!EQ2%pdMONio3yv~wq;KT#?)V7;r|J^== za$19DV^IoOh1_z=X0YOY=DA+5?uQOLW$u#R`qC*#@5!eF_S;K(^5H+O$ha(+(aNLr zT)-LhQnW*-gulIB!}rg-2w_I2!9_jI-~|;qGKpjO3|{lyQtgih)y#PDql&>`yJeZg z4Y9ef)7?rV7?Yx85=2Yl_%?=X)H3OLHNN4FURXMtrSM!wECiL7$EBI> z`mx`E@1B1po_gUGz;zKN863|;l3Msd8EKNpexL82`E@kqR>WhBgDT|?Bq`$-3&%0= z5n;`7K2Dr^FxDtLC}p9znY7l4+yh-tu8Sr;R;-Wt?xdd6PFm7?USb8Aeg3pH;ozku zJ@IfIH#iMi#2L-RZjs=X7pgxsuoAI5Xt-ruch>{B`K~{}4NBrx#hI1enAsmjxSo@L z%jT|7o^=uhvCLdia4k}!N}|~`#vQr8?m9@a7^#g>@eRsZ1H0|?QGEVm+euvFA`%5` zDo7qTHPcMF1LhhBNfN{LJtRpYR>JL^fLrf-3^Q-P6OA}VOyl5}5XPAV=o*Kp+7vX75sC+!S~O<7<1P zYVpiPY&Kk5;*eZtiOC+8NI{e?6fY0Stqt)>iU*L8v3T)q3PQ)4!o*!N%_y)g9Iu2p z1MYwHX`DOrRby0k2V6om0eOF`)FIKGS2P*4*Dh|u9= zIAWhEnEZkF1NAo$I2Li7$d2#PManYCpwUW*7043{Qs>F561-2wQ1eX}uDkWOxcmMG zka=bF$0>qp7wTaGhe|JXn!**-3|3Ke)Id8^cggyasY<8-CWU81QH)cLJpvo7(ut~< zqF$d58~8A8330>1bv!t^i#Wuub-AA|0oGU&uHPk6kcWDwooa!Tm-}5W!v%D|&7*{- zg3w}Umc@4s!bXU4xr#=Z!uS<5Vk_~+ML)d(_y6rhG~ANz*-}5tYuh4>8g=NW%|*+P z(jcDyqZXu<8KQ!4f2EaSbspk^0SQb}N7>1+*IZt}?@>XXiQ@ zahYft#6lQalo*w?fn7iHA?)?3kD`J)Y}$Ymc&bQ|gL0{iBw>8AV!2rP+dOUSp6TCN zoX9O!L3D;k*NDw$ckmHelAy( zMn4YRYgc?`@@5F)dI4U6P4cuZGiX6sk_Lq$YFwpCLIvU9BI6_Vs`%ZkhjGbIZ-Cpq zG8&nSMq=T5zFH4a+PrRIa>puPD^Zm<45OTgqP>l z#Cp*0)?KtX- zp3I}-a~mf|{7DYn} zE&+?0>GEL{i<0LH_KH~}rhH8RsiuGH(Eaei_0|TG2%ckM(iooOAPhtJB~3jS#W`L_ z19go>h}k0UDuRc|@4%He-iBY@_Yh2F93smYhPbSJE%NVab-z#$wSaox1!90dBLYno z-!TbFwvLmJJsj^_Z9Ga5W04qcxq>L6d!_E*iu$qEeqr*|({EbT{lDe2jRwT-;$jeW zZQ7t3?Fzyj&{#yQFb7riClF`}AsWB`<703uUE;!Uw`_;1>or?2cm)v-hJR@7f)EW) ze&ywGb~-SE;$=+1UD|punSpPa_9gJpB1H_h>LKp?H+J4?Gwip^r_m7*2$;a65qyU= z4_3uhPzwltfm7CCUEM5Nh|KX(cdB^eg;(*D%YTZwk%je2QZN+oqQLA`u?8!M7Bkh# zM&gDko{ZP^^Xn;-Nu6BAw{wr^s8iGFFA% z5Cm`&gVe$y(RHJ*Lj`G7)u|wAK^(?6O!cOaia3K>LdZ?Y0iu4a85o>&>{P7YU4ff~ zKwL*jGDQY)YGE7-YK};5-wFg(t5z#wEKnJb`I(FFoqI8!dG!s1CP2n)Rvw@$mW*zh zV#JuSQg_P0$R-nuRbtwjE>;F#L6;^I1i;!|0lsiPmb+3U+mB27M8li<%)06fdx zIE7O#BlH4XdGqb~^}P=x^~NDe0FT1j1{iE5diC0^Vq@-J6pTG_{ifPxlxs-geAwf{M3>glI0or3h9 z^a@~B8~VE$x-&B4!d?jOdR6-HneNWqy<>+ka!)GS*3wT0UT;J=`>LPfZ!gV3Y&v1B z2hY&=Zb#Irm^YOpk2fSkmHS~up{#cDhQ%JN%_Xok@(gYI<}1shi^KUH z?PH*O!;ZeZHXxWN%_7MTDGJhozSQE<>u=xdc9INlDMcO7pc_t%60MDikF!q zV4F=?ttXW{PF{sy(1j?DYJmh*#R|`hqQyRuPj0m-4&7^4nEE_ad`ALo>Hlcf*0o_v zQ_)2Xq3{~w#aIyK@$5K!^2Jy2{R^&?gha$xM6zjx0w{Q~=s!`2J!3ev9^xX(N)*{1?6`U3Nq~l z0BaAYurJHC+MA`cSDLN?Gc`regM%z}V1f=j_0Lyv&gIwO)p~@8u7?xA5)P8DKx-l1 z>=m~Qci~nQm5>6OQr!QW$s<};r=2vSxC*aZHYvutG5+J2Be3@PDoj|D`hwUlLfwll z_@XIGvS-?l;yA9eIDu`o79Gm09r=?08c+W~5_{xz7VYN$3A6>M1~ zWU0RM;-sGG?^&#Z%C+;-?uPdEd^;gUJEWu;dQs$9trTX2xZi< z_vd!PZaaJgrqK^4CD@SJumYW|nL4In`CPFmkhay*#fstqiAAC)g(-F6jtBmX%Wt?1 zwV)G`Q_{5{xdc3eUUv^S>VLuL;vO9L%W=kXk z^ANa2k@PJTWRN0^8ijb%4QJMVe4DAK|E~QPZ@SwRX{Mm=-pn)VPN>B~wI1qA8^EpL zr8fp}&Xw2WnOEi_bW3pklK9nvoXs_$)fqUr0jLOMo7M3)h=%T*?Mga$%@;2UWL5Ey zM0IqQGQ4-SaX8_S{V^eM5J;re6H zORvS>=G9Sm%96HaaPMaXJ0`GBoGVDsQji>uA_|hHmZ=~~6v1b#L^N{Rz-Ak)gPsHT zMa4-G*ihn^#eEjEBt;B2ICx*&S(f|n-vonSn1YN7LWU(-3JNtV*jmwM?LdKcy2o;d zbOe|5#6Moa4==hJk=KF5Wn++GauR8p04}pRc_t^vZxvasW|Ft94xuhVp%{8CF*5B% z__mvWYbDVPo3+GWCGg^YeEX=w@xIkpMmO^d(Eu{W*m<9co5Sb@gGEe~F$mbhkuX#b zQSu#F3bS8DJuBhTpZ^lSee@~R+$vI(1dyl-66XrSyQhLMWt_)21tvL&ul}4?<1RT zh(q?=6&*H4;ADs`6VwVxcQON)MLg09Nkbz`_IUx)M6g+m3UOt|$GlpKm--@{a?XVa zOWkN>jJX_?SSU;yNYRk)M51yqK>>1OJhrwAZgm&afSyr$q#Dw}q zX(~Z$rBXqyR+H|!XI^<7y;sk~3v=p%;x%3g^?D5@Umrb{I)f^Rf*V^s;^GDr0-A)58?1627UhEGiVG*pqA_hH+B_CczpV0lqQyFsxEC zsQP?g1UM>$USaf95FmEda-dNg2Xi9eCqKObfBDC&7)X4$epPBl z4*NaaM2H{+6xOaFIUJ!`<6*Io;6!P>;yq@_qB_=JXDv)UXdkRmw(zr1HUgp+wEL0I zpUL;M;j8r-^$KF~l}S^l|G2p0!3r`8*bnLr-uS%xr9gvEm>*uMTcQ0~CY zU*Cb>-1kS!k6l==0$&uQ4l?>En`W*c%{qX_Ao3!q6s$xGb&f?Eg{b=UeL}3U>Po0L zLd>nvy-^{epi0{=+gzo;Lw~m5r}Yzp30W2Bb<98uhc3GkcQkK(eQ{}ORP0HX(&ER2Mx8(KRSAM+r+XsBuz zW>I?m0ej-rrA?7W4RPlftbgOdzeORv?QW5#R>BBt^f*h!=$Ox_gs{M zEW(ps8nGR|V?|up6y!RH^C~rkBTg(br-V1c6le5aj2B+-L+Vx#Cd}#ky4xcYnjFVv zvy^6H)V<1Sc$y3%v?7ia^yr6|iMWkx*yS^yz}$I#xO>*0k&2r}p`*LxAe3H?KwPfD zSg)2XX(~x(;grF*;~RcL{%E;si!-a zap$HLBzawcm*$7~zw<7}fT`m3{z!_C{3KCLd+zgGxyDcnzJ8-zTa44POf0DHaxM7x z2p`&beH^?0?&x$=#9@ST$rto66ChN+j%r2LV1=?E04DWJZ@!-9Uml$C?a7XD9vnXL z9*QfmRh06}PB;oX>%$IejTEJ>mGS#OJ&yCQohdu16I9WtM+n$Q?Kntj2&Ctm6gCB? zi1tkI(?rmcL@r5CavhR>!pj=ya5MbJ3CH4%`TaQi;%kLV%Ol)FQm_yhc&S`L7)4D5 zQ8_Fs?uFY$!US!Q62Ku-slJ61_2Y!Y_s0j~ zFy%89mViP}-e3MnKqdP_zIEh&*l?{?<(jV4J2XyXe@m$p$j?_a1<7o-%{EiNJFB&w z0eGnQ5{E;`L)H+%Avn>p_F>DIam`HJ|J1XngG%9nz!ERBq8N#1 zV1)If=&{HN4DzyElQq4-BDqG=oPIKZP1c==6OWyW`TY%?b;)&j^0|M)^*q^asURd> zXjhO{v00FcZDFe_W=b$6IeO{BxE5|&!;V{Tf`j+i1?8v#7WX7C$5=xc+cM$Ql7=Q% zd9W-whNWGWfwkC=ysy&7(=WV=Q_sE-S?2`w6JFxD609Q^-UW-K`u?=m!;kX(x{?;B z!VC=bW8AoL$ogN$luvAf&+oXMC_^09Md3TDWspuNjY(X$g2fhvECQjl4_ zCtYJ;_8mUq9*Qf`$Mr-pGK3;rS(L&nci}J3yo~d%nu%A~143r<%%bG_aFa{~IH7w% zw}idlg$3c;&nwJoL0oZg=(aF8WiZ<1CfH}!Ph-bzw?dd${OZ9c@$+Bafh0|kayKTV zoq;;*2h4ggo!0#1?oj@3x^--l($>|IC|HIBRX@Y36^r8!-XH6Y??yoPMwEl#L>2y0 z7Hj<>Cvvk{&bmmVJC~PDg}Mlti+UE|jEk7J}*mX`AnvvgK=|5+P9@;of3B>R6-G$A27u6uN!x_a0uDpW;U|F2&sb zn!-Q0qca8&SE#^o73IX1f)sHNd*-<_rzw?@29dzQ2<5aNhwZs5cG`S1DH;+XkdPk8 z)egaufHSG=YJH3;Nzs!I4b#Sgt&;4Mj}Sh7J^N8yar3W`RJ+kg4YDvrB_Q*Of;AVF zN+@C|x|#Ac#z-2QS;9`=pc2o+F^BGt4c{{n-IYKR8Ep89>0(L0h^E~|>?D55-OJG& z?HW8c>B#BpH5KHMb5B_<_M%sYOR$IPI)X)F;ZI&dB(ETGJk+uPr+oi>yjBD1VB?Uh z17Y%}CZW)Ds2Bt}vWLW?;gRt~*+|HW$cnYN7p{RN6oeV3`aFE`Q#)Xn9k+v<)Zhmt z^d$~{cFS*Z&%=+&->qK}7e=bpY)#E8h|NM~PX4Up6trF=GdzqBR;?o{R~lmFl7orW z0RR8-N5e~MFdm`hDH@h>0C#VLl-#?%rhVxWDwU7AjEq}^n3_f;sNmT*=Hmw!UXEAm zDN>gdNW3=Vmcoh@#m+_Hs&b%An`krZX2?*57UVRP{OuSSJA4>-PWyx zho62P=UsIZ>W18fibEo-y_oF8*Olf(4cv*jJJ)<>eduygISjlsXP%|vqz!cV87f(b zuO4~;Hh#|<(q$J#Au_*=KR)>^W?XX_KDy52+gs~-76+|k+(ut@Rq{P89papSq z1<`7?4#TnH@?^s15o6PJ*2Gs2J5bWE70;sHsA;cz=Ha_PxfuU=eLyT0-7NaxWuF~^ zOmk}QdeJwbKzwBzk*kQH_>y(9L@1KcJ!znt)UnftHp4-C?*^L@*pB?DP?o z$wSOEcc}v*Q4KkGv_*2Q8&C7uWd+_Ekh3)MFjU7DqY~uKX7`?a31ANys*?}rc?iW8 zb`8cAaPi!0b8+^?SL3z3qfT5G*|ErUu5}K^7Q$l-Ads4YMJV7ub+5$D$_rKwA8iUm;$e=0~!HWGGb=Yw%1tt-t}?d`Knuf`If zui0J2*Ln`c>J?vVAs`)2$wwHaNZc}>eT_AdtMGa~L(H_Y=PE8NuYa_|8WGa zr71P#Zrpm;efarpcc9^vgsEMNrmfVoSd&4+I@{lI&NA7iAe>o}g#%d0O>oGbyI{M` zH$(|BqDDiK51CMziVe+MdwFmC7qgGNZ*AZjg`Wo}^-SNkP>|7_Q&{BM@nxoA107xC zFfUBdXMJJy_{W?9q2weT4&NnrQyl`MbqZ#i*hEByV4E%%LH5k%%nRK`iPU27@24R? zzV((kXwO|yPB?LiOy~JbKsG6b8Ej=GT>P_}aQEy-;Z?^W%ISoPy|{f(E^uG`Qwnty zBEfXgKUddJ$G(L^-1h#pSHsC)IT{$4k1Bi0vj{P0HHmrBQ4Z~j%|~JDv7mr+u%%xj znH=!)ygJUh;8MI+j}a1UlVi=ARwS2%MG0-uJXY8lQT#YWhm&I4P2Y#Z_x?OO+2@}| zaB^3+P>^EMugo?v1t>;&hDYOv6?HS3ncEk)XoA3H`nFU-pY<^N$>(tX)i#mKG@K&WrD56P( zvR^`tepYoQ%zEr^xZvuU(xAkFJVX<>TMqTMHG8A+kp7nNd)_OB)yWh+%ix=nWL*1_obp*D^T4Z8Lhx8bgb z{)CtW*Lfd}to2gLsa%JtbH_+3WTg^t?3eby#%r&l){X%?T}!ddItZ2TLa4DFR`i)X z$ssd~d)XuKdosYAhk8%`u!X&E1g`(KFG=Ad1Y~|aLR=n)ORm2K_dN168g4~`XOc!U zG^QIx+8ae+DI20FNIn)?I`y)adnN6tj`mG>X#?x6zA~mAbF@sVb&>`;Dn9D`yTfV; zC(s%h$NYLN!5QaXgn!K)5WZX%8-cK!Mp7Yuil5iO<+qFb;ctO8=)Kt+NtCPtq^M_GJ#jhF$QrxK}Y9G=uZs(^2|SQ z)+LuA3px;H1&iX+zYgI9$Slew#zP^zs2>M>en)(EyAPq1)?_-D4BQb~q`d5EwZb1s z6PIQeN2>)fFds3aWu)`czn&Znky)Ysd45g}XPkd2-be$~Ec-qPoMX_fd|AbmT4fe= zkP7o5dp!~at_L?IbX;et$7v{xf~3kJ!}*uKunTtFa&yTnkx(H`LRgPTSB1#47DGmL z#z42+c`t6c;}57=UzJp*-nNfc(8|49lU5;-_d-iRVdsxohM}7j+YlX|!P=Do#~-*q z)?c|BCLKWH#W21Rv3GnJF;3XRrI%TawWCw5WwidhW*%Jl`>8~{z#OQegu35TE<*K_zt*bNJr!f zU_p$nho0odd{Ya*-n3zAh*mTa10A&@t>Lw+uS1>xargMWJGNKCM}s-}NRJOf3jaDO-oVEW|jcfdjwr8SJ>#Ca~dvxQ*(%w)m0@ zx+bmYjNTXo)|_Zoq#pDI6;iTM)|Dx*-}}jhc<$fx5xP}aPHl}sA%QL`wk^1Lk?>LK zos8Hewz6_uK)$ED3mgw+r|Hx?(P5yPDH526@x^+bbMaO9 z>q~PmFSM|Mx)OyTsMTn3Nj0rV-d3wUADU^Sl8}pnLv?EOLJ6$7PB)dkzYaT1^D|v|ApSGeufw4Cpu?6QPeFlWCBs=x-6`h zWss66bVepLxE@MeqyE>h^~UeTSC2RVChQl^6rqJ}D?%1^8_k{-spXO0ZWns}GtI2t zX_p(|z)^X7i@7NsAQRxvfBgr}yW(0Teg_6v0rz}`k+Ul*cM+6}R$UMck`-b?z#=zE zS7zKW)yf`hAd3dj=_EMrp#8ArhU+0~^a*^#EBOlHX8Wt8hAko%#@KRejih|U`-WUG4KQS%QkmP)Ga)VFb*|B6A;8>8Dva@^WmL90_WH|xay|caL2<>!j?Maxdk*yWlvM-XIYqg zRVZ-rYB=nN)|v`k3LIp$xmdNs!*Pf0iw)LT1zs9SWj`MQl;;5iTcCdDD6WZE;0iOV z_vBw1*w3wq3c}c<=62xXYj41Vk3I>Ms^}-o!S!@Ht@Ju*Aui3+it;bXYOw-M?3UDZ zh1Jt{eQAFczTS8M>#e#H{`1(Q;E__tqX0$l%2W_mvMIYv&l;UROU#yWP){B7r!G$Y z(G0vc5F>U9;DLa1x<4D1iH1gCn zT{1pnF>76kt%a~UiCaFzzQEZj=&B%z!1amR9c)G3 z+|ME)AEJ;&Q+ehTWfS3(+irpVcl#{7Mt=_1&o?Nlv|(qWCQS+gyU{3r7Unj(SSw_a zkPAw(=6}pjaOQazV_ry#92bp9cXQHWCK0ndlE)H1TUi8I#ZIJB%@-O=|AdDuv1$cU z_E{w8_AGYVdJ7!5^QRH_y^e9?E2xD7!c#>{Q?mRHiLwF0Tr$r7VB-^cfZZytyzy7~ z?frj*^*cmxTVxPw9g6!Y$Reb&PHNDc;#69J+3O-%Emo!*=qwpb=y34$ol z9YN)JkP0%2CrG{Z(u7VKGw+%umGcIdTZ(N;Fu=f;%p)9T$(Inba47wTx_bqdyKjn! z@c~h^VIr-sG2!Pn!P?_Hal{w*z^1EDfX|9{*&xg!G%^n5VKaN`~K zAkBCjEA;H3WRk*NNKbvy7q9O{;tI}^_ZWjEV*BV8at32L8Ngv*+y&cjwt>R+xuHmb zR*}vnJ0Nut&iz$hfKmB*iVCun<)NW{kam31;J$&jPBaPf3Qk#_CdGe+P6hwEhc$EMVrGjYRSY5J^sZ1)3bOSmVXhd+U<1sIB1^4vq%dbn-KBw=^-6!s(WS`d{ zRh3p-Xr%%!ORS^N9LrrlgJ1E`sLvNv@#gEUg%b}x2wuG(Wsg=TMU-2RVi*v?ic}Cj zJ!g_QB~M8$m{qfmq<#Bh7ti&l_`!vjVou)x@x^f26eT06jS9Z>ixhB_b_FT!Qpp^W zq#f>1z;Aup^s=-fT- zxa~0m-RMYotVqMposFYj$WZd&)PkWz1hT1YDm?7Gi#P?h zIvx$ukvfhvMt$YrDcI_LYon45NEKc)Lj}l@f}01^bV=yWh-Klx&Qv(M=10H-k3NOV zX5J*OpjYj{yuKPbIyiQL)-BfBLtYD!K(;{l<`Zb85J&4mK~0q1P&g9f2;VsRC~UO$ z>hO}eBs|i4%ZQj)*QiR)u6j*kqd}~8%y`Kp1P}J($?#qtnf#n3y>M9L}s~ zqJs&F=4iX2-FS!UY$L27+&f9EOfVt)67tLvO)*Wcaa8CUHI&&&C$#~W&9yQ?>53a} z$K8)SEez(%^3iC3b2xl7Vx4JDBDz(p9!@^?P^?n62+{$m_7)ZR0-C|>lQdol_Z8r? zCDm~eHX`BP%KR!`czqtuIR8S-jWThy{7Q!^5K+V=jv%>Q-jb>lvRV`WWYCRd**Im2 zvKqsq3&cL}IKi%;{tQ014h^ux>O-jPKmQP+r#OhF*Y1I?P{g-O{Q6uVW}F1kFA zKKlaBxbzxim6gySJrd`DO4~|=l!_p3AqRqLy+swISQA$AX%TV-k;znQ9f<&#@`=gV zZHKKLdy3(nC^91-sST8&Il`r0Kc`#N18>||JE9SnqHb~0up@^ zGI$GL!`{pBdZ@s+W%>v#J4U5S9<2z~QW;Lvz{e+Vi6i#j8Kyo*VigAM zGEj#1)g+peJ4MRga4ASyQII%Po-C)*jfUgnf@`kB1AloEwbVhWvm13P5FDxAv)PD^ zTatYh?%kFPBfGW~3N;-{1g|hyvA&b0Sa0H5IP!qKvGzCvCmBGABco$=hr~f6d|&vq zTEZv45hc{oYh2&N0FhV0DLT_XnjCGQwrX6l782t1n6aSEnZxP!O3~#QFsH z?kpAef(0pBj)qglukM|V>u>$7)IQX&UHXrJ?Zky)-9%YxSr9zoh=cIH)w@x$HHP;Z z8?+76)P|p&1;vpH91JVZlA$51i7IX4m+|mlpT&8XT@6$1LVrZ$E_u1=GHFUR&f)M2 zDu@anEhg(1Zf9kc%oTj@l`HeCvEZVif--U2AYG_q2nYP&D3IIW4sQ7zmR2k%r7W7=^?VBJ-_ zWSSQFvN-jNX=mxUTw0v{lDym@I%Y%_M0Y)P0fdLDjhRDdb~7|gMKFx(3BT36EWlMa z{}#V_@KHFGF10?(n5fnfZaTl*j|Z^sD&08#kbSUPhmS5V6aScQfM&0Xw5+-lsvzX4 zV(kS8d{-zpDVQGdOeAyjO41wh;_LJAjM#o6iCOR@yn7zbqsTII+&B}?S+CA zRz$>GfRS{W)T&Ym<Vu?sOf#s3NmLKlYrai0|!wnLx31AqT_KfZg; zMVOC@^q>>YzO4NO5_A)eO|mnD*zN=Dh}#97BzA7}tI*4+WxY4;YtY?MTXiAhnj4{V zKnYDF3v?2I6WkS`!HkVp!HmnV#jL+P4OGTSGZ!oFv&5JfaW>jy+JU7b$M zi-7N*cM+a{V?G)}QA&hvffCkqFG;hx-Xyal#~6#7R1nsrq!63eURpzS90VrCx+_<4 z;?#pM-U$&f>5;p5%^7&hB)-f4OCzixtpPZNiI%>IoYX-4d?8hEkhvupfHU)sdvX1* z?}6=@fJVYs*fRA?yIfQd3Mw;X6TC$H}gyv^Jc+m(W*ooQ5`TjP-|Kci6Qcy`1Z(5o1oearJBruL9nf1 z-`zfo4{x;vIy^^+wWSA!Dk1SpEAdeT@S0>$8^!6pNkXNrN&@7U_s+skZ@&Xsm2pT4 z-wB$S!U9Gw7KHjQ@R&qX??fs{|9{aKr5gzQ;X6t`Dp?(ePuUgQZnBQVB~)+$VQ7B1q1qQ*KSH9_bYvGu~4*|j&svL{P z)GYmcVx$U6pkgL^4n3!kD_D^6oePe#Vm;}vue^@4FS{CZQy2Xa;4y_R5NPdCrUu}^k%aPSHXZS;o-kNkMpkh86v-eny|Wi+V8Q9P5+X!OYtUH;xB~P@NfhALJARK_@4gSQ zR}m!|MWO)(1lEfqf&@9AEs&JqWe(@i}D~{*QtQ- ztA#aif--9T1L!V$=r9pZI_7Y!wNgbgZyqyRqZsZE;K31vyElV$36|SP6!JkWlnUCB z$dnNJUATPat+?~Shki_=c@mQlX5PDHsrJz#5Kt0Mk z0&^Ft*%ioZ6%9&l8K*ootB`t?eb?o*CwBOi@XEXzzVqEP;B>5n8azadJMwYQMF6dK zqfm}!Xv1dK%laXj$l|Z058eA*NSI?~qLPb|*LuPWB zO=3pRN930A*mE!8%o&%$mN|5%sB08dp}Y$v_SQxLo&TXp3{@mW%5mf3`*YwJ7JLCT z`UR@L%MO#_)aRfha3q;AS}lmc-AA@zs@DQ!i2wi~07*naR0UO}nU)vW7UXHAmp$fz zbiSP>E=ogtI9TN+Adf}DybEt>RfH}|x0hn`0cSOs%JnAVi2e3Nhm*k1B4H?(;ht<$ z&6BPLEWH(RXjV(>6?v8?wdKni2Vv06pp;mtwR51 zU%VZDQaoSNiKENIW-14mPO7%ME#Qem^FYkQ>GjG2G3DKym z5OX}4%a_YEfwNpaVIQd zI#t}x%KFW}LR#)b!U06kVFZc18H*gc*XQuD z%{UxrAh$ZBHb_5n#7P?zg>Gk-#K*wE4O`5b0m+Tvv zHsUPHhfdP?&I%5kvKPh&q@ki;Oy7u>i%jbT3bItDyjsePlK0IuDNUJ5)mnhMGex0H zElA1n5H>>TOCiXTz{Sh1`#I*Z?c4KU*pyVjGz7`qZV)wCr}|6QsV#8l^YU73_!5+F zQo{$$?a~yOHzQ;~N?a?T*ZK@!eJ>aylM-W7TzCM`VFB{oyOE8&IA87d{6oTIAg4Um>8BCU}Cc_5_UlQ((~j^BT0 z86mB1*(g;Ik~WM6+MgoRBA_?&*@~NGf}Uj;o$o~2`(X%m zUp58dYmtepafmKbvF9CetP2p2MEJPsuR>0WnhmeV5~ji_6phLOH_DizP!APqLS+c4 zCm@j!{Q&)hpb*$nuAr}f03Dvf#TDkCr*qp-Y!BxN}dbT=tGy2;+sbwhILmP zC#h8FCKT3pjq&niRGx{WYsBCA${lQV8dQ>4IDUs%?TfCv5%)j&Hz|fn){0~ zCqcJZ5REws1(8^%IRk{vYlOWpCtTL{-6X_#Kf&~GeiWV0?t4IwO^1q;9 z6p5|{1wzl2&q#u zW}>_bUaiG=e(nIK&$vjEB-NlB_4ze)a%6%{Q8R2%3y`tA|Hs|Vkt2f(*#auQ4c#Cfdaok8!kBWlO zL3dp=NPduJC>s}LW_}tW62F9J=GAcSmDl6hSNddUBlmg6nH#3A+{byJ!jP}d3UWfR zzA{RU%q$5un7A^IJ7hmh@FJOl#mT&yMh|i{fR-?oG(l@T+}Ta0E@VI}=qku^PU_%$ z7haC%U!4b=1PI(RY{Ypd8En}grpwvvEooQZVspu@1KXHgUW15dgE(qn;|<@3BliCy z)(B$wDc@zD-p>CdKvRF#URi2?DXj$0@=_9^9_1xRs_j;GYy#Fi7Y%l!G1HL&UDZy^ zo!5_G+ywmM_YdHzn{P*Gs_@I*$fV(j?ic-fN{3>F-mM>LBFo9!SV1qPAr76g8+QEA zMsOSRWwxTO7c+7?VnHppV#%aupKY~dY;iRGE0o=C84=m?@4Oek_{}{?QXi#K2Vx?s zsZfD~h^@T25DymQ!3rYRV^2%Y7hpL)&N1jYU3bD8mpArzTsTyQBKefCB89TOxV zPeQh;l1+`h9Ly>WCK4E`#aUs_!ywsJBrT!oWTM|}y@}GKWC+=J6^f%gTUyg7Dj6bT zqyi#-%O;{AhJ@3c0_>I$`yF_G&VbBd><=yK5r>nO#0?^Y3FDhVpFz-(;&)8Nb0bS~ zt}+MZz(?P_dDvm{2XW+HpGBt=BVpyA2uo?hRa#eNl5c@53)eGGuB(FQ@crfRzS_qh zjNAYnG4mPGx>OJsX{3$(oMrLwQ_te8ORqvm)6OhVluF%$5N^LxlEFD|r%<>Y7Ij!p zDNqsa{t`C?NaGY0kAxBf_|}&X!G;qjNT#Sj9&0R84D!)ko)dZbH_tGcK;hIL52Cr) zxk(V><=6cJ_x(IrY2)*C1@1`$$+R)Ve7z@Q2uT)+I2u=^~5?{fYLfyWV6 z?F{g(BlpL;tB#Y}6XO+5r(&{z3L@Wcvmh;}Dn~2m0m~^1);2(ABuB6bLY5m0d8%Y8 zk}LD=SRB0cx|?y=AO8%m+KqY`OHRMD+KIjzN1v6H;wLYD4Xb31l8Z@2+_4D52!5qg z`rSPS?MYo2*7w`(QwX9yX?;~*F0ElmeTgf_%o|g;Qg=;sT%4m*fZ5a)XD%XJ!eh_; z6K7v`6+$*Sxh3hCB+8fZ1Y2^YM?Lp>7E};{+;c<))6um24Df3M=R|E+9BcPW%VZ`k#!)0Nq=RUOVY$IXbiHPEXbfCo+C8|S~4(1ao0mn;;I{e zDFa^;YtUJ(qEWADZ;0!`vf-xL`Z;=8F_u~Sm*9&M{1Um#(hy??4*F$(eN-2G+2K=m z!-qFr7v-!bDPYDTN>R!hhEk%^YD<3$;}0o#Y7aas=A5w(>kABi=3V#T=Xc(NsM3Wb z^WlU_JVDE^Y>>H2C4aQso?@JWOw!7oFXO_jmMvN4Q6&Kzjjj;K~UmG|D{ z<06JkC{@3on{?##^$Jy&bIieFqsM6otQs=zR&~}luHE=GRFqw25IP_ zDi-$I$vvmso{NODFZXwLOZ%U!ti`d{FV2Oa^+>_=!(bZXkK zn8KQKJIO$s#eour^QngST14!6oYz1bAQN*vWj0|}D5+zYk8g{;KC=TlQAb*vj|$h? z8Z=TPER4)7wGy(*#iek4h3r#Ns2~kLz(Y?zhqEug7OB^XBn<@0FTB4CFsBSwl0~nF z%q`^1$27#gyYDP%-!6{Vj_X3;$C!!tTEO*%R<(fAYd4M1sJ8m;hi2^!%-6Q=IZbq- zX$K#i@$JcuaUL9+ODySGlo5nj9fIVTd;jPBI?lT2a=bjhfy^z-(3hrvD>0b{X)Gx6 zBZDfJ^m6aGbV>dgMWv5G3ekND-4*0x&-Lsn`d? zMNzBV3bG4U60K8lIL<%{kR|MY&*2QlI%=6Gvqp-H(xTqM;%c~ze`xL2Uws0m9ykT7 zR1La3p#P0I7}w3jgU%k&8jhj>a(;Y8K`ZKQNZ)K`vu(DS`rTQB6omg6{))PeJmVrT z@bkM?#`V9x3%|JY9%MmPrpLO%Cs;6^sDcn67qF*U8g&$p^ltpfjw&+%xe%ci*`xuB znIlNx%^8aa?X?TG|KNtw`;s*V6eCqCOL|!5n#jge%oZuyeu>dntQpzwLngskS-<^v z_~qUAV}Qw%9MQbkwa=BXX<70w@a$R~9zeI7;^>2>-~;bnUHWod!io5f9a5R<>=PB( zVla?e5N!k-S_NSRy?CZUqBVQQw4cLZkHtRlkR8CCi$Jv!8VOlTI`Q9Uor{0GK3B$s zk#>dy0N7QbExA+%ZM>{+D0>*C896wwnej-whbt@N)gQD>|!C1N=KMUUoF84ul3=_mt2KcVv9)T z&@1c7Tf4an*q4HwJFo839qdGik8HjndiLF2x;ned#N#z|c1v?OkoGbvmaK5?6df>x zpBpFjOmFJIf(mjx49*(Di!J6<^pg_dND8wG{`B+q#YLSLg{6pz$cviOWW&ldT)%XiEzSU`{Df) zS4NqzcA-+ri)|d=P!Qt@ZBIULotY$IEMa{lL)EXMR!VdkLhyTYQsl9vbqVB)wc+%!Uo%#$1)m(#u$5E-fS=U6fw%rk@v zV)2zpQ>XuU;TGillQxE9o>-cfKe+rq7_!zX8Z8V@SQLCRM5%+!>&B(m-Gcj`_&fR; zth)q}FwLmNAd);Q4ZqS}mSqwYN2nF%w=K=M$OmYTW{QJa`SqoF?x8p~uN>s$cLk$j zYm{@bnzExeNu%}3&2%?65e_RQ3nZsjGkkC0`P)Pgtmm)Pd6vW-#ZdTW(bd#FAJ&Zb-U zoZ8fZH+k=~d#61PV3Q@epoq8-5bLXKpX923ZL4RywYRjv1muYg!qni6hQa@yeF0t` zNQJJGZAjAdoYNz;cs*3HiYyCvOtPADre3|yAugO)lnYTl^E(Vf1c9gUcx}EbiBk_nmlLDnBrD?2kO_(g!;^aiBOSJ$227YL9;ToyTeNqXnw7q|Tuzxw@ysCiXk zvM0rmDm;-94pLE&f>5KavYw}r`P^y>t+-KcIgoyqZX4sv`+pIWHkc@*CsKj36yq0& z*W#W^_m49sDMQQ7NwAPNbmpX<>EC!u1$l7BNjo~m+_hw{S1aMggX*5I%{zIYUW-bt z*+yEx1XvY!5M>@xzl!gjbsnC7buL1;12IP&kgu!mO~E&<;`N3WOFnF zLqw=>)kYzzzJmky-5aY<7>_gxbzH}#FEOVP!j{UifiMuJf)ukkn5dwF6fUgRD_RP|H=ywvfr#u+uOo;Xn6%0JaNJ@0 z!y$mtiMUV-EpMYHE!I$SVR`Gb)UDa4&svLHLh1&1_QjWR-lbRL)rLV#l5VMd6Oic1 zoON-J3sSf?yj07A6zZ*+WQSv?!#aYfj?aF0EA0RIkE7FN{1d7x#WXMDot$L`xr?O= zGBjyS3umXvJ*N(m{e_-+cJH(o0Ia=4N|K*JySmzgw3dghzsVixCQLxP*&TuLO=kI4W>mC1j1~nG?~Nw5l&maaFJV|K|gbB_(=X^Z(@7Iw96$!Q} z1mc3_YfK>{N3It1inMII6Wba`APr623|vk5e(Sg+vDSnRbg&?oYG1!3Pn!&fx|6B% zrX7KWyUaI1ZWnkZ8CH7H)iZJb6VD4Z5Q(^XoX?bS7{d317GG+c01s9WHd4_KRDLX4 ztPaOwm5Ph69sVUDnB(Lsg~8-JWchYRE7GKc85+`u7bo>hf6sz4um}Y?=|_P1%Hjp4 z6>zl}H`H}{GZv>{9&Gh~(=-pDimPw96~BMrA-EmmWL5<$>TbYZb8X-$+$_DH!cHj( z0S5`QpP5KjnsknAV;b0v>w;oEe4jnA)g~JVX&K4XcxOs3P1batnI?Q>penL6lVg`m z?@@*@ws6XwxZ}QAxc>IvppR^aE_tbRKA=Ktaz^&tRUwf=Tlky*gAGU8DbD!8r=fB! z;{G|<{nI;O_fLLI_=njWuHy%^;MT-Y<*!wtEcsATZaiezXAim?NdL+Ge|`$RKfOgr z@W>~?NBXOhtXeHK4F{}P$9O4Ci{kf4vFnWG3k+ZNi04O-~{ZO>$I$4WA1-q8c2g0RR$ zmXCENbl}9JnKrBl1%F4$Lx2B3j($~sEtx92yqcO9vw))N(nw_Ck;7IfcO1-*El&O6 zPcS#)8kMD8mVGiJ7&-kXm0AjZ?TCWrb0{^aq+dxi?Kw!|x=`+{y>cgxKk{I#R`uZ~ zp^TB%+@RVFWh7}?kvz)QHd`{rb07>a~7!mK?lyl@5IC=)*9MBe=8HL}Df%v|ghdK>LWf#mnoW zk1()1Y~6GEJxin@JZN_BNv{BAwYR>gK45bPDzaL#AX+>cl*(KHwZnPhw>|g-uDk6H zDQpQLS*mg*0W_#^m{F$E?;6gE&Ucft)Sm|Kqo;(x)_Gx5k@pA(l;5Y3Y2RDqY2 zs>EbU!z2H4T8hrK=deu?A_mP4M;z2q4IKDc9sBS0Iqb0I=2|qAd?CN9mRVD#E#4tf zNXZ7$DiIpQ;iJqtadcZ3uD^2@Zn^VbfsDqyUOpRz)?E5P^2}4wDWbh2EpNi%eY5Is z{$G1n0%ccKW%vE_ucQ(tnL66RC^ic*OHv8oi~~)Jf<#udAxH&2AGX_(%fxcK~en0?Gq(k++0jR{T=z!0Jd7k|<9H&h?e zzR=db@`!ycg#o>rEvv6u24g;3lY+S8Pj|W~cJE^z4y<+XZ*MG%m8y*Kr z3c>{>TZmH-{uJe*nMymWltaekal)(r79XE~t_vD!lTvS9sgk&U3qqS}ggQv!^gOaH z8RXfH+d{_n#LGE+{Tp{+XIBr{f)Pn=j6<_2&e#=uF$NZZGv~xy(ruMNKl&|&UW*bd z6CQC&iYl)_^_?J)5JhB|Sa7g0K!=K!Fm2f(eP=&?-xr z0w$Gvu=4VcUc2|V=WnU=SoN?CZQy-{?HYqgxIT3YQJV%O$wrB){;8`oHekL zL>0s{W;g|LNJuiSI}fpOMs|4&OUo{6@SS^pf(={$0Ge8XtLdCipCzictM_NTa(Huq zj2W&`OtC1*ZLCH^z6*2T`)-`}mf1)Z_X-l?b9a0@HAas4VbY@#EsxGe>hWcT7~~nT zW)2z>(yC3`CEWKb?QO^tQrtK#CdvrUltz+$=ch1LUE8@4Y87~tyMKx8s+p#fL$Z{| zS?@d*fB&|VF)5xyyqpu@f@QAKbf;8Sp57uO^F9dYA$$+AaI;CGH!&3t{qN&g|E=#! zg6VGdkt8dn(4A=$(Eb7BP(f%eN|pe#DVS2k?+_?M5iR9j%zNjX@V>Xr5tAzxE5Ia^ z=*hFjJtL^49eS7CirlNdt6;wX^5)f7ooS4@Yv835`XC{tOn7>58B;-edwaEJA)!t< z^QvCjwHIGm{S9pIGQ!;FZB*S(yhHC6ZqPE!U!_VY3qu;f=*htrWb8xH5-a11i`y~n z&?$)LsUS>C(TVl+H`4A>$O=ZY;L0FD=65##OeK)#J8%;rikF^(ZQEbQ7r(v+-NG(H zUxd575;G)8(JBC_wv7-Z&Ox8vft5PJpw$^oRxA9&tmJ#m-yDhd^XB1*);J8i&!$u2 z^Jz*84>S^kdw5>`Nu^YEtqlY}OrVgMjGZ})FMYie&+Y0)mn&Of%LkrD1R=C%p>Lm{ zoWdGp=Dwgv8DVT0ucJui$wF9q#-XkF|vY64eM^l!%sYg9Bq460}_>DXWl|V zN?xVen#>{{fUpIe#tj(UQ=^DTpG3x4pu}wGxW&0=YZ|=G?S}CC3K6v`h^Chix|LPz zYB4-TtMc*k-)szo%oKe8-VM0-z6VjxPQl*1mBvH7lcox3RNlw2y!>_CI-d^f5;heb zfwqxuHAO6%|3RGi+G#L(nuAvA*-4qIR(rSx>zIYd+uA$+Zhzk!R6(|OF1^gcetBR8 z;cs-o1*r-SXA-h!DGAk`p+Ht}n-X~UtE+CtpPqRUg;-YWsrx91k1a)EV|h{}ASpRV zEDkCc<T}aP|@cCjMk)(H%a{Y`5m}^ z?QPhRhmbr-XD*^zf@BS(AbzYoAj$v$6?REPK~(uE^P-y53fBviTP`AF3YdNT3@keL zY+;H_6iExD-MMniKuBsMpSnI4m$fbI`09`p|B|&deY{bd;Dlo011<&m%;ilWZ zi{Cx_BJ%Mh5~;NK9*F{T)1WrHVLT*Ugfu;wYC=GQO@O3tE(4<_I?YPZ5Q~+NH3clY z_(B|W#KBT&NTp#b1p!ys#g@fPblwBn9Y51y2Pwk2-32QwjBCGr7k>HhV=yfTp_g!d zob;FdO)P1@?P*YbEs1pdC2X(fi3CTvGE70raF<=$j#nO(h3VcS-Kt6iE2PhQG~QO* zbWH8plX>l>*D;r6z)w}mx3zQ0H5OR9@4M*YjUIHWAetgV8`LBcA~5D$?I|ZDDe*@S zY=TKm5)v{W01Hek2g3)ps=$V-HK9GLbpi!&!IY)oCSBZv51#f`aX#7YRZ_aW>WQ=I z98H%ptp|K~^3^8ZMqJGp4-p>{clWR!R{R9|7M4vlk-?sF20#48W_<6b_e)nQ2I8ns zS=nicBSQ1t=Bj#mPQWy? z@&%+xvOrE;9`f3thdyGg)Pv(+ zc{o0P{yZF-(AEtl0xyh{)!BfD%>wTX(mXQ-3N?M)6)7yTxV zo)_dQEotmOyzfEW`Qx9VoS7_%d2w$71gQw2s_1`JViX~crRfnQ4q#97L$fi=oG}fH z=bs~KCTyj~eaNFYY9^=>>g4(qi^kK&3?}qS6U*mVh487*Yb%rBEtW!eDTeFUt;eHJ z{zV|)aAo%``Ek2`pq%DOp=}G*If7);`djRjnZZbMq&U zE|(L}RNknMf(RlMMT}VY923^R-ZCBZByRkVJMh~lpF%E{lor8~t5*Gr>USkm9_V;7 z`ELFP-iuRCI2Nr53lw@~@%W%39ePnH*>SyssEIYS5e#AQK{8HgN6P~7=(eD3ami8J z@Qa6V_lBRN$D}3wgr_lWe8YspI(fmQ9|-bz{jt;Wu?xDeiS327#_lZ~Cnn~)|j z)}nB--?E|&hYb6A20IDhWxi1+M3YrG&nRhhjKYwa*r#?ftyI-ad&=4x7-TRNAn1nia39kAjA z5eGXO+0;@=z62}L9^3W|ZdmtCX<*6hnsmz~l=8&h>kHI*&nIO{IBH53S1kUhuwy8_ zO|K)H(*WjJRgjS9tM7E_+cBWZKJnXk?7?I*ndj z0assfDGr;IMAqbxjg@urF;K{EzluYJy$6F@5f8#7#mCgva;>$-ig|1$X*r z<#g0UlfQ7gzmK+uYUF)G;pZ*pq_y>t2e;r~7GHo@w8TY0*yG;BSV3A#MWOjzTldz(z5}Eu3n_y#()hA8w>Vs6 zMG!WZmJ}M(zU)rZUoxo#%DFDI#ELlk^ta>mx6P4pYI7hZ5UldE6XnwMwnd-cRps@y zuZ+r82_gKdIW~$JdE1w^Vabw>B#jq#_u{Lo*5dg+ITRg@e!ftUys3Ik1Ofpm7CqSl zK5_9vOh4ifF|}AI#f8IUM~wz6a-t>{MdMOr#2AjgL$zXS)O8$@p15JWCop1^mzE1O zkwI6<;J1JJ3p#K8HhO7PvIA66n`G7-NtQe}GL|HOS4_#`vX3ng8qgSPX~>r)Rf6hh zd-GJ{vQBj+SyW1#%aSX`c*x!YV^bM?@%mMG`h~v=w66-{O|&rR=(8p?-!6IzxL zmQo2R1~IwmAulcFE?e|ZNEEx#k|3#v4*(B7C%&b@;8#}KNVQxq*H#icM4a@1J6Dqm z#eQZTLn4d&H~kuS+<6arkQ65~PxOirVWGaP&qN^o5Br=S|LRv^@kc*`B-7k&9_g4x zp@-=cX(0sg#Xw?1J4fA3c^Gi1SqhE#g5Ml`e+{2z?#p1A>uRLq?w(v({Eq9^+=3^b zdKNa-;`)5lKMsXoOCqmiIfoCQ{XV>ssy>^dH{05|^g|Z*j?m1~J;v_{&J7kNp@I+-U$g#p{New$ zqlY~OQ!U67|4y)mqyBQ&q|IZp?Uj^?lV{D83OVK(Wyq}G9aRvouu-E@qw6(VnbpX& zhHJtF64HK#W0K!;4KiO#8VgOrkAD73-1T2Sk*tJLEGY?tH9M(pgrvOYrot|>tf4vK z*crI++FF*qpmYjb#0+f00D?wL|Zl_4LP>vFX}!Lz0Ftc4xlu3>WI z(z+DHogyyN*WV(NOC=kjI(F>s@$8E`aP60GfMJ?D$q9)6bJB~9ve)0jIZ-a)&}|S6n7gev3379dwi3Z^rI^6|3Ia-n9Da<71}uTmOBZOBJSYgmynMbpJKoMejL~g2+^jvXZFD5!t7l6Rdw5Dv-}Y*5)@VPl+G6 zcuqai)07Y0pm>^i`SE91NjtC8_Au`pb;UWtRV!<{Nzuv8HG z0GjpSq8zU2O~|)ZsRFv_q6l8!UCPC*0)d7MD-PF_cjoJvXmP(->^Va{hR9avzJw0$XOccg(JJT}7`SvVxESrEKi| zsvv&%^T)DQj;Y}M@57(E>kK7XFhoBpVbZ7?=x+k3TndPEvH6W`$U>-l27DTZPYVgv zKtRymzGNwK>Qz5s+J_RrkQ%Nn-YRKBybn}&;iI;J;Dv0L&c%@JU(b$Nu zFPC}-cdB}xqgn>-uT?+8D?r4^3yu!Iheha#Dozle0$oet4)yL6it+KV{OPTQI`m#C!jZ-bTocv)?`HAc20j2`Yvv1)`>K5 zbMZl1pW5M#R565IP%wn=!{>9}yve6Z<=!PqIv0^iIK-&5r5HUp9ixCn1?UqbvW7m6 zPcQK~VU?#7HqUDBm{Zr3^!ZZ5*2dP(C2uem4@Y!ASp5DJp^)_S(6nZLq=niLCOF~m z;|g#>yxkqiZh=*zBz|ug-4n!D=W%g)TRK|37cRP}HDBr2hbB&a%R$YzsL7_O8-Dq# zspq(-oJkXSdKI8YXyo=K`U_FI{?50iCb`V)2$~n(BYgL0d`(NH6+7#j%V&U@v)Vfz zsZS!6-)fkG@U~k!mo79GtK@KW*|SuRutl1l9$raAFaZr^?XSbZ$EE+a4+MIfKJ+>K z0t%56zYFTmI(4)5!S3T5Q3VO=wELWZ!JZxQ3&Nc{5@@V*L_Sz5%h3CSPC;CfPQ=6u zQ(^)*p~Be+7ju+oeE$lfM^2>=8JflwjQ)Yos^_Y16_pBNU>414U$MGQarFCILluN~ z-`u%ml>rv&{_UaF@VP3_=#J=F_G*8nXq|afz|lR&pA}IPOP|#6SFimAB6XORvg%`o zRNOh@nFg2jg-NR$O6BI@&)D$Um77X`DAqqEth1?f%&Xgr{e8nTzVf+!-^E?$&FIKl zvcT%u?H!A%d;99H8kSYErE|%z0GwRi5SmvtCD0HFJk-|SaY{q(6uHUpD2TRldninc zZ}hO6_iIX^LIS%CVy8q~r!`7G!=fN+YArp>z%pN}`DsdESS4U#J}|p|#eN75>wo;P zDF}zOe@?CX2i^Q;QvzNB2Ilfv?JK@AToM^p1>vCgP4+NcuhYEm0VaW}$rtLC%djhm znp;cO8(@CDgKU1UDS^Hcu)wXe+dIx5Zpn;@g6K)I^d7*zcer1udH(}J0_NVf_7!K0 z#G8(of+&UQlwSgN)=0cq^BEIW0%q%;MDi`CE?7Pw9d0Ave8d!lgMM($@yEgB zG?k>0(|4fUYjl)@(j65AX_{LH%EE5Mdyc$W6&>}cDF_Ggvvtcet;NFkn&?&|7Scq# zhq-rZ&+fT{_3#+ZHy>36@h{ellMLq+kI#FLRGecl>5K^l;UJq4_ra#>fcpKgN64#{ z=9p0s4!D`SRqeD*-0Oxrx2jgjjVT4;;9VB*EzMNR+#Ub=I5f~W(3)mzIjUot@3NC~O= zoS{pAMA9+XPt0mx`M}WcHgb0ucM1|YO?Z;*fh31<0tN?J< zXbQ3U>;WZkSJ}pw*4w;hEXZ|SD@fqPuU<0G8Y~BJ+*lB5a}0RQ!2H{+_7&e6@3NQ> z3KA$v=aPT5z!CrljrWNer{mvcfEBaaJH9YZ$KP+*6G}n&c3W0`YARxhD*=1uM46=V z@gC-L@kH|TZ(Oi^=lB?Jb)%ec3KBT+tCk#v7_I>1PxCW;68QI6toWr9KYH!<;d{jB z-Fd<)h!-hq;FG{57FarZVxD+UrYThbt{dqZ@sXC~gjWzRP@x^lw0;Z*ivhfDqy;@e zp7*$cxoJwz?i)w5%5NlPdB7-0RH&O*Uv;K2=E5dCT=i1B%UZj3_QI8SSN)9%a^V4~ zAb##Pb}l<6ZcFFE;1a+bK0)TRL9bz7fW>ts6Tj`G_G`8^=%(ZDHV3qVL}!aBEEYHi zFmnM+8+S9=m;pZx*zX(QyW=e-q%p7G|1M1h*@t8|cP@K}LHT_E$R#|{gO85^xVLQW z59Taf`SamgXyf(1O$8Zb=A^pOmfsD7cTE_d#e$8EehOL`S2q69;fvt14E>mkRY3y!6k{O?Cj}jZj)sd3ealimITZSYJpcwoK<6MP{Qvsu zmQv*!e{sSVj8RWso6F{8U|^uXnHiPc#9Qm$#^}p*H%hVJ;7Q?PzRtuX>HPHcH=*1d z{JbvI(e|Br9v9;H+6ynB>iL)Y$qz2?G3KTpa(p-Tpy-}weoHdA-hiLV{b zKKs?nLP}^ws3M}m-7qiqQ@bkxfe5B|NXglh>^eyUc|-+GqZ4n zeybLBWIIPd*jQx09RUtU{uvcl>+zGLqf3u_LEo#Nh+{fh_*!-z?3+LEPk%2L_C@lU zBI6uJ_1(`r|1G3=y9x>UxmXiz+~sA9ZLx#>706?4JK>uabNl2X!BJ$bF@Jk4%egHOnkj+*}#gImlw2L90vZ^ zvmkikPT|9z&}r*lp0oYWeg{JAm=)FYtrTF9Jlp#y-i$;8@T?^pPm1uTvbe?l1+ zaxGk?Cjz68bE5DT6L`*Ho;Ojniosz8yO7*jCHaV4I$X+u<`D?H5p)%Es#8m%0kjd9 zKI}QIZ8;d--Q0nRL~Q6<0f$VZMq7sdRK`qpD09`rXtGH^MX+3OLq3w*Y>S$ze5$o- z=SKl(sC`4;3Prn^xoJfom@bA}M7}n@U~9JxM$j9AFR0)b^WT*20u${tyG~UbV4{q6 zK~IU>_(~cbGyJlQry@NL+ntsLq$EbPrD>)QDMS}Oz{qE+E zAR1vp<5ItJ_E!A1^*a!Gm@)5qD2<{9olu|HnC$H6lKun?OeAHWp`L2am{hfJ3)3<| zMYPuZedeaI?oe^IO6h;u-`(yT7}(YKYq;c#11p1qL>vsNyq4pS`ISF=tv1=CO||V0 z;pW1h(J4(W^oRAG#`H;g-XRQGi8M37m|eMe6w~m4PNEvzyaSK9NlL#SO}E{yq&kCl z)N}vsNTMC{FWp8*Zi13UE|g<2+Oh=e1LXoq=QjIGF&!J1jaULyXY2{b9( zABc%f!CaTL!~iY`0Mj3ZFxFn5-ZSku+zlZ($#eHWLg8Q{muQvKFfqy^_Y9zemHV~QS=O<-&nL&u6&D1SrCV0L6tUz_jTo@A{tF6854>jy4 zPY2;h!lP$}J9RGv!HikugFYP@XuDY}5g4Nonop<36`nv0@ib2Iyp3dYqu*B$V`G@EtrQiQLjGs8tQHi_#VKT4 z$E{ksW6Pin7^HgQ5J^REh4`MVdzd~6Jz2n?`=Utl-mxt}7SogRk`(Xo8zFLjxl~FJ{_4Vx842X* zZWeP9{-Ls(lY8a86@XPurG^b~r>$?QN1wBv^?vghTZw){VoT99V1NoA!1=q1E(QBR z+UkwDUV0Jirvi8NO=k~^D$v+>^?hYRV}7Z-m~>sWYQ|2|zzU-4$FjyW=Crs?j%*{Z zC&vEaR>We0!~tWhhK4du^xJ=H{rk;uzTA$ee2AFMQjGd2;nBzRLqf!sJ?FX(!tEMO zML7h&%4oz6g&_SA{hjePWbNAb=0MCiq&S81L=7-&GApKgCP$uU1wm6Cgz?8X4l~ms z5!527+0ZF=0wstYo_6m=Zk*C9niM3s0Xyz@BL~_;Ps{4#SwXNdNHHh`zBI@*b+i~` z34w{v3HA&5&!6N+%(dtG(?VhTjY}uLjV-CGt_qE*3WEd`wR8cJ2>`{p*4@Okp8Ht7 zTtahr+m4tO(v(Ij1Ed}I^Wvnu9k;u7ixjBbxpoHEy;d5Wp5qMA4nO0NpVfc-$LdJ6o;#L_h1Hg{5+)&A_ImXDUcAm%k&lud!sjwH4W&J0OF zq&@O}2U=UT|0fe|slX3K9*;Cxt(|UHk@J^hFXQ#qS|+$yU|<~D-GCu$`rf9P>&8z* z6Uwe`(VJJ#BXRSdK7cLAIkLzD$t1Ab-pH;qF>QyI5+I3RnB~=7O3+UMZZ$31a z7bGQbpklH8x!6H#MJ2Dp?QUI{wRK6cZ1f-0eaToCH*$KXp5#9d24RU4+#y8SScEmx z8s6Lkt{wv9L^w@QVv!`Vsiov?Ecm2{F|IJ2j-4D(Z;^ZV*ph)e4hN8UacCf^DcCK} z*~KCVxK0MfG|Ci9uV1e{s#p6Ty>Z%|vDu?pQI9{n*OJcg%td>Pn4Juw;66rt#3qpEMee{0!HS6c#6`oT0M;; zG}ZM-$M z=@|9JJ-1KM(4|>@wN|ht_vw3~!q9+yKEPkl&`UK*!muzlCa%Kr{JGUHdj$%d%wsNp zNFvS&O(+6KOx}Dz2Ky1I2%K&4E)4MyP2`7RSQr`WYr@T@DjZ$l)md)xSlx}zUV}TK z_lB(jkJO_$)=I4PcXw%e`435zHxXSEChf11kd0zNJ&%FY8)G89g0JZkxBv~Gb}3{n zrDeyQmm7Iu{&N(qeMst&IhQA0-2b%QklCl{UsGnp4h$aDTOct~CfXZeB#yBUMP+7Y zPOFT{n@UI|4DEHNwOIPqJ`s{1UCpL7Z$K|374{5c@(=FkyifKsuO`GXFZjC2Ro250 zL87qA`{dM~OwRvO6(#txikd7XC26(Qg{w-?R9q|v#`J8c!iKnwBHZ|EcJJpDUyUZ-cJ~(_ zuZf~5TC*j88;6inlJ zJ67q$HROaYUI}KI7G(!4u)SdO-|+L|i;}XW!r}G_8oB5jM$9Cj&EtkZb2X8Yo4@>% zb~|anP)55#Fs>kM|6k*kCw<=M&-G%27Q;$%8hQZ9bl7a!*^1tfD|z~z@WYdn_?2j) z+axNMBhW>53oH=|ckQ6KWeTI*0#u9kVZUl_?;IoQ8QPf-ynQuecc3!;BOYAM>+H(M z_0Qo65-$d0_}RZ)hV4HC+z*Ap8@&GUqmief=)6=Q{x4E&LMR{}@v6amr%q)Rg4>c_ zl@E;ZfK|p1(63BpnUBFR!Rx&ApFx~S!AUls+49$$4B}Wb$-WAbIk^5$EyS7A=ymhU z-<`D@TUuH^y_(OwT{eWG=6+IqP^=YsJk%ZM=i)#|lO*Sbijw+@;`pWXIG&?w;7}6f z!V((=)?%#Ra;#bi$?D9Is0K^+KY=e(Pp4qY{faH$W7uPhvjv=d3tfSv7_^%;3-F2v#8{f0p8nXJ&|XA@y>PneK2(cm9I{qS!3w=B znsCCHT6N_0dt#>}Cx=;t3aIfT7ZGBs12H@gS*{g4j21|Zlwxfu61rl7Dp&F;&NN+@iZGXr#1oFe?>10>4n z6x~C%905gjGeur8{Qp!!CY1RtR45%zQ%kEvl}G)z0fd*d^l;k~brU)r@)oL=(6Mz> zM=q-D5kSUnLzUL62bbC!jeQx(O^C&-FawBOO#Er+<7dvc?&*j+Ez_PnD}B;(PK~$_ ziQ{7yC?Nr-kzL7BFO2yY@rVa&xhOP3O(N6oe17y+Ym1Z5=J$f;`G2FzYp@Co9vj5% zwE9uF_`3clDLNn<8{5-$7C66OwS;t> zS3CJ*kQ`JwcKU5>^SX^dwFMAn`l7#i&*gUr+xB*7MdGAGm$*gTK@Qi{V|8+MK>dB0cuub8g<%YBcTA_aD zD454?1dKxpYp+OqW2e|~188@=OhU2w@AQ`%#9>2aCuJF&&7t_Y)5yroOdDhnVOe6x zpwJhUx^`OV;fWK!cl2cdIWXe-?HY=T+S3qcgz0cK{Rq6u}*0?ck^zuL63c$CAr@N!^Gq=kI?EsZE=N%s9OX#2coAN?P z#C2?NJ?ww%6~GRfuh3^a5s1xu>dVsO!+~W>*>l~pUE`R%EhlZE0`QQ3jQ{aCne%$g z?gXmnG^~SXqA$Y6W{sK6R4hhLxVnyn?gg8K78TjffrlL^;(2A+RZ>#&{SAv2I@{yv z6(ttx+I&&)R)pE43v}du+_d9vg#OpAqO1rh3GnB;%sC?zUp}F1#Yr*fzis01qp*Lc zah#KEp*SsVEXPLP#i=m@tQljAn?FkI@P^AmcOAi^f@f!Q9^~`-gkR3Ve-YXwMuPX`Iuy=pL~VT}m99%Rx-1-uCnB@R?vaw3H$W@e zar?#kPSP<0^>mKp1H7yPXCij~4IgFA5THj=8(RxhG5U`x+{cV6_83P)_HBFl+w=T1 z?N3>L%mS4PE-*bT_8bKa{7p7#Ox=8Y6?eEIK1iRSi!paYfXfUE`?_Tv23`Gt@j?Az z0qMEW`F``AkiBuE)zXM@A=nxZy3JHD%Ga{3K7t^BF{t;z>5mML@>(NGn?GG$`gqz& zB~Jrg<{M~a&paERfLZwq)Ioe?G{KAg5%P4F#7~hY;A)C~ZMJ`ok0?bAZ20R?LkRY_ zZ%5nz&-W3#gXB!J%MaU=Tn(BOpT+o{mO@`G8Brob(Tm0<9|S1=sQ%|iKTO|S9GAu# zuZ3b*C>eQxpc7p^9R{IAjKN0z$AxORxMen4@>^*JJD%iVsDJZ|@czpT#D~*tYLY{X zaV|%a2tyi$p++vQ-eR@SvRzXw>%_|OHiMMQC@V8k94`N-9ed^&)fU*8dtu|uH`6)J zP?`Dzs?jO$jj^h+-iUfHjug~;gFDMM*c*uqq3h?X<{Pqd6SUrWa!W#Yh*f`~Rc-_6 z{aaph?-GDNRN`o(_r0PP4m#3FP%DdeJfzrX&Qc0`4}g(oHe>g4cR_k(bjQwAUm9L02FVVnURQ$eo$xKM`CqDQAaJ57Pb#u_h6!apHZTKt;O}nXf`n z1#3>pGGApI>e*_(D|Xw*CQ!8P;OrjY99#H`)d2p<>9HCOhaDeUBHsv0nA)R}qt*nsxZ~ zOGVpiK79j~NZUUZ$B&>5`MC8vlxn9&s~kbEVK8^j?MtY(Fy(Bq&Hr9SEDL<#ZnN(wfZI37ngwGHTmKXcj3j=@rw7+X;il zS;IEhd%bU-SB#XNIX%#%dC;6z=Qk$?+DNC%@7G*5M*;RBB`}mlOtKD0wW9XQ_ter# z({&iYGjUmQ=ZXt^6vu_iKTE>B@0NmVDnl5fe{!|cWjCaytqsj9%XO8oW-w-e+6%5x z#B_W)<_opk_!N~i6rf%;9rRzlM38c~W-S%@N-Cn#Z^>RbPQfr3fM; ztv$@`qn{EYn|fV`EDL?Kj`4YiJbeCxFuaDu<5|uUy*K5WFYi#UbC=Xf`br{Xq^RpF ze^C>ZF*GzZY+mlqSgIDu4;lY01A^8&@ZnEmjO&Hap+6jFYt-vrcyUOGZ|2nBJ@Bw- zz2Lw%x3~5zGKL$w1t({OhzDOZY%Fkb0XUTH9OPLPYkkNp$Np0TXR~aM{J~DEueqC2 z+va0mo4yl(e-lB0(-?b~O8ii3Vzk*#o-+=_tS`HX3|fzgfu&GWvY=#HZOqWL7s!7b z4mI#aT)32A5HzG@B(JY=vXhrOgh%OnutyKe?J&Qy!&rT4J!}`E@Hmiz#uCkMd`0ME z%^BJ%{u@CB>1G=~7X$CnWZ^wbhkQ1QjBLqHrTqe7e&}w-I^XALpkO-zurifHh^Q28 zBi%cPu;ad**^*l;arM9TXenLs4AB4+1Hj{@>PXLEc9mbN`LDpqxGhMp28zM-ld)myq^g$a3fSAc3kG4)i6rpzP=- zvT~bjrQhqOzdL4{pVT%RUFNW$Zgi&xXb9lpWM*Q zwqt~ILbhOcC#jBw2nwOd;a5gr;b+<{491l!o{OHJ!?1^Y#d~aDS=yy5%T1`5ATD9` zJ(X`c*qqXD0#(5JNzipNU=vtDX)IS zmJHJ@gDAJE?rEWtQXsDjEv8){`nXW;l2=q^RZ?+IJ>22lJ1>O^e@pt%;mcj| zt7;nu1(qad*pTy(p2g+;hjd2+6zOpHT;N9xaH<{%VamB~Fh@Ks{Ks8YZ!By;iwdp+ z_MLwpVj@BT)ZILFS)RDNr}i$F%ya|>)Bs8AlDSwnYSwDozk-nK0%%#JFUDm(b@f}j(W{ebc^@r$ppKb`{l>BYo5nT^(Q|GR=7#h~#>hKc zRu#k`K}I>gG^XkW7X=y=?5hp*CJSn5L*V!j#Dv*cX=DBkX=PCpMZy!ol+OlD=dVwe zKDYqMiFM|uS6Lb^eg$#4zsSf*H0qU48H4O<-jCyaRyrQL8} zX|5lys0_^&u*Jx^G=ij)jFdDlF%gs5odS-A z38(<1f$|1#Jp}PVD`oS?035#h>;Bw7KZe=jJC2*dnZklZ@7*?MQ6pdqF1`8Ee#s<= z`+%%90xj~1sOy`&dV16qI+101YHE2SGR?~or!h9FRu&w)3H;m_@{d`Z^Fe_aNZ{@E z|4@XrdqmnVRAlbvcDMe#DPpc?TjFl>@wWUfDoN}j7+Dx3f39(jNQFgzoQU0MNCpd4 z??r{H`Rd2clybGQm6y0s8BlC<=pHNknx+1M%9Oi3b371&fEqmjO4@~pA3KaZ@YDUl zntgp4>0NI=2A)SO$Sp>Ui6{x+Qb4I0h5B~41p=awWRlabJ;M2fM7T1@(}YMwBDRKv zwZtQ>NZHmbh3ywbNa4uL=o59dwLbPa%mFf{5}Y%Zo}NvynBMhLZ)43hWX2=zZovcs zhv({0cUzplzBxz=TKl2a&#a~cETQb@c~!1ITdgfsi~Ow`*8xpoMa zm#si9{)QflxTd1Q1L^4V`dcSK>R(DLo<-NPKXA`gF@Q zo@V3m=E-DpIb?aA%JTnu0e(U6I0d$j%eX6yNzMxcb6*j0(UeH<^f{ws_6PrFE6Eu9 zmP*(sXXpKVRn@r9D~uf)8`cin1ZqPa9Dc z?!w>oWjbE(IopVS=UBqA0|f9pDIhI=(8!5di((9CU`h15m-XudiKkuivW<9 zfF{d`a<&u3oX?x%S+Bls|4NRXw-44{QNYA-B}5Hc^&fe+EFptY-P6r0Rt~a@E766R zJ~G=!XJ`A`;U%A$6rs**+Vr!C<942El=DP6ZactTAN+}#SHKA$vwkHzwMv=GJNHMP z&T?d~S5C^LnqskJdUYsJQhNdEV5&}$tg>PY;)i8SEZM{QvSa-PdtYS>e}suS{-iYO zI9#Q&P-4#6XwhYj_*B7BAZ9>g77mO)uhHu?L{b={vmA0BD;0y zD*L2;ii=)PvHxPtXR6!|Oi}rUc%_#9GZZNO*ho0}$hrGsw}iNvD-*%U0G7bL!6DVB zUl=Hj(sYT6-_CRx^EdY#Id~iQ5%WDa&i$u_b0KGdjLiM|bW-EYq6=z*N*TiD$!80& z$+MVb#lG`%% zKkn+EXzHslV;}>P^xq1mB^Xd(hS!VZ@rvt(yZ2R?3{J-bJKpj0A}eu8CE(kz02PE2 z#xtA7y%xZNV-fb%AC-1cjSU;rB2bzWuA=w^>hBQa5RKda5?$^Q4uI;VV7l(qfbaQwp1yt}B*)8Hy^u7hv z?7f7v)hs_)*O!?2mdI+-5@;y=HkH6ezjAhPZt+6glx>@p5A4)MW@-%-S(dZ)CuSMIUby$+HRH%|0bxg} zu~Y6jOpUiYo-cSU?>~v>hVxs!lfB5WgK~cX^ zr+5u187bW1RLjhfbtVExd7@dT_Per zrgmJrDIMl}Sj!dZ4fvjCRL{A?clSeoqJFRKzl~Hy4hSBKt;$m`54Rh)1o%cRMkrxd{Wl1x)&}8OzLHt|3|#OL3i7kngY}yq9i`0!4&jUgv|^ z6V{a)O(zG9j4Avaf6r@Sl0V6FdvTZKdXtf`ASAJQ`}jxwSKz*jA2;UpMZ7_5%i?-= z5-7`cbATgqO-WAY@T1UL z%*9{c`3#ehy?XYvcr!Y`V2l1R%`XLs=c8;emWfEay-|WB(#+@d{d8)!Xl!%&DOr~g zw2+=Fk2v8}HwQ4f7(@liUUIc5J&loE{l@EqM@^Tm_&|R-AXgj5O7j$2!#=A%0rV|u zq=v?^Tlj`XR%CYTmr>3@W#e9P@6ZuglwVw-L_w~u93!&Ppl&{^?bA94^-F4EfxEFt z(Un|J%0C+7Wm040aSq&4wGi^jFGi|qH7K9Skl7zjpgm|^x;`B^H_k`)On31BM|;N^ z|B%ME+v(`=apFf6M$(!N=Phl${w4FH7CtC@kB7+5BF4v=jp zz&awC&Os64rk;h1VlvTKa~1|9awpY8(}|PslF%ubTx^uSXmF~+ShQC78KXuh-R zOPuey3I=9tt<RfDI%OM(vg{*vU%le<~T z=>c<`SEcp2&xNQ}6wX$;VmIr)z70z)_hi`MLf{K|cbVVN!B@t#q1JMZh zP+wyTum=3O*yus^TIA@8UCgFsL0qrludY(7uLauTBD;TMlaT7dcP&lZe-3?!ex{O! zOO^N7eXuieze36=>Y+j3Edk;FGj?7-(fO5tsuzjFMw~r@Sc|(jr_FQ-b0d1sI2zo3 zyP=wdD2z0UPDXM~LjqpKgeOfX{#TM3^_fgquU8CD?quv+mx;EPjsPmf20TcVu8dH= z9|2Xq^oK8ECdzkI!;hEi9VAVbG`1rKn-Wx{I^yv0C#w}^qF4P}e2lbg)v~isscrvt zT!HStR5C!bO``E2bu-Su)~!?e_H)B}>C~(Xg>7WD@JXLWr8qPnWFdSqR=I8^;{bgk zxi`e%@2hN+7caLfs;-E_^4%MV?480z_ka{p(Wc!63O+fVvwh8~6Slc0bDL_-_$bV; zLo~X$>!f8y|40C`%r6DRS3?~d1a%7;wNcUg3itUZ;z>Z%AUB z#P)}h>7YjBoCUFd#ya1Gl)W6zaTB7%xlK$G1`i^n*}$PF!1h(v)S`U#mqb1sm!-<@ z&$wa&{j13`LkoeQat}9F%o6IcmJgtYQTM}lPQ2v@AV+&OyY32b-koY|u5~-FKOKVe zXPA1rE9Z2anH%Q_L6*ZJT9S6rQ_ik`hRmHmlWSkka(P=hd&OXtBa(0a`lvlC2%pD# zWUbet(?VE#A(-`dwJ&=)ctwZd@6A)}s##2%u`jcnaV8zK84&JlhspDBWtk24q8~T`8afx0mkGJ^OzvtmTuLr;! z%$hH8ai~@UQ5eU+49VO!ylPIlbHDPP+%hkPvJ8}cTbOM?+M5J zXklpX_)9=yFBNQMwV=C5^89?ioxX@#uxD2AFV?+Vx~FuYdYHpoK>}3;NV^!C+>9W_ z9n?9bMjBMK^%v%)) z#TZE3JYkZTP8TowQsecP4RmZ<$IWXmG{V0=b2*$0&twl9L*^DU^h_r_D_J^eui5ZN0Uk*p&?oL zvE%{h2>%eoE%}b9nt_RBtX{mMJ3jcm1|I3&l~*q2kk65irUg4oKgrQbs6~|LKSJGL!LyMu}WzP>L6h( zopKPKAqlZ0ohTN|qq9z29n#tQlK19t!B(W-c!{YieDAoa1+#}Mxo9X!_&XHhbJ)>Wwz@^GeEVL-Xh)$lUeTX%N=JR|#$kdNCCqinthiRb{o0FCUFw zUvKUkxnk)INmu47i5&k?Ouv2C{CAkS|N3txv9O!;C-I^a??pi0FHGAQ#-E46qIc;{ z<$nE)_ZgFV%ugo#Sm<#{;oQdDK0KR$dW8KXL?kqe66Q%< zgNL7<@J@?OePrKK9}xnm@+3aKh-a`fw>XLnBCjZW`Ob&V{{Vm5g>m=5hxei}_x8A#-AubAfl23_{Xa>T?`Dtqop#a9CozL3Ua3Qi5dt`+&3kx%um2Tddcso{w zaGIO3%0r53XODbALdf9IxtG`3C&#HU(W!o$RwzpSv=qzB_D_2 zTwGH+TQfQAc zp_$ocWi&GI+_VX^xN&oTpX*w1Q=ahY_l?u%h70RxnTON%>v=3`q+2GC+}A(a3kwzj zH{s#052EhP@hRHvnxBtUV^SFKx}%6xs~U@i5_^%nY751jj|Vf_P3kP)ckv%mpds8+ z0*M(~<^U|D|H?l=?Ne(WSrr}qc%a??$KVpJ5T#Cpff zAd2j0?=}K2Ok0qfwEVqe{gkq~ugFyP=K`jypZWY|0ek&Op7HOWE`>|rMf^6F|5O(L z{KbJLDx1O7D@02!d=Y%@#NK^EtF@R=Maf6_;|hZoYlj{ysi#=sr(3b76O&k{PeMb* zMGc0J#|}iR&T6#a;$~2ZNk;@l0c8g*o@zOg0Kehvlib1KM6Mo-bDdgNv1vyYX6LiU zwCFrI;!miv=}=bnp3-1Hatsr%1@RC3>|%-E0(Ne)E~IZi(={=W`;oRqp9pq?E>~V5 zc!6af14Y%(aF}~sp=?Y^GI>e@R?IOz4G{k_|B~hBu5S$K*_n#5+ix@<*ZE4dkA!z$ zgg3tb<@qC$G^S1}Wh_$FS#XO%4&w;_XF%OjAr@2uiP|zxo4eo8lEL`xbvika zT(l&x`gG=q$G2B#K7Cjxb+l}rfNxG^Zty#0-imUVl#%{OM(rYFv8MO@Z9`DdvnhAk z%df!N>_1Q{5E%uTiF=MEuL5+uuU@Q2LTk%&+M++qazAuD(Te2h>G4&q%sUET7W0A8;GOY< zJJm|iOieGJ=`22CUr?Q%5U3b~Y)0G9*7L_*oaX(PY0#kSJ2hWgYdFj)`-6RbxM$*$ z8fhvE9%p`7p~rJX!rv*bHox;y{xA=(l+5&*WA_pORuM;`LEJ&TxHvkg8CO*g692Yf zkNh}5P&?d|S;wV@g%ZBs@?+13U7Aw(0+t~&p7)N25W4%rZ>@Us+Vk@zTI7zgNNRd= zYFD`OWz4(t4PkqI!PJk>|I2fJL)j52Xv2V`y#RThMGF*;_#i*+thk>T@l{z~Y8EA^ zXP4imtW+6}7e*Mkap{0;gK1|NIW%>EG)QT$-M_tURlDxe#bx*gzd}pq|6pt(-2t|_ z8Ts$u?dDIu#QmT%*JG`$sq)7jR~!grG@`4fJm8EqgXvaM%dz6~~hp|K{dj*8jV%9OQnpwFRlEt89~yc|(xd zQ`TTsk?rtA-eHuKw-aVmkP~@GobM9LNE|AlG~u}ucjgK9*zALK<>WgGb5J-~BoI&( ziSZ4y@(JsH4b=9QogngEd;Du@{2{{L*~1t8gLPN}abD9<4VaJ5oj;h!uI_B#Nuc(v z0J4-N=9dtE$<3EItj*H;!?YpkJfpfxu|d-;l0wvP3egq`^IyJiuh>_`+j$MXl5&PQ zXhwulF3G4ONAMP75$2gU8=K9=a#o)9+bTrpEC)rncnb@fSfBw3&-4jf`Y$9%`(K=1 zcUe2+RRg=syL?ET;9@XQ$0ATDW0g`a4@=I#r;+ZKy9b$l-4)ihhoyt0i^8Z)g+0F1Ninv2CC1*To$*6C-|Kfr-Pz zMNVC-14^ zx({o6b6iP{4Z*vcHpNXYfn|fs#IA0Tq*!QtSRE?KrYq;Bo{Z=zMVSOMx$&Fl+%&kw zIBxsXp=)DAp*Zm{&2p(9PETktB7#I`k=jJvRuS67`x4c8DubtJ*WOpFh_D`W6)rnk z2M&c)Ot3BeyukA_$(18l(ZZV|IO%T-IlpuKE4^^FzLW~u4(zXebBz8a{EeFYEq-Lj za$0dB_sMRTKSD5&1Lc?SOycn%8Nl%bTbr~lhr!_r3fHP? zbIn>%ptAfda)bGB5Ko=qN`JPVX70&RY#y2dN6SNInd;@!d_ck@)|7Z%@7iiXY8g4yet&(c7Gq$kE0IU z8ssS=)B+t_*Q1%P@ZQKtry{cq4f4+%^`@*xZ7KtscAWW9Q~v;3mZG~J@Xssx>KNVf zfnB3Mu3SqKC(?qJC_Kx8u>~gGCnq`?e1*Piyvl-1j4x%Zi#6|)>lLfC`Wq>qwHGZceT?r< zHuOd*$4>9tRt%g2ue2hah;py00S>=spkE;j6TnBW&h%|=mF~$HrW(aTVIQXu-{AX_d)lutf2(zOdp0BqsHLnhB$sJWfEw8_ z6yY4@w0c5R7RpOt-MN>A8W|6}|1SClrVpHTn5N{tTbhsVFqmpEj4Jxu`WWj01zzh#A#k zXNuZu7Lq+*JPV13PXBC%w}uc#lo%S3706^@;fL&%SNt1Voil!TRk1Azkt?3Rfl2ca zd_FKy|Lc6Tx!k3fn%p;|6iT-YS)=eh>on#JE_5E!XU>-ygC7b9#$nz}x{SwVGcmN- z0R`Qj1Pilf9=nL{?Y4#;jWSdDBkV@D3_OouHi?eukC;-{~42aYHf?Y5G_ zOVsnq?NuS2atPqCU`3>m)%x#-va(b<-`w0!L;`pP;bgx#e+vY!stECQy z#lj(#XR$d>NfAv#a|}3?B(mS;vojQSKe44aw6P zoU{wCLp@V2Kc6a2sp`a3 zT&vFg8<7>(VLRWBo76QU+c*u|vk(iLdrRaFh9e7_ zM-m$|bI^4JTpWk+yW0^%0+fv;rY*=L4mO(igvuY-SZ%kh+j0#~iE=sy;lOVcUr8Vf z+oMc}n1q|Rifok2x`vfAL#zwqd3G1UXXwY3&R6t0h zOL_RR(3UX)mr!;bJjSB}cW0yZWrxh9?T)!)`6>-)QCsj~@IQuQlxmu1~7FNXjH7QR$fikFEm%kN0aV3j2_r+J)I$$#NzedKa-aQc)U zz+R@1UJ$y)n?Q&)Zlco?sWZrK2|r5vy>-(9w_~u6Ss5=UrRM+j0&ov}N0@jZd48GK zJGo)d-=y7eGd-VBWDZuOx$Tjh6acYk*#_l`vnLPLWtfP{z@;3ih+k~=xHlzOCwe@4ni!4Sad z?7hop#}&miRTGepn6FDf1&21m@OTdk3Yzad=G-hl%2=lO?dm;376MUyOm{Z$ zwnut3>(mvLl*zUZnmBqW=n=hFGvc<8EXR;7-OBg3*9QhjlE)w!QW}b=;A4?%r1_EK zk?S=Cq7zwxfoi>Rm&>)#Pgm_)Ma92(Q6RT;Qcu;y5yl?|E>NDTkYECC?FjqRn94k1csYL51axht!{qfKfsP3oO4bANGvc35qovC%#9;5_l zmnx{8D)&l+<2S_K4<^%AhOP90ODIjtvBBaYn%IiChF&P&~QFd>~{`E{j4_LxI0ZD zoXgbF$mLNgqdweC8L8!3a#*!gT~SuiVrKX*gvZQ^vO@I@Jq%WPJV+GNl6mM)Sw$o# z&P!`daiCE@jt(g|b4U2VS(ot#*{@qxt2Sn!2^DZ9k&TuiKZ`8k-+c0S)czbJN63cgMM)^2!`O!0}C&uN^@jypNNL3&(_^vVr zx*QRt`Yvv;C{E~o`-e?g%VvSAzv=FTSJ8vXgYymKUBiQzU#B_OyQ<0w-Ara>^R)@H zTrz>-A+ynSh4wm5)}!dgYOP&=qmc_-5}6Rq7yRSPC&MibXYK@EtQj&|>4|Yc?osNi zhqWUIco90$>0mPsj*L`Jeyd&=vNyOnZ{rw>f*UAz`%>aTU?g;#QgI!=bq8m-W^+vTHYu+WFo7%t+YM=ck|&5(Z%|B&)q_)n|# zbXXWYUkp$RpZQnvuNr!M?y?6H@m68|zp(m`2xeZ?h)MQ-aq5GL*XNuWttbdbHkbc| zkB8D&%DX!Oom#1fl8C#8G66NY`85bCinM~2YoMhF zg(y@EAUU9kjKBrz=Xn46T95WoBP;B94fR?&d;(nOyCpr|8Z>ZT(;DXyB;C^A2CV7R z2q-5t)%XWj#CvS9@q9t6q_3u?j+&bAqdDItgjU#Sy1(WdnO-DK*X+}%)yA?i7#C@> z%rxS*MJXm&{8sh=*Y}sul>;qRz0xTAqy;y#e#P2gEJJA6uvO5-7iXN*btjZDXNJ6BH!d|Hn$S^k)EGl zJ~az>O{U4sX_86w@D>*QS&&}FOX*Z<;*7N;_V!-K-ZBN(jG4L<3yT+yo?}Y*s}NVg zqI2*E;RwO%J{NQ0^%J9)1isX7zUzJ9XE72Rzs)+NSfj(}lh9=SPN#ukIvvY$MU=_9 z=xx^Dne?RXv9lg_)gP8#(FU*5XmmstlUHb+)(rnm7s%Ay-3UV#NrnwL^3G?&$ za$iQro{*R4H)98(!ty6e3Fhm)d_P|5Q?*tK)f4vfLUjS(oFq@80mjEHz4P+g81zGj zNg6fwIZeG0bal;n{A`IWd}Jh*gHJdLNv2m@T%Y!tV0KQ^njEKireaaG#xsVAr^3K_ zfJDK5rz-{XR024fl&Wd_tgI*DAU4@`lj1{;9hU5P3TDs19WP3t#0tf)e&pY++g?6C z5Rf6>4psuN`X^@U(Pw0S>Jwl-3^SvI?LTt&xrMvcsPQJ!e{ej8J**en4JEjnkPdvW z;5fWs;53qoClO`>*P~-0a%$jFyQclmh5$HmTcP}tYFc~Q@2oYgXmyoXX+tko6_WxG zQJa^b&@r`=o`xUv&%*e4PdIeJAX-{r@J%pv`osl-e>=Q&58`iOgwwt62S-o#mbe+hbrbSC+xlJ@slVUtlMuV12OO_V5cp&Pw)Sc9dHv{ZVSWRag@iVP!Cm{({qh zxcjUjKjXvLdD+f^o`vLIKR*+Rgy)jq+nPTmFo*oI-7LT@*NXM>F{=Nv^MRCdflAqlCSPw|D!8q9%x;h{KEo1pjlI{ZUlCGk3;4(d=>YV817o9_|0op1eKEO z+AtGe#fY^K;y&^+p4(=7eIHFfK*vo8rP_0((Q@n;vSQleaV^AD(+wZd6T@LLa@Dot zzv;s4P)gz2%W>q<-`25JAKoBN9Qt5L++yevhL4Dd=xIK|snYh60ee<`taAg6j19?K z+Xb&x#_^*QhF#~j}a$B~32rY`eRKSd2Uq_RF!-TN^SP02!r^=FvZ*{SU z$_#^n*bhrmHHX@$eVE(rVGY=nINy;FLKzcg$pT82l)V&tAau`Bz;Ka(g3H&V6KlI8 zof-3wMd(r)FZw(1i3-_}M_^W)vaez1Rwj`AQ^t-IeO5oGYS%z`YtqU8<#`MVp+M(F z13I@#{GS;4XF<^-#tzMeNqknUPd;{X4@z{)>&(bV7#J_U?ZMI9`cvYsw8LU_P#XnL zSR;5`%aVMa0Lkd(JcnYG#Vel9x%_Wpt2PYW5?1LmpshG3Ua?nVN8|5N= z7f&y#`mfpzc%(P~DiG!T3yk z4>#`vCsr{E!~gjBKL@*lEtv$pSk^o2PV!Y&GtOA)Oy7v^xnak)s7H(xF|60gogNippU3k?bn9MS!aCz~M&L zhE)olt1=FB9jj##G<-7@DK({qy}Z>xd>u;HvmAoO9g}5LI(pJqsx2vEmKnbE5@o@M zs<8)+Y+zay?twyHbvqNmO2^^iBEfpH>n9-rKMP4Q5bY84*QL(qrf!s#zrk|u>9RwW zL5@JL1msDDuY2elNx)H<$&DzjFd4oP%aRo=lA&U#B}1^HA=wTf0h!+qb|v-1R0}(M z5hRKCLgoBNHb3xh(+}>$563knbBf+g_Q$hq(1fL(XZhpTExqZl>7W}oKD#D0-8}>v z=$k?L_UwQNidfCU0Ayr(UN-&dr&cy2P9FekW|7e z&uKR5YWXb$!q##`xUsDB`;DC#uGY83BpMHb^5^kXJ^=yKT3G~}f4JFGUs(92@a6V8 zI(;6s=&LWovn2lFVWq^%U9$+SoM-@h4Qlj~WR z*OblWbkhIttwi19Zzetucvu|FL|W~lf=pQI9AA%eKj+w-kH-hXKn(8>EA)o^821ak z?d1+(=|=~|0SMA`+$80H@9)o7vUbl5>};%P4IBwbV3AKOP**Asr}}PJKm~{al4R4E zGpL!%QRdXycM;&PzD{}Aam|F){C956c8!m5x9uH*qM^i9^k*oUPsYNQfD+fItS%+Ivh z^HXYGKXNT^c2W5@^|elDpv0_KCzTy9z4~Yn5CzWi54%AmI@fRag+iv*qPD+fazBvc z19K@lI`m7l!uDndm5x1InLk!IoB<4GOzwEFAe3TX)*H?2jL0|U(uWtB+=2+=}N z>XO*qFl37i1PJ12uo#tPD_g%X65qYYGYO;D_o3{i01j8_6G*o{Q^nFyL=MT@s|X{c z{PV{LAT2I(k<)lCZB}iszp^n2P>sT9>#Ow59^bMk8G6qayWjfhdHl(-tpbJ0i3HF5 zAq$xJX^s3PL~_N8BFyCDP!WOflmKbXP5INag?;N1?_dbxXYd+MlS!$bSs0)w(quh} zM2qyr@Y34W)I-IO6qdq02tC@U{<+%YJTxH3tTA#%U^0&%Bd;Gltao?(XyZ-0*iE7W z9qp|{(c-t~c_iYmU61XfPfjlbVel%1W|N1m_HJ4DGN0}EUQe#jdt7FgSJNg}8fc-@ zOE8k;+avOm=Fxiy{D^ijjs>O4POWr8E`F{B9XYCcIp35s#c;if#7|}d$Dn4;VnKf$AA7p*lpy| zay#mTSY1C}X~cx;8~pf8!K|sx(2pS(f1dL{S8Gli(qy*TT}04Iw3xbUF0x}2p6K4nRdREcYgDeXGUB^O0hznfNp^FN%J-0L|+K~ z_31jjtUf6oywoCKgYiX3Dt|8jjywbO5l_< z)();ObvcefVaoRpL>Z@<;vR|-N+Jos-w#9{f|ZB!O!6fXxH7amqo(23wPxPv9Fipt zYnO`<0YX;zg^lA$qi*k?07|TtXtZ4MY!5K(T$mFIqFxKVs&mSKSw8Ep9zrx zo~2v4e*~+)CXdOhy6Vs8q49Tix8vqYCx6$J9z^fCayd?B%YhnJd%c@gFa(LaH5bLV>}7x5} z09G_*3!G_hXt>cc>ws6M0Lv%#Ma+{meJ{5$U?N~u8Pq^3T|B|!#oyB4-(|MBrGA8j zTS2M-$dk?|FjQBWz_uKlY`;$~Y13ejZouYV1Gix~!1hd9?ce#M*%fvEq3|nxbJLQk zYo16e0pC`}yC*fcVNX0GrDcyKazdjgy*S^3i1eTmxHd{ULw#_X@Z}>ET$wr^X1LW3gRj5~{(Q%g5*L8M z+(Tr?5f0{%uaKJ4mKfev6wn~xcXA4-PO!sJ?AvWAx80Nov_e91S8V2cwe=7}5FdXx zQgHloeqGxU#Nz`1nm&!4Aq=NyGtR zroMh+NGA+UjLi_fnIvieukd$&XfrY2hN!)BQ#&!>kWl z=vGke4hq3fmifl@6YiHzy+5`rkP0M<0kSKvNo=fITNWjn$E91z#E(4hmjTTGz@nkE z5p^(`)94-Ms)#UF^JO0Kn~6tb6X{d&*v$-u&~9{oGs>Om3@HA7Dk$Jw+F-%UR9eU|>dhiW}hd1uf-MgJa z9F#3zlOKn}Q2qVgU{Ae+=X)mdxkY9|-Qnh1u5$@?MK>4f%#ndJ z<J5adeDLtl%FJdTaz(1M|LYVotDaSRo_%(wvw31wcApn_Nuq$*D|^!a%wv)>o-` z?#(f_ehh+-o`Z!MUPu@}e#KwW)rBoBZajyKoXw|Z5^2!(uXErq`HQ*b1$XD{-;JFo zM(+46xi_4NH#6U5^x<&F@sN^J1OK(vZhIiEZb-F=r7(U;LJpTh=B`h>+3_;WZ$lTj zXu}M^t^_wa?i{0SR6fKi#=KyefI~#@WHj{1el+WCy1MM4*iv$|PZiP~Q*CT+7L0r9 zOGIV|!f16`_5XEmmDaa7)|Et>!^Y5dZ6{tRvdb8~n-P3{$aIcJNn^!_tH^Wk)v$Zo z;1v+^HQKLLXRDfb3W@?D$sw;7wU+s}5KjJXVHkEU`pffO8o^?@13RA%2osAxD1QGC zkn>|e(83^CIG045vs$E>?w9;u&zGo^RR>NV=BaNXavwA`>S)o^&7Gk28IiY`goKvK zb)&hiOgRUaptr8>VMcY*mBkgV^A*et4xkVB@c`%iVohnTC7`I4k`VE6Us3{BF$J>* zTfy9r!hXx@=$GA)_smZeZKWVsiT$kQ2_DH80fR4C4LbG?7J8ZpmMKTb5tBT zQ*!$a+qZ-9{%L)*GAVoOF{!xxmP6FuFck!5{??Y76%sx)tdwBDkl3@n_;Cra4*~z-bA?%7z zPQjrbB4t+%&jkQ6jjPQVmwH0mU|Q_>61o!_ti@tjlOHdyuJID zYIok^f8<19$m_hb@ALsatz9FryIJeByI&Y#0 zwB+euS96JF;D_0pY@JkQV>fT&s z7{uA}i|SzEpgc?qz8Z42*=h^GeVpw_3(PrwTJA~m_`&%krRJ9;D8!jUk!lj9F!`sF zyI0^cA(ypC8|gc3J_ z&N}_4!<9LJeCWoTTDlfyCY~8v7xobQQ$K1P_S>>_QDq&&cob~KsK?kOcCVN{WC5z6tuFAEqpKN>iGWQ zptOYr{pL!C4vDPWH<9UWJwX$_qd_CdC4fBT`FAA~cwBL}Gf!_N3R^=^08%IRJ%}xg zX*0oQo4VIL)Am_=>JHr7{`q&{QHRHBlc&m*Wdp!QTIAFE+H%8OHqK9QwUggFeGw)a zwd&Z-_FC^t;z=55$S_AdQJ2~HE@2=r_`;dyCEC?+i^ziVZ-Kb9Am@m=#22qeQ0#YF zPnWapNF|2a`$a0X%mx#(^lYb>-#YY@O^t&m>bE@!wEej|##@1*_d)(VPZ;G|8sot4B;SC$y(hRN}zuI;d6&)eQ;72f@ zJW9Xj@V+6i(&rfbK`HZ=Ip(waE`+7dTd_ zU=cI|yJ{zkus(*%DTnd6^q#AkNDK$KbyAmeFDHG?Q?-MC2D8#SaavRqVx{V^u%KDK z8{wLC?Zq|^-{i3=huT}qmYuM2ORjNFLP1OcnXficm~5F}@m`Mw&I%!=Rp-JA?ERIx zOg0XS?8==^A9M23@5H|L$a%M04ODwP%k7%}9gtt_z7l(UJbqKQg$@J*x1s}8H> ztrtoNUFTgKPhns_cKvro0DDRy$DSqo%Xa5j_)=H(h0T-KasTxp)3|CI)P{bM$DVPL zapD9OImK?nV)1->kyAglby>5_8(ydjKHnJ@!>rVIBV6P z0;Vsnb*TVcI6VSVZ`;d?h6+Sd{#V(~zP4tAjnILR+9&JBN$07J+%uRLLuB*P6jnFM zypfyo7>hhjw-G*nA?@A6TU&o1BJnRJ$0I$6_p!JrAtt^a=S3AC9yt)E1EbkzL$^N%%_y-;mj&v8fZhiGP!B8s1 zV`cA`Tbv@2@|^{7nntTx?wf3)WK|+U)mRHC2(c|a9K72i8Rp8~eUnSqY0Rz`o=j@z z@EP*Oxc!dl7*&_$jR3Et9VgAl|5(d9;E8j}JNxw42WgYCK>A|V57bvcgPr(a**GQ0YM_>ydQ! z6cTs65xh{V+D&mrCQP#yC{QnE)(A306lK{G8JBnf1uNA0amM@WBB^@_S%X`yT#5eWLq&>glq| z^RuXHhh@v_J$&U$EJd$MVrkH=NK9$gw_}xWp9;5H$Tetv14*AEmVG=pUSm~ zH5N)v6hc6Q0JmfxI=g~LBEs*Cd@2l92POq&!~zoZdLVDh?I_wkpK)O=n1 ztLwef9U;|%`PRU>(e+ru++*OubiH2LAv)v2uN zGa9(NT~QK(_BM}v!pmy#R{rVH&yqb#!@>Vyi~Kvab?rx@kLSJ?S=^fPpN{g{?+!?5 zx!sFOvV{LJAE~cPRqaq*>@EJFdf?A!#$K%Rr)=S&-d^MWII?bV-f}M)yUm{_gx7l# zS?rbhugNs(>qBox(-Wj?08#1Ek2le2VPxl1N1gTIiiz>|=Jqxz`k+<9(GiYxG<#Kz zCnzvfe5OjDt0B(m$$uy-bw57)Q-|H0miB6Lv3KWFHQwQ!SB&-gbOrL~@g0isENZwMU@Oh0|` zRQK`GaZ?@|fBX7=ihtA{V=3U_bzesdRxiD61LLW%itB-w%*Ei%%36|F&aPQQZ(y3b z=!hG1#DaA~@v7~B`ksy$u>V<1iaCRud7++_qRmL;!2irUX(dFUmqV37fl!|}q&&d? zwIYLqS{MRD#o#5NO_9QoKv1VS015^G+LH__^7#MM@#=k_1Yl~mA~Fa)2M0!0QcL!WvWc~a^$D@8L z-nGA>1IG4Y=T#;mtWNRv=ZwjH7MJ+vXRH@P8KgKMVYSXaU9NNl63Qu2*d> z*c(Njma-%SxJQE1t1D(+x}v;JLA({U260e2_ zIbKD_X_9_S+Wj%zz_P7Hy)e(4Hy&zU@iEg-x1Ev_&Hn4OAq$5v)8djN5Hk`2rg1)U zCZ{NBZhfV^Xa$BzPhkl|%to{M38VH?V>;T;*wA22?%o5xr?j~g;}e_>8z7=pAAqZ7B=b?MT`|CZwz=!y303q)4eX^GP;1d?%C zlk4KjTDno}f9^@sRK<%dgtDCVw`lts5BNO46xi_We74in7-uheDrb57!;u-WxqEb+ z3?gM@!FW{Z+&P}#BSA)Vb$kem)*H;)>=VuwHM(}kaH!<$_UrFj%&QrB3S(eLsko>r zCtemt-umCxD!z3otQh}MQ+ zZm`lh+5b&fFnsw#Htp`nbs9cir$U;JvNI@t-JX z9Hi?r8}PE`PSbX7&hQp51O?s8wGjC;%pgtoY!-h6e8f9v$WXB(Oy%?7Ik4K|&Yxp8 zohdK>Yp5^KNom&#OoWr)0Haj9F<-n~P~RYZBjJB$aj#lpf?3^>QzwTZu=i4{B|=>; zbge)Ch<-jeII1G%Aw(evG0S&JKF=E&^>{IqtaNcuLDf(JM)Xg34D)$VeAi zoZ<0IUf#YZ0L?nMx1Aq6UAcbw&wLpJ@$IvKC(|3OQC-p=-9-tum=KAlP9bt19e|={psJMp6h?{%iU^#`5pg^qs$2XeG0pN zfGvYra|{Yrj?0Cw!4Cf(XTxV^M%TovF(e^o;jZINR~I$4^2^KF%)umOC-T;r|IIcK zmc2f24YwZ2&6)`+_7Q+me{Eax&z>^w0NA}ZR15ww<*+k{_!*`N+u`+V9V-x{u>K5L zPC|r@6pyNK;|qjg@VZkP;zjr{eKo1fHO)W%uu1fXJ7H42l2{0foUQE|*(CrkAzFW; zx74f-AHxp!LM$=s<9zlu+anJCnc4fL?w4T+>gbHa&LDyi29v*oXA`nWmDCG;Bcp%D zxGyg;8%jp%S#@~2W}60$8nS$_`I8i7}{UME14j`R2NZR+~NMcM$?fS^*5F@;j~sRX8A}nWhO|JI0!YIpn;(xEVL~q=gBGhFQwjD@oPQ zzpk-$5^JpOi7pnpTR9DVhv#R!EO{f~gc%r`)gD}EeNhVz@XPrEPGe1MW!&9~=o*=) zrMHgZmP|0W*cg(d!_K^;Z0M9F@7|{#VG+;3oD5A<@Hei&po>F;(K8Em7qZbIUg}GA zQ{xdrapRj6A=H;YDUAU>5?; zJKkk&nLXM8_-pTj0LzN2lQ)L*^*dGGg#MMDbS*PUN|fr7U##Yy?B4W0-_43>K<0r9 z5cU95uBYE+iFes98=0YC0VZwwX3~LlDXK1BYU0ykpk+Yodbb-JNq>+t{y zTF2KV>&_XM96pmFBuy$7an2hzK=`9%J!*lL^aAAzuXu-R=cS7+#J)1xUow3D);xZz=+UXL ze+Mukw(AL~InHzImbixQkfUR`TU#>ITR8tJ(KkJ#?J@J03R`Z_j7POd(XEjeMH1Ki zjuUWhWgMA#Zt(RZT5`wU7lb3=+IQ{mvaIrxe_68W$v3pTs|G?P``~cfNyZy(uV9B9 zwFXb51@=1a($!Qyab>7t-Xhn;BFqe-!2?O4m3Ku&sA!AV2pd}4n| z)RLy*6rPOjY^Fd5SQTbaO54E;<;F)hnyS`VPq4mNG66dL+$7+A`{}H^tWn$ zpZ5&!(Bq%!2TA>{>R4$J!t6I>i4f|0%-4+a&up6!l&MkFsD0%I`wNSYC?xP6-mdC+ zdYf%dqftkrVmL1bT;9D6{w6fDhBJ2Q+LTwhnab@BE0YYcpbwuDaJU=RIq|7KOeE)u zOgGtFP`g5CRAuqfZ>MMUIv?6vS>8UKOkZl{s{k)_g)sO46x|v@X~JKww6@Q(Sjff@ z?hd7!-X9GX9vi-Zs%q=8yyeL?*5T!Y#n#yRiZw|!R6>lSV=ViyVnNSi{=zMF{h?Ws z^*y@rNW$xP>qqwN$hhAuta$gSX8pRIz(T_=T#AiO`DQ1$GmyAG5d|Dm3t|=)h$#+D zt?RJZ=@BdsqFpx><+`fqCVaAw=_|j4G02*OJPK15FQTVYw6@dJ0@ZD<`sZGp z5>R0z$HCQ0Ri}GqPogr{WPhco=hxnP#I-GNXDQ+Cs-;Z0Wnh9InVcB;FNGC#Ci&tS zp7@u&tkMQS5t5nbp)E5D)<(#ulH$$0XGsUYlspYFrRG-+it~hTH>ahYyQNxsp$fon zrNGJc|v(BpV<{IqGG?{fOxUFFj8Z)W@A}i&xdzTCX72i+Ic*J+P!v|lS_vr zg$)U+Ew!XX{f#juMx4$qh>|J)47(kuETXaK2eSE05eb6NV7}6Nw9Lk+{-SvcLN9&|TKJc@w7gglvHBkj&UBkh~JQd*6eVUfP z4&jYeo$nQ#?z*w7UlE&PkNIi=F!#6SvKNqZRos37SKYS5&bZ~UdVuhUh)HFw)gH{m z1o}@5S9+`!aX_$j9?#^%T0_QlQKNTR@h+m$zb36Qz0l;oi5E~>AAy+>V>TV7VK;yZ z;PJ!SV4EB7F@kY@XHts`O;nEeny#N0Ic!WHt@UEH_i2VduP}1bxCz88#pPnmdTR)> zq+}^{zPrYmj63mb=A}pGICb%fPY|(eiiF)2@uE>Z+6O6-*zuYesn>|cCQFYW9a}7O zXz3+7NfgMrUfFA#hcd0McHUb6(hU=~!)T4jVJwb_(aUTHX79L>aVLMzOLQ2KqE5K2 zwdRyxBzKLF@l|ZrXo0~<`_r`sYvY`s>W8Oaf~vlXBVw`}y(-Fgw;G;?OmC=Zp!K&b zIna%3_cK$_2;b$=UY2uZWi)43;}3<9<<~BvO24M>_Klkw-{cCRzPH-4)7cjg`~-S+ zvTeVErbs7P^jxQujA=$0P}lW-d(}{~CqZi?#bKF|vrR6i%RC&eptXEP5j-88Z0X(z zXR5AbANH!}WJk<=;;+Ib>X$frUU5(GT0L{R#<@{6p+mK&8ycZBXeoi$zHQB=px(Hx z@c=Mp{9;6#7&ky*MMn6vcZ1bctlx3~wMSKhH9}G(y~F`l*=ZS-NiIrSr*^v;2{`jg zr>?0S%M_*#Rg|;iddM@)OjPOoGosM;_sWFda?j!?0G+XUfz}qLL8zWdn_D@I^~s-4 z%UH0hQS-f3O0BBfb?IVgg9wy`gUrO^;tf9hjO5N_>q@(11W?T&*xmX^&pnTQK-B;g zS$ga@r)8G^RHuK->oSl}r+lSVd}eP|F1ZSJY1YYxRZICPF$sgF5G)w8mdD%G{ZV$2 z$v;qMWZjE4I|0sEFLtezf4e>$@`%@za@H_w&F7cmvXL{7UaXkXvQ`K5VTSa@PG|%P zAiB9qB9oCb7=XQ|aQr8!_x2UDN7(!@WvTPa#(+<(rUDOe%yV?gd^O++Hf8w1o2w1qG*jV1yS*vz%E-@A>7n&t}83Tm&S zY}5hT;2)YPHbc8vN<5S|*J^N8w#~w4!6U5I*?K$Qs>_2we1r>{{21@bp zc(Jb~(w{yr1!!!&t_daMfZG?KR6i+@j{8R*4{f;(?9}UcrvQ(3EYV1k=Hu+C;g`1* zzzH=W($ndQQ)6R2P^<>C?>p1qJu#J*aE)@S$)L z$ti36)3)C0>AuEVME`VgL&*_RzQWJW;4nML#uy__>?Y?DRyd0%GXrgAM)|N_j89Pe zsWBF4EiPW9m9{4PtB4z`RFnNO1oR$)gyW+WtmU`c06m$ULfA60fnAoTlidFG%QMiQ za=@^+GjtBHs|{xaLGCQZBE)R(K9t9cbvqb_y*>|U!w)W^roXXz(>uGZf(~GVSaSiH zN;6h+qW%p1N8WT^KEccIlgXoS8TvOnTtWVl-`K8&nds<9u#bA9F)%gVvZ zsdY@JAk6)jx0M~@xu#8Qx0=7!*q8Aoz-f21q>I*%cO|LbHrCd_o{k_szC#rHVYpZh za@Tc9e?V5OcawHYA}qRW@>7TCUU^ZciVS~LQV;ZLHvZb)u#;F|MSegscJT@$RaAD=LCO8;qDC}$iXA<_DGmY-kNe28FY3zI$h=C8V zo2g9rP1Wli(HDkUKyC)|uvxO-BMJ^oRG;e!Uvw zySA9Qnb-1kNl9bPjQ6+Z6{zzFiu7vwe%6v;l*!z)q(}}~cS(X9M0VqwWdZc-j~~Ht zwiix#U#}Xfj;+Jb)J2$9b+DzVLxv7_C-1UQrj=A%lIQ+1&rga`YA{<}LxUKfuf|Gk zrb;ovU=!|LHxrX;aK^vP2wn&lxpEq)DLUf(^9+IrEyUh;W9nfI=>n{Rj8-yUv2#dT zC<|5L6AwC~Odu};!QsF{u8vuDw)hs5wr%+#`IH`PDgRb0SX1Dkr(-s#<7 zC^5#=>F&-uH*3MN)PI@f$VlKs?L|?{opI;h7H9HpEzi!5c{?-N;M=se;O5CpeT6E$ zqoq^0YWjRz-wJGJT)vS~nPwE8h41((8oOQ-^>P19p!~^iLX}*c0Fs&D^9f!_j{wB( zaB`Hdt?)pMPX9X#p_*3W%7VN{?I)Lv?VnGF8}eW>9DefYL>E+ zM}`Ay#RSX#%vdbQ>AijIk<;CU)HTL0-KpG}dA7W&vnH6Nw-o%tl2`hB$9&4nmh3&M zSzqdGgJ^M1RhOPpN0O?aF^`4AiJ6o>_VhJnl-7_$vGvfPSxC2i95V@MmEivAPcMOR zdqXQtiMVP3me&K3qKw@DUoI0fQkU(bKwAjq_M=}1bMm)h`pbnn(a|W^GZ8ZiF<%XV z`g9XG*F><@RMqKpA$(DP8uIjg!n>DDpD3r{WC4Pg;@jZq$zlDkkn7{ey|3n86L8sT zFi_W|lfn}Z`fG`1BHwK1LSbKCrx9DV(Oz=NaB9$_Rkyk}-2`z+_2 zfSid7REz5p`1mX4hIPb7Y$%X0%MStAjoI368(I3vk@qiGG^FG1U9c(^)vgq;y5-u? z4bk}Vb{;6ESXGc2u9SSgn!aei(KW@dDl$8a#V(AG8JK%-cw?ftCP#l$CcDn8e-$SDRyg@me{OJt>{leT%?^MZ%~%_UXqB5Z0B z%Zt9ynke?QR-yYvoK2v=WO`aVv^WIl0sh#ype{V?^cl#?RK}xa@Gq}>qeu$aI#C|$ zS)5UM-aP+)-%=yWPI>+>8V`x2fa6t*0|OF&Wdu$U&$b$ zmPsJ5yeG^2e&@o8Vn?T+XGM?Exqhu?-BUMh<})(|(E_Iu z6|A2kNx=L}y%u@4A0nyfbULsQN!5!L6(k?R8mhP`WjMp+cR^UtGC73qySt&RQ&w*5 z{;gjr+qm?w5*Isv)uDBAl%EDKEWv=pPp=0O znRW?O8GAYa+H^(y2;6n`vop|7#l_z{GH~ze_~w$Qx{~xf)mfFGgG>a+>nzM8HN?dyj9n&~%)Yv5mpdV53EGv_>}%?yAf=F`$2en+ zADunsGXOP1{|hKyUl<7@@%2R`Xy*sMljj5#XzQI-%7CXd=uTO{kf-85Ov({+@ALk`)^kbn=`;aW+$?lkWv||tp zx6&;jr9ZR&7U^dbk@2AYd*?h!M=Lzl+izN2qq0=JM3C||oAHSEcemZitQukm35F5l zbnq-YConfmf@Ta?yYZP)H+|nD`#NoW+qWO+lLKhzS;$X?ADT%l`wkK!bli7090GFj zfXTV=S|@du_H)h1`$i9diln&b+h4zXz)gccU9xkL{Ut(%oot2`AT3BF5s|j498acd zKn7}Rr!IZ%q-9<|<|tn0(vaOxiTP2oaT{MRq%ntYru#?i?Q75D+ylezYgZUqjb5i& z^hJbZ+e9Z=U2S+U404B4_%bGY+h|X?;RW zuL``aN@$SVU8>BZ_eFLRi(UXX+IGLOC@30H@+Caf{|!-RVV^C$$*Q(WdS4w+bm6ew zZ-bq4o`$*RD5-y774a;oJR#xYoxNDJ7u`~Mif(zV?kK{)$MGSAsB$5y*<8bD&*n|R z!KEFpfhSjacH{x^@6Mv_Ri}!q@O?DIOzT{L2AKNo&G+GSI=1$a=54~h@xu4KX(8XU zD`-@%M`qktKX;3?(49n8nCP)Nl?cjo#bQnH`q$v$e|ZrlrdWsFn_;*rr2^i?9HAaJ z1S2(2DL0AyEh&ZN{Y+&Z&=?t%c@4-6((B$#xKqPO?K;0rUz&u9tSM4*YKM(p_5Lqv zTTwfY^V|xIRX?{8y9?jvFB?Dh@n*X2Y)G4DWkSTXzn=ucOBqn>_uOO)>hk$`RM3w} z?jxJcc{o!$6_FkZYI%r7+?2HXggsoEfE1!WOAHS}$Hw&0+JRo`s-OZ*!d`f5nTHKZ-(hOY1-O7m#qvl+>vws4` zS0P86Nw=4c&b&f>TMJa{t}hoB$d^$$JQ%YRywME~C4_2;Bn3^BA@VlJCg%*lr!28w zmsGPSOX}H`4HfKI_iNcz55HBT*<}@vfDT!#m#8s{Ou9bN=E&O#uwXi?h~B=%3U`ku zK0}5Bd(rBVm~cu!l)Qlh9eI?9HRKWIl6Y~@XcTm-U2}BSFPShz-0M=Bxw~x-)rat+ z^igHTL=+HDt+N3^)UnWWTaOhw=hc`nbuH=7Nhv-7Z1N17q0i-@9)`rSKbX|Ab2lS` zG|G%eHeZ$0FjuylgcJ0)q$AJo%ch>Ei$ME(2Q8uj(?20#{SY%0x^296Niq+8Z_1&ci_=zl6h(IpET-%8Kt=tBY*Q^c1n$oPh3_CpMHUIkPUE;nuFSmPLAnZ$dHdmUt}0vQS8o zazd|aW*I6hXlhPfex>SdD>`zBIalYdttjb z?*JE$4b~Ju02Kh0>^-oFXvf?61ZFuj2>b+_#457x?%WtGi6!9!XnUC{3`*C<4o6;~ ztA27iBP-ZIRjSLEHHWy7Ek9aMi#FrUIR(Ri3bvomw^lxzl1t5~qtdskM`T}Y>9z#s z(+3&BxnO+#4cbtKk}<_j zB$O|kBZEAtnml?M@1DdOHQx)pWO9eS33Pf!&kM`v3Qn?8jZFU zB``Ih;sex;7f=uHEnv7(Ep;FTsIsrG|GQZu@%rq+Z+>f4; zX&Xp($~Poz?&5>$LL4vap)c#rr_HSccYo*FkD3wvO-Y5;VWaDk)Mnwhv~qz%>n%%* zQJll#FBfkerKHYvJwkFnTzN`vIAK2S>Sy+VsSKaK0cY2gK1RGm{76~ge!%IPv>^LM z@{8d&c`fv)R+qyZzH)o!!<5$67&gyzuzq{AaBq=4O5WOgHS)q*q7Pi(GEdi^QIek!mQl=R`{wYQlL&=a?7fd_Y}`UUGOxUZq;~l2Na!+y{d$uHP979<>^ay3 zwJFunCtJbU8ywSclmV;O(O0`pw>~^N#$*YA-~Pw@A02L7!ur+Swm4sUeu^ks67oHN z?W>;~Vw2fRi81uFUx`gmnMv2^EtMSfu-s%SCXV)|JvTq;8MaZw80Tt>qz|em3pTqh z7rof{*lQ7?aMFeIz{cC<`h}Mw2+{c(am{T%G=MzqMm_BRRg#{fS-y*E&88ddq!l2kUbH=ZSKWr@Dr8bMj@`%bH zH>{%*NBd&Z_4=%T~(5J>i1~_Gc1)_|ijxeG)mm`+L?k3ZvRtRw4jO?09;{#_~W~6C(`N5WPMdy|j zG-lP?mZz5k8HX1176*B(jFM;44HuHdNLdZTHQua#uu8CVOrY6=G_JSGs^hh+=o|}knAMz>C@rVmq{EH*m)LBz zhH}htvb2E9Bs*QAv4GRzDwin1;Anuro*U*$TKBktQwP^7%NifPyn&Fer@kXUde)ie zi#>^xPYG%bp&K07f>LKcZBBA;_CTc)l)zGo$PJ?moW*L0OrhLOm!5d#_w%VAb_JKK zBf z{F8!*$V6c+&3BUOw}$?4Q&@T>UQNjQk^TTC=F>V>8>Ozs#**Pc zE55&0o%UciBcL2heD63hwMh>91g!Z9y)||Ty;MJSE5alT$B#s{+h9l&y9QL@J8k}9 zg;i7A(SgB_CMHD1q(Xzd3$HF(!G6*YNRoMV6x2ybK5(PC7m!ez*bb%WJgLe`1!X3l z6rU*fxZnI)K3iL)uhs0&fuHgbCK1WQLp2s6Up{3oN~rft$Kd5d-gdD92aJMNG6WBq z3ut(K2_vbT@RXyW!$<6yQe$?ztVW)EZz9A_t{r&*rJ{P{tK%O2<=BRqAFwQ zoD1fy*@3nsRNF(87OP!(k5_CvN;G zDl*r4oV4j1XR# z529(#?JY)Hdr$koq2|5sXN0W5wxJ3U+i`_lq*RG*Y+LNWK z#8QRpVkA?HBAhT>M@#0Uc)XE6iZZnLGO`u4O8Uef^%@iIDVg#udO-~N89;{sE z63xtvu6A=qhV!_9U&Dakrj_D%&-);F9u1*0)M1$EsoJ{u%+U`O28*gkKr#~cC9KyY zBi;5ZDTHl5ZDPsS81y(z+PxjJ6DIq+5(c`5g$1I|SrIGs8K`^@lG0+`VkUbj5@+hS zYW_a`fs~v97+$x*OM5F+k`j5#yMVvEPrUehm6b2{bMo`^414I);*HxgWdbfb9hV|$ zMiqIf5-=Ql!-qc1NetY{<+~ ze)fZ(ts6h6Y2InsJ!Rp8P99h@PdGL;yzvZyZc!t}SScm+@&CTu1KD6+PX+P5b}7b6 zrm6e;g^c@<11ao@i&Te#fR*%qT+lgz#-9U1^?JEtSrUN;j(l}nA3xZ&4!9-CCC2V~7z z{rIGzc5y_P67fXXYbDqZaitI`S?R&~oD~mN??MY{2ax%7h@nlt~Iu+<5G^k;f&YsU3!*O`SzN?LF>g#U&`` z(^E6i+1ARF@nE+}l|gJL>Pj1q&QFy=H}fF^lJ87Q^nz43`c6?AHj+7Ar$Tz)!;2iA z=}f%cdse=jS6KU0$~4V1e7Zb@Kbdd}*$(Q>rh zI81B)*XxLCNPd;8own`7gd0n4!4p^Vs|5(e>nFdL3FarS-^j1%P>_KC5P-i|hCJ&{ zMw=v?3!rq%m%|XV?#L4z#kZ5E-mX+ykLx~+SGJWL=j0#RfzYB3(~L_g->J7~%~(MJ zRJq4y74)h!@mz}|YTJHd>QQ3FN)G0yYpEma`ptrhA(3<-|6c)Tdo?IiC*W7jHWvEb zQJt-;nIGZ|SWfeiTa}JF-|Q;uj>=!kL!Q%B+t_8_2HbJ!MIe74s*T@k&idx$riZ>z zXB&7fJ$^eAW_|2;D-#5Y|GBd9Gp!|PR{>v}+pw2RVJ5gNScYn2tlSG{E-0lnbk_tU zd>F~Mo+1xC#Ufk=QCCSH8|p9qBlhLrZ!P4n>n~Zmsjqjxzb-#c9fz3VG>5QKuSa49wwI^n;gCQ>8gO zl~EKBg0R{q_a4r^w3Gn?5ru9L8;Yuz@(eWdLn=mKt{XJD5k9-Y)OU9=M^U2||N8&V z5odkaex1dflWIA}nHhoYDts_TdzFdLBaOu2U`Q6CdByuxspXNygktZa<3&e1cp3Ij zN@k*-6t6p$;8%+Y(zL1GE$UN)rKCux_yV$V@PAm4QC)5J&EgMz zwdYZyXf%Q)rfcErmMZv6JuA{0ZnRIRC6;Vd&(%JjN$R}t3<7h0>Upe_mr?mN0R6?W z#4Qzkm$Vwe{f2ubv~IF)eM7gsC~ON`c~~8Iom?x=R$= zhr!Z4v-?Sw&x=$Vx&^zI@T67uY5bew%`vZyaPB*nLF}i_Q;>8VzJJXht}&o@(jkNs zBy%~Pb5df*b*R*;Y@EN@-yPux-OrP9M8QBhOlg0MGVDTrk%ITA1m<;L04wLd?c>>cCc?|h>!X}BW1Qq5HgcoP=~LN_ zI|o5$uK?sV$R=!Tc!mY$4&kBl$u0$Tr+st~SM+yae19JPHVNKt^XI0#b4*pr@J)6R zRVVqMfhRjgR)OVZ_Po<^bPsGp`nd*6t87u#O9PMZK7aG(&48Q!aI|q!o=cKn9Zt2k zkqcz=BwK@}qnMK5HG~pC*_~yp#uYT3?K8L*cPU{mf1|{#0GcO4b z&!6blulH0d7%>mM-x6IoowBt7qc&Gp(**`$wcpwYi(0|Cb?1S@G0v`*{k*P6n>tAa z!)nO>ydOE5j@~$m7rOy@$zoLW`PC^te$fac&^`L~*11>@r0PFjTh}0){eBe)YASN+ z)ZEWlJ@2F3^w#91OS&fcug1`2q;`kUozNE$<7e4={iO{>GNi$&^S*yyym(PWCs;(Q zvFeK9`33B2^p!)Pvlk;+UZ0K6(x_Nk#Z`RrCEm}tLX|n9+$KvhrDNylAA+#C_kG}X z&JS4&^ncEA0Bz+HKGjvfJ=&MneG~u zd}{SEmNl*xE&hPDVk7kj{KFML8-nCQLUD2HJ!aF;G+KTm{lviQ*bq6kVu7o?&e6o6qk`&O*MlBDUAw3G{7)P}AQv&eSjQ>}4nrt}gITscey7aqe%+pdZnhp!vGW!WzaoH{Xv{ zwqWlTQ*=ul(`GQ&{mA8)fOWa6(~+V_DIY4Kk}gV*!?-~Yd)(Ki;X0{K^%gnvGY z!BD6xQ0r~I2YUV}PthnFVa;c1X=bHZL^L?)e?C?#y49XQwDzn@TR?VwSf>lu_4NX^ zgAMD|wC^y!rZdN7Q=Jp^obPBCYsMl=y|;4e@YTf5_Y%7fVF5W>e9}4UGDuaDFoi#j@=Zn<}u zMk0HIyEY*^?;!@b>z0Wfvn;}obV+XdB=YlJjlrfnt_UddSl^IpE5uwJlBO3@9T8lk zN>ZUC$`Bn5o-Nj%okg{#Psr=qd{6T6x$vPIIT6yEn^!FR3BrnS>6M?=u z@_e{2>b$(ct{V`vBV$!}q6GFkreO?5ksRlX>ix>L@;0t+IsB?q-p`MrP~?2-5cbr$ zda_D2Nki#XWgk~Biv%#>2euuxS?int>?^tskFfe|XAb@O>1>R=P}c4Jpihi>0xvU`{dnd;y2OHv zlx6kejccrf_UU@0X`eE<-KyF&@`s#l;a;diDe@D0a^$g8qvd+|_|?Sry$9x$8ENB> zG;AW?AM`xnfl;^mcH4)M{PgejT2@|>!!^MHc2`F%Bd7hK1NWWdf!tiP7H5Td;e}~v zM^8pU>u;I^tX*R7UG`7=8Eads8eQ?25UTWR%xmg=wdyOnNh_ytEk${gAMr}9QZNf| z1#CB@qvLZ^I!#lpy*VU*UAG6jQIk4drd-BbQPzJGvmSDV_YFz961F%`=8My4m z`GS@cpeE?&y;5}f-FlG|1M%uT_A3=_ty)!C%XDbHE7ZVW%CW@LiZ}fidLb;wFa>HC zj!q(T_0huVFqWUzz~~= z%%1uu?Jb(1rtyBqjtW*WqgB+<^@~*5qph8CaY2OgrNesRU-%El0N(&O zqIr)S(<5P*5l9I%0K)y9&kC3B8LghwXkP~ZW5u<%-Ryr>Z$Gc~1U&qPW=NqbRI-O7 z>0*tk{gexY84xIl?_+&%0Lr7FyY>98dAwVYBJ@&%P?8z+e{TU=D@AIyg6(>_7o*p< zb65)67W<)TR{$}VR#ys{@&WX$E!0{d|hZ_XQ|lF2U|E-muGTaHL5=9

    U1J{C2DTHY@Q?hB3*fcvi3^ptR=I&H+@SC$bSZ>&ra4VcV1FutT%KEB&PR zfqQ+j(8LN~8J`t%S{;6*^j7!TACIP%0qz}p$i3R)N39b=h$B(v=o#M*bF^rmQyq`$ z@#$C3oE^nnY;LFd{kK%f&@Ba4$G3mqa|`I$+TlLZgXOuv_am6Vy<4>|lLB)s!? zA-O`1^oyzcSIfRQ!nZbFL?^2^kd;>+@_@Wk{1mDeKENwAVS!Mjxz@RW*J3?S=bij= zF0}@F(6S@F3Xt0n#1{*8?3tH9E%!Vr2Y1zCxc;6 zutPu&D1Fwy?%>HNB;xQ-#y5+8s7`za*Y)#CwsS)UO7})UAZ;W$bn13rvfdQzDosI3J`)qugP^7!9#^0WvJcxf zAYA<5L0-42Eg4VW?`nEgO_I6w&JRqUn?6zm2Ur{Dy|^r@2xLXhpYR^zJL*V7J9 zk&2>gQHKG=aS_RFS8Yl-vv+en5z`VxZa)AzA7`RhBqbcpvlYw3DI}+A=T{}tKLM>@ z&A(L#4$T-55O^=qfZLd`?bCdEcw&Y>8t~!DKtOX^_UBt+9k}LBFVS1kcg5|>ipt@=f zhfXx-MTAW~RH_4kiZiDVsOSOh5$hbcLS#n`OVC1#RTBS zZU<36EvmMFH3B`aidlWh%!7;mC#P7wPg6i&cXP%?#FMDF=%RD+k3kv^K26V@k8EKK1(r^* zq1cG3_QwJHa4j$wb8xrQgMHuFh;}`3A7F^(q}Gnc=Tx&ik^U2_H*E?LhFwMo8pZs?y@soPq`Ws{;}SWTz3#O ztP$a~rZ1i3he+5{#~2nx;Gd!9-UKPOf!%_x$=`!#$B@Z89DE)SGY`kzhWW#Yik`et z;}bmMmZ*r-x-~Wfs})n9x9IJV=;y|GyEM zx*W!^4!Fuvf~c6{r>;eD&XK3X2Iz7C^-e8^w!GGs0y>{HuD*b`TI!DDjTas5t!h3E ze%LbE8wV(;>l30}UfGZ`VdQbuG#9*k%d5-~?~<{0wo>&Cr|++wr0u)UcxarKn6?yI zCz=;04r-#vJr#EYXS*>sl3XRHAi|_K#Hi*#TT^@{dNILaqS?v)4A>=Xy>`DB0(}Pn zV?TA;)U4L$%mxpov{PfbObwTk%EPa@TSd-r1e}3lj34yybAz-~b|b!bMA5$m!L9L= z#x5eLAtKx>oBi;K2bna-d2k=o+}N;smMhb;uroU(*<*F*{m8b5{+Bnn3_b zltPtvu9-23USb9?d}WM4zvJXCA;t){`U>HV%W%_Ka=64D%g@n)I^{TK&j~=B48waXyzB!x)MGXaRw=y|=P_xSgXpJkdO$VEj+ zJg&;?g!vCZspTD+i^KR@yl+>FOUbG_r-Fhq%n#k$z&5`1Are|w8;kU5xcORj@OsG)rNba)#oztKx{Z3wawdS7PpVM_ z9CGnq(2(XkkF8vJv+Ya7zAp-?HVPjRx}u$ z&`Qi#cODo$1C)1XG7T#y$FFj!$t#o2q_In%h5a0BP%Dqo+K*1;L7;$~x)_~Ut>J39 zWaI&MX*~||4n0tp;Po>t^VLjEV;sh>#IlYJrQ>z?vb&}_pv9?zKXkRc$C}OoZHIl4 z;+?tL%si>DBIgpxkytSWyj1~{D*b2SFK(A#dW^T)dBZ(Q0 z1v5?ku2vm3VFmC@H1CIna{2tNjaJjy+mLNx3KnfnlHEHG8mr-0oxoRRa0*QNJdxKt zLbH<{gePj&ntCcoS?0A~N?hF3k+%M>=H0fjp%DA}01!*J+K#{lSSA~-*VXBz)%RI) zr1o&pwIEK=SnlDODhj9QJ9G=O@)G@$^1iPAMo=@7)XsY0X_;!Jf(OdsrEG>Urdzl$ zf!=${Cs#bzTOQMwxmuH2*|Mh~Rp|=6A4z_>n&&51Hw;|CylZBH(T|7t*7UnOJT6ht z8d^`N&7(Y9YKK7H+r!tCrA42SpC$M(S`vSUe$86~NA$c&xQM6RVym9NR(;u(C%~KQ z^G=7M>7850e){AhrgJ-Oi~M@mV;Yx3^v(;B`Tir-DIuDx2D zEZM#(9n6=Vq9-WI&ApZh0=c<9GwlUZpaxgHk*HSX=}fgbO7oT9r!@W|a({M&z~1PB zf}c`I$YCm=|Ew<9A$IdFWpw^Onyxx7s^@Dj@>N7sM5IeWLMiDENs&_N1_9~rMoHd&Co#EbG7(e2Z5_IH`e);9bjqCSWPw?=S;XPR;fOzDTp%-5x0H)L|Vpa|WN+4d2 z>!Q$*|F*($O|5(^O#2%B=r69{)E;Oc)*P@>e|sKXHkR2$aRfz%E3~F6i6BLSH{tIk z)DSAgp;uS8K+TIn)Whe>LLwOlis|Ivn>m~oMTXbuAR6SLE*$})C^dWc_TT=#s(F2XQyaZ(eosy- z8;_Hxfaj5}dtvcc3l=fTpW0QZ~v00HYIU%DV zVT%p2dfNpYd+M3k0DW!YLs@reknQ=1S&8V|rdCBXP^R8&bAf>O7mIdXoHYRpFhE*p zbDCj|i4UQgV!K=e1g_w1b~ni}a>1P@0idYx;y~1HWSpF?V}Wq|hv;~${gh#w^BhzF z6e#`W2)$M6Z`{6A zuQ*mCe%*5{{$N7!mTRx+y(!Z?1IOZ%zS@Mc;U|6=1T z5MuX-aX!jU87Lg;HoA`nvM}$&{^J}MN~uvNb0IS_^=qrSH)c_o4(k_|aYw5NkpP0xEC0j^RIwm3RYX<`yGsEvC6%_*C1< z?{YwNv0`n=Xt8~sX>05Nyg+vM(I`+aphR0a8}s}}O6rD_hA&4^nKrC@AnfW2H!xnV zKRF1XG1e;C>1L~esMR{k%u@$xqJMjEac{!!(xu{f+wQAS;T@15=^Yvb(@nZ#T330* z3ug^~%QSwYy@p$@4jBW~7?fMA_UUnZS)vSEtk00$pe#Fau z)20&_2Yn_1ggC~}g@E$y;}1ej8$IF(m|Od3E60bL5jLKiHBhzIDvIQsrPxBMYVAdb z)xQ9uZMFNi+D9vW6HrJa;P~x-rOfTMXV{NUNz06G0{E(V?Z zmsZo&uRs(ty%ciLCTbr|(DVT9gAZa5+Vt(Y0~~{|kq-6ZV>UA_9Bco>wvJuAt(MEG z2SJeU)NcS#(+bSqNiG3YNJU`K!=U3K9eiO1v7v4hKf(FN<)7HUXqE9}&1?h?P`&VA zoVe-WIjpkL`(UA^*wiTj2)kd@%?ld6d(NRf3f6&C|k zMgOwrD`braCH8&OZ?xYeO6HUkpxW*(soK3dj zkpO0ujF6WnN=1HT+8h?a#^&{Kp$q>)N?-%D2j_AReLpsdjyE5i!`q2gy}V;dZn=U4 z&vzGW?1Z4rXBm(IhwUhN1KU)W8=NkvG-TTKFuP!0bO-`^PeG2hQL(3EG)he4b>jYM z074&I``r&}+=C2IC9>@-I|C&wFWy+F;({E#A5koxg&KMqjz=g%LRLChIB_YIylTad zB1{4@aEzl1GGIUNcwrMkyoBtqHRe*GO`d=AlcE0pIyQ!{R(#1)zeeYDf|WBL@QcfM zTR@ET8HHp8rr7h&wb-6Zti+TO_Ii4+V{vECzI>%Xy#N=K>Fny-|A%7AtWRdUXO`J* zqlHk{tK*(H^RQbUYIVaTpbE#h!dY@`tKn`u*VT`q4dy7$mF$fV+f3;~o7fEv4=Y8( z`jtp>p&uMVF!|`MB!)OZ9NWV^E@T{UGW;$nug^HM1$ZN-T}LmwVqcm4cJI;lR3N+M zcsME5A6N&e0>dx7rPbeG#oe*=cDg%0amuP~dVHI{>F{5w-$_3zvR^DoV5*_1Oa*8UQ zR3^=IY7%`1tbmXl7~b&mn!L>Wtap& zag3Fpnzlm{+8-7m21!(n^c*pJHu0Rarz9PAe@q{lJ?}3 z76j?WwGK$svl;SIAT_e{yKJ&c8Ttc?3!%h@-x+ioPgAcgojOK9zHdXh# z(KSVMX2ZGtjvHWBWlsFwZ1t34POCf$Wv^z?r!R#PllK6c{5acT?p4wVOqmoa%*QHg z%9OklFsxST)TalyI6NTTH<;R(NKL(w=HC1q3FlLD`Jgg3Rf*O3h>$YB3BI?tw6E3; zW$%TsNOj`3Mj)4+4>vhJ8iZ~W~WHYZ7Hp^NdwSkIi9o? z?-mp3c6JF<{j*eYY5q^ot5093Mt)_jq&`6arLWIe1?qqx4EhP#0O?zFgO%a=vrXOD zF>~==5fx3=XWD&$xg{he0imMfDUP>alc+fA@CA0<3a8hc50cgDoSs>cIZWRhPO9?_ zYcB>w8oBh*va#RYa#v1k=2K^98Z>%|?xp&nU-~bPQ-NZguq$04#pzk6V5S6+kGkr` z<}(k#x`c#yz6;Y`uZ3WTZ>C*nX4VnRMIZdc!3Tk;vx**iai-){8(z17*8zvuysZaT zb^n;R8tb$QUdY~S?ubQQ#ysOjk%tBeu{#-=Cq=PK8(5)LSa zQ4+en9uFBn+?^nr4Q)TQa^Ub@_-a(u)RVm--cpq<{ZRJ_Kxl=j^ky|uN|n*YE{NT= zQzi>|qxEr$X;tFGpS@oxPHb+0G)F}rP_#OhT)s&NVT~fpnT<#-Z7_3dwK^jp#l~)H z-tSN67Q6*f$i$(&z7j-7=1Vpp0YNa4Fi7aSoww`xA%JBk@XeNvY`kJ=d#bD z&_y;a9kUEx;G{Xc?MRMk9LS%E36V*g%Dt^kbGEd1t96>IdH~X9)Yv56UAz4?a^LpX zn;p6hlKR_NQCmjT&1FK3&Otz2KOgP1fsJ1D0>x2}y{b_&%|aYk43&I-Y)lp(ZFks% zhPqo3tz^j?`HGmR$3RuYghkg+7vppiNV8|VA@5s@w^<%zj6Z#~Bck@>!XBVeI3Q{7 ztsVAX+>KT#nWjgFDHQ}pKT5~YBwRg4g;*VqXtr)@**|UUHK$>PkE?DfAbCBw0$m9; z99~0422l5djtF}BeB!GG$ixV8%A3}8E>`Rt)>ncexdB}|3u)s$9?(E>uIk3z8Rx#< z3g#$oc>+dp%aK9oc7lP{sNSHO&|E|=MSB)GowtUW7vU-RAa?SDRWC& z+5*}b2`8_F!uo*}(Pzz)h^QHB=f+XEE_ShPIH?n)T8(w-Iz?+rEVmZ*aS2YOL9c|_ z7s+zokFV1JnX9VvC?vS*S&l;36$QMzDfCJhC(wlL*G09Lf)}Qc#(xNW;Be9PBa)l_ z_05b`bHErtkfWgtsLNI`INedt!v!soaP!}Vl*b0h0@mG+n(;DAU16R|{R*wz#TP*> zeSmJNU*mLnJIou}jJU2vj3Z?8LmO?!fjXV@?TItTGZIYzmXIsX3k~Y2acj5_a`);{u0I#9T|O^?GQ-6lAEJ*q7~V6kl$r> z4kLhhtfRQj$^H%E{(&GGCz{YLA4dL9AC>ek>HcyE$wT*dqCv*&`SZX2Jbt$ZH(^3t z=R2(~Ue%Ca4%%j`BOqjJ0ygvwDd@2KhFLv@)7nR z(`#O6Yvk#1RzY?z&jiDI0YR$ZruxE;V}IFS2uFC$TMas`3*r@GNN8TO!-^Pr`(8lp zp|;3iYRm?udKMMC{jW8=;texHy~YgdeJLkXa4D{<6(L&VKM->oK>cj_t4cR;89UHw z3ay@s3-KTO%3Vl5-E8(uVQh$@yRzS9#@8$Y)roddJD`y&<`>qT`}2u+*ieydg(Id) z&q}5d`Bny}etPcX62;yapxmwNr97uJA*58fbv?5;Oe?kgELk(Fgoo?hl6IxP2)%-$ zb&o$gPM3m=1FuzJr3AmuECpE=v{7|fuEZLDs5|Ngy`|0GC8) zGQdRassGz@nC!6}6L~c263)@LUOtxpfUdM*z0LZ;Qvkd)20uS=4y{xjE%QECsxWx| zHIgnpJuzt7C5Ry4i9oT9q#sAl9J?y*iOoHb$}QcjTI7Rv+{PctnHKs;?~H*;j$iGi zE~_Swu}9lUgQ|5QV$RdcXEy;$=#9*5=f)`k&AuVTXDvdaY)nklf;JqXNr(DkGNUtX zwoFJRfFZuP#)9a~LZuk$&re!D2rLL0lV>f+G_xJvBsPNd*;4JA zw*CGhr)NKJ@<9)bbN&OB{V-^?8S+9JacI)n2-N=M zH>KBKTz)BDn)UQbLLysdb`dd+xF#lYH>R$KjmTvlb5>1Xb*PhUVG*Ug&`S~s5BmF{ z5Yh}?Mj1u*UZQ)GbqAk^8Xp1s4D`BpZWzMwDqCiAO|`{Xw-rQuEu9_O+SA=u^0q|{ zdMN3{#dc=NL77i=o&0kjC9FEz3<)U%*aB`KvE-)Jeg4n}ww-E@>k({)@|ZD$ z(Bx3DdmaJl zq?k&)pjQN-KkBzPSzRa=bv^T)`W%>bF4xv=qeh~iT-pDBEx_)=58xfzyz|=6dQ3Vu zKz}?R0s7LewY2%1A&tN zW<}f1#nrdkA$lqjDRb4{1qwPp^ERz|vrJMIP??DL@K&Wsl`5#Z!_shuU<_4uz0|lq zpiF8AaPGfDIy&VG4V#R+3r8r@+!P6kompBCH3Jxj+V)l=#Frg~l%B4;O5SA~yP<4| z+I!0vXXuOjk|cmY!0jDZVPpeHlI4T~f9LN4fMfY%GcQ^tYT+~e(E1F(`h2{`0Zmgi zj*BKoFJ$A2&-~-SWq~rO|0F=j)asla7yYwbDoHj!Ox!NfH*%M;RWuac(Rv1=#?`Qi z^QWRm!0#bo0^R}{&uay-f*5@zRA&nkhIyk)@aaJR)s6)*K=ZnCSqEg$e+p5n8EQ|+ zp2ieH1}3)N{C+edm}X$e*z9^2#QY_Sk8n^&!;z!i%}mW_)fcwrkj{1u1L%!BN#-Wk z_JuURTBWK;(Qn{)AZE6{X3Ftg@K=4pU< zyjxDndS4O~tGIv=Rcp{6{aI?~G-rddEM+DmrdaBek;j1NvOm5<7|fKB^Gc`9-CCuz zQAYF$cJlNwZ)~P0h!RJB7P4b>avpq_0)&Wc7yU&8L*p!8E9;oL$Gz_%K(sHLE+^md z9?G8K>PMbgb_+yJL16>%nT1k=izAuqspz6o-PfgQXGpkm=i>sl6j)3;Kn*NZS zI*tw?&NraR5dkV(D?Vk)f6Ck}C~{go5_Q2Xmpc9w$+3>DiU(YeLHfv=Gi$BjaUbr@ zCa9Lw=%n_*!=9*7j9j9aJ~KI zHmLkzz4DtnFTWYmZm>vybR@>d8c;LE;LmPYmXle7c&NX8>G*HGWsAQ&xM4p*ucnR( z`35`&V|D2;dG~&mLP~p#>WG{Mfok}bJ0~{PDZ-GLi8Xr*9+g7z6 zIK#SA+*T`fgU%#<9^H85cmekY`tIhluo03i(cidT->1r$rs?KI4a*P=IKuRr*3YJt zD3~l{fXro0o6$$;f7Sl+JjY+0G|c^Tq1o-@x0PEQ+Lx~=5=lC(eSJHJ)>4>GPx{TQ z8obg8jz-@nlPKt?uJYg-C)L}|LQE4y*5wf#*Nw8s7F+2#g6=)Fms` z+_Pf#HL?0nr4yG0R8DenG?L-G0hD{RS`Qf-(2LjC$zWZ3BjY%qxNB5ofUSNTr1@ZU zsJ*6K>PW|cpwN0UyEJ>}_6S313Phk&iB@2pOuTE0Tl)YtU?p;CkKI;8hpE}#S;$HBIv-Z0jvSSVsSl&$6bMw|@y zfDRj2cyBmy4RtFXw|w+s0`Psy4W(GS)^vwN+Vx1fS9*r;^*x#ttIW$`XYv8KwD#_? z6uijTt1ab)WplYtKt(rgJ?Ct$&dh}BOh9p`+ze(s_Y2x`yNcrSBB^uy&uPUM+ba*g zlmH_)Si-CZCYwR%j88FO<2uLZ01aIZNsgw1uVfg}ORP8^N+pJ5VKowi=3(%e69T3+ zB|7G?$Xtsp*z{YW@|wx8Qb{pVQk6vXL|=4>`0cxa1kF~?U5wZW%Psoo_=KIDB9eD+ zmfW+gV(C=l*+mJnVdI&SA0(F%P-!XKNh>LHJ&fWnnsZYk1@&I{G8l^QG}s%KH$+CF z5qKI~uVnW0ohovy8qzp5P23OL(BN5%rse6aLqon(t`HZz^(Z9SkaSdH&Uf>}s6s|grKN8E z#F2;75FzO5W^`<)_0gFgq;FPN>r!V8ZU-6B0FvG%hASx-K-0%s7?1??=BduBvv?aDa#a>(i|kV>qo$RnPBUmXQaAU@KtV&ylz=)oz1@NNAp$*d#B)Dp)wYn_aHH%CCOyd znz(=6&lq8A0RmpWHt7R|x<-bkF6SbMwN7%*oF?%VOW(7`QP?PCV6lQA1l5jYOsu1y zMwjt#X#COlny+2l>rd)*|BI<2S#hDim|rM6Bz;h}sYZ7U^~-QuMMFX|KG1cR<@}`d z)uZFRyauD-(SXXWeRXtkCWe5q{~*Kz%5bk@9?k5Foq25M7_{10eae5`Vi<07+$v8^ znyh9J<7`c$;M)=lqWQ!7%JOHv1b@X|rrWB>OxsarAGRwAps;D)sfBo-teBU+)kma@ zd)(*P2kPZLTxrYwD=hk$9*`bSv|l~R6e4SjWYzAzD&AO^PThe8uWLZKJu zJgH=ryO7vR0(jJByArLHyGKsSSzpR?oztX#k}3fm4fqvsuUbHTi|@~igVEmubaVP( zN@^RM!_sXqwD>a4*lXW8x8hlS>1U(#fud@iR`&0TKaZTe2Qi%&N+j*|tX3Fk31)BT zvrkGErC{sM6lMj3QVjUi;4MLWvOkNtQ##B=m!I`v+o#Zffs8cpy|jc3YGWQ-#i&4v z(MuosLTk^pk1gs1gqQ583XZ?-YZAq+q&cPI^!3kg2fjCc)pM4wlx^aB={u7RBnOE0 zr5^m{om0f#cuzX;Q;G8J3l)-CGraJ> zj~cw%D&`9dsJdQAOX#C)*6x|so~e3AmQParVxhjK$F`dR70;vFUF_D2GC-Q$cJ7tn ze;cpJK@c8p&5$i+q3Me=frafxYYCBKJ_;hOCJjDYxATdOgsQeUTn5@yI(HlmBQmnx zzv7E#CM783sWPc}=7pyLz@4W>X{pt}(b0b69NlAjD*vqk;PS%agdi21i+*d`<_>Vy z$J#y`NJs$HFOjC;e@PI}dow*PLljwyq^*r4hLz)tVRW|udltB}>lz(N2YD0(>~5?Z zRNX-Icok|_hMoq;Obq+UOi*m#ts7pP=P_s#Vxau{-z;Vpk9?vyuS-T@4gCXDQ=%)P zsj7w{@T*3|jE$8npHN*Z(FmXQzMqXBa+6A?KeSOCr(E7!V5YK6+>w~B@|E~fDP7?& zksCRvce*%MwhCAC(!D}MUtO$>+DVfE)OE3a%ToW8U4tlyOt+a~t>!Q6<4j9;pTHs+ zO8(EI%<{!ih@62xBWveek7H2YZhw36<)&5-pMVW$$< zEk%^YiC{4*veQog5(<K-#gq8SU@A`3bbN={Be|6b;7A z9wF!I#Y4B(OlT!c7a~;=6|7NRIu3c~g^BV^9QxJ))V()AwzhKk6j;9#ESFyzv=} z6eQ9H=oBq`hI{TTh;rCNjSNJ6mFtYyWC@@RLGBL(KUija zdVxo~S<7STs>S^mE<6*np{o0c+Z&Y0z`GD)8p6ZjaZyF6JQ09r@wCm{H_i&#{K3f- zO^`Da^g~YmtXlSlplkdNt<=&;YW9S0B#TPsn0zn26^QE1ZIw$ON&Y=hrIl1j8G7IE zfn0AxekDb8jHM^H5s;r`oh8*S3Xh_(DsHhFnf^fLKh}I;f>?XNAA1B0E_G!1M)USY z{5=;LB`v@giJFRn_HZulP%g>vb}RvTnMU!_ez`AUkDFq>LCHmbPZ{JG&keE0vQs)RtPn8RU7Fh*>q^8S1pYg z9y_1oPv3K4DtkHF2^3MAW`u6&9`H}2m2!uviKYumZGyr_!TX<<@tX1ICa_71il2{X zuNYylp9&Ag+YgLGZvdt21ELfpR-C0x$ysIHo;SOfiq(U0gBolVnFxWp^MX(&AX@Oe z1RDBkCZv9V2{;W}I=bS7o?2`*$9y`vC?eh66o9kANHdsNSFHUmqfo0Se;GnHfc~_P z{%l!`ypn`?)o8pI?S^TC^8vf}XXY z20~Tp`?y^TK*_MiR{ua}<;yom3?}iY+DXTwNJ$|U2Pn7Pm0F%@1LzoDAx$h@o`kcxXcjMzz^1G*j zEg|9LI7~E)l*%4e8e$A-DtF1v6bklq%e>_h9z4&C`=#vBgA12-N+9R4%2DefJ0^ES1m5(m9k z9-`c$cp>z{8NI=A3d0B48coiXjfY8&4y^Y$;7}VJr9GW3vJN&0f~y93;ldY{nU;q? zp?^gn?GfCyq@3TiFdMUb_lBs z0FUCxm_M0lbyk@_RgmhxFe#gE`RH=C|7pjcy$#9lxfqSdfqjP=lKyW@E1a|};K_!e zmC7hleo?BB7*_sB{AVz_H1>!s=Y+=V91lbKJfGi5{sFgNof~Yevbi!;RqQ7C5bg_@ zLy=4^j^a6kF6m5Hu)zyXnXhJEtC?K8poH2pCB2k}lR>|c4x`81jS}lUN42hORaT7t z6)f4)UJrrMjdl4S`ZiV@)oPB5m@jEj)+lapBusa^JYL=cHbN*yRR<&RWCEu=EdM)e zel*6vU@^e3dEnsVDw1SuGkM)2NdfbQ3WKWN?|^|qTtM^M@B00$wCSD)Qz!X~u9jx< z3NV+vNbF$LJrR$JT#X{Z&RDHvj)h}P&L~+gqXxa`#i~l-UQkyfd6xox3#UGEMLYmp^0j?AWp8L9lC zeejg^eKX!yUf6L~8@23UENk}ouV1A`iFmxxn7V_&21z6}FnE{xk&c;xAk}hHa?hO- z^rxO?Eqlb^Rga5$hnJ|JL+T@`e#TYCoP!@xKISZ*A!Bk8JRlWymg9|07-fLn@^OIp zl0^gq!yuuISz|LH9Y>~wGKIvJ;JBH#paqmhlK0bQjzC45@7WaLv-N|!zy~-k?1kzm zS}`R4y*(R`B!ISNps&`GzqVuR$@WmiOLsa=;KNj_Le_2?{I|^|W{IQ*U-jTqC?otC zYp*Zb2(rXzL5R5!T8h&$b10&plUqb^n>pZz@gl94XFN5qJe(LaTR!-j3}dElVOOA;C1Q(UWUa>~vGKa~L1IT^ z2RfPO$f9hF#I=A}s-M1RGGh@iTb8BZd}S{B<{dOkph5}p{vA%ZZ=O9Qn|6=SHIIyw z1K#HnKhIv~Oh?iRo%q_x{gx#YcaSM?+;t7OkZp_w?f-UA`?*9AV_STifKM&=^;IOE z)Mm?ih7>%&1bgFy@qL{fE7p0b)TNn`y>~U};0SZ^fPU+dc0sDFM<2ZJ^p#imz&{Hk zwRQyG;Lvmld*m6t%>QWdsaUkfWh{)7!wUA%{EE3Abwz`BI3#PbJ9tGF7$su9+Kdi$CZyv0KKZLESbBN*V~-mpcyESD`-Q$zFC#c=f2J z2kF1>gt(k6;Fy_mES1P$PoX=y_R{>R)TiQhPeY?^2aSG%c+7V-psyU;T(`*|{HeCo+kn6+rsc}0R&I>Reh8hb z|8MN1|1@hOE6gUMv(NVV){di&UQ&oO{ZiFlHBV5@4X$lXTxobq2cm?J>PSVLtBQelngte;3^lOPlC9B|IL9j8o+@MFm> ztISk8@k@@IirjpkX>GS0@Wga6MBv8cjSaw)evQ=$3crtRUb^SZuOzh`-0#QEJbLUP zyFkRIv%I2}gL6-N;F>THMZ}b*yPt##qZIQpY=+(+$Eh+jJ8@j~t!}FPcuHjFM|mM?!Vs@s{VvHa4+|{z&JkiG{yY= zx6zZ?(H-^kpnNNeGgo9Lu?Ce?szMTaud&S)n4n!3Z??;f`fkRz4 zClU|!RLx^5;^$T5`RV*AhMIwr0V7MUN*c)zjD6>u4>CGxgijr$Vro2Bcx}jDz$)o3 zyTaDa$$f=x4BHz#N!5?(*jqaMa}XsbApFeiw^bGCW-cr2+QdSzOb}UljHVBKJNeaU z(dk{`iqo!R=MO2I;w|Bn(bY~3t?ayy7(=_|tiI+G6J*$u)~`_S0xH#%5tytLLhYWG^qu1}{!sF;%6$ znyq&CqnFhDsgV?an`dP-+T8rgk;?Z<@PeZB<&bZ@4ol>YmkT zkR*PkBQNu5u}pRr{*hkwcTGT5?HI|ZWgf!!{Q;Oa5&6mlFUf0j3c^bu%Q!HUD z+1P78y5?z`{7pu5iy@g`2+tuE0=E)m50!<`tM@5W@N zlX{wVIsragxelmdjK_b0mIZSMQcbN#Ek5fV=Fk-U`M|JeUSAut|5>P# z;il%6T#DwJ!)*W6)zVZ}EJgN+5WeXdt7cAhOg{zA1{>J-s=t!fE`P)A}YsECw+E`a8u>PB`|G7XsF3H#gDVJ z%{|$LW~!}!5EJ2AuxXI*tjMDR^}k-xqw_9&ggy4_*%sPe3yt!$l@*vK8azgcquNU! zn!vX)PMzw76eCzzGaEK)3Xb1ht%D@Vvm&6x{iM@+`=G4eCgnRGx%jj|Br&mN8Hx{ z^7Jv7UrOw?1p_EIIq}sc57$8F^yhaiYS~29$rmq7+Igv!%Xbzc8xQd2EHK8X9nT|G)Vn^8&Ly@GM~+vs)KhO#rj)IeLmc`lieB z5m5myT+8`8K2(ACVx3fbw2etlbLjR&%%kr-#B}z=?+y73z zcf@%zCtklPqV-nK9J4`O2mLb~^~_PJCUsKOx{BQr{75tVaW=tGv`JpL%H8v!dG4LA zD72j3=~aHX$|++YQM6Nljwe!;L&%wIVXk3#ewS7CAtqFl#b3OZ5*W=eT=`^$&^BE{Yej2>w7Mf9j5?m}L z#=xs{dVx;~GMMEfZm{0HWp1sUVtkU0-H?{n4CfNJT4Fl{UGmIZsG z!BMD0PhXAs*5IkyxPep4&eD=`%vNiprF)YMFRt`)owI|F?HFirJ zy~C;+Q|@cK`5K6F?#;L%=<)CS&a0S$4quJ;qWdRCc@>=;nI5#* zVb}4VoNn3L=+O#p`|vztP_=kvUDi^6Qa4T6a)XNf3S0NgRVYrAzzR)td)&)` z%GOeY>+tWDzdT5lDO^o&krN>Z+yB7nft$R*lbcxr$`?#_%esc5o4~ayhd~kOeD1&S z9x%f*;*3?<^0kH%YlMWyfiM(wd_K_3S-;&caV2VEg*R$j}!e55s@$C)&wFgCNY@K4;ny zy>9g=C`-hqIi?-wDV2@MZV9o+rVwh!AK{_OWQNN_omHJW81yZv^KN7`=|8aUT8?rx z(oSZ3d%24eyb7|*$~dg~5w*z>9Q-?_>g7EZNIPegR&n4%WA%EJ4G^EZ=2>)luXEHt zOmxzO7xwfW*Y@=mK63_A&-gsebCYy|h#4@PMH~*NYh^ z5<5kp%Dh@f5Y1y76CA&nQ@S5-5h_1=VxAX11}tu|P><8VOBJIkJPYGm=zqq~%T;@1 zG)P)IQzPNe&XwP&*wQ=(&&3MItVJ()10O&G#nCLDZ)#pwy{9gFJs8fwJX+5=B7*C> z1Fe3vl8sOx<=C0(1R78PR<1z#mzMtxx81UGbWE>T%Imr2Pj$g@KSo!()oM?=2f_Or zXq6oy9P$~V*7k|ddmWQoc0FPZorZN8?M@ImN8q&oDowH1~1A0M3i*Dge|7XZrALrGFo#x^&J(v zbrvS>^gG`9+}OR?d9zt=v)f;1zaD2mu23iK39+sYP10DhOUb<>Z0$aM+wQ9A@XHh8 zM>tVk2aGg!OIi%9szS+Jdp%+U-sn?~q#FqD7!F2E3j)@E8-;YxN_B^0oS#;(5F z4&}RoPd`9stCs6-fqy-W+#DWcbzQS(kJ#*v6bHpo$F9jL*ex+zw|2PiKhSY_>`edH z)y(D0))hUj)^E_#EJ2snhYR$l064vVhhe#$e-9!zi)swEP)|$CN;4@Gd8RSGx~9H! z&Ge*^eD$HRS-HgVmy}4}@rbpeXiXRtM(+!jASrWX;)Got_{j}a{LdlaGANZb*$!`ke(NVxS>Kb$>9U)QE68M~`C!8nmMDYT*fW*@a zMCELma}GBoD|o5yJ}DlU;dHVFlh}6DMi#LNpfu6VYL?1_!=%8q*FLD29pR}EEQq`$ zi=RJ8_yuXqEUm!&x0#Dep0u{I#$&$3E*zhOo^+Xj7k(Ul#tcNsH1ZLE(a+=LzJx6{ zC_u$UHPhp`l#q^(f#Yy}df4RC^Idf10B95et^3%{|V1!Ha>>%gTh~?IvLH7s%rz-Z(PT-E1Br-08s3<#4 zyv^)c*mad`kHFu5)Ev~WRK}d9lBvdYwSLlmcy_=hvh~n(IwUT_Y$TUJ!3`~z)vOm; z7dl^ZtNI#%VouMrH$2Hnt70rhTH(L4kJfhlhry>DPewl#VRUeEtp_Y+_!BKU-_h!B zcPGZRzV*=ni2o79fSU38f|(v}3dy{mbv5%$#gMKk02(fH8asxgTrw zu11va?Rse+?v4fyISG_dUX}$0ZL457B|OcZ@VZ_SbA}c>1t30ljD3RiiPbW+~kZ_Q#ayQ76e z{vL0=J0iSxF9<7uI6`&i7%f;}m=2cg6pY1xfu%7H4*mAyRF%Vh&6U3nqiXSp`riSqOp`b7_CHg znsx41%PzWKxskP%8L2W@xNk5cQY%(_P36Tk@<9^s)xC~R*yDIFRsv-hhK-u)_08J6 zfVd17YQ5^niqZ&mSaX1~5~}wbB**@IEBZeNy#8`b_B0;VBoNixvqmNBD#gUdt>+sH zHx!O%bvNv;2?ELWS(prma9{ffBW?P6BWp2Apm)j(-gMycjQ*>Qcnf~1x8ugJT&9e4 zCO@?muN8u<&9kkMn&&Ol?= zPT;U-4IBsH7x*$K(*BBXFB?Uy~KcY7jV0YZP6LPV4kK!{RzkN~MigmS)p z{K0z6eq?A>tuIU$(DWKLJ^x@I0#d?74ucmp1QrDZRnzE&pCtH3rB|UuHR~EmuuahH5S{o^oU- zzbGW;#-HcT{7&AAD~xR7SeJkN0sB#+R5tOPN5zjllt+jEnpbfReX|hUe(MHj%R1{^ zc6#@`HX#`!dt;BrLF2OH^5E70aR3f^11vuQ5Kmm!G^3rsye-o~4xUIWy$h^W8f?*x z&4Ik*slpqD7yP%n!-YxE0vmltz5m2}Q0k$}!P&5<|HZQndf1fWsD zF%A+>v6!6P-uPTC`^0?Jwka(w)Tv5an?hXwAn@ijQa`PpYqvG-#eki%*7RMa1_dqV zihjSkQq1p#(t=awfEyM-sZTmQqC$4r+{99}^7g4K&m7>(sw#C#nF6kcw28vIGvw@P2_ zD^UU~>v$+{iI)J51o(BuijWy}zd0q$Y}FfM%Pz&*B=DRDC(I!Bd+LEhdBpp=tn^2U zhu>;vznQ(|p%&BHrxn)IJDsG#d*1nKXL;Q*+b$oP@uF6DNaZXu{ z-(lAyfX@d0{=&Dz&?fyj5$v+-H*BbuVD-y5Q^PJL6VSk7Xz+RjSrtYw|9jNQW`3B; zamI#e8Ydc={m6ONbV+(Mp|Shsh46DLPR|Q>v<<~=ZviBlcqN8&CC4{v4GyJ#A8A`OMJ`0iSIOYl^BEX03UziOM?Z8xZY{i?sNw+s!G8h#xtie8zyqlKx4`b?W$Pm3y^(T`zyY?uW7*VIG zT4=ItWGm+R0Jby=#hQYbu-Vdiv%Sq4|yJU&OCywxC91nD!G=N z<>cvxr13q$e}-iFD>xm#<{$6P96f9(p77#rg=U*w8F;T~Avo;6_(9=+Fa(MUHd_GH z`Y9+4ZHe677tFgFLPEaQi5+~d{IWR8KzkL4o7R_92e|;=4}P&=UPUwXZT$^W^h(xl zX}0J4%dvlfoAK}NmaT*ru$3Ya=bYM8yt`?T`$iupYk2FE9N?x)?0|#^3bec*{8zHV zoXBq7Ei1;B!)NzR@lz8S4M5FacN#uB4dc>?M_J7q-MN`zaY^`|G|?4o)ZnnX_7wka z+St13($Ss7P#_?Xr;@udia6w->35Pjk+T45m&Pl}Mz}z>Q9Iv0&33-v%x0eA#QZPf z;>90_rCBBdWU#yV(KE)v};PxcG<~Ac4S`JE|u&uvt6=sx!jw1 zadCg=8sFdhUmmY}&g;C!^R>=F;y3;lkG&24sO|k1!hY}`GX6p;LZ3}XThi5YzfZf} zf{yXx*K>wDsmBx`|0c+fsJ7;n7xcV%6RYa{>{^4yR6-5rcbH8SxQha!r7Sz|`u06i zV3}z+wMwVkoI8P&b}|U#BBn#Px0#Xp40D|0hRiE&OtA*4o1xhz-c9w$AJyc@8>o1e@sTGuqA8I9_q&g4>911514PQbGjav-5>&S zUp@S9tD}$nFf3xFt7hk93z*lu@3bTAheQ_4G!X#=rz;rXL?A=oz&$j;2cdh$54+7- z6N-S|1sRzbcxITKNhPS&Lem=;EN!s7*9Cd;c9XGAktHqSG9(#?#3q7qrCHQ6aM)p) zp09?gFVT(9S)?EJInGNn@rj>_r2A8Ja}9!3lgt10e{YHl5yqa`piU6POxfS@3l9?Q z0#X6gxBt3V+9)-_P0H)Ws(FHFwW`+Ol2dW8( zRXygSUDVEpbiSwaF42B0ma7kzX=ILtK-8sMbS4I=GLF9%s(Wv3C%KK*2OX?wvScE4 zyr>*9l6&7PFxn#sS0e6Qy~kYiY`ux9Te>z9ZmAiC)_m>F+VHd0Q1ix+RWY*ZD6 zUuilc6@fs5Vn5iE5ZiEMYFL?M!|J_TMNik8;yZ^x>^3)boWX|_ynC_|V8+MX$Eo{_oN?N72lv601DgNok*WSV2tiX0PfQXO|@? zkY+wXia}+o&X;A2$rc4z+Z@`RS7Zl}8EDoBuz8L~dCiKMMmsC7b$LF1bl2W3 z>FDL5WX%^nJs{4!m`2~p<`VrK^K;C4H|D2>mR%juBVT{>`MZG4jyGKFcvS0`jh{Ny z`(f_mH|{8-{bgWnTZ3R~gS=%RJdb!7g%l%hwf;*@2V>P|%IDY>hid>0vQ=4yWJ4%Vrt~@#L z%dr^@x%0TSAnCcy0#yQ*`R2IdtHqlGJy01EiWeN*zVjO^B?h%n(O^H#QW1LgVjZGk}3<2_D)HCAg5}S zxPi}e+Y0)Zd7;D3@P2uRqdyDx%!wE_s%AfLHiQKpiGw9Icx5RHn?;n6m@UP%i5{2xv;Wg|H_@p`CTw&VN}dkiZtp7^e=Kql~3j@)SYn*ZXuP5G>neAH5d2THlS zp{G@pBSX~SDD33Ki&dcT5mYB8z(ba(4hEMfSX=Fe>Y_d8#E!LL?~+|s`WCLXrd+Ig z_1?`i6eXBxdgr9AVBT@c;0i5I&HZ9-MALmGB5&7Z4Jz>hCt^o>iJwNLIA$}J-S^43 zqlk9)z}m!qiA`WPeaZ129_RA#s7|aFv`AGo_zgbGdU1VYzOi4=!24-B(h!*NR-y?H zx68uv_e#%9Py5R;jok)t4b&gJqJc(<$a7@#?QxflWd(s@=Rf~jK{RWKK zpy-vwDoLFwP@3FGp)amJ-T;Q+f~F#DSt($%FQ3xlg_`ONpPdN(hSG=%o6haP4o&R- z1Omiq8@cwi<=;iZlHIWOi)JWK@RUplr)?kr$K4HRkUpGM6d|K<|2uaQxfsoTW)9{* z+m&WoPt>*iGzNGUu=8nDBzPRJ+vnJ%45<^V7$4$1CsL_~{4R>cQM#Iix;@<@4kYdhmE{i`|gX|4rJ zr2iS%Tmfr&=HGU%3&*5e^5(hzEvP^Pf~*HC&;YD3_|#OO4T2bozQ{%9Y;;QO9a&jx zVc4com3spm6HSB~AAu_nSE;Cz3o(|FcZ&>~N*Sy6-wvSlSsQZ%N# zAVQ<_yV|kdGvEx2Bd4evD#k$48=?nVxxV(i?5@M{tft=WHcyPd#DttyT8Qkfdwut8 zDev?LB7@rYxHtL%2nWZ)nmdJT0e)jK*Z{x-g$_IU#0n+Qg~#R3t8iPm^_8+%5nT^g zE_hi2e4nj1j5u-}(b5(sDT;uj*0u%66#uZkaIsIc?BnXkTT!W!zM4D9n%->gvmk9# z-qsoFlhxO;+yr+iMM>I`T_w_i+^&monM%!UVSCN#EyIzMxUA903w_mB;1x`988s`R zedGiB2_I*SKboOrG73KQ^~dZkKVkn?1rZy$617y!ZO55-h@AWHkAecNqriEZ6G;Er zZQY2wtD`>Hcd~6G^a(|1>|Mb&I?P{u#6BROC@qXoKTx3J^}|PKC(o)tp!;0@mlST_ zeZ_PW?KZR8#75Z{cBVpt8K~~Mk4w1DK1zvOlmlH`wyH}}2RvK0t26y7Y{!qs>qgd0 zzfzXy-VOf9-;>sMIbvHztao6ocv41@9=A5~YmN*5_lajTWRi}Sjql%g9l>=EZC)j_ zq=;2>w5}P7|1rz+$mW0s07iIAE?^6~4cEF~P|5F{gjivrNT0KGGg&IYbF;cO{~;&K z*bUmF5jkexXFqbLC{(uZ`+7E+bl2!B?=D|VtNAqPUjC;@!lhPYA*DaRjl&1X_p;|4 z5`@A9$}{E`y%>X?chMdQes3=qr*tu`fN~V^y|_&BArvzS)<{HPpF`o&NVofP z9UH~_?%pcnj=X|jGL}rl4V+wPeZ6RrjO<5`md_TaKs8|H^AIEx!llTgP}VneOp?_> zc5oS;o4M7B%Q?V8ftXef?EF!MTfD_~YBHFSurDwITRWySDCTTdP^#(MN_o68HPY0p z(WuQx$tBbwxoiO^2+hkt<1MjWSd;{@iUlF*=gL3i?1Qen(Lzt`lMpp5iSNF&zQ=Ut zCaq&Frug>^UvBU}JEj#9yAU|`bSSdLKB$Or5HK0aBAy06J`khVqvo{i?y6dHg##eK zR~td?6s)@zkE1pC^+i3i60N%9NeUPn|B+}#dH0-Iu0?rIG+=wtkK>+$tk^MxgqrQ+ zBw!QY{tMhra)d!UQnZ%sbr+4W>^khGRXAw~FL$4QyBQN-NhiQS=T5A~BgRy!3{-Xm z9}r+l1zT`eG@@oL{*~i=b9064U%)oS$N-^2NxRaH|JcvCX>s1wl;`%wtqkj60AHkY zN&uKTQC9)J43t&^a?DVrkJO*T>1(|G&Bf^$Jh#>ymNtM1aG*Uwqltw7kaPa1lGt(@ zO?8!MiH9PP(bS}8R`6GccaBMYHVCD|0x7}Ky>s6wUdWx&TRmb1(XAE+C_K*|&x&$! z?52Zq4J9{S)Y1a&-GH&7jE9M6cYMf!pzm3>p}d{p78d5c8l3*;P_v2;}!}C zByg^6_`B$vuvyVFhN5`{COPqEg7kml^~68yaKMK&IG)A2m% z^8xMPkHQ%I?AObrFKo`_eSSKUk+eFQEB5T>DBKI06V0)@S@+;PFkf})xB!eHVpu977xpi?nRU7A`1sXDLE~V#m zcUo1uF)^9ymaU#GN;?|m;Wp7(Ylf18>b%+C&?^kpw3&MKb@~Zys_{M!6z9~KnQlMK z2krM|Lxiv|+F^5IVk0+w=ViT+M@)+P_`25DI7 zA~bFe5*h!$JucQ+f{ygyt@W}qxp@CX zek)*xz0su%bnK742F*(avzrIc%}=Y*r_w7PjlxWbhU##kd|UeE2iRM{Ny?5+-|e|HT#s7fMUy56 z#UBH0h+2M5L|I9r?a^Fy7Ky(w<$$tYRlbM3DCr?{cb}F5Q>7pW9;3Adq0;(Xt)IW05a7d05wQaL#jY)>;Ww*U{{7>eKhiqi?8i(5rbj6X%QPo31SDum)W`XdCY`@46Xv=G6i~0{m*m!7FLQorOB&Jd{DQ)z z&8GMV1(pfA3XE^cp`tk0&ETl$Lfgfyf@G0Uj0bR&%csFEBxoZI)gI`}XYTJXHVL$^ zIAwS^$D_n@zdXk8WQ!_Vk_PR8?v3$^*G9-nV!I+~`qLTq&Y4MuZ07=E$D9g@M9GO@ ziopyCxaGD&OQ|?z-#sPk)ih{_u-|6OjUY0di~@01kTsyfvjKr z(!KA)%lIbX+k^45Jz~g4FK3`e0)r;oUS(e2@f{Oj(^DmuUzL2Q@T6g>3OVM>Gk_lg z*S@;_M0^h;xOfxuN>y*7Y?>2`1txC6i5TmYVHh#=;hg()3{Bi6Ra%Bo zaw4C=hM^5|lse@&0b@#&?2h^H}dLt z&$*EnYs8?y#vns#KX_S+_z4Wik_8&cwPAF&Q$c{NU0+fL3f4xi@gGa&w21`(wZi`E zH^uv^w+Y07s}j-)=X!&2M=I@j%;p~j_o0T5#xF&Zdzycp%IgdHt%|g#Q-#D@mnGp9 zi+GXtNt-(z!H%RSp<0v_Tt8ZrQ`!UNmyc{|uGh?@A368&Q+UtZR+;Jr~|^MAbawqquSWi*c#wgpDDNZ9y)>=Vt$(P1{%no`dsjNINP&uj(X$^-THeiI0fvLhmi_7y zupESztVTslI{yXH;)oK}^RAY1-fNs7`uCbMvo-?v1`t2p*L~q2e}q)j10dpS^Ysi= z9v5Bn^}*G;Xx%wi*F|{{Sm^V zl~K|-&#V(0shiTb6|b>{8}(Lho)vuv1&jP@Znx|7?#Ok8&7+i&h^e@YBsC8Te_|w< z=!0;+%t zzt#HL1TO)^@7F)lC{SaDJE-pNO!?!lxg=(Uo7)5F{fU@EN7SjMeA}S0#S>GfU+IrM z#~*)T(L@2*DfvN0>VZ;1`XBhk4t;P@)Lz?W7*&@ii^wQWWfSEGWkx5>y(s4Ixm)e_ zDr!zFTIqOMA@*dKQI;}KvpI4I$zbU6)+*hdzY}%YXN|A%zxps1$m01?3p5}+7POx~ z{0e`bu`7|Tz$d=psQFh$iAhv+k?o=<6`oK7nx(<&iQkZkTxt^q!hVY2;InWfPj)B> z#6=MQUOiFDh&~v^13*U@oe{3Qc6=(PBcO-^Ak&70Z}xMIk$Y|D@*564KDo_0Up+&{ z1wRMmJ#7xNGKifuARQqL%lQlw;CdMhEqU6b>4u=XY*b23PP424DQTG~R znAoK^2H21on`Bj!E4TieX1hwjgSrdrL8gu*l$HCvBI0P%lVx`HY3Tv~DY^EXi0MS0 zl|4|-C1^SJEE7E`+$P%TTGrL78WZS|{xLxbWtyF6Fj>;qHv=$HD^?V(EeN0Uef}^v zxl`ioXg|M(-5KxcsK+Zz1y4@N&lFH(=-xIFmnTEeHKsb!$5kg)?oNZ3JeZyYHq=?j zL65L{2ECVCW=a&hlyaZ=2GMrN<4)2Mke~oEsYHFvrl=v25m1V>{rW*om{&P03Uzhf zf}UOW0#Gk21rsbaY3N+NsX2CPukKHy`Z_G;6aL_Z9OF-+jk0IvAsR4lyz0p|Ln_zg zQi%htjmyDiX36Scmy1y-;lRnSz!HKOtYfx!GuRa)wYBiapGZg2Yp6VI&R6biJ(#kH zW*&IO9BENmFfXv3`9B@toI(to^8`s)x$zF}DkQ|du+$^_c^Nq_Yz2I`2}JDO-X-@s))nV;4nD#XjtcOZi(Cq-Z)jx-)ofE_!@ ztp`|wMvqcLV%xL7jI&Y>ZBEF3UZ5JFF<7WkFfgaPi~@XPXW2Vib^=Wy0})7>t2>&U zAf;JB=H&LcZJ-h(11U>&` zs};g#?S8i|oLIC4{at}z{0w?s zZV>7NUS<)U0vg9+JMGIi{*8@3aZWbfBB4-jgdyiN!h__ZK^b^SHKYUp)ryvz8Xs}C9QBI8}f zjrQ@^N4I9`mhSuqB)%)J@HrhwI}XGba?Z;V>|)8~{@?d=>S;FBx(<$*WRCJPLDeGnsjWI^Q7vyuSs zy3MJ5{_kE3<9n{FHYHTbmxvfP3XT!+W~Ah7uT4EXcj7?r4@(S~aBr0En~z?@>r}t% zkJRAOk!!gX4|SNA(IBqOiYJrCE;HC=GV`cvV@4n7(!7r8vsk&z4<&f>I}PCNVVI^mS!g~fICc@z-zUoK%2d3hvwdEGggdo665V%n0>yilj*PFtI) za?C}$tf5nA6cWq0)C>zO1?j_31Q#&+D)OGg$xz@!l8A@q^93Z1lx_rZ>&+ci+ry!+ zX8I=_3@OOrvz&}_D-6Cc!R4#s>v8{E69;;TA)6x5s4R058p}#w0JP>FP)^WgS-s9Q zam;z9M+bZ=4WJz}2LI^!`f4Xha%d8@SP@y{!dF$XGcjQI$tmwNSc-KbY19YN1sByo z-z2L{X^elcTbbk2Cpqz#AOc9)^`>e`+R16JU>aWJ-rGOv%ImPCw{x?=GPfoxx8#)W zM$|H$u>FxO+2MWKB}_|0;A+vk56F;21EI$4e0S8CY;?tU zMrfH(*x;_eTWakjK^H9)yL!bg3k}M#hc+i9KY!XhoKy28Ebcd#Q16t}E@>3&%9Xc> zHd@@>!I~OoQ!s!^L6Vn?TN@~8wB*tiy7n{pKHH7W+k_>-=KixHPH1rLFgdfs5GSA| z#BCNY4+3l7AlLn9OQvWdtRxErwRixeTJ<&AR+OoYfJ+Klet)53N3Lf)7Nnb~j{9$N zUrRFQZ3#&%`_8f~uAa(Aj1`&GN>}Pi#!*M#3KrvW-_Y zK>NUETFo>SS|WHYz<{qe`u9h7@-`2_^PG%=)H?*iD)Lg2seu&XK*ch5Wo_CVrW$>=EJ@9^G8qMy~HTEeN-$dix| z%|vU*nt`NiI5y=5`EsG9bK}jS^j_J{`)n@VL)_vl99U^Px$7r7biOWP`pS%9W2HxA zrsD}k?x^#E)a*wMWBU@f?bvh2fuN~9S*lXe8C>Sw6JIWG_wx0|;%@>TJ&HuTYr{fi z`b8Yu!amK=98aTyXf{jU6Kaw6p=W420GuIRT<2Tw8D0uHZZTK&y9WV&pnDZOxEhf` zI$V>Nm@cnIE^S5bn-H`g+qWqdcSSc!B-g&vDrQpxOdXurd)jAwc{=IU9?H#6Y{j{^ zV~Au^7UZ{V1gS?4N!0HK@VW*#4DS95F>Ht-33At$t)?36EEw&m+K33u@(sOWs6W#3 z+|>H88eQ|du}|W#Ipk#fArI#Qq>pqY2IkG7e$!v%ItMk&-&fQyr-Ery8L3oF(ruyp z;C=ch>!yVl^d>h{hu(~qB020<>~79Xc0vlC->CiB+bc3csH6SoTk0vS;^|`9PgSL- z$;??YyV;&#sfIL2 z0-4}jx@hv??FQ%n*#5QNt!M(t3+2706shZPBONl82 zO$rHl8IjS)rQXHl8DqBVXPT;qz&_pFgg4D(m4{AQ;r8CiG2WCQ=mkEx^P7a`WdA88 zZ2_q!+=}mrc7pL zB9HfU6%7NVU$c@VZ6I*TgC)^0tfW~Qw(LAjoE*;?kS8BUy~~Y*#^=5 z4F#nBol6|v8$Ui|mi5S#JGjTJ;Gj_ES4nk2L&}v`2|6>K`?@YI{5&Tte>@P4Y7@n2 zmU(SFm7>Uv&%&-BA!>;N z-`kbY^KJw~607d>N0;SDa_)X?$GdU6d_`BW8yHzTxd4ma` z&4Iqf)4-#Wo6y8(4e!SODVT2dSQDqQoDnlpDRP!6fOURvDBYuo%s8@md_wkfJXelg zo2U<3re@`aZxZ9{fk@&?2kN#PZrVBRPr41w+(8(@RT8^$At9`}Z@tO2&6u_A!?911 zZa;*@jAUN7ckE@#r|3pOrAF8`9$Ot6Y;|34-#cZ(2eFLiF@`0VL&e;yjf_Qh)&QeC z%9mg31f5LyP0De5*^U+3x1zpBTs53?ct~%*pzJKc z5khudB5PR*z5HX(rI$3t7QVR+1ED!xS*M}1zgAsD#(~4sl`J&%z(29NkdO;})|FR_ zq?1Iejl30NxA;fCuQpfUGNJ;Pth)*t9%#gt1kYGYFwOKbo+K_IF^|ZkHg&3eq5Bt9 zoI!O$^saQURV59wJNF6wbe4!tC6Kj05K7V~$_*|Wc@w=sk6dB7~TolF-}lb$+of3?h+Wi7$NtGE~&+vlT#CTx5J-9PhVrvydLMVeGtRPCFn^;hxW=% zrQd+n(H-4!t#A+=WPm*DdqQ(yZ6dy(X6dxs;5MG@W~MRNm*}fdlW3{pWTP^Ja+7XG z5NL=cF!1%yxM7%4qPVeq>^qHxFMi<)WEod1+leL{c0@n)=9RqibH#irYj^#>6IK3# zT)3Q8$sFAI%-v&*VfYZA6FW|^b$4OzT?tAmNxqX@bN%0lO80QL*~HvGGyJ>erK1jUkdRTto-C(8>{Ol(H||k4G~5gN)h4Qw^RQ9`~j^|p|M?+J#kHj z2O4BzI^*VzZ6srz?CQ_AUQA1L_C8IqK=43$6*v)dua1m0*y7SR_L?Hf9IrlY`a6V- z3O*L53r$q}5&oU+D6xRKZ>TSN!doo7%tJ6K5-pNpZTft3;rD^P?9Rl=;vhI(x!UDz zD({@c>^Pp$7ZFE|)n2|$m9ysqu?BW`ErcEqfG47IS<2}fa)1(h?Aj;gmA^)BTs(}S z%_>C0uDE5kJYckk%?vOgKBt~C_SbY2N_n3T$m>{c7}=qh4G%PpEzm1}Ik?$HV}tKo z)cj34h(az}@?YoAu{Vx9C?a(oH{r6Q4{5T<+TJ~t4d>r17Cx-Bm?_y=w{VI!M_D*e}8@!l|ky?*lX9?r$#<~UCh@SyBPwu zy_M$BZB)5UFt-1#c@5rr(zFe!quHY~=H5htOch8l=L!u5<}lO9f#a^Gvvybg&7BgD zfe6HyE$K{nW$tRyuZe>M{Px}VyFBmFu$cQJ!VBbwaLuy->=_{nm23nmk#rkd?c01N z!p%o`sM=WXh{I5@Y9mGUn5+ji#n=y~Y8g;kX zTth(kfHW{F)a^8>`}vSBr{yEO(nPM1BIAmipOyc%<@bVigv5oe0X(tmGf z{{0!^Ax%qvutgnT#G6gQew4djQnIop%8$XWibGl7YD-A)!e?8D^_z3U4~0$y^z#$X zoW^H|o*>Sqi~ru^zD#Y@?d1!voZWXYk)YcwDim`0>+}6fX&RT)oL=eDJE_mLFqpL5 zE9+t1lGYd%quY}c_Lt4)+xZkeu8g8n9*`GJmf~ftrMwh zes!5$0N><}#5OnFE>w)|<^EXuCrg}5)SKBRg9CC?%5SE$=Ak0?OS0|GOOE9O+ad%$ z2QfdfnXwf5MzS(dPv;EgK@>x&FU{+Ni>M-l2`e9c){wjt*s2GzaRJsPqdz`i_d4_Q zqez%~pgAiSEqn9-`W4YVYFGDWrEaH)*In`F#nIG+)xqgc*Aa@ig7E9IDZC~%w&}2& z&{?FoLR8`536DZpE#yRc2|x1kDKcV=t$oWI-L}vBJ{7y<8kat;>D>FN0@OVqhMr$K zg!3lkB&v;MfBwnhHVIZ=yCJ5CZHsY)E-;dhE|Rqu_;aZTcE4Uf>Aiz|*5b^W{PSOA zxaoU4Pr!C1@7(fKin#_&F7-V>pFICpcO4*xB~nYw8L+F%4bJ<&%y(Kv*ISH=vGk+n z0k}7nLBU+t1)mK>w+bB;EQ__*o`i!C#fNv!X%80tV5!_@!Z6sDm-M(DO7pWVe6YcO zcudF5?q1|Ji;a%Q$bzf^BPw$<#q-lMbLUqk>g3ZmTvsHkI8tax@7yWNc;nx-(g1TY z-4EV~?q1J7fszo>33<=N(AOnijZCFWUSQcz<=g#+Xt?MXr#i!H(Qju}?s|%rpATuJ zkZ2)fZe~V89M&wmo@xVY<}XBSm;|iBQY`6|Jk3Pi|4-n+GJ_Cn)Oy`M^dA2 z6~J{ZhGlc)twIIm=O4`}+S2ZU*hds5wKU|BCa1jXv~bX)FS3}8kXj-#_qvXQU5BD@ z(`_cIVPed8(&q^gcw*XFtgh_kSzURQ)vfMZl8FRD$C`=}oppm2($Y^Si3Htim7OWq zg3#*n{mTn8m}Fk??{(gFf$ds|#7ey$Z<#j@{VK-%3RX?O_J(YiptOD<;*fO zXH=GK>M^2rePYZwR(9_BJq}ob=l*FAFc=kgvLHhm5|*{C`r7=$M<+?uJ3I4C{@l^; zjW8Q48=~VHsd_;P*}z`T)^S$0L+Zynn7Du-Y5oIWAL7$(=FjSO3L~0p1T`kW8tm_+czhd*@lBGav}Ys%pXOw z^wXIUPtrSo{(fecl!xVYRq4s+t{U2jbDYtbg}u3uRL5sWk9Os4_7phta z8w%H=V=n|@&)hjF)% z7hYdvCcUL)rY61LeokTLMDrjoMRM*9Z%2c0;0dw+ic*L2<%mnu+V`krbR)iBtna+Q z-?G#q4+{qwhNY|7SGhofT&35dzWiD9AuVra^dnKIwHEw0m)LmSAM^IwPHU}sSlWrLGewk`QjPjZ$zop`3yj8jTwV&j8QF20&(UaQ_$ZKFvjSA`hR`>ldjpu+-xKnBaMY=&Ft?m3jIAZ9Kfl9XBb>NJ*7^sp4j7@5<>)-{rDac^z zdqCSABdX8Z)B%;hDJDzM1DvuHWx5x2DYKPu^*lIyKFkB1W}Dp$i;}SxT{`b(mhZ=EtYhRXCXyJ*NW z*9QELbmojq3Zq&*ILEq0bow~=oZr&8V_8Dtrip3oJgo1T?0%Ai313-_ulQ*~hc?9x zlUzXPooJA>=p%@RKwBB;xS@lq)crL3!3=+YMz;Cm(p8&h0{o&^?dx z5I++222(dzre8W&l4Nv^KT-2<>sqT!uI(6Uc?R$yrO%yl6H&YE%gq10Awm`JqmRhw zxBqiS*ZW!grX!~>%NUv9_y?-)^S%ee8LiZTqTkrYsl@@^YtDwlc&$4HQZufZyG*)f z&)4Kw1E(j%#H#9E+E+G>xlLpJIhn0n-pXYPb?u%E5kMZJXla@>GD|)!rhJ&C-}~$E zAfQ>NdS)phRPqm{*}Hl4;=hTVAFGwxu6aUU_B)$9+xQLysC0aq{8JgV?QA8%J=)iE z34y0cw7f{yUjjT?<>u)B#F$p25(7|$wsY8Ik5@~nej=5hFzBVfqV?Ay$N|dqF;Qo0 zUAZ6Zoz9OsgkptT^uHMfdrT$C@JxCZ3s&L3&ZShBPr?p=hhBMBOuwLOb=dNoMr{71 z`4|$CZS4uemsk(iOxdr@`fQm#>buahtk*h$Lyt6jy^`!0(0Lsf;510DI{rX{42y6+NxvnE~m<@b-kD(i$i0HSG`Bwq{( zUh`k+=lsNNRxygm>d7YBA&m|(W>uDb&myUc7x@lb*Z9Il+^~y^*n=Af*)joA?7fMO}B?bZiT1|Ky)^5kP2% zSsE}g80rB+wP`98iT09LHUUG*2^q-vNck8;DRjmez4SEh6(fIwUKJhm!xZEqup4_V{a^5gM48&kZC+d zIOZu7L|Jb6lHbOJg5d=O5$;pkhZGuNXi6)38xFaYcq-1g-PB>VUevqD zJHZbCjXLzxov1ilANc#Bx1&F( z?29j=Crqe?37~oFDDIjlRMdsU-K;jg11~2V$F4PVn;ZU&L|}*wmKn!=rUmDtAeisC z025vJKdZh&o>j2yFW7b!msSk1&6Xy|x|d~gYs*1j#^=;kdu#Lx@dc(z|H*EH2U&i=peGNaXj@p;nIBW}6|n z!iY@eYF?kWY)uUyvpMY1vF^sOtkF53YCW`6KKV{03V0zyt~U2IKA#KTshWLM^J9hE zel|wx?T93v1RDX%GD*x^bUA~)b)6@^>-%~7!L;0Q(Ckqh{!1A4S!4)jbc`F=>@d|hTFsz3H^gkfDcD4r+DN214{BZWftytKG**Vv!ifiIL?-yb$d zo<%{@emjQFRQ_&OG|e}R>ec$7{;fPaWBI{C%fn>)<_i4d9Zl&3>Jf@XL?|hryu(-=J*PJ^fwL2LA#1V&^>@P~&~; zFddYVsW(ys!1Sy1oW~-w)S0wsyedv~h$B3nN__v#V1roK7NaAKAnVkM)kiT!&@USepW59?croXm z_>~|3o%4{r$o%T&&>MT?%P(h92IQX#8mW(;4*0BEuNG8pTKa`|DfzftM6S4l5cN(} z6s}3Be%8X6vZpc3BkFhPb$5a`cmlpI{>ZJGy@BZ^aVDDhg&cK*d)^*7qdA@swj&kx zC8c^`S*ccZ%Np0zv~HP7K<(NigP8zDKUwJ-gp&=r8P5vB;fucajXZ`mHR50d{X2u< zmBJ^~Am{(f)Qj@YjkXWZN}&(h1jhV9pAaCnlg~k!vf>_J83gVE_2`WqcuyhmNo;Ve zx1*T;+LVvRMc^gmS`1N%Fv;YTK3~Y=KMD=1Z{JZlAB|f&J=a`Vkdzv=&XX9IiBF^{ zp9FXzhL%RV6q{rVSH%ZjC712|BWKOyz5(alIjg$R>gs>BHj&Mz`o^|A+8yf|%jnws zdZtqe7q77nk$29Gb0x)E21qDW;+y6UpsH)DpMor6L$3=8(Pa^iE>)_}Ad$hnhV-Xf zd&w!&^1X3@-TNq|)7xAOvygNE^DtFx7>bo{n4k2qpp<9{r&U*5z`d-4{`@g^QD zZdmCe^FxZm7xpCt`}M`UTZHgaBmu$Q84T1`Y^Xix?c66VPa6-+&h!!-4D_TJT?~tH z?dWu49xfu$@v8qYGYYyxuITaAZX_KMwr+F572dh9aV+}Hb=mOS`!<3a*52?R|8rWZRUvt- zF&|4;N8lu`h5%-5uX+TS$K}%pj_EWtw_}2mD#29~?LXdW$BZ3$SRHnJq+j@X$}G-g z_% zPjf9Bg4l^Tgk+P$9ZdZ2|FyW@2+}FN%?<9%T60%x>9g&c%m4YtzP;8ej|6qq_YQ^d zMP}=GZGg(@(K1^0Do0_UztsO!rVNGresda0Pc*XEwh0Gnnx~`@_ZaaCmGh z-I1Jg3HfdlZd&NMgNLO|Wls%@e^%q0el`3v zY##LV+AewrPv{{KYP|?SCebaySll2Uv5fVAn;kT7DpTwghaY5lVP&jpFSgX~HU|$N z<|NO7_IB_I=ps>m=Gtzv`iY?;sT1S&ei|ww`-AG%ZNPrA_Jry7z09RcTRjm1vOqZ8 zsrBQhiuNOqE3xK<&&vgZMsuFuO{D=(HH4y5W6al0q6dvXjJ(+X2{UJs{x&khXhN{4 z+$6Y=6ZWDuneOz?TsPU<9P3KYm76wnoAu#}Jiv|FcxXMm0aCqp^c~z(3s}<2?nbKV zmP*PjE!4Mnch_-hwulVZ9CRloS`4??!a4i6hx4DjTlSn%DdbW&vT)mge_-aTfmcs# zmB(Z>O$#lmS0>8b?R5oJms0zE&Gz14G4CUM&E1t4q#M1 z(EJ}wR~^@M_qL}#ii!w=N`r)eg0ysrbV^8vlF|(_gjuMxfPgg8lG4poq@`o@l#m!9 zurU~m_YB|P^WXE4&%Wo}=g#ZC?sJCuBYqIt*}$Vne4?O(`ED8(d(-B|P)iq7M4_0+X~_F9GNZn^3=Gue*1=bmMx&)1W57Zm}}&$jg4l9M^CVeb9F${X1FOsaU6 zYhO)~<=%J%IaA@@Yc#YLjLeX$7O&aIKOtnU#voQUcQJc8p!-nPq@_3Mrj>1?fKz!iZU9p(*mTcR;Yi9y)tly_UzUI@>wW$v5S#)SNo z^setlyb1DgmX1M+eLuIJnU&vJT~_^@ma(@M7>JK2{nmib1r7A?-LEFu`n?ua6za#E zdcJW^I$%ppTFf^nt6_2*+IW4Qt7Y}VO0!%Cw_yxZ`{T0K+x5S6ofc8K`P}m)dN-6!v{|FE)G4pFPUw1LS z?M97K^o;C$LYkn(^Y31O6#F=I@1-W&rCG0SUD`EL$ayIodd|4$MRJ*ytin)eB#koL z1FeG1r%QO`nx}mipS{l(>b6j1b~SHcE^F7ZOkYD_ORptI3@xdADv%g7cOK4esX19Y z+ONGOPjBiJXlpjjx0+n>Q!iaHpPCqgy2=}s{_5&9=G*K0hbVv3XCTrbfCaS2$*)^6 z-SiFEbWo_DZULQm8d;dzj(jfs&++8`_hzJ}nPQH8W(dQH*Oj83UmNgtb02F!;S}Yk zi`^t~giL>p>+;ul3)bxl?2AY{%sH^L{%y9Z5!pgKPBhBrn~-D<%T-GlrT(Aar~J%= zsFZAejxRd#3MB`%^aV&Z5nbI6Q+l}{4l3jupeXLV zPyE(@PiAU|D;R;(UXGq%UjEb^)aI~qtDxDq&!nCSv{K4Qyi53@{qH!5-h;rhRq9ik zGxJsLUte5e4`cTf=A?fj8hE45 z+^`_#OxIW0p|F2T^kz^t@i=H6SvhF@t=Q_S##X9+T}C86w1c4GlUz&o3suA-vex%g zu6b-J>60F$f53%?gRGY}761m-nn6@XO5LAEggyaqOWboqD)0jC)vN7_iotVUw4`g~ zC`lV^&?_kCIJylzG&UM+#_}%kB_tlNS;14R0K8O+^2MoGvl*7`wD*Y1q!=^pRZsPz z>}QX1s)!*onPHuE3FWiZ$gqHh2Fp+aPbh&tmH> z?*^mlNYbDgHjg#2=e2~quxV@;hF|E;V{2CJbHurzAVW-3sRfAfwJ&Gx;U)L9d}AI4#g%*yn-%i(oy?)}5j7 zZ}t%L%JBX0#?+(rI|=J4BYeBa*o40n0O?u3v-f@@z;^?GPrb@WfLERWtxTs#~T+WgYn!z}95K(Oe%F@#?&^U4n!w0AchzWnE-1MT!Rzqz-9%3#6&j(QeK> zOW=W`U|vrPm+&0ff~92m=Wz{KC7`2$z8wYov;Qu2`ief0&dAc zwWWf(n%UQlX`3>3x0F6f<&C4h!E8)z4vul)jh~y$Mk#55=7vh$3c-)R7z_ciPZ zk}*kQxX>72DGAYGE>TuEilcXS7v+apR|WGEYrdgU^b%j|r>gMy&tVQ*Sz2~7?~i0$ zayw{mpe7QpGq$*7QUyj5ClJ72;JE3UA8uveGE^SYHm`cRK8z>Ma>fns%X3VATGd5pB1fXBi5Z zN>bc}7vh$RNoqJ`L$DncUofI9U?>-W8~ zTdhNVEZSEyE@LmAEE<#fQ_LD>R&mV8YT34ZSngWeh ztNG&1?K^DKevs4yOqL@(5?oTCH+KxNM}h%e?f=dYZ$A%`$^yDA^ndq;kX_wgb6y)< z(izc!52V9cIXH9|AZBxQ=6bi8ccdDS2(o_nmQCLnnqa_-1L8_bNV^l7Vv%eV{xKOwK6`7`xiy9j8+I_{c~=v6IrPwGh{`{J^LT`sxAUlf1$vOK6G^9OI$_b z_~(zMl?#}6VYcP3X{wiV&t~@Y)y;!pX|HAkM+wp~H6l z{NupCvB^{6;_U46Xp(t}(D$&bM@KhA%a#m#(XoQD>jMJ|(V!hUxCsCO zXyz);*h#ycabl$jr|<|)%ke5XWFgO{c`tcPYm0SNr!kzjOE-1o7H8GIdYz3LDAT!6VUN%&e+@Kmd;rL5^yjoqNdP&{W}3qVuyO9tRitaWZ_;rzRZ)t{=6iCrXn0 zrxG1oGoKWi08gHuZmAW~OLz6;@j-~0=<$fy5`MrSp`D0N%a}r`tqN4WBbt9Gcb~Xy z3EUjkijvRV#SeJ{K#%XP+vKW*8D+cXe-ynKiI56xw`a;sZl^f>#qH48)s70QwXoI` z8gN)m*{JT53J#Fo&91B^ybLT}ReavS%^z5#{g$wPBJ4}}Ftj{qbBSm=Ywrl?M|DH% zTijoZ*&u!DeTa!<+(B=MfMzq)TW&!A{DMRX0P{>;(;YJ-oeC|9KaE@4l$R82^+2{f zAG!1TlKSPqJN;&#Gg-aLGDNCCz**5XteE=S?3+NetYdY6R>*G8M+D{v*=-pP!G2u@ zPsWq~W2jYzifu-hkfu%+mmI6jm0t~`t8imvVp+^wuQ2)*H!LQMMJP$}~sLnvWgf!6_jaf8geu=kR+FS!+0F23x@qe&u79c3RJ z6z!n&kh0sEL;rBcbI-3w1*k(ezh?ppGVNUl-_)y$q{=ebZlhzKu|{^v%1R;k(P7}l zwXgSF@}d{Sa@BALGgejSUeYM{a4aKic&M`-ehi#HZ~n08k`x@U>0h-UM7x9M1bwkD zn-xB_1Fof2vs1UUn`EzJ*BhjS1OcHXd-x32tkBukAS=IiIUum;pM!*i`(Kmnh%Nq2 z3I=I0icnUWH?RJ9ad{aU{2=2Hwnmgb41@E4d7I`q8?G)KGx`!ZhdbnR_`oEZ8Q1iw z!@E&B+onpFyMC{IH!ULpewc?=xy`YBqv@D>jDDy;l7DvEC6TJ>Yi+)h`yn03af<5S z>3B{mH#^7-a0(UI$o`9J3b)yw4Vl*5?eH*IRIHus>-#N~PfyYrg7sFY|8did^1C?5 z`#j`!PJ_zr2$BXM$Ik4OrT}KCifi3g_eVe@4?DA>-8Br;=+cF$gTB_98z5qHEVt** zuY+i95m1+LZDPed9#a{eh_)@+8Xnn3<1g%-X&F0WIdO=;0MGutTOq*I043@!(>pXj2`vFy0@V}*Wma~Sg9Meht$(90j`lcSVzLg4y8*_n^>pRinn!^0Tu zMy|HQ&=jY@EUygPV)1aWn6^Y1?8jxQV@{1Dqjesnc@RypWOq#ml!sPy0c& ziA*GRF{4eTl8NPyVQ}nuj*hF0V-(fs^_@|*;7Cn;J>PD2N&WYi3$T(qy^BsJSI01) zy$=dc)YJQQ_+~}8zdeCs(uPZ)^^J0~=CZ2H6(J{JnCyEf#g9Lbq5#vug>I2kFO9hb z`i;w$k&GPz{uNU^v^Uo+vDb!D{?OYi_l&-aeNE1@tV;S-dE%)IXcewDe2=|#r?6pv z5dGll*nX}|w&ICHUm*ZUkS9ok#Jc4)eI1G8h1AfF8URK?}HBX;6Z;jKdxtCQ7;|LAA zaCOua@EN~Pi7RUVvSOC{PcZ-JzWBeKMTo2dxoQps3Vz*-YaLo}l?{c(sqjj56G2ql zHX&^1in7|_xANwUTEM)B-q-_dViM!*Ut6P)sP$$&ZvVac^?8C0p*`+|xFhU-Sz@Mb z?)|DjxH2@XaQ^)pg^!i0-@-`dr?u~|mCfE3od-w@yz((Xu@2YhIhOF$w;o&)p*Lhj zdb%He1-#}g_}??Ql@uCL=t$doidAZ+iPo~AeaDV!1W7-1&DpnpHg&3_n-d_<^ol>3WrR8P<_qj5N*E*(qky zuY{O7^kht}(&>j``nLNrOS2a^&ZHS0TXvgD_Iw7&*0dREuLtL)a}c@i3AdA>4n48* zFO3$Q6ZOkyF_DR~bxN$}O(lmB)?KylZFInm5MkNuDtcPM*kCb#9C7uW0OKbyd0yIv zzcX^9Gp!TAxsVXDO zNP`P@P$T7bp-j7fTa9wJ<&Y12oZ>@7b!mlq{kDpgtR9bE;eh8bWE62Y}@A!X>}5`B`4Z>~V1DGyK|)rftowJE#pw zFg^5Ed+&5}RrTHzo0^kU)7BG|MhW!8w`4Beyu-IH#RX^!oV@yDh_!9rP zA23T^&rzClEIjmvcXEJ^%K9R1gVxhM^o4t%lM~6zXDjt4fpWdMT;{vALhYa1!C%_9 z)sH_v0QHNZxp~Uc;$dN8Nd#VN^SfJ&uGeVMaA~=XvrOl|v5V{Q_gSQ$Z5`aiJReTe ztBM7{jG}6~JK1+^(i7?U1w*6s^7+FSc1YP2UzRVfS*mEI%Pd8Oh63*~e2q?Hw+FG{KxmKN8r|;(JD>xPe#bAQR)h@qMOfL?uV;4;c%#Oz-zlVs{FlcF6nb)4 zcei#Ae3>;PC6y#n^&iZ}w14bGKT9a-mssgMc2e7Ac?vm;ZWJuNz9Jk8S{rxCT{rBH zM@X*)Db7Wwwebo%5y93b(?=WFWAN{>4k`|1>Z;+UMVa_wMw`6KV30c@cd)D+uUX31s8>;l{tGFRfeJsx zmkW4yv&-r`Ud9H;r5j!y4Ssi0&^%XV-l`Of+l~r`2zaO*~ zXVFi#zz-s~v`7sgEqdKk0DDAkKL6d9{c`>++LzM@c^L)TR=slpKrU43y#%8laVuDPF;Hf_O{-rtM*|qt6BbOsBy}uflMs;=>q!D z$RQ~bLdNOs%Wq$1u+3eQEsFsprnBI!ogQb@L_O3K+@IrTh+0v-Ok*glaI8cSV|H7dn57{Go4oA(L95MFJ`$ZY}%-&#rB8z1ZXb zeWjdX*GB$bu4Nk%uJ}lUa?Ou(<&9hsa@8C3ufK>;HRT^FD~mjuq6Abmcyu}JQ#*K_ z-~z|;@MK1GTz{3D)AcM9&wMz&C2W7$o!BA&|HlO}t3HbcQxzN7X=bp*Ck{WktFwSl z9pltrkap>~v%=k`Y#V%$ET*TafF{uG=BxEt&@E35XPRWv$R$+hW(u{@SoOPl60RZO z3h3JB3I-`f*yGk18s&3hGKG_cl_Mh3EciI(Q}ZN&`t=X(=p`^Z2uFqi2geQc4Z{Wv zzRzY-5tPQHn=|(>&~D(L-=F?RMSgyYRtEJ)#bkE#0ztu& z{Co0;l9R9klB7FJ(q(^oPr-qnh9* zHi8Mr%E9$6V6DU7Ztwpt>`eE(vTCw4O26hdblD%cN8d5)F2JKUip0#k7wed}t6D@(rT}?iKuv zZ1Pc_hHA?W69f|vG5`OAd2>lwmAdgs1_{K+o8PxoO`$!J73fokW&f7;_5D~4--Rw5 zdnMrYn81gAg5QI1*%xx8-^u}{R(0-kHW{BqXY@qn$?_Cbx@!3ed-T0cD?cRdSK;aU zFF*1B>xe+qEgIsvz^iUu3xR5I{l0%c6t9i6a4}S3kD=~teJ;qeYhoKpKEYAsczwS} zf7i;C@BK-YNm3|aIUcjQF8Tcf10YfG?T5_LcB_hj@LGg9eijY%OAy>)AXiQ73PXPJ z5x?5Wu;m+_nLUPp14c0+j2eKsBRzYLoc2Z@O+DP}J1P={;DsbT4zRdiH0X9Q*4Hgj zuLwbHBqAH;I2)9pk9;WCj%qDXipGAvT5$bNvLJy$?JF))d^ez}Ze+xxt%%Y@#-3j@$AE5Irpop0)+4&~zT2lP(v>UB3X zMhJ{*9uwHOfgw@@es^6O3!NpSq~>9|mY9I>0{Erq{op^G*P#g&R=Pg{Kzh^w;mcV%{5CcNozbDOjZZ1Zb; z68-0z##EQpLrIXi^H%hGrM=Oqoac+1RBn71eJ4F-r8B5-F=T8plp;~Yx+ahA<#;E3 z|3!kHNlE|hJLBEGB>NrfuiPyq%B1xd|Iuok_c~9jbZ^1iQR(z)s@|Xe80lkUtWM#s z^7|L5niJFDe_9hD*hqr9s_L&2(@QVuAE=BJpU=&~2lE}Ce#w`kCe*`B_21s04r01x zk}xU#a4hqEw?kpekn{u`i6v|vJwI<<=M0~;aa)1AXON10h#|=As@Qk$-kB{ya4!|q zK4gCy;D>zm029tvPN8G+dX-^_n-<^YtqPH(<=nZ@03py`d!%drVZe`KrM6MFaoCI$ zr|Bi!T3j9$b5BJ@#fsJO(VUVU3jWBQSZZKu^K`)``0wK4q7#)90(Gf{#R>-At57NB z()@tS7N3L*1rlZZ%{Mj?G*_-vDY!yH1K}F{*Gea-OBW1ILB=no3hQ0(j|w`y#@sQC z45&(Wy5NPllJgc1wFZ^$*Ii+mIh}x&BOVMa*@1(XpY_U>E9svvp>4iP!SZ~Fak&++ zk$Ph`V~1qPy;cjSAE+=7)WWx=CF28zbohW2NoXb{^N|cEC#OjT(u z5X^lC&U!#rdMgP&{Fwp@n18Nb^CMElP`=ZUuNMsEUeDBi2Dd5wkia)r9bGxSP8J}n zL-+|>MO-E+uQ7ecweFm=CzI(L$6pQG@ij#)fS~zjM|_gRdlL!PSjBW7)_pI2%R1O~ zVwM~7eCE^JhAAApJgmAaU@RQ}vcuxL^}^T!mveO2y$O48i?UvFwc_e{_}+u1$m&l> zp7-Pv3mB8Sz$8A|@~(Kvc5gEN9Cm@e6S*yzsXy$YQ6~_^D4t1 ztE#B{*nPVEgb>0z{}5XNtvmyHAS=#QzFDaX3FSIF>`eWH8vWONEWT)6>IN7p_+r2( zRIaN&7%_&A0f5)}d@gVh8|MZo?8@Lg)q7)!Esh^YRDM0H`-tiZ&8=t!vr2hYm`{5< zEI>h+8M!{DA|Oy=Veh=}PzhEN92%EiXR^G(g&eP92#%Qh`Z&pF238UKs#mos)K;J;zXWyBfEG`7jyU{w8*@=8 z=X#+^3we|WUM5iCVVg}Bvv46juOgx}^%0bUHee6CPw({Atuo1Z1rWaJcHhppB4?{# zALMB1upiVtiIl_ZO$~7Xk$9k#ZMdfk_UsPaA&>?qA0I#WeWVbtF0i~3cyFsy&hbvy zR>CAwfq@YGWuI=HD^P{ms z=Qb5KagUXUT}PF1S{pUl_rU>LGT~$|N2=&$nPV2tLUNMnSE}~2OUPz75&j~1{q7)p zYQ44)qH36fJQmV$DPP~cMWc|5Ucua^l~zNY{0pB${-amk#2b!dgvy}QXBr&(uS+{N za|r>%pS#oFf_StEEY7a;O}oiwkDaFV%@VA@iN)z{J7vI2aJ@{3U>^b79IT4nYDhoL375|%9_a!TIB_V&v8Su6FbGd%x)*;C^#@WJuV_^pS9uRk|KsB0 z@29p~zkX&Sb>1pK(He(ya6J=M>f&3Kbz)n*#gCp79Wn zn$BGpt#V<|kYo)uaql?R{d3$|;1(BU3N}Xdd7tU|#O8iFXL@({!KDXA$?){2*>a$p zG*s)WF->lx8FF&(**%FWTMWGdHfLw)na=5 zm#&b7Jg4Cre(w&Ft>`G7H>h8}bm0>OG9LRmDt~~(#oo1gIJ?F8IHzA|-7JTEYbFb+ z(=GT&rcOWWNg3%(Vt9d_;Aj|uvE%2BOR`-@URT?;qS{!F zrGb2_Cc>Y+tqB~1Wb#pF;wbgB`u$o)w6iAC3`+IzEghJ!w zrh;b!ZWmTNkJwj4tp?crh8w6Q0q?za*QiJP6 zpZx7aMJhp;@y9yXV`<1Iz;Y&J6d_Ph%p8Wh;ODymVoBG#mA1Lb?8YM&`{SKk)gyIf zvSi2kLqzmaEd_KFe`PG5!no}WPzcs2CZz`APHFa+Dq8CvOp^KLvQRw;g6j6U8$K5x zkR|V33!8S&S@0wwG(V9nz^O+C@TD9Rp+sZ`;X)IvX_-zAd7dz0$vW+sL85PS@WQUe zk>z|{#R$syd)_e2(tyh0z&OJWVSS0&-Kopb-B=QWH*|Wa|3v#df zt`lOzT}7mBXj9pKh)F&J0t;C~V)h9oyzXUBKK*=|EuX`<_B&hr%P0GnWYa2>X}Fwk zNg|~S2%KnfSwq*+qVGiGU&k?XXj;We2BV~)s^h1hB>Cwz5W5CU5PPZNxy{3FtFd)| zz#X#o84advKRYp9VJ;>Pv{Ix3lhvLh86F<`mFhmJD%~kX(y}KEL?pgPy9x)Ak5uYXsBYP;h~nXk67nNKbq+IB zf{RIi)g(R?OhP#gHt5wTewB3Jgh1TqqWzbad7}YQHbY3OYj2nucnAW2(mE51B?;3w zOER#|UJtq45PpeW*)^bJ3I~&ze)KWMh9IQ|_9Pp5;q{AcjD--K_~Ie?Q5n~FI-t(G z3Q}ZuhK~Y=xdPeym6HY{4SYo=@}eNeIK#<*dfm^rp%_K2HVCu7s=>rC?sdUrW-T zGOPLoN7)SsGvE*UOqUw&I)O^QWU5l&vUazgjnv_bsXtzt)!vEchHs&O4`_sLaNQ*^ z%EI=IC1LFqZB`}Ut&1+@>Tf59s0qTXH(wUz%YrRsUJCVCpQ%1NCen_-p&4%c-(BK)00jWv$(6K_@G1g#^=Bv}v$Bwd-Pb$fu1L>1ftarfc zW6D6-M7Z6Y7~QSF(|L8lrS(x>{8)A`?3S_VihB zS(f^m{I-IfjBji#m7pdBl9Sc*qfe)?m|x6igQVF%;0p6UF(}?ola`qdE=ApEy zxkSsG9c>hx5KGlZ-c;i0^)4+Y@}-DZ)oy^@?5bRPWgtCNoVYWNN%G?;yX;8Qc2`jf zG{7mKF4^eXe2_u1flb@jZ;r?&_Wa5I_|L z+)tt4)2tW$b9#w6YQqJT5xurL#vNvm3qhD*R47?v4_@ugBIkP&CyCF0)u`U&!gO5N0y% z1s`TwtrY0KapK`o_S4n8KBFa2eUP+TE>>7+L0I3mZJ$$p#DSskcmFxi`}tuU__ya1 zYjFyRy~fLU=V(gFF|zvY#tIqPGZTe<_ukuGx%vFETbJ;=c2hPMbJFgf&C46WY8Nfs zKq4f6BVjX1-Lws4?8SGg24En^yG}0X%BIg9xv7QrmYf_>DWisdMytRe9-&5Pe`5a% z1Om%b{vgFgsYtg29~;d*04tqJ$DF=L_TP8KF4bn10%SneFnHKoKCAfYeXxGT=zbFQ zDSu<#>&brv@8EJs)v+;`Z-TeYA6dO$QNqR5pL14|0FRC0>zauMljJQ--ke*}w>Unh zn1kZEIrn($meAJfV)83&Fdv3S&-I9Il2C|D&6apAho0td`%`=fJA2O zGGMk7i4v1V{{0nEs?kY7S6|&n)AXanKpbQjKQDaoKb?ZTsh$HxCG#M#X2bGf(H7gG z*S~kTLeRT;bu}9pW{J}sEpJt)<>up4m{D=bD|3zyFi1-9$3E4?Jqo(a(3s?zy))@Ot*vlh!;I*3$s*4o(c`vY5#$WUy>bW1C4;Slov*>R4bgx{XHwCjjHFM+c9k&V;sZ{M zc~F=Eb1zQjNY9!Rs9UpG>)Wa1zQpK#+uOKY`suZYB!Uvi+bH_mZ2GkfF$vU=2X4{( z$cnJhuI3M3BVj>ZeML*gS@}z^`hit51fi>@mKVDtm2dzbaTvun(DEW&&an|jfEUPf zu`-(pi0JX(ux)F#7@eVp2VBFxw8w>xW*FG5Xf^T zF1oH@Te1jIL92Lpw+pFBMwy5AZE7&ECFUFN1r5)gpJ5Gl+Qi$QXt<=S`OuGE+b)wZ z?^-pJHoXqDe3qb9XT-ds9w8)Zcbe-R@b*s^-@NF8MDI)Tgm^vu>)p2Ui5v2@Ja{l_ zrQPcMQjbBe#S;Eu*B{jjDfR^(Amx)V1ElTiwT9EImL%1Iu5|O9G7t?sGol`bDqRoQ zGefA{PQ2$B%_g}WFUFZ}oBTu(ZFA$~Locx;CTh`1n6DEY;0U5XdX}`jaXimsyZs;M zEO7*e-O?j?g5f3^U#*EH&@p&49}l#i?$7eL)POBJxJ#^s_e>uMbBm3@ED@twRf)){ znfT-A$*z(V=c(ck_FldKznJM8TH}gy2>X>NU`z};3%s8uNR)IhzZ=7|7IGDtwtYVX z8BZ?e8qztRvn0}S!-wOl<6OC7zP*E|o?baLgB*gxoj%3&P1YM8U0K3+Cko6JU$g8} zfA{zS0T%kbMY_nldd@3M9F!y(zjN17epo;<&RA^@h$4X6b!RaqevXQY^TK5F8T#cyt zyAa6ZOi#(#HvDPtMzhY7yRW5^=DUsxa(SVIRiPJ4_`&ZDMO7XLrb`ZBFjm$+=7{}`Lc(X9^ zt}?H}i>R5XPW+(kEP@JvP)PzkuhiXKJEOop2rMFZ&J*Z${>G$Z1(c@ z-25(y;mGUdHYNm}1IiG~ME|lP2^3pd(?ZS<+Y*2>Fxl_-H~#9hw2gfdpt{8lkmmdpyl2wN?U=_Q&-5oskJ-=7W$XH=!hhd>6K-Jp}kCY8vU@AjRPcSAy>KUE!@#{7ytl zgCbJv%ER|cGRFPIt_ue#EWx!tM8E%lYq{ce9|YixsB-^eDN2YpF9Ql`dGsR&!@nYG z`(gEX+dtVhXUPH722C|pWEx8YA1cf>gDo_`od9#G%T5jEcurQ4D=LV)15&V4B;j_Pc3OxxVH8-d6)f*XJb+N%=8zG1Q>;`*u)uLx!3G`06vVT4^;zWPQP2P6@&d z1Wx-5c=cW{vs+Q`_tCim&M}v#ZLxHsa?6|xaf2|1@PnZKf+P9c#jr7)S#mv zfCF64S?dp&*d0jDVVa&4b{iatv+uCq)4 zQHW||0*I0bAa0dG_qw15_ooA@G&YyF`t!oIK}+g+V!s-q&X}jrNCZI1yn-y&gKDeF z^kj(s*5sFAuOV&QGwVJ)Fc-trtbvLa(%jsN>fPi7U)C~T`8P52DvF9N+r;=Scog|O zJss+pvP>>VmbKR3RcutBK#4b9ZX8pFwrB<;IDlhpg@CUJd_h3y`+=^b2ZZIA0KX;*=wbSkossU!%_VZ9 zmiKMNXTOvb)P}!$78pcCaZ^eT2)wHmBd7UO4p9qh_k>&$ZT^D7pkN-(R`eTViNy@* z9xVL*b5 z8j`63H}$@6p1L4=`N^cw;WPIY`a&b=A^+tJP=>;rq@_JDFPH!O^;?4B+{kQ5yPulF zz9dV(O3-El%@rRmhI3FPyDZF4HnFC^GoEk-sCaciT!uASAZ=?;hUjl%EA-skjk^1j z2d}C2g2h10RR1h{MshIz=TbzhqgZ zRpqO+T>e8y`tmfd_W+er^b??)4?eH}j-D7qXsd1;JW8E|TQN=pd}>b6=(Ais&>Gan zTD8p&2Zf2<+!VS7#Wb+gUk(gsA^vT5t=GFIVQso6BC`(;qAHY?E`epG(vNI7)3eat zdbyFRSduiX^M?dl+SD;rUf}W?=!d>awa=}3H3%+##Y`!ZV!}`OZ9T$^My{5ku{oWs zcxT-$OQ_wPJnrKZ+tIi3%=DoMV9ewm$^ffemO#+U4f`SCcNKBMi^fFtgz#Y-KG6{u za!bX&)G6DoUn=oU!$#hZk`#Mj;HfoFU9d=jrtK%AWK#&bD;kRt`OZ1ufB2;e}38kKVG^P04gAL#ic-+eo~wW>Z%Kwzuc zn7e9^U&f|S`>Y^v6p%?VX8Ed75s7Cv*L!oU9r8*icIZG7fl#3;p>G>|#eNpyH70<5 zXk_spl8#=yG}PsACzwEzy!ZoEusisQlio_;)$}Wi@8ZwEC5F`T08MS?PpOxw4wXn` zkIj&C80>BHI^OILetPNET2T5nE~~c{q2o_ZC!}K2bdu5E9;0%tsCubUnp9Q}O{kV}YLj5}MMzRMOfUdf#hLPuXQb^~%!>IFHB_z{jz z@2Y-jJO8DBB-cMk-yx8}3h@;h5_ZF9hU7k;B&i#OkE&y7J+K$*hr^IE1JjM`(c6;O z9sNqVSQC%JLTpaTFBZl7wpjVua=B-X=d0sRK^nPojl*(rz{!>V= zKNr8)k|!O@s1p+%&CLm)LqG?SH2hU*nmO|XhNJUkcm#KRWG$3JA(|&YP2H~qV?rs? z7wy5L6ZeB;f`8O^G;ASVex;By-c-_PEfqWgz1+&kPmL6{N;fS-SgA=Stn8F0;pTbv)z6cCoz(p9 z(9bB9&!ktA;ilQgSeKt1+Qv)-%b%U@o++U}JHy=B=xx7=50`OjCxy#{ zFB>Ptut}3|x8CDUbgTO*aH0urtC0yis=}eG_eN|`GY6Y-_1lS`_pdL1`)`yvy65#J zcuhXPkoX|7IPPl9{$@GNm8vS&(zHwpRa#!LheCA_NDN2d-bM+pcUjHjN7XM7lt)sS zw*f!@Y5^Q8r*L{Ll@xj4F_DmWl1?EMKH8x55Y@$$kH0MCyz9l=cAETItdNUVf*QqN zxP_bXnz_0)5R~>4`M@ccESMiouA=6n66IUyHTzN<@Zb&8{l&fPav)l0sSqm2I(KD# z&vsno{yBhWE6LZ4V5%3)NbTH-s5-fH4)U@3uwF_F&qA%#D%i~io0T>m@q^ao1O83{ zq3Qod(iv2;H~xreN*vmZEW~C3`9umu(BNF9=S0Cjd6>=YGb!e&o!ZHfB^DVA61&j zN7xSpa3F(=&*zY)m$&zlX4YuPW{vr&mOaB27c)jwPyC6LtI2UxCx<5$E1|lwmUE+b zxvVX78TsLk=oW4k|yZDDfxO8A~r;TNx!2_E`NkwMCPsX`{>Y79iU~Z5G7wOIKNz>{f%egE^u~}eXP+NEs2}Zg{;@D6zdNgeZYxgOp zji2d{SqFdaRemCLNp58h6iMxG?v0pq;R=8cVHbR|DGgpmrLZT2lxFu-U7##ahDMab zjk^ctGBTi6XD)%S-ge&hZJI57e4NzNN2$Z4kd9V{Z^l%lw4We(h=IovP@k^Ay+0mp zTyZ7PN=SF_7mMeB&Gi{paM0j|<<`$|77Bm&u5RoS-ICoZsUG7S{L5Q}3-pJ*z)Ys@KgS~wR`m=93NEHEmu7=*(o*tbsTH?{?7$)At%^Z^d2biZbV-m%6>&tvD1zIzgO zYdxZN_2z?7vhlW2g>DtFTEYTK)7AU=C04g&#G2{98tS)OIeZ8(_*G<#X<#mB`7q z+rx(@JLp7zZ_f!74@7C{T_!x2EMcDnbQM{G+;qo2nEfK~8qdRPX@wk-B4ii2D=zf2Lk{;D5HB8z#WII;wrq_dvDC zaHcln*jqPFW=}2=H^h@_^Xn~8A&1^F|Z)dkm}hF-sRfRe=i)YcZW$g;fKEder1vRYWdC?NFrhjb0Y!Uy+pP9$cGD zpiIYTCD^L4!p(V0a`Ooo&fWE1>>G1qXsSLL+V`;*n4G`4Wy;j@*Uc|vC9I<}jdEuu z7pZ$hsBL~ldG}14ywIoO+YgSODueZvRH);M`2&wEYd5SWN^g<{Tu!o^NR*c79ESh0 zyOM~@9gHLlzq8ufQusUyG^^{t(j+Y*B0y#4#slT$4kY$J1Sd&j z-4ieplSx|I7i%K*ogr7>(R89B(&h(%O=3D9(mV@G^7%d6srrR(K?gkuuo&XWFuy8!a-D7OEyvZrQwN+(NmWV5${ypDI`8 zbeY0p#h%``Sd6}XBz0697Iac_%ik4z9jL#vODJk6kd!O7`a}ll=57L*(Axaz-F<=j zBh#W5_4;x-269;W$SEpvd%a@X*Z}agT6hry|IDA2im)iRkhI|a;ty?WSM;`4bdoc- z`5|=V;#*(f_Z#&#((;bq^!)Kk_xIf?L8wBWtI^7m>PmS1-QNjbLa(Ixd{XovXa|5N za>(O zE-tBg-7_OK<%Lg{JRRszzWM~({yqLYj1z7Eo}jTOAWE}`s~F$dhYoWgUt`Yv*MSGIz#YO)gPwyR1_5c2lpHkFI5-OXLQFiu- z(>O?0%HCU<*_lU4*|L)Cy$RWlqsWY7?~#>b>o~}9#`kf&Kfm9@$*rU5k{d>S5==T*bgqk1M_VJsEjUe6FTL8Lr1&I$M+x3@TE3#u1p};`D zhse!eJFR7m33?v$utQg=rOe3t`kdGR20zKDurr^>Gq+G{GCcSE)O2bHO&BiY`HTTD zQaXRFE&c25)Pt_NcK6Ee_vXXTcDB_qTo1A_RX`q=Uou_Qbxh;@9Fz83`0?xlxF=h{ zhcqPQ#Y7KMOU`$u`W}fzo4$ zcCJSxu~aO1$;(bhPI{-eJ?daLRUylV|7N8h+fA;ee>)7{XdKgUTD4}vw1WD#4gnD1 z!+aL~dg4;!p%y?W!@c=q$L_*7fl&*N?&*!+d7e(JvD&PB{4MV!;DW8HPc<2rnj{R<`6o|c$_1e!$+RF6wCMyq65)*_R4 zI*&<@hw&{*R1d&4O-}O6&7`EU&+H8xwwRPop%XpHag@A<)LzxTxu9BOf}x&Io#)C` z!qoHxbi#(X$uj#m?UP4kkRbRN8D`uU2%}ei9}%SD+`uwbZt!_IMG0T}b}q`Tl`MMt z9B0Xbw=CxOwBA^)$tS=rjijtSjF8?ldRT!`xCC6yq+tdsR|0P`b^`X?|M?>O+`OQ&4UPx5!>+32MTZ^A$X&ICF*&nf!iI5Oc z!S(rd6YVYPoS1v!n)_O{HCOEZ*cuEYC{LJxjNh%k&}sztbUZ=>rVUlw->+#GnE=`C zu;sBBbnhZLCN#O$eqXHt(A@xj)KNYGB)Nf_X%lHm=!nr6CRg(FxvCPR*O`n=68i-^ zz!P6>#_z}jhdTM*=bQgxXLX)@Ao+gu7eqztNb@B=kXLC}eQu{p)gE;-SM@LC5e8>c zKDpCMGO3XWNwme#CWJlU)yK(`IEH8hbEL@l_CV< zVmKVQla+Se%jUbyqOsot6B~nTh#|+Xg%4mIUsx%pmDNQ;VpGnWH!PWtkbZRrWl0LI zY8HuwKa&TRM7N*9wx*qc;(A0lyNsgDMa;H*@U!p8#yMz<1-K8xn9BX0Eac{?Tg z8D@WwU;a^8@3UGgKnn*Chup%xnOhS5*gAHZ*P2mLy5{L8{`>_#F0T2y_F&)ZEkxe} zz8uEyew1@=Pi@wo5^9K!xZRhWFA4T4Zw{iF)J1}hM&VyKYB^&vh1%<+-9Ta&zMnZ$ zW!_aquDW$FjN4>amg_H9m>3+$Q9%Y@0|k73&=J%BOoSVYk@qGVgDj%_ml#lZNCQu` zubN5(^->;RY=p^c^I>YW8fvBB4t+!=I10ZP?^FJ$YEg=xD1F;VAxap^Seg7Q==qV< z8H!?Ntax1gy^x-jIMlF*>g<7oP-Y0ONn)lct7q)3yUDs!^@+9H*FQPqVe$AM(I&NU zk@#{D363{^!u)+q(+O;#RP{m0PUw>5Fo@jJ&UNBkbrq_tM+EzpUxqhy;9_EO2llMk zz)SwFW3tV0UGU!~^;?2;k^8cPVwVN}~ zz>S><`ELB%+j@2Cy8sCB?tAntX;Md@Lw0o~+)Y0Nz@W zO9y%SL?5TmsNvP!3cvmj&0&eO93X59pRd72)SY9s`UO;sU(V?z1DHBD&1KWjy;pP@ z@*5Uj4tQ7z@>11ozbe!s6hY&R8;o4AhwNN(KF+b7lNRdzQv?!U%~qXrL;kt(&11W6 z08c~+iVxWTX}>R?A8+>0#fH$eEYZTdu_E$$R_N$n+>Mpvla&t|e~y%-;66@-q;21x z4}|;=h~ZKqVq#ooUplQ>f2Us0QMT-SwIOM@(Zk)yX%*goeOVX7O`R0W2Xe9)qwlbp z>CY1Q^<(JGBhHGuWZJRjRciucX~t|-_8BZFF4MRgb7%;5gO`29Cn%ikxiFtp*f~z} zYG%ws{GXedy>LCYAnjewtRI~WEquOOMQ&Tcy|Zf7>eFze+hxQQxf zm*w+br1+T|FE~gGqPkdfM)$yX*9tK;KF2pfs1g9haW!Ll*i#Fs8xNEJe-LOG9S__+ zD9gl!TPr}Dr4pM}g= zTw#hJ-s7&GX?H2-b6|1K0S2j`In>#UPO2BdjPw1l2}WYgQFy4f;Hh zs}Ba<-P>$`{jDpC(YCrvkOA>8Jz)0WWC9}|TKkY!s;6LS_t9wFF|6x_*2ASv=QN-` z4n`U2wQ~+c_{@}sJ*V+=aDg45s8d|R_u47PGOIGHR%GIv5bYl$hh6UX8r+-(Vx1BMz&V~y_#EyLNoK85+P^jyAah98JdKyc2*u9d4N3KgJx&uoo|7>?XvV=#f#nDk^=eh9hQ~mfa z0f#L1d)sB_Q2q7W=Cio&_tIzs{*%5cQE-uM;ZL#ULSjSq;*IdFADc7#i$?u=f@;Yz z=C3H|+#aDHnhmTH)yTDtG2hk;R4t&ZhNw`11AVcE^wQ znG}R&7kX(sQvzPEJz&pmH787hoAYDAeL-J0o`TA%CK*72b#H1C%=Mt(t#6Ux8-u>~ z9!{qPfg|I~Ib^{~WAd9cXS2)GDhvX~dx6Q$OuO|eT~+qfQ`PIObil`#_P1{;O|@`^ z5i1T`4q?eg0d}R93cL@p;tS%Z-OixJdSGM*pc z&O=0{09(Fc<041uOYI+o;KK*BnGS}&5ix}a!rwtw2wCpB9b5r-nIhWQ5E$(wAtSjW za$Hu=C4T1eUF`Jb^deA4UTj5snI)9idbmrQx{@n6Ai~*9$>GE`oEwT%p}y%(v^ee? zUlIS%{0kjpTe4|xSAAs~Au2ksp9R2hWaXHH6QTe4+^twa{VU@^pu7egY{-}vW)gOA z)>wquYOONDiDUCM3u1}0YZJb4 z(ZgJ(Hf?&-D@ESNmpLuU86N4jm}+|^FrXy8qmI

    S|PpV&D1CHVMi1tEZUEIaIxb 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 ALjV8( literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/model-icons/gemini.png b/services/app/src/lib/assets/model-icons/gemini.png new file mode 100644 index 0000000000000000000000000000000000000000..f1f675bc1fcb595a6d7b9394240a99858a28a927 GIT binary patch literal 28112 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

    F!2;=U8(5Wl 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 Zqmi2OnKx$0764habkWL%CG$g){{xsQRh<9; literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/model-icons/generic.png b/services/app/src/lib/assets/model-icons/generic.png new file mode 100644 index 0000000000000000000000000000000000000000..0b9d2ae3853a16febb8974bebb97570f597a8a87 GIT binary patch literal 71717 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 literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/model-icons/generic2.png b/services/app/src/lib/assets/model-icons/generic2.png new file mode 100644 index 0000000000000000000000000000000000000000..ff07b5e6d1a507a5abc32c4363d023a0f4a58967 GIT binary patch literal 325524 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°Vwa&63@_1Quv~jwf)^E9AbTdBVxElF4)KO#k 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 }FN@_P=6XqmMDtf>VC0O_n={Dg*YM-c*EV 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%0Ac0cA4ju^5lU1&gX06??>V3eupc)&$A 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

    L5os|XM}=Ka1#HuWKS!E1 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 AX8-^I literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/model-icons/index.ts b/services/app/src/lib/assets/model-icons/index.ts new file mode 100644 index 0000000..02cceaa --- /dev/null +++ b/services/app/src/lib/assets/model-icons/index.ts @@ -0,0 +1,25 @@ +import allenai from './allenai.png'; +import anthropic from './anthropic.png'; +import cohere from './cohere.png'; +import deepseek from './deepseek.png'; +import gemini from './gemini.png'; +import generic from './generic.png'; +import generic2 from './generic2.png'; +import meta from './meta.png'; +import mistral from './mistral.png'; +import openai from './openai.png'; +import qwen from './qwen.png'; + +export { + allenai, + anthropic, + cohere, + deepseek, + gemini, + generic, + generic2, + meta, + mistral, + openai, + qwen +}; diff --git a/services/app/src/lib/assets/model-icons/meta.png b/services/app/src/lib/assets/model-icons/meta.png new file mode 100644 index 0000000000000000000000000000000000000000..9d3ed82b06718211ac915aaccb5709fb3c5c37af GIT binary patch literal 30327 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�&qrwJmK_qjy}b#$W_hXlXx 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 literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/model-icons/openai.png b/services/app/src/lib/assets/model-icons/openai.png new file mode 100644 index 0000000000000000000000000000000000000000..01eb4cc53176a51db34e4b2c11a8591f40c42ecf GIT binary patch literal 46614 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�vnW7@`)tIw!m&fQBq?oFbs%)`f?r!AvTGYmW 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 IJQ*4P1;nX>F8}}l literal 0 HcmV?d00001 diff --git a/services/app/src/lib/assets/system-mode.svg b/services/app/src/lib/assets/system-mode.svg old mode 100644 new mode 100755 diff --git a/services/app/src/lib/auth.ts b/services/app/src/lib/auth.ts new file mode 100644 index 0000000..4ddd4e7 --- /dev/null +++ b/services/app/src/lib/auth.ts @@ -0,0 +1,205 @@ +import { env } from '$env/dynamic/private'; +import { CredentialsSignin, SvelteKitAuth, type DefaultSession } from '@auth/sveltekit'; + +import Credentials from '@auth/sveltekit/providers/credentials'; +import logger from './logger'; +import type { User } from './types'; + +const { AUTH_SECRET, OWL_URL, USE_SECURE_COOKIES, IDLE_AUTH_TIMEOUT, ABSOLUTE_AUTH_TIMEOUT } = env; + +const DEFAULT_AUTH_ABSOLUTE_TIMEOUT = 86400; +const DEFAULT_AUTH_IDLE_TIMEOUT = 900; + +const ABSOLUTE_MAX_LIFETIME = + (Number(ABSOLUTE_AUTH_TIMEOUT) || DEFAULT_AUTH_ABSOLUTE_TIMEOUT) * 1000; + +type SessionUser = Pick< + User, + | 'id' + | 'email' + | 'name' + | 'preferred_name' + | 'preferred_email' + | 'picture_url' + | 'preferred_picture_url' +>; + +declare module '@auth/sveltekit' { + interface Session { + user: SessionUser & DefaultSession['user']; + } + interface User extends SessionUser {} +} + +class InvalidCredentials extends CredentialsSignin { + code = 'invalid_credentials'; +} +class InsufficientCredentials extends CredentialsSignin { + code = 'insufficient_credentials'; +} +class UserExists extends CredentialsSignin { + code = 'user_exists'; +} +class UserNotFound extends CredentialsSignin { + code = 'user_not_found'; +} + +export const { handle } = SvelteKitAuth({ + trustHost: true, + providers: [ + Credentials({ + id: 'credentials', + name: 'Credentials', + credentials: { + email: {}, + name: {}, + password: {}, + isNewAccount: {} + }, + authorize: async (credentials) => { + if (!credentials?.email || !credentials?.password) { + throw new InsufficientCredentials('Email and password are required'); + } + + if (credentials.isNewAccount === 'true') { + if (!credentials?.email || !credentials?.name) { + throw new InsufficientCredentials(); + } + const response = await fetch(`${OWL_URL}/api/v2/auth/register/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: credentials.email, + name: credentials.name, + password: credentials.password + }) + }); + + const data = await response.json(); + if (!response.ok) { + if (data.error !== 'resource_exists' || data.error !== 'unauthorized') { + logger.error('AUTH_SIGNUP_ERROR', data); + } + + if (data.error === 'resource_exists') { + throw new UserExists(data?.message); + } + if (data.error === 'unauthorized') { + throw new InvalidCredentials(data?.message); + } + throw new CredentialsSignin(); + } + + if (data.id) { + delete data.password_hash; + return data; + } + } else { + const response = await fetch(`${OWL_URL}/api/v2/auth/login/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: credentials.email, + password: credentials.password + }) + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.message !== 'User not found.' || data.error !== 'unauthorized') { + logger.error('AUTH_LOGIN_ERROR', data); + } + + if (data.message === 'User not found.') { + throw new UserNotFound(data?.message); + } + if (data.error === 'unauthorized') { + throw new InvalidCredentials(data?.message); + } + throw new CredentialsSignin(); + } + + if (data.id) { + delete data.password_hash; + return data; + } + } + + throw new CredentialsSignin(); + } + }) + ], + callbacks: { + // @ts-expect-error ignore + async session({ session, user, token }) { + if (token.forceLogout) { + return null; + } + + if (user) { + assignProperties(user, session.user); + } + if (token) { + assignProperties(token, session.user); + } + return session; + }, + + async redirect({ url, baseUrl }) { + // Allows relative callback URLs + if (url.startsWith('/')) return `${baseUrl}${url}`; + // Allows callback URLs on the same origin + return url; + }, + + async jwt({ token, user }) { + if (user) { + assignProperties(user, token); + } + + if (token.createdAt) { + const age = Date.now() - Number(token.createdAt); + if (age > ABSOLUTE_MAX_LIFETIME) { + token.forceLogout = true; + } + } else { + token.createdAt = Date.now(); + } + + return token; + } + }, + pages: { + signIn: '/login', + newUser: '/register' + }, + session: { + strategy: 'jwt' + // maxAge: Number(IDLE_AUTH_TIMEOUT) || DEFAULT_AUTH_IDLE_TIMEOUT + }, + + secret: AUTH_SECRET, + useSecureCookies: USE_SECURE_COOKIES === 'true' ? true : undefined +}); + +function assignProperties(source: Record, target: Record) { + const properties = [ + 'id', + 'email', + 'name', + 'preferred_name', + 'picture_url', + 'preferred_picture_url' + ]; + + properties.forEach((property) => { + if (source[property] !== undefined) { + target[property] = source[property]; + } + }); +} diff --git a/services/app/src/lib/components/Checkbox.svelte b/services/app/src/lib/components/Checkbox.svelte old mode 100644 new mode 100755 index 119805c..5915af7 --- a/services/app/src/lib/components/Checkbox.svelte +++ b/services/app/src/lib/components/Checkbox.svelte @@ -7,17 +7,30 @@ checkedChange: { event: MouseEvent; value: boolean }; }>(); - let className: string | undefined | null = undefined; - export { className as class }; + - export let id: string | undefined = undefined; - export let defaultChecked: boolean = false; - export let disabled: boolean | undefined = undefined; - export let required: boolean | undefined = undefined; - export let name: string | undefined = undefined; - export let checked: boolean = defaultChecked; - export let validateBeforeChange: (e: MouseEvent) => boolean = () => true; + interface Props { + class?: string | undefined | null; + id?: string | undefined; + defaultChecked?: boolean; + disabled?: boolean | undefined; + required?: boolean | undefined; + name?: string | undefined; + checked?: boolean; + validateBeforeChange?: (e: MouseEvent) => boolean; + } + + let { + class: className = undefined, + id = undefined, + defaultChecked = false, + disabled = undefined, + required = undefined, + name = undefined, + checked = $bindable(defaultChecked), + validateBeforeChange = () => true + }: Props = $props(); function toggle(e: MouseEvent) { if (validateBeforeChange(e) == false) return; @@ -31,8 +44,8 @@ + {/snippet} - + + + Sort by + {#each sortableFields as { id, title, Icon }} - {#each ['asc', 'desc'] as direction} - - - {title} - {direction === 'asc' ? '(Ascending)' : '(Descending)'} - - {/each} + + + {title} + + {/each} + +


    + + + Order + + {#each ['asc', 'desc'] as direction} + + + {direction === 'asc' + ? `${m['sortable.direction_asc']()}` + : `${m['sortable.direction_desc']()}`} + {/each} diff --git a/services/app/src/lib/components/preset/UserDetailsBtn.svelte b/services/app/src/lib/components/preset/UserDetailsBtn.svelte new file mode 100644 index 0000000..b4d64b6 --- /dev/null +++ b/services/app/src/lib/components/preset/UserDetailsBtn.svelte @@ -0,0 +1,156 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + +
    + {page.data.user?.email} +
    + + + + {#snippet child({ props })} +
    + {@render joinOrgIcon('h-4 w-4')} + Join organization + + {/snippet} + + + {#snippet child({ props })} + + + Join project + + {/snippet} + + + {#snippet child({ props })} + + + Create organization + + {/snippet} + + + {#snippet child({ props })} + + + Create project + + {/snippet} + + + + + + + + {#snippet child({ props })} + + + Account Settings + + {/snippet} + + goto('/logout') : () => signOut()} + class="!text-[#F04438]" + > + + Sign out + + + + + +{#snippet joinOrgIcon(className = '')} + + + + + + + +{/snippet} diff --git a/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte b/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte old mode 100644 new mode 100755 index 4bbb704..d9a9a34 --- a/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte +++ b/services/app/src/lib/components/tables/(sub)/ColumnDropdown.svelte @@ -1,13 +1,15 @@ - - + + {#snippet child({ props })} + + {/snippet} - + {#if colType === 'output'} - tableState.setColumnSettings({ column, isOpen: true })}> - + tableState.setColumnSettings({ column, isOpen: true })}> + Open settings @@ -221,20 +253,36 @@ {#if !readonly && !tableStaticCols[tableType].includes(column.id)} - {#if colType === 'output' && $tableState.selectedRows.length > 0} + {#if colType === 'output'} - - + + {#if tableState.selectedRows.length === 0} + + +
    { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + tooltipPos.visible = false; + }} + class="pointer-events-auto absolute -bottom-1 -top-1 left-0 right-0 cursor-default" + >
    + {/if} + + Regenerate
    - handleRegen('run_selected')}> + handleRegen('run_selected')}> This column - handleRegen('run_before')}> + handleRegen('run_before')}> Up to this column - handleRegen('run_after')}> + handleRegen('run_after')}> This column onwards @@ -242,23 +290,36 @@ {/if} { + onclick={async () => { tableState.setRenamingCol(column.id); //? Tick doesn't work - setTimeout(() => document.getElementById('column-id-edit')?.focus(), 100); + setTimeout(() => document.getElementById('column-id-edit')?.focus(), 200); }} > - + Rename tableState.setDeletingCol(column.id)} + onclick={() => tableState.setDeletingCol(column.id)} class="!text-[#F04438]" > - + Delete column
    {/if}
    + + + + Select at least one row to regenerate + + diff --git a/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte b/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte old mode 100644 new mode 100755 index aa427ca..3f99a53 --- a/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte +++ b/services/app/src/lib/components/tables/(sub)/ColumnHeader.svelte @@ -1,15 +1,17 @@ { - if ($tableState.resizingCol) { + onmousemove={handleColResize} + onmouseup={() => { + if (tableState.resizingCol) { db[`${tableType}_table`].put({ id: tableData.id, - columns: $tableState.colSizes + columns: $state.snapshot(tableState.colSizes) }); - $tableState.resizingCol = null; + tableState.resizingCol = null; } }} /> @@ -228,42 +245,44 @@ {#each tableData.cols as column, index (column.id)} {@const colType = !column.gen_config ? 'input' : 'output'} {@const isCustomCol = column.id !== 'ID' && column.id !== 'Updated at'} - - + +
    handleColumnHeaderClick(column)} - on:dragover={(e) => { + onclick={() => handleColumnHeaderClick(column)} + ondragover={(e) => { if (isCustomCol) { e.preventDefault(); hoveredColumnIndex = index; } }} class={cn( - 'relative [&>*]:z-[-5] flex items-center gap-1 [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333] cursor-default', + 'relative flex cursor-default items-center gap-1 border-[#E4E7EC] data-dark:border-[#333] [&:not(:last-child)]:border-r [&>*]:z-[-5]', isCustomCol && !readonly ? 'px-1' : 'pl-2 pr-1', - $tableState.columnSettings.column?.id == column.id && - $tableState.columnSettings.isOpen && + tableState.columnSettings.column?.id == column.id && + tableState.columnSettings.isOpen && 'bg-[#30A8FF33]', - draggingColumn?.id == column.id && 'opacity-0' + draggingColumn?.id == column.id && 'opacity-0', + tableState.renamingCol && 'pointer-events-none' )} > {#if isCustomCol} {/if} @@ -271,8 +290,8 @@ - {#if !$tableState.colSizes[draggingColumn.id] || $tableState.colSizes[draggingColumn.id] >= 150} + {#if !tableState.colSizes[draggingColumn.id] || tableState.colSizes[draggingColumn.id] >= 150} - + {colType} - {#if !$tableState.colSizes[draggingColumn.id] || $tableState.colSizes[draggingColumn.id] >= 220} + {#if !tableState.colSizes[draggingColumn.id] || tableState.colSizes[draggingColumn.id] >= 220} {draggingColumn.dtype} @@ -413,13 +437,13 @@ {/if} - + {draggingColumn.id} -
    - -{/if} + {/if} + diff --git a/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte b/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte index f681e00..955dc8c 100644 --- a/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte +++ b/services/app/src/lib/components/tables/(sub)/ColumnSettings.svelte @@ -1,227 +1,152 @@ { - if ($tableState.columnSettings.isOpen && e.key === 'Escape') { + onkeydown={(e) => { + if (tableState.columnSettings.isOpen && e.key === 'Escape') { closeColumnSettings(); } }} /> - - + +
    -{#if $tableState.columnSettings.isOpen || showActual} +{#if tableState.columnSettings.isOpen || showActual}
    { - if ($tableState.columnSettings.isOpen) { + inert={!tableState.columnSettings.isOpen} + onanimationstart={() => { + if (tableState.columnSettings.isOpen) { showActual = true; } }} - on:animationend={() => { - if (!$tableState.columnSettings.isOpen) { + onanimationend={() => { + if (!tableState.columnSettings.isOpen) { showActual = false; } }} - class="absolute z-40 bottom-0 {$tableState.columnSettings.column?.gen_config + class="absolute bottom-0 z-40 px-4 py-3 {tableState.columnSettings.column?.gen_config ? 'column-settings max-h-full' - : 'h-16 max-h-16'} w-full bg-white data-dark:bg-[#0D0E11] {$tableState.columnSettings.isOpen + : 'h-16 max-h-16'} w-full {tableState.columnSettings.isOpen ? 'animate-in slide-in-from-bottom-full' : 'animate-out slide-out-to-bottom-full'} duration-300 ease-in-out" > -
    +
    - {#if showPromptTab && selectedGenConfigObj !== 'gen_config.code'} + {#if selectedGenConfig?.object !== 'gen_config.python'} + {#if showPromptTab && selectedGenConfig?.object !== 'gen_config.code'} + + {/if} + + {:else} + {/if} - -
    - {#if selectedGenConfigObj} + {#if selectedGenConfig?.object}
    - - {!$tableState.columnSettings.column?.gen_config ? 'input' : 'output'} + + {!tableState.columnSettings.column?.gen_config ? 'input' : 'output'} - {$tableState.columnSettings.column?.dtype} + {tableState.columnSettings.column?.dtype} - {#if $tableState.columnSettings.column?.gen_config?.object === 'gen_config.llm' && $tableState.columnSettings.column.gen_config.multi_turn} -
    + {#if tableState.columnSettings.column?.gen_config?.object === 'gen_config.llm' && tableState.columnSettings.column.gen_config.multi_turn} +
    - +
    {/if}
    - {$tableState.columnSettings.column?.id} + {tableState.columnSettings.column?.id}
    -
    - {#if (tableType !== 'knowledge' || showPromptTab) && selectedGenConfigObj !== 'gen_config.code'} -
    +
    + {#if (tableType !== 'knowledge' || showPromptTab) && selectedGenConfig.object === 'gen_config.llm'} +
    + +
    + { + const modelDetails = $modelsAvailable.find((val) => val.id == model); + if ( + modelDetails && + (selectedGenConfig.max_tokens ?? 0) > modelDetails.context_length + ) { + selectedGenConfig.max_tokens = modelDetails.context_length; + } + }} + class="w-64 border-transparent bg-[#F9FAFB] hover:bg-[#e1e2e6] data-dark:bg-[#42464e]" />
    {/if} - - - - - {#each tableData?.cols.filter((col) => !['ID', 'Updated at'].includes(col.id) && col.id !== $tableState.columnSettings.column?.id && col.dtype === 'str') ?? [] as column} - (selectedSourceColumn = column.id)} - value={column.id} - label={column.id} - class="flex justify-between gap-10 cursor-pointer" - > - {column.id} - - {/each} - - -
    + {#snippet children()} + + {selectedGenConfig.source_column || 'Select source column'} + + {/snippet} + + + {#each tableData?.cols.filter((col) => !['ID', 'Updated at'].includes(col.id) && col.id !== tableState.columnSettings.column?.id && col.dtype === 'str') ?? [] as column} + + {column.id} + + {/each} + + +
    + {/if}
    {#if showPromptTab && !readonly} - {/if}
    {:else if selectedTab === 'prompt'} -
    -
    - Customize prompt +
    +
    +
    +
    + Columns: + {#each [...usableColumns, ...(selectedGenConfig.object === 'gen_config.python' && originalCol ? [originalCol] : [])] as column} + + {/each} +
    + + {#if selectedGenConfig.object === 'gen_config.llm'} + { + if (selectedGenConfig?.object === 'gen_config.llm') { + return selectedGenConfig?.prompt ?? ''; + } else { + return ''; + } + }, + (v) => { + if (selectedGenConfig?.object === 'gen_config.llm') + selectedGenConfig.prompt = v; }} - > - {column.id} - - {/each} + {usableColumns} + /> + {:else if selectedGenConfig.object === 'gen_config.python'} + + {/if}
    - -
    - -
    - - {#if !readonly} - - {/if} -
    -
    - {:else} -
    -
    -
    + {#if selectedGenConfig.object === 'gen_config.llm'}
    - Customize system prompt - - -
    + - {#if isRAGEnabled}
    - RAG Settings + -
    -
    - k +
    +
    + - (editRAGk = - parseInt(editRAGk) <= 0 ? '1' : parseInt(editRAGk).toString())} - class="px-3 py-2 text-sm bg-[#F2F4F7] data-dark:bg-[#42464e] rounded-md border border-transparent 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" + id="temperature" + name="temperature" + step=".01" + bind:value={selectedGenConfig.temperature} + onchange={(e) => { + const value = parseFloat(e.currentTarget.value); + + if (isNaN(value)) { + selectedGenConfig.temperature = 1; + } else if (value < 0.01) { + selectedGenConfig.temperature = 0.01; + } else if (value > 1) { + selectedGenConfig.temperature = 1; + } else { + selectedGenConfig.temperature = Number(value.toFixed(2)); + } + }} + class="rounded-md border border-[#E3E3E3] bg-white px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:border-[#42464E] data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" />
    -
    -
    - - Reranking Model - +
    + - - (selectedRerankModel = selectedRerankModel === model ? '' : model)} - selectedModel={selectedRerankModel} - buttonText={($modelsAvailable.find( - (model) => model.id == selectedRerankModel - )?.name ?? - selectedRerankModel) || - 'Select model (optional)'} - class="h-10 bg-[#F2F4F7] data-dark:bg-[#42464e] hover:bg-[#e1e2e6] border-transparent" - /> -
    + { + const value = parseInt(e.currentTarget.value); + const model = $modelsAvailable.find( + (model) => model.id == selectedGenConfig.model + ); + + if (isNaN(value)) { + selectedGenConfig.max_tokens = 1; + } else if (value < 1 || value > 1e20) { + selectedGenConfig.max_tokens = 1; + } else if (model && value > model.context_length) { + selectedGenConfig.max_tokens = model.context_length; + } else { + selectedGenConfig.max_tokens = value; + } + }} + class="rounded-md border border-[#E3E3E3] bg-white px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:border-[#42464E] data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" + /> -
    - - Knowledge tables - + model.id == selectedGenConfig.model) + ?.context_length} + step="1" + /> +
    -
    - +
    + { + const value = parseFloat(e.currentTarget.value); + + if (isNaN(value)) { + selectedGenConfig.top_p = 1; + } else if (value < 0.01) { + selectedGenConfig.top_p = 0.001; + } else if (value > 1) { + selectedGenConfig.top_p = 1; + } else { + selectedGenConfig.top_p = Number(value.toFixed(3)); + } + }} + class="rounded-md border border-[#E3E3E3] bg-white px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:border-[#42464E] data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" /> - + bind:value={selectedGenConfig.top_p} + min=".001" + max="1" + step=".001" + />
    - {/if} -
    +
    + {/if} +
    -
    +
    + + {#if !readonly} + + {/if} +
    +
    + {:else if selectedGenConfig.object === 'gen_config.llm'} +
    +
    +
    +

    RAG Settings

    -
    -
    Settings
    +
    +
    + -
    - Model +

    + Model will retrieve relevant context from Knowledge Table for accurate + response +

    +
    - { - selectedModel = model; - - const modelDetails = $modelsAvailable.find((val) => val.id == model); - if (modelDetails && parseInt(editMaxTokens) > modelDetails.context_length) { - editMaxTokens = modelDetails.context_length.toString(); + id="rag-enabled" + name="rag-enabled" + class="" + bind:checked={() => !!selectedGenConfig.rag_params, + (v) => { + if (v) { + selectedGenConfig.rag_params = { + table_id: '', + k: 1, + reranking_model: null + }; + } else { + selectedGenConfig.rag_params = null; } }} - buttonText={($modelsAvailable.find((model) => model.id == selectedModel) - ?.name ?? - selectedModel) || - 'Select model'} - class="w-full bg-[#F2F4F7] data-dark:bg-[#42464e] hover:bg-[#e1e2e6] border-transparent" />
    -
    +
    - - Temperature - + - { - const value = parseFloat(e.currentTarget.value); - - if (isNaN(value)) { - editTemperature = '1'; - } else if (value < 0.01) { - editTemperature = '0.01'; - } else if (value > 1) { - editTemperature = '1'; - } else { - editTemperature = value.toFixed(2); - } - }} - class="px-3 py-2 text-sm bg-[#F2F4F7] data-dark:bg-[#42464e] rounded-md border border-transparent 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" - /> - - +

    + Model will cite its sources [1] as it writes +

    -
    - - Max tokens - + selectedGenConfig.rag_params?.inline_citations ?? false, + (v) => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.inline_citations = v; + } + }} + /> +
    - { - const value = parseInt(e.currentTarget.value); - const model = $modelsAvailable.find((model) => model.id == selectedModel); - - if (isNaN(value)) { - editMaxTokens = '1'; - } else if (value < 1 || value > 1e20) { - editMaxTokens = '1'; - } else if (model && value > model.context_length) { - editMaxTokens = model.context_length.toString(); - } else { - editMaxTokens = value.toString(); - } - }} - class="px-3 py-2 text-sm bg-[#F2F4F7] data-dark:bg-[#42464e] rounded-md border border-transparent 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" - /> +
    - model.id == selectedModel) - ?.context_length} - step="1" - /> -
    +
    +
    +
    + -
    - Top-p +

    Number of chunks or documents in context

    +
    { - const value = parseFloat(e.currentTarget.value); - - if (isNaN(value)) { - editTopP = '1'; - } else if (value < 0.01) { - editTopP = '0.001'; - } else if (value > 1) { - editTopP = '1'; - } else { - editTopP = value.toFixed(3); + id="rag-k" + name="rag-k" + bind:value={() => selectedGenConfig.rag_params?.k ?? 1, + (v) => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.k = v; + } + }} + onblur={() => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.k = + //@ts-ignore + parseInt(selectedGenConfig.rag_params.k) <= 0 + ? 1 + : //@ts-ignore + parseInt(selectedGenConfig.rag_params.k); } }} - class="px-3 py-2 text-sm bg-[#F2F4F7] data-dark:bg-[#42464e] rounded-md border border-transparent 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" + class="w-16 rounded-md border border-transparent bg-[#F2F4F7] px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:border-[#42464E] data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" /> +
    - + selectedGenConfig.rag_params?.k ?? 0, + (v) => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.k = v; + } + }} + min="1" + max="1024" + step="1" + /> +
    + +
    + + +

    + Model to reorder retrieved chunks or documents based on relevance +

    + + selectedGenConfig.rag_params?.reranking_model ?? '', + (v) => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.reranking_model = v; + } + }} + class="h-10 border-transparent bg-[#F2F4F7] hover:bg-[#e1e2e6] disabled:hover:bg-[#F2F4F7] data-dark:bg-[#42464e]" + /> +
    +
    + +
    +

    Search Knowledge Table

    + +
    + + + + + +
    + +
    +
    + Selected knowledge table ({selectedGenConfig.rag_params?.table_id ? '1' : '0'})
    + + {#if selectedGenConfig.rag_params?.table_id} +
    + + + {selectedGenConfig.rag_params?.table_id} +
    + {:else} +
    + No knowledge table selected +
    + {/if}
    - + {#if !readonly} - {/if} @@ -820,8 +893,16 @@
    - {#if !readonly} - + {#if !readonly && selectedGenConfig?.object === 'gen_config.llm'} + selectedGenConfig.rag_params?.table_id ?? '', + (v) => { + if (selectedGenConfig.rag_params) { + selectedGenConfig.rag_params.table_id = v; + } + }} + /> {/if} {/if} diff --git a/services/app/src/lib/components/tables/(sub)/ConvList.svelte b/services/app/src/lib/components/tables/(sub)/ConvList.svelte old mode 100644 new mode 100755 index 1ea8f92..c198df4 --- a/services/app/src/lib/components/tables/(sub)/ConvList.svelte +++ b/services/app/src/lib/components/tables/(sub)/ConvList.svelte @@ -5,8 +5,8 @@ import { Button } from '$lib/components/ui/button'; import AddIcon from '$lib/icons/AddIcon.svelte'; - let rightDockButton: HTMLButtonElement; - let showRightDockButton = false; + let rightDockButton: HTMLButtonElement | undefined = $state(); + let showRightDockButton = $state(false); function mouseMoveListener(e: MouseEvent) { const chatWindow = document.getElementById('chat-table'); @@ -14,7 +14,7 @@ //* Show/hide the right dock button on hover right side if ( - rightDockButton.contains(el) || + rightDockButton?.contains(el) || (chatWindow?.contains(el) && chatWindow?.offsetWidth - (e.clientX - chatWindow?.offsetLeft) < 75) ) { @@ -27,24 +27,24 @@ function handleNewConv() {} - + -
    +
    @@ -52,10 +52,10 @@ disabled={!$showRightDock} variant="outline" title="New conversation" - on:click={handleNewConv} - class="flex items-center gap-3 mt-6 p-4 w-full text-center bg-transparent whitespace-nowrap overflow-hidden" + onclick={handleNewConv} + class="mt-6 flex w-full items-center gap-3 overflow-hidden whitespace-nowrap bg-transparent p-4 text-center" > - + New conversation diff --git a/services/app/src/lib/components/tables/(sub)/Conversations.svelte b/services/app/src/lib/components/tables/(sub)/Conversations.svelte old mode 100644 new mode 100755 index 112fe53..9cdf3f3 --- a/services/app/src/lib/components/tables/(sub)/Conversations.svelte +++ b/services/app/src/lib/components/tables/(sub)/Conversations.svelte @@ -6,7 +6,7 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-svelte'; import { browser } from '$app/environment'; import { beforeNavigate } from '$app/navigation'; - import { page } from '$app/stores'; + import { page } from '$app/state'; // import { activeConversation, pastConversations, type DBConversation } from './conversationsStore'; import { showRightDock } from '$globalStore'; import logger from '$lib/logger'; @@ -33,28 +33,28 @@ older: 'Older' }; - let autoAnimateController: ReturnType; - let pastConversations: GenTable[] = []; + let autoAnimateController: ReturnType | undefined = $state(); + let pastConversations: GenTable[] = $state([]); let searchResults: typeof pastConversations = []; - let isEditingTitle: string | null = null; - let editedTitle: string; - let saveEditBtn: HTMLButtonElement; + let isEditingTitle: string | null = $state(null); + let editedTitle: string = $state(''); + let saveEditBtn: HTMLButtonElement | undefined = $state(); - let isDeletingConv: string | null = null; + let isDeletingConv: string | null = $state(null); let fetchConvController: AbortController | null = null; - let isFilterByAgent = false; - let isLoadingMoreConversations = false; - let moreConversationsFinished = false; //FIXME: Bandaid fix for infinite loop caused by loading circle - let currentOffset = 0; + let isFilterByAgent = $state(false); + let isLoadingMoreConversations = $state(false); + let moreConversationsFinished = $state(false); //FIXME: Bandaid fix for infinite loop caused by loading circle + let currentOffset = $state(0); const limit = 50; - let searchQuery: string; + let searchQuery: string = $state(''); let isNoResults = false; async function getPastConversations() { - const tableData = (await $page.data.table) as + const tableData = (await page.data.table) as | { error: number; message: any; @@ -82,11 +82,11 @@ searchParams.append('parent_id', tableData.data.parent_id ?? ''); } - const response = await fetch(`${PUBLIC_JAMAI_URL}/api/v1/gen_tables/chat?` + searchParams, { + const response = await fetch(`${PUBLIC_JAMAI_URL}/api/owl/gen_tables/chat?` + searchParams, { credentials: 'same-origin', signal: fetchConvController?.signal, headers: { - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id } }); currentOffset += limit; @@ -117,7 +117,7 @@ } onMount(() => { - $page.data.table.then(() => { + page.data.table.then(() => { if (browser) { currentOffset = 0; moreConversationsFinished = false; @@ -146,7 +146,7 @@ } }; - let timestamps: Timestamp = { + let timestamps: Timestamp = $state({ today: null, yesterday: null, two_days: null, @@ -154,11 +154,11 @@ last_week: null, last_month: null, older: null - }; + }); let timestampKeys = Object.keys(timestamps) as Array; - $: { + $effect(() => { timestampKeys.forEach((key) => (timestamps[key] = null)); pastConversations.forEach((conversation, index) => { const timeDiff = Date.now() - new Date(conversation.updated_at).getTime(); @@ -190,7 +190,7 @@ timestamps.older = index; } }); - } + }); beforeNavigate(() => (isEditingTitle = null)); @@ -205,7 +205,7 @@ const debouncedSearchConv = () => {}; -Chat history +Chat history
    - + {#snippet leading()} {#if isLoadingSearch} -
    +
    {:else} {/if} - + {/snippet}
    -
    +
    +
    No results found
    {:else} @@ -271,14 +271,14 @@ on:osInitialized={(e) => { autoAnimateController = autoAnimate(e.detail[0].elements().viewport); }} - class="grow flex flex-col my-3 rounded-md overflow-auto os-dark" + class="os-dark my-3 flex grow flex-col overflow-auto rounded-md" > {#each !searchResults.length && !isNoResults ? pastConversations : searchResults as conversation, index (conversation.id)} {#if !searchResults.length && !isNoResults} {#each timestampKeys as time (time)} {#if timestamps[time] == index}
    - + {timestampsDisplayName[time]}
    @@ -287,40 +287,40 @@ {/if} {#if isEditingTitle === conversation.id}
    - +
    @@ -329,30 +329,29 @@ - + -
    - +
    + {conversation.id}
    - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte b/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte old mode 100644 new mode 100755 index 373c3b1..f62ad76 --- a/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte +++ b/services/app/src/lib/components/tables/(sub)/FileColumnView.svelte @@ -1,6 +1,5 @@ {#if fileUri && isValidUri(fileUri)} - {@const fileType = fileColumnFiletypes.find(({ ext }) => fileUri.endsWith(ext))?.type} + {@const fileType = fileColumnFiletypes.find(({ ext }) => + fileUri.toLowerCase().endsWith(ext) + )?.type}
    {#if fileUrl && isValidUri(fileUrl)?.protocol.startsWith('http') && fileType !== undefined} {#if fileType === 'image'} - + {:else if fileType === 'audio'} - + + {:else if fileType === 'document'} +
    +
    + + {fileUri.split('/').pop()} +
    +
    {/if} {:else} -
    -
    +
    +
    - {fileUri.split('/').pop()} + + {fileUri.split('/').pop()} +
    {/if}
    + {#if !readonly} + + {/if} + - {#if fileUrl && isValidUri(fileUrl)?.protocol.startsWith('http') && fileType === 'file'} + {#if fileUrl && isValidUri(fileUrl)?.protocol.startsWith('http') && fileType === 'image'} - - + + {#snippet child({ props })} + + {/snippet} -
    +
    {fileUri.split('/').pop()}
    - +
    - - - - - + + {#snippet child({ props })} + + {/snippet} + {#if !readonly} + + {#snippet child({ props })} + + {/snippet} + + {/if}
    - {:else if fileUri} - {/if}
    diff --git a/services/app/src/lib/components/tables/(sub)/FileSelect.svelte b/services/app/src/lib/components/tables/(sub)/FileSelect.svelte old mode 100644 new mode 100755 index fb271e5..a1f3387 --- a/services/app/src/lib/components/tables/(sub)/FileSelect.svelte +++ b/services/app/src/lib/components/tables/(sub)/FileSelect.svelte @@ -3,7 +3,7 @@ import axios from 'axios'; import debounce from 'lodash/debounce'; import toUpper from 'lodash/toUpper'; - import { page } from '$app/stores'; + import { page } from '$app/state'; import { fileColumnFiletypes } from '$lib/constants'; import logger from '$lib/logger'; import type { GenTableCol } from '$lib/types'; @@ -13,18 +13,29 @@ import CloseIcon from '$lib/icons/CloseIcon.svelte'; import LoadingSpinner from '$lib/icons/LoadingSpinner.svelte'; - export let tableType: 'action' | 'knowledge' | 'chat'; - export let controller: (string | AbortController) | (AbortController | undefined); - export let selectCb: (files: File[]) => void = handleSaveEditFile; - export let column: GenTableCol; - /** Edit cell function for tables */ - export let saveEditCell: - | ((cellToUpdate: { rowID: string; columnID: string }, editedValue: string) => Promise) - | undefined = undefined; - export let cellToUpdate: { rowID: string; columnID: string } | undefined = undefined; + interface Props { + tableType: 'action' | 'knowledge' | 'chat'; + controller: (string | AbortController) | (AbortController | undefined); + selectCb?: (files: File[]) => void; + column: GenTableCol; + /** Edit cell function for tables */ + saveEditCell?: + | ((cellToUpdate: { rowID: string; columnID: string }, editedValue: string) => Promise) + | undefined; + cellToUpdate?: { rowID: string; columnID: string } | undefined; + } + + let { + tableType, + controller = $bindable(), + selectCb = handleSaveEditFile, + column, + saveEditCell = undefined, + cellToUpdate = undefined + }: Props = $props(); - let container: HTMLDivElement; - let filesDragover = false; + let container: HTMLDivElement | undefined = $state(); + let filesDragover = $state(false); /** Validate before upload */ function handleSelectFiles(files: File[]) { @@ -76,10 +87,10 @@ formData.append('file', files[0]); try { - const uploadRes = await axios.post(`${PUBLIC_JAMAI_URL}/api/v1/files/upload`, formData, { + const uploadRes = await axios.post(`${PUBLIC_JAMAI_URL}/api/owl/files/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data', - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id }, signal: controller.signal }); @@ -118,18 +129,20 @@ const handleDragLeave = () => (filesDragover = false); - +
    { + ondragover={(e) => { + e.preventDefault(); if (e.dataTransfer?.items) { if ([...e.dataTransfer.items].some((item) => item.kind === 'file')) { filesDragover = true; } } }} - on:dragleave={debounce(handleDragLeave, 50)} - on:drop|preventDefault={(e) => { + ondragleave={debounce(handleDragLeave, 50)} + ondrop={(e) => { + e.preventDefault(); filesDragover = false; if (e.dataTransfer?.items) { handleSelectFiles( @@ -152,17 +165,17 @@ handleSelectFiles([...(e.dataTransfer?.files ?? [])]); } }} - class="flex flex-col gap-1 px-2 py-2 h-full w-full" + class="flex h-full w-full flex-col gap-1 px-2 py-2" > @@ -196,9 +209,12 @@ .filter(({ type }) => column.dtype === type) .map(({ ext }) => ext) .join(',')} - on:change|preventDefault={(e) => handleSelectFiles([...(e.currentTarget.files ?? [])])} + onchange={(e) => { + e.preventDefault(); + handleSelectFiles([...(e.currentTarget.files ?? [])]); + }} multiple={false} - class="fixed max-h-[0] max-w-0 !p-0 !border-none overflow-hidden" + class="fixed max-h-[0] max-w-0 overflow-hidden !border-none !p-0" /> diff --git a/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte b/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte old mode 100644 new mode 100755 index 5714ea5..20b2055 --- a/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte +++ b/services/app/src/lib/components/tables/(sub)/FileThumbsFetch.svelte @@ -1,41 +1,47 @@ diff --git a/services/app/src/lib/components/tables/(sub)/NewRow.svelte b/services/app/src/lib/components/tables/(sub)/NewRow.svelte old mode 100644 new mode 100755 index ff6bb73..b74f3a8 --- a/services/app/src/lib/components/tables/(sub)/NewRow.svelte +++ b/services/app/src/lib/components/tables/(sub)/NewRow.svelte @@ -4,8 +4,8 @@ import axios from 'axios'; import toUpper from 'lodash/toUpper'; import { v4 as uuidv4 } from 'uuid'; - import { page } from '$app/stores'; - import { genTableRows, tableState } from '$lib/components/tables/tablesStore'; + import { page } from '$app/state'; + import { getTableState, getTableRowsState } from '$lib/components/tables/tablesState.svelte'; import { cn } from '$lib/utils'; import logger from '$lib/logger'; import type { GenTable, GenTableRow, GenTableStreamEvent } from '$lib/types'; @@ -17,26 +17,47 @@ import AddIcon from '$lib/icons/AddIcon.svelte'; import StarIcon from '$lib/icons/StarIcon.svelte'; - export let tableType: 'action' | 'knowledge' | 'chat'; - export let tableData: GenTable; - export let focusedCol: string | null; - export let refetchTable: () => Promise; - let className: string | undefined | null = undefined; - export { className as class }; - - let newRowForm: HTMLFormElement; - let maxInputHeight = 36; - let isAddingRow = false; - let uploadColumns: Record = {}; - let isLoadingAddRow = false; - let inputValues: Record = {}; - let isDeletingFile: { rowID: string; columnID: string; fileUri?: string } | null = null; + const tableState = getTableState(); + const tableRowsState = getTableRowsState(); + + interface Props { + tableType: 'action' | 'knowledge' | 'chat'; + tableData: GenTable; + focusedCol: string | null; + refetchTable: () => Promise; + class?: string | undefined | null; + } - $: tableData, isAddingRow, uploadColumns, resetMaxInputHeight(); + let { + tableType, + tableData, + focusedCol, + refetchTable, + class: className = undefined + }: Props = $props(); + + let newRowForm: HTMLFormElement | undefined = $state(); + let maxInputHeight = $state(36); + let isAddingRow = $state(false); + let uploadColumns: Record = $state({}); + let isLoadingAddRow = false; + let inputValues: Record = $state({}); + let isDeletingFile: { rowID: string; columnID: string; fileUri?: string } | null = $state(null); + + $effect(() => { + tableData; + isAddingRow; + uploadColumns; + resetMaxInputHeight(); + }); async function resetMaxInputHeight() { if (Object.entries(uploadColumns).some((val) => typeof val[1] === 'string')) { maxInputHeight = 150; - } else if (tableData.cols.find((col) => col.dtype === 'image' || col.dtype === 'audio')) { + } else if ( + tableData.cols.find( + (col) => col.dtype === 'image' || col.dtype === 'audio' || col.dtype === 'document' + ) + ) { maxInputHeight = 72; } else { maxInputHeight = 32; @@ -63,6 +84,7 @@ } async function handleAddRow(e: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) { + e.preventDefault(); if (isLoadingAddRow) return; const formData = new FormData(e.currentTarget); const obj = Object.fromEntries( @@ -82,7 +104,7 @@ inputValues = {}; const clientRowID = uuidv4(); - genTableRows.addRow({ + tableRowsState.addRow({ ID: clientRowID, 'Updated at': new Date().toISOString(), ...(Object.fromEntries( @@ -90,26 +112,21 @@ ) as any) }); - console.log({ - [clientRowID]: tableData.cols - .filter((col) => col.gen_config && !Object.keys(data).includes(col.id)) - .map((col) => col.id) - }); tableState.addStreamingRows({ [clientRowID]: tableData.cols .filter((col) => col.gen_config && !Object.keys(data).includes(col.id)) .map((col) => col.id) }); - const response = await fetch(`${PUBLIC_JAMAI_URL}/api/v1/gen_tables/${tableType}/rows/add`, { + const response = await fetch(`${PUBLIC_JAMAI_URL}/api/owl/gen_tables/${tableType}/rows/add`, { method: 'POST', headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json', - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id }, body: JSON.stringify({ - table_id: $page.params.table_id, + table_id: page.params.table_id, data: [data], stream: true }) @@ -129,100 +146,93 @@ } }); - genTableRows.deleteRow(clientRowID); + tableRowsState.deleteRow(clientRowID); tableState.delStreamingRows([clientRowID]); } else { const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); - let isStreaming = true; - let lastMessage = ''; - let rowId = ''; + let rowID = ''; let addedRow = false; - while (isStreaming) { + // let references: Record> | null = null; + let buffer = ''; + // eslint-disable-next-line no-constant-condition + while (true) { try { const { value, done } = await reader.read(); if (done) break; - if (value.endsWith('\n\n')) { - const lines = (lastMessage + value) - .split('\n\n') - .filter((i) => i.trim()) - .flatMap((line) => line.split('\n')); //? Split by \n to handle collation - - lastMessage = ''; - - for (const line of lines) { - const sumValue = line.replace(/^data: /, '').replace(/data: \[DONE\]\s+$/, ''); - - if (sumValue.trim() == '[DONE]') break; - - let parsedValue; - try { - parsedValue = JSON.parse(sumValue) as GenTableStreamEvent; - } catch (err) { - console.error('Error parsing:', sumValue); - logger.error(toUpper(`${tableType}TBL_ROW_ADDSTREAMPARSE`), { - parsing: sumValue, - error: err - }); - continue; - } - - if (parsedValue.object === 'gen_table.completion.chunk') { - if (parsedValue.choices[0].finish_reason) { - switch (parsedValue.choices[0].finish_reason) { - case 'error': { - logger.error(toUpper(`${tableType}_ROW_ADDSTREAM`), parsedValue); - console.error('STREAMING_ERROR', parsedValue); - alert(`Error while streaming: ${parsedValue.choices[0].message.content}`); - break; + buffer += value; + const lines = buffer.split('\n'); //? Split by \n to handle collation + buffer = lines.pop() || ''; + + let parsedEvent: { data: GenTableStreamEvent } | undefined = undefined; + for (const line of lines) { + if (line === '') { + if (parsedEvent) { + if (parsedEvent.data.object === 'gen_table.completion.chunk') { + if (parsedEvent.data.choices[0].finish_reason) { + switch (parsedEvent.data.choices[0].finish_reason) { + case 'error': { + logger.error(toUpper(`${tableType}_ROW_ADDSTREAM`), parsedEvent.data); + console.error('STREAMING_ERROR', parsedEvent.data); + alert( + `Error while streaming: ${parsedEvent.data.choices[0].message.content}` + ); + break; + } + default: { + const streamingCols = + tableState.streamingRows[parsedEvent.data.row_id]?.filter( + (col) => col !== parsedEvent?.data.output_column_name + ) ?? []; + if (streamingCols.length === 0) { + tableState.delStreamingRows([parsedEvent.data.row_id]); + } else { + tableState.addStreamingRows({ + [parsedEvent.data.row_id]: streamingCols + }); + } + break; + } } - default: { - const streamingCols = $tableState.streamingRows[parsedValue.row_id].filter( - (col) => col !== parsedValue.output_column_name + } else { + rowID = parsedEvent.data.row_id; + + //* Add chunk to active row + if (!addedRow) { + tableRowsState.updateRow(clientRowID, { + ID: parsedEvent.data.row_id, + [parsedEvent.data.output_column_name]: { + value: parsedEvent.data.choices[0].message.content ?? '' + } + } as GenTableRow); + tableState.delStreamingRows([clientRowID]); + tableState.addStreamingRows({ + [parsedEvent.data.row_id]: tableData.cols + .filter((col) => col.gen_config && !Object.keys(data).includes(col.id)) + .map((col) => col.id) + }); + addedRow = true; + } else { + tableRowsState.stream( + parsedEvent.data.row_id, + parsedEvent.data.output_column_name, + parsedEvent.data.choices[0].message.content ?? '' ); - if (streamingCols.length === 0) { - tableState.delStreamingRows([parsedValue.row_id]); - } else { - tableState.addStreamingRows({ - [parsedValue.row_id]: streamingCols - }); - } - break; } } } else { - rowId = parsedValue.row_id; - - //* Add chunk to active row - if (!addedRow) { - genTableRows.updateRow(clientRowID, { - ID: parsedValue.row_id, - [parsedValue.output_column_name]: { - value: parsedValue.choices[0].message.content ?? '' - } - } as GenTableRow); - tableState.delStreamingRows([clientRowID]); - tableState.addStreamingRows({ - [parsedValue.row_id]: tableData.cols - .filter((col) => col.gen_config && !Object.keys(data).includes(col.id)) - .map((col) => col.id) - }); - addedRow = true; - } else { - genTableRows.stream( - parsedValue.row_id, - parsedValue.output_column_name, - parsedValue.choices[0].message.content ?? '' - ); - } + console.log('Unknown message:', parsedEvent.data); } } else { - console.log('Unknown message:', parsedValue); + console.warn('Unknown event object:', parsedEvent); } - } - } else { - lastMessage += value; + } else if (line.startsWith('data: ')) { + if (line.slice(6) === '[DONE]') break; + parsedEvent = { ...(parsedEvent ?? {}), data: JSON.parse(line.slice(6)) }; + } /* else if (line.startsWith('event: ')) { + parsedEvent = { ...(parsedEvent ?? {}), event: line.slice(7) }; + } */ } } catch (err) { logger.error(toUpper(`${tableType}TBL_ROW_ADDSTREAM`), err); @@ -231,7 +241,7 @@ } } - tableState.delStreamingRows([clientRowID, rowId]); + tableState.delStreamingRows([clientRowID, rowID]); refetchTable(); } } @@ -244,10 +254,10 @@ formData.append('file', files[0]); try { - const uploadRes = await axios.post(`${PUBLIC_JAMAI_URL}/api/v1/files/upload`, formData, { + const uploadRes = await axios.post(`${PUBLIC_JAMAI_URL}/api/owl/files/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data', - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id }, signal: uploadController.signal }); @@ -264,11 +274,11 @@ ); return; } else { - const urlResponse = await fetch(`/api/v1/files/url/thumb`, { + const urlResponse = await fetch(`/api/owl/files/url/thumb`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id }, body: JSON.stringify({ uris: [uploadRes.data.uri] @@ -312,8 +322,8 @@ { - if (!newRowForm.contains(document.activeElement)) { + onclick={() => { + if (!newRowForm?.contains(document.activeElement)) { const formData = new FormData(newRowForm); const obj = Object.fromEntries( Array.from(formData.keys()).map((key) => [ @@ -334,40 +344,40 @@ }} /> - - + +
    (isAddingRow = true)} - on:keydown={(event) => { + onclick={() => (isAddingRow = true)} + onkeydown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - event.currentTarget.requestSubmit(); + if (isAddingRow) event.currentTarget.requestSubmit(); } }} - on:submit|preventDefault={handleAddRow} + onsubmit={handleAddRow} style="grid-template-columns: 45px {focusedCol === 'ID' ? '320px' : '120px'} {focusedCol === 'Updated at' ? '320px' - : '130px'} {$tableState.templateCols};" + : '130px'} {tableState.templateCols};" class={cn( - '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] group border-l border-l-transparent data-dark:border-l-transparent border-r-transparent data-dark:border-r-transparent border-b border-[#E4E7EC] data-dark:border-[#333]', + 'group sticky top-[36px] z-20 grid h-min max-h-[100px] place-items-start border-b border-l border-[#E4E7EC] border-l-transparent border-r-transparent bg-[#F2F4F7] text-xs text-[#667085] data-dark:border-[#333] data-dark:border-l-transparent data-dark:border-r-transparent data-dark:bg-[#1E2024] sm:max-h-[150px] sm:text-sm', className )} >
    {#if isAddingRow} @@ -375,7 +385,7 @@ variant="ghost" type="button" title="Cancel" - on:click={(e) => { + onclick={(e) => { e.stopPropagation(); const formData = new FormData(newRowForm); @@ -399,9 +409,9 @@ uploadColumns = {}; inputValues = {}; }} - class="p-0 h-6 sm:h-7 rounded-full aspect-square" + class="aspect-square h-6 rounded-full p-0 sm:h-7" > - + {:else} @@ -410,9 +420,9 @@
    New Row @@ -420,8 +430,8 @@ {#if isAddingRow} @@ -432,18 +442,20 @@ {#each tableData.cols as column} {#if column.id !== 'ID' && column.id !== 'Updated at'} {@const columnFile = uploadColumns[column.id]} - +
    {#if isAddingRow} - {#if column.dtype === 'image' || column.dtype === 'audio'} + {#if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {#if typeof columnFile !== 'string'} {/if} {/if} diff --git a/services/app/src/lib/components/tables/(sub)/PlaceholderNewCol.svelte b/services/app/src/lib/components/tables/(sub)/PlaceholderNewCol.svelte new file mode 100644 index 0000000..96897d3 --- /dev/null +++ b/services/app/src/lib/components/tables/(sub)/PlaceholderNewCol.svelte @@ -0,0 +1,324 @@ + + + { + if (e.key === 'Escape') { + tableState.addingCol = false; + } + }} +/> + +
    + + + + + { + if (e.key === 'Enter') { + handleAddColumn(); + } + }} + style="left: {colIDPaddingWidth + 32}px; width: {colIDInputWidth}px;" + class="pointer-events-auto absolute -top-[26px] h-[20px] rounded-[2px] border-0 bg-transparent text-sm outline outline-1 outline-[#4169e1] data-dark:outline-[#5b7ee5]" + /> + +
    +
    + + + {#snippet children()} + + {colType || 'Select Column Type'} + + {/snippet} + + + {#each Object.keys(genTableColTypes) as colType} + + {colType} + + {/each} + + +
    + +
    + + + {#snippet children()} + + {genTableDTypes[dType] || 'Select Data Type'} + + {/snippet} + + + {#each genTableColDTypes[colType] as dType} + + {genTableDTypes[dType]} + + {/each} + + +
    +
    + +
    + + +
    +
    +
    + + + + {colType === 'Input' ? 'Input' : 'Output'} + + + {dType} + + + + + + + + + +
    diff --git a/services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte b/services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte old mode 100644 new mode 100755 index 8009d68..44a17f5 --- a/services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte +++ b/services/app/src/lib/components/tables/(sub)/SelectKnowledgeTableDialog.svelte @@ -1,9 +1,8 @@ - + Choose Knowledge Table(s) -
    +
    (isAddingTable = true)} - class="place-self-end lg:place-self-center flex-[0_0_auto] relative flex items-center justify-center gap-1.5 mr-1 sm:mr-0.5 px-2 sm:px-3 py-2 h-min w-min text-xs sm:text-sm aspect-square sm:aspect-auto" + onclick={() => (isAddingTable = true)} + class="relative mr-1 flex aspect-square h-min w-min flex-[0_0_auto] items-center justify-center gap-1.5 place-self-end px-2 py-2 text-xs sm:mr-0.5 sm:aspect-auto sm:px-3 sm:text-sm lg:place-self-center" > @@ -187,32 +191,32 @@
    {#if isLoadingKTables} {#each Array(12) as _} {/each} {:else} {#each pastKnowledgeTables as knowledgeTable} - + + {#snippet child({ props })} + + {/snippet} +
    diff --git a/services/app/src/lib/components/tables/(sub)/TablePagination.svelte b/services/app/src/lib/components/tables/(sub)/TablePagination.svelte old mode 100644 new mode 100755 index 291fc77..0324999 --- a/services/app/src/lib/components/tables/(sub)/TablePagination.svelte +++ b/services/app/src/lib/components/tables/(sub)/TablePagination.svelte @@ -1,7 +1,7 @@ + + + + {#snippet child({ props })} + + {/snippet} + + + + +
    +
    + { + if (v === 'ID') { + page.url.searchParams.delete('sort_by'); + } else { + page.url.searchParams.set('sort_by', v); + } + goto(`?${page.url.searchParams}`, { + replaceState: true, + invalidate: [`${tableType}-table:slug`] + }); + }} + > + + {#snippet children()} + + {page.url.searchParams.get('sort_by') ?? 'ID'} + + {/snippet} + + + {#each tableData?.cols ?? [] as column} + {@const colType = !column.gen_config ? 'input' : 'output'} + + {#if !['ID', 'Updated at'].includes(column.id)} + + + {colType} + + + {column.dtype} + + + + + {/if} + + {column.id} + + {/each} + + +
    + +
    + { + if (v === '0') { + page.url.searchParams.delete('asc'); + } else { + page.url.searchParams.set('asc', '1'); + } + goto(`?${page.url.searchParams}`, { + replaceState: true, + invalidate: [`${tableType}-table:slug`] + }); + }} + > + + {#snippet children()} + + {page.url.searchParams.get('asc') === '1' ? 'Ascending' : 'Descending'} + + {/snippet} + + + {#each ['0', '1'] as sortDirection} + + {sortDirection === '1' ? 'Ascending' : 'Descending'} + + {/each} + + +
    +
    + + +
    +
    +
    diff --git a/services/app/src/lib/components/tables/(sub)/index.ts b/services/app/src/lib/components/tables/(sub)/index.ts old mode 100644 new mode 100755 index 13e5a7a..7999658 --- a/services/app/src/lib/components/tables/(sub)/index.ts +++ b/services/app/src/lib/components/tables/(sub)/index.ts @@ -8,6 +8,7 @@ import FileThumbsFetch from './FileThumbsFetch.svelte'; import NewRow from './NewRow.svelte'; import SelectKnowledgeTableDialog from './SelectKnowledgeTableDialog.svelte'; import TablePagination from './TablePagination.svelte'; +import TableSorter from './TableSorter.svelte'; export { ColumnDropdown, ColumnHeader, @@ -18,5 +19,6 @@ export { FileThumbsFetch, NewRow, SelectKnowledgeTableDialog, - TablePagination + TablePagination, + TableSorter }; diff --git a/services/app/src/lib/components/tables/(svg)/NoRowsGraphic.svelte b/services/app/src/lib/components/tables/(svg)/NoRowsGraphic.svelte old mode 100644 new mode 100755 index f3c1e9e..ca41a17 --- a/services/app/src/lib/components/tables/(svg)/NoRowsGraphic.svelte +++ b/services/app/src/lib/components/tables/(svg)/NoRowsGraphic.svelte @@ -1,6 +1,10 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; import { onDestroy } from 'svelte'; - import { page } from '$app/stores'; - import { tableState, genTableRows } from '$lib/components/tables/tablesStore'; + import { page } from '$app/state'; + import { getTableState, getTableRowsState } from '$lib/components/tables/tablesState.svelte'; import { cn, isValidUri } from '$lib/utils'; import logger from '$lib/logger'; - import type { GenTable, GenTableRow, UserRead } from '$lib/types'; + import type { GenTable, GenTableRow, User } from '$lib/types'; import { ColumnHeader, @@ -21,29 +21,44 @@ import { toast, CustomToastDesc } from '$lib/components/ui/sonner'; import LoadingSpinner from '$lib/icons/LoadingSpinner.svelte'; - export let userData: UserRead | undefined; - export let tableData: GenTable | undefined; - export let tableError: { error: number; message?: any } | undefined; - export let readonly = false; - export let refetchTable: (hideColumnSettings?: boolean) => Promise; + const tableState = getTableState(); + const tableRowsState = getTableRowsState(); - let rowThumbs: { [rowID: string]: { [colID: string]: { value: string; url: string } } } = {}; - let isDeletingFile: { rowID: string; columnID: string; fileUri?: string } | null = null; + interface Props { + user: User | undefined; + tableData: GenTable | undefined; + tableError: { error: number; message?: any } | undefined; + readonly?: boolean; + refetchTable: (hideColumnSettings?: boolean) => Promise; + } + + let { + user, + tableData = $bindable(), + tableError = $bindable(), + readonly = false, + refetchTable + }: Props = $props(); + + let rowThumbs: { [rowID: string]: { [colID: string]: { value: string; url: string } } } = $state( + {} + ); + let isDeletingFile: { rowID: string; columnID: string; fileUri?: string } | null = $state(null); let uploadController: AbortController | undefined = undefined; //? Expanding ID and Updated at columns - let focusedCol: string | null = null; + let focusedCol: string | null = $state(null); async function handleSaveEdit( e: KeyboardEvent & { currentTarget: EventTarget & HTMLTextAreaElement; } ) { - if (!tableData || !$genTableRows) return; - if (!$tableState.editingCell) return; + if (!tableData || !tableRowsState.rows) return; + if (!tableState.editingCell) return; const editedValue = e.currentTarget.value; - const cellToUpdate = $tableState.editingCell; + const cellToUpdate = tableState.editingCell; await saveEditCell(cellToUpdate, editedValue); } @@ -52,25 +67,26 @@ cellToUpdate: { rowID: string; columnID: string }, editedValue: string ) { - if (!tableData || !$genTableRows) return; + if (!tableData || !tableRowsState.rows) return; //? Optimistic update - const originalValue = $genTableRows.find((row) => row.ID === cellToUpdate!.rowID)?.[ + const originalValue = tableRowsState.rows.find((row) => row.ID === cellToUpdate!.rowID)?.[ cellToUpdate.columnID ]; - genTableRows.setCell(cellToUpdate, editedValue); + tableRowsState.setCell(cellToUpdate, editedValue); - const response = await fetch(`${PUBLIC_JAMAI_URL}/api/v1/gen_tables/action/rows/update`, { - method: 'POST', + const response = await fetch(`${PUBLIC_JAMAI_URL}/api/owl/gen_tables/action/rows`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json', - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id }, body: JSON.stringify({ table_id: tableData.id, - row_id: cellToUpdate.rowID, data: { - [cellToUpdate.columnID]: editedValue + [cellToUpdate.rowID]: { + [cellToUpdate.columnID]: editedValue + } } }) }); @@ -88,7 +104,7 @@ }); //? Revert back to original value - genTableRows.setCell(cellToUpdate, originalValue); + tableRowsState.setCell(cellToUpdate, originalValue?.value); } else { tableState.setEditingCell(null); refetchTable(); @@ -101,20 +117,24 @@ e: CustomEvent<{ event: MouseEvent; value: boolean }>, row: GenTableRow ) { - if (!tableData || !$genTableRows) return; + if (!tableData || !tableRowsState.rows) return; //? Select multiple rows with shift key - const rowIndex = $genTableRows.findIndex(({ ID }) => ID === row.ID); - if (e.detail.event.shiftKey && $tableState.selectedRows.length && shiftOrigin != null) { + const rowIndex = tableRowsState.rows.findIndex(({ ID }) => ID === row.ID); + if (e.detail.event.shiftKey && tableState.selectedRows.length && shiftOrigin != null) { if (shiftOrigin < rowIndex) { tableState.setSelectedRows([ - ...$tableState.selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), - ...$genTableRows.slice(shiftOrigin, rowIndex + 1).map(({ ID }) => ID) + ...tableState.selectedRows.filter( + (i) => !tableRowsState.rows?.some(({ ID }) => ID === i) + ), + ...tableRowsState.rows.slice(shiftOrigin, rowIndex + 1).map(({ ID }) => ID) ]); } else if (shiftOrigin > rowIndex) { tableState.setSelectedRows([ - ...$tableState.selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), - ...$genTableRows.slice(rowIndex, shiftOrigin + 1).map(({ ID }) => ID) + ...tableState.selectedRows.filter( + (i) => !tableRowsState.rows?.some(({ ID }) => ID === i) + ), + ...tableRowsState.rows.slice(rowIndex, shiftOrigin + 1).map(({ ID }) => ID) ]); } else { tableState.toggleRowSelection(row.ID); @@ -127,7 +147,7 @@ } function keyboardNavigate(e: KeyboardEvent) { - if (!tableData || !$genTableRows) return; + if (!tableData || !tableRowsState.rows) return; // const isCtrl = window.navigator.userAgent.indexOf('Mac') != -1 ? e.metaKey : e.ctrlKey; // const activeElement = document.activeElement as HTMLElement; // const isInputActive = activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA'; @@ -137,8 +157,8 @@ // if (Object.keys(streamingRows).length !== 0) return; // selectedRows = [ - // ...selectedRows.filter((i) => !$genTableRows?.some(({ ID }) => ID === i)), - // ...$genTableRows.map(({ ID }) => ID) + // ...selectedRows.filter((i) => !tableRowsState?.some(({ ID }) => ID === i)), + // ...tableRowsState.map(({ ID }) => ID) // ]; // } @@ -148,30 +168,30 @@ } onDestroy(() => { - $genTableRows = undefined; + tableRowsState.rows = undefined; tableState.reset(); }); { + onmousedown={(e) => { const editingCell = document.querySelector('[data-editing="true"]'); //@ts-ignore if (e.target && editingCell && !editingCell.contains(e.target)) { tableState.setEditingCell(null); } }} - on:keydown={keyboardNavigate} + onkeydown={keyboardNavigate} /> {#if tableData}
    { + onscroll={(e) => { //? Used to prevent elements showing through the padding between side nav and table header //FIXME: Use transform for performance const el = document.getElementById('checkbox-bg-obscure'); @@ -180,48 +200,48 @@ } }} role="grid" - style="grid-template-rows: 36px {$genTableRows - ? `repeat(${$genTableRows.length + (!readonly ? 1 : 0)}, min-content)` + style="grid-template-rows: 36px {tableRowsState.rows && !tableRowsState.loading + ? `repeat(${tableRowsState.rows.length + (!readonly ? 1 : 0)}, min-content)` : 'minmax(0, 1fr)'};" - class="grow relative grid px-2 overflow-auto" + class="relative grid grow overflow-auto px-2" >
    {#if !readonly} { - if ($genTableRows) { - return tableState.selectAllRows($genTableRows); + if (tableRowsState.rows) { + return tableState.selectAllRows(tableRowsState.rows); } else return false; }} - checked={($genTableRows ?? []).every((row) => - $tableState.selectedRows.includes(row.ID) + checked={(tableRowsState.rows ?? []).every((row) => + tableState.selectedRows.includes(row.ID) )} - class="h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + class="h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    @@ -229,7 +249,7 @@
    - {#if $genTableRows} + {#if tableRowsState.rows && !tableRowsState.loading} {#if !readonly} @@ -246,134 +266,142 @@ - {#each $genTableRows as row (row.ID)} + {#each tableRowsState.rows as row (row.ID)}
    - {#if $tableState.streamingRows[row.ID]} + {#if tableState.streamingRows[row.ID]}
    {/if}
    {#if !readonly} handleSelectRow(e, row)} - checked={!!$tableState.selectedRows.find((i) => i === row.ID)} - class="mt-[1px] h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + checked={!!tableState.selectedRows.find((i) => i === row.ID)} + class="mt-[1px] h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    {#each tableData.cols as column} {@const editMode = - $tableState.editingCell && - $tableState.editingCell.rowID === row.ID && - $tableState.editingCell.columnID === column.id} + tableState.editingCell && + tableState.editingCell.rowID === row.ID && + tableState.editingCell.columnID === column.id} {@const isValidFileUri = isValidUri(row[column.id]?.value)} - +
    (focusedCol = column.id)} - on:focusout={() => (focusedCol = null)} - on:mousedown={(e) => { + onfocusin={() => (focusedCol = column.id)} + onfocusout={() => (focusedCol = null)} + onmousedown={(e) => { if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if ($tableState.streamingRows[row.ID] || $tableState.editingCell) return; + if (tableState.streamingRows[row.ID] || tableState.editingCell) return; if (e.detail > 1) { e.preventDefault(); } }} - on:dblclick={() => { + ondblclick={() => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!$tableState.streamingRows[row.ID]) { + if (!tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - on:keydown={(e) => { + onkeydown={(e) => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!editMode && e.key == 'Enter' && !$tableState.streamingRows[row.ID]) { + if (!editMode && e.key == 'Enter' && !tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - style={$tableState.columnSettings.column?.id == column.id && - $tableState.columnSettings.isOpen + style={tableState.columnSettings.column?.id == column.id && + tableState.columnSettings.isOpen ? 'background-color: #30A8FF17;' : ''} class={cn( - 'flex flex-col justify-start gap-1 h-full max-h-[99px] sm:max-h-[149px] w-full break-words [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333]', + 'flex h-full max-h-[99px] w-full flex-col justify-start gap-1 break-words border-[#E4E7EC] data-dark:border-[#333] sm:max-h-[149px] [&:not(:last-child)]:border-r', editMode - ? 'p-0 bg-black/5 data-dark:bg-white/5' - : 'p-2 overflow-auto whitespace-pre-line', - $tableState.streamingRows[row.ID] + ? 'bg-black/5 p-0 data-dark:bg-white/5' + : 'overflow-auto whitespace-pre-line p-2', + tableState.streamingRows[row.ID] ? 'bg-[#FDEFF4]' - : 'group-hover:bg-[#ECEDEE] data-dark:group-hover:bg-white/5' + : 'group-hover:bg-[#E7EBF1] data-dark:group-hover:bg-white/5' )} > - {#if $tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} + {#if tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} {/if} {#if editMode} - {#if column.dtype === 'image' || column.dtype === 'audio'} + {#if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {:else} - + {/if} - {:else if column.dtype === 'image' || column.dtype === 'audio'} + {:else if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {#if column.id === 'ID'} @@ -416,7 +445,7 @@ {:else if column.id === 'Updated at'} {new Date(row[column.id]).toISOString()} {:else} - {row[column.id]?.value === undefined ? '' : row[column.id]?.value} + {row[column.id]?.value ?? ''} {/if} {/if} @@ -432,23 +461,23 @@
    {:else if tableError?.error == 404} - {#if tableError.message?.org_id && userData?.member_of.find((org) => org.organization_id === tableError.message?.org_id)} - {@const projectOrg = userData?.member_of.find( - (org) => org.organization_id === tableError.message?.org_id - )} + {#if tableError.message?.org_id && user?.org_memberships.find((org) => org.organization_id === tableError.message?.org_id)} + {@const projectOrg = user?.organizations.find((org) => org.id === tableError.message?.org_id)} {:else} -
    -

    Table not found

    +
    +

    Table not found

    {/if} {:else if tableError?.error} -
    -

    {tableError.error} Failed to load table

    -

    {JSON.stringify(tableError.message)}

    +
    +

    {tableError.error} Failed to load table

    +

    + {tableError.message.message ?? JSON.stringify(tableError.message)} +

    {:else} -
    +
    {/if} diff --git a/services/app/src/lib/components/tables/ChatTable.svelte b/services/app/src/lib/components/tables/ChatTable.svelte old mode 100644 new mode 100755 index 506b03e..5e815f2 --- a/services/app/src/lib/components/tables/ChatTable.svelte +++ b/services/app/src/lib/components/tables/ChatTable.svelte @@ -1,11 +1,10 @@ { + onmousedown={(e) => { const editingCell = document.querySelector('[data-editing="true"]'); //@ts-ignore if (e.target && editingCell && !editingCell.contains(e.target)) { tableState.setEditingCell(null); } }} - on:keydown={keyboardNavigate} + onkeydown={keyboardNavigate} /> {#if tableData}
    { + onscroll={(e) => { //? Used to prevent elements showing through the padding between side nav and table header //FIXME: Use transform for performance const el = document.getElementById('checkbox-bg-obscure'); @@ -179,48 +198,48 @@ } }} role="grid" - style="grid-template-rows: 36px {$genTableRows - ? `repeat(${$genTableRows.length + (!readonly ? 1 : 0)}, min-content)` + style="grid-template-rows: 36px {tableRowsState.rows && !tableRowsState.loading + ? `repeat(${tableRowsState.rows.length + (!readonly ? 1 : 0)}, min-content)` : 'minmax(0, 1fr)'};" - class="grow relative grid px-2 overflow-auto" + class="relative grid grow overflow-auto px-2" >
    {#if !readonly} { - if ($genTableRows) { - return tableState.selectAllRows($genTableRows); + if (tableRowsState.rows) { + return tableState.selectAllRows(tableRowsState.rows); } else return false; }} - checked={($genTableRows ?? []).every((row) => - $tableState.selectedRows.includes(row.ID) + checked={(tableRowsState.rows ?? []).every((row) => + tableState.selectedRows.includes(row.ID) )} - class="h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + class="h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    @@ -228,7 +247,7 @@
    - {#if $genTableRows} + {#if tableRowsState.rows && !tableRowsState.loading} {#if !readonly} @@ -245,134 +264,142 @@ - {#each $genTableRows as row} + {#each tableRowsState.rows as row}
    - {#if $tableState.streamingRows[row.ID]} + {#if tableState.streamingRows[row.ID]}
    {/if}
    {#if !readonly} handleSelectRow(e, row)} - checked={!!$tableState.selectedRows.find((i) => i === row.ID)} - class="mt-[1px] h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + checked={!!tableState.selectedRows.find((i) => i === row.ID)} + class="mt-[1px] h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    {#each tableData.cols as column} {@const editMode = - $tableState.editingCell && - $tableState.editingCell.rowID === row.ID && - $tableState.editingCell.columnID === column.id} + tableState.editingCell && + tableState.editingCell.rowID === row.ID && + tableState.editingCell.columnID === column.id} {@const isValidFileUri = isValidUri(row[column.id]?.value)} - +
    (focusedCol = column.id)} - on:focusout={() => (focusedCol = null)} - on:mousedown={(e) => { + onfocusin={() => (focusedCol = column.id)} + onfocusout={() => (focusedCol = null)} + onmousedown={(e) => { if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if ($tableState.streamingRows[row.ID] || $tableState.editingCell) return; + if (tableState.streamingRows[row.ID] || tableState.editingCell) return; if (e.detail > 1) { e.preventDefault(); } }} - on:dblclick={() => { + ondblclick={() => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!$tableState.streamingRows[row.ID]) { + if (!tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - on:keydown={(e) => { + onkeydown={(e) => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!editMode && e.key == 'Enter' && !$tableState.streamingRows[row.ID]) { + if (!editMode && e.key == 'Enter' && !tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - style={$tableState.columnSettings.column?.id == column.id && - $tableState.columnSettings.isOpen + style={tableState.columnSettings.column?.id == column.id && + tableState.columnSettings.isOpen ? 'background-color: #30A8FF17;' : ''} class={cn( - 'flex flex-col justify-start gap-1 h-full max-h-[99px] sm:max-h-[149px] w-full break-words [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333]', + 'flex h-full max-h-[99px] w-full flex-col justify-start gap-1 break-words border-[#E4E7EC] data-dark:border-[#333] sm:max-h-[149px] [&:not(:last-child)]:border-r', editMode - ? 'p-0 bg-black/5 data-dark:bg-white/5' - : 'p-2 overflow-auto whitespace-pre-line', - $tableState.streamingRows[row.ID] + ? 'bg-black/5 p-0 data-dark:bg-white/5' + : 'overflow-auto whitespace-pre-line p-2', + tableState.streamingRows[row.ID] ? 'bg-[#FDEFF4]' - : 'group-hover:bg-[#ECEDEE] data-dark:group-hover:bg-white/5' + : 'group-hover:bg-[#E7EBF1] data-dark:group-hover:bg-white/5' )} > - {#if $tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} + {#if tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} {/if} {#if editMode} - {#if column.dtype === 'image' || column.dtype === 'audio'} + {#if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {:else} - + {/if} - {:else if column.dtype === 'image' || column.dtype === 'audio'} + {:else if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {#if column.id === 'ID'} @@ -431,23 +459,23 @@
    {:else if tableError?.error == 404} - {#if tableError.message?.org_id && userData?.member_of.find((org) => org.organization_id === tableError.message?.org_id)} - {@const projectOrg = userData?.member_of.find( - (org) => org.organization_id === tableError.message?.org_id - )} + {#if tableError.message?.org_id && user?.org_memberships.find((org) => org.organization_id === tableError.message?.org_id)} + {@const projectOrg = user?.organizations.find((org) => org.id === tableError.message?.org_id)} {:else} -
    -

    Table not found

    +
    +

    Table not found

    {/if} {:else if tableError?.error} -
    -

    {tableError.error} Failed to load table

    -

    {JSON.stringify(tableError.message)}

    +
    +

    {tableError.error} Failed to load table

    +

    + {tableError.message.message ?? JSON.stringify(tableError.message)} +

    {:else} -
    +
    {/if} diff --git a/services/app/src/lib/components/tables/KnowledgeTable.svelte b/services/app/src/lib/components/tables/KnowledgeTable.svelte old mode 100644 new mode 100755 index 3291230..4fc58d8 --- a/services/app/src/lib/components/tables/KnowledgeTable.svelte +++ b/services/app/src/lib/components/tables/KnowledgeTable.svelte @@ -1,11 +1,11 @@ { + onmousedown={(e) => { const editingCell = document.querySelector('[data-editing="true"]'); //@ts-ignore if (e.target && editingCell && !editingCell.contains(e.target)) { tableState.setEditingCell(null); } }} - on:keydown={keyboardNavigate} + onkeydown={keyboardNavigate} /> {#if tableData}
    { + onscroll={(e) => { //? Used to prevent elements showing through the padding between side nav and table header //FIXME: Use transform for performance const el = document.getElementById('checkbox-bg-obscure'); @@ -183,50 +203,52 @@ } }} role="grid" - style={$genTableRows?.length !== 0 + style={tableRowsState.rows?.length !== 0 ? `grid-template-rows: 36px ${ - $genTableRows ? `repeat(${$genTableRows.length}, min-content)` : 'minmax(0, 1fr)' + tableRowsState.rows && !tableRowsState.loading + ? `repeat(${tableRowsState.rows.length}, min-content)` + : 'minmax(0, 1fr)' };` : undefined} - class="grow relative grid px-2 overflow-auto" + class="relative grid grow overflow-auto px-2" >
    {#if !readonly} { - if ($genTableRows) { - return tableState.selectAllRows($genTableRows); + if (tableRowsState.rows) { + return tableState.selectAllRows(tableRowsState.rows); } else return false; }} - checked={($genTableRows ?? []).every((row) => - $tableState.selectedRows.includes(row.ID) + checked={(tableRowsState.rows ?? []).every((row) => + tableState.selectedRows.includes(row.ID) )} - class="h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + class="h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    @@ -234,132 +256,140 @@
    - {#if $genTableRows} - {#if $genTableRows.length > 0} - {#each $genTableRows as row} + {#if tableRowsState.rows && !tableRowsState.loading} + {#if tableRowsState.rows.length > 0} + {#each tableRowsState.rows as row}
    - {#if $tableState.streamingRows[row.ID]} + {#if tableState.streamingRows[row.ID]}
    {/if}
    {#if !readonly} handleSelectRow(e, row)} - checked={!!$tableState.selectedRows.find((i) => i === row.ID)} - class="mt-[1px] h-4 sm:h-[18px] w-4 sm:w-[18px] [&>svg]:h-3 sm:[&>svg]:h-3.5 [&>svg]:w-3 sm:[&>svg]:w-3.5 [&>svg]:translate-x-[1px]" + checked={!!tableState.selectedRows.find((i) => i === row.ID)} + class="mt-[1px] h-4 w-4 sm:h-[18px] sm:w-[18px] [&>svg]:h-3 [&>svg]:w-3 [&>svg]:translate-x-[1px] sm:[&>svg]:h-3.5 sm:[&>svg]:w-3.5" /> {/if}
    {#each tableData.cols as column} {@const editMode = - $tableState.editingCell && - $tableState.editingCell.rowID === row.ID && - $tableState.editingCell.columnID === column.id} + tableState.editingCell && + tableState.editingCell.rowID === row.ID && + tableState.editingCell.columnID === column.id} {@const isValidFileUri = isValidUri(row[column.id]?.value)} - +
    (focusedCol = column.id)} - on:focusout={() => (focusedCol = null)} - on:mousedown={(e) => { + onfocusin={() => (focusedCol = column.id)} + onfocusout={() => (focusedCol = null)} + onmousedown={(e) => { if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if ($tableState.streamingRows[row.ID] || $tableState.editingCell) return; + if (tableState.streamingRows[row.ID] || tableState.editingCell) return; if (e.detail > 1) { e.preventDefault(); } }} - on:dblclick={() => { + ondblclick={() => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!$tableState.streamingRows[row.ID]) { + if (!tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - on:keydown={(e) => { + onkeydown={(e) => { if (readonly) return; if (column.id === 'ID' || column.id === 'Updated at') return; if ( - (column.dtype === 'image' || column.dtype === 'audio') && + (column.dtype === 'image' || + column.dtype === 'audio' || + column.dtype === 'document') && row[column.id]?.value && isValidFileUri ) return; if (uploadController) return; - if (!editMode && e.key == 'Enter' && !$tableState.streamingRows[row.ID]) { + if (!editMode && e.key == 'Enter' && !tableState.streamingRows[row.ID]) { tableState.setEditingCell({ rowID: row.ID, columnID: column.id }); } }} - style={$tableState.columnSettings.column?.id == column.id && - $tableState.columnSettings.isOpen + style={tableState.columnSettings.column?.id == column.id && + tableState.columnSettings.isOpen ? 'background-color: #30A8FF17;' : ''} class={cn( - 'flex flex-col justify-start gap-1 h-full max-h-[99px] sm:max-h-[149px] w-full break-words [&:not(:last-child)]:border-r border-[#E4E7EC] data-dark:border-[#333]', + 'flex h-full max-h-[99px] w-full flex-col justify-start gap-1 break-words border-[#E4E7EC] data-dark:border-[#333] sm:max-h-[149px] [&:not(:last-child)]:border-r', editMode - ? 'p-0 bg-black/5 data-dark:bg-white/5' - : 'p-2 overflow-auto whitespace-pre-line', - $tableState.streamingRows[row.ID] + ? 'bg-black/5 p-0 data-dark:bg-white/5' + : 'overflow-auto whitespace-pre-line p-2', + tableState.streamingRows[row.ID] ? 'bg-[#FDEFF4]' - : 'group-hover:bg-[#ECEDEE] data-dark:group-hover:bg-white/5' + : 'group-hover:bg-[#E7EBF1] data-dark:group-hover:bg-white/5' )} > - {#if $tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} + {#if tableState.streamingRows[row.ID]?.includes(column.id) && !editMode && column.id !== 'ID' && column.id !== 'Updated at' && column.gen_config} {/if} {#if editMode} - {#if column.dtype === 'image' || column.dtype === 'audio'} + {#if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {:else} - + {/if} - {:else if column.dtype === 'image' || column.dtype === 'audio'} + {:else if column.dtype === 'image' || column.dtype === 'audio' || column.dtype === 'document'} {#if column.id === 'ID'} @@ -410,26 +441,26 @@ {/each}
    {/each} - {:else if $genTableRows.length === 0} + {:else if tableRowsState.rows.length === 0}
    Upload Document - + Select a document to start generating your table -
    @@ -443,23 +474,23 @@
    {:else if tableError?.error == 404} - {#if tableError.message?.org_id && userData?.member_of.find((org) => org.organization_id === tableError.message?.org_id)} - {@const projectOrg = userData?.member_of.find( - (org) => org.organization_id === tableError.message?.org_id - )} + {#if tableError.message?.org_id && user?.org_memberships.find((org) => org.organization_id === tableError.message?.org_id)} + {@const projectOrg = user?.organizations.find((org) => org.id === tableError.message?.org_id)} {:else} -
    -

    Table not found

    +
    +

    Table not found

    {/if} {:else if tableError?.error} -
    -

    {tableError.error} Failed to load table

    -

    {JSON.stringify(tableError.message)}

    +
    +

    {tableError.error} Failed to load table

    +

    + {tableError.message.message ?? JSON.stringify(tableError.message)} +

    {:else} -
    +
    {/if} diff --git a/services/app/src/lib/components/tables/tablesState.svelte.ts b/services/app/src/lib/components/tables/tablesState.svelte.ts new file mode 100644 index 0000000..992bb02 --- /dev/null +++ b/services/app/src/lib/components/tables/tablesState.svelte.ts @@ -0,0 +1,280 @@ +import type { GenTable, GenTableCol, GenTableRow } from '$lib/types'; +import { serializer } from '$lib/utils'; +import { getContext, setContext } from 'svelte'; +import { persisted } from 'svelte-persisted-store'; +import { writable } from 'svelte/store'; + +export const pastActionTables = writable[]>([]); +export const pastKnowledgeTables = writable[]>([]); +export const pastChatAgents = writable[]>([]); +export const chatTableMode = persisted<'chat' | 'table'>('table_mode', 'table', { + serializer, + storage: 'session' +}); + +interface ITableState { + templateCols: string; + colSizes: Record; + resizingCol: { columnID: string; diffX: number } | null; + editingCell: { rowID: string; columnID: string } | null; + selectedRows: string[]; + streamingRows: Record; + columnSettings: { + isOpen: boolean; + column: GenTableCol | null; + }; + renamingCol: string | null; + deletingCol: string | null; + setTemplateCols: (columns: GenTableCol[]) => void; + setColSize: (colID: string, value: number) => void; + setResizingCol: (value: ITableState['resizingCol']) => void; + setEditingCell: (cell: ITableState['editingCell']) => void; + toggleRowSelection: (rowID: string) => void; + selectAllRows: (tableRows: GenTableRow[]) => void; + setSelectedRows: (rows: ITableState['selectedRows']) => void; + addStreamingRows: (rows: ITableState['streamingRows']) => void; + delStreamingRows: (rowIDs: string[]) => void; + setColumnSettings: (value: ITableState['columnSettings']) => void; + setRenamingCol: (value: string | null) => void; + setDeletingCol: (value: string | null) => void; + reset: () => void; +} + +export class TableState implements ITableState { + templateCols = $state(''); + colSizes = $state>({}); + resizingCol = $state<{ columnID: string; diffX: number } | null>(null); + editingCell = $state<{ rowID: string; columnID: string } | null>(null); + selectedRows = $state([]); + streamingRows = $state>({}); + columnSettings = $state<{ + isOpen: boolean; + column: GenTableCol | null; + }>({ + isOpen: false, + column: null + }); + addingCol = $state(false); + renamingCol = $state(null); + deletingCol = $state(null); + + constructor() { + this.templateCols = ''; + this.colSizes = {}; + this.resizingCol = null; + this.editingCell = null; + this.selectedRows = []; + this.streamingRows = {}; + this.columnSettings = { + isOpen: false, + column: null + }; + this.renamingCol = null; + this.deletingCol = null; + } + + setTemplateCols(columns: GenTableCol[]) { + this.templateCols = columns + .filter((col) => col.id !== 'ID' && col.id !== 'Updated at') + .map((col) => { + const colSize = this.colSizes[col.id]; + if (colSize) return `${colSize}px`; + else return 'minmax(320px, 1fr)'; + }) + .join(' '); + } + + setColSize(colID: string, value: number) { + // const obj = structuredClone(state); + this.colSizes[colID] = value; + } + + setResizingCol(value: TableState['resizingCol']) { + this.resizingCol = value; + } + + setEditingCell(cell: TableState['editingCell']) { + this.editingCell = cell; + } + + toggleRowSelection(rowID: string) { + if (this.selectedRows.includes(rowID)) { + this.selectedRows = this.selectedRows.filter((id) => id !== rowID); + } else { + this.selectedRows = [...this.selectedRows, rowID]; + } + } + + selectAllRows(tableRows: GenTableRow[]) { + if (tableRows.every((row) => this.selectedRows.includes(row.ID))) { + this.selectedRows = this.selectedRows.filter((i) => !tableRows?.some(({ ID }) => ID === i)); + } else { + this.selectedRows = [ + ...this.selectedRows.filter((i) => !tableRows?.some(({ ID }) => ID === i)), + ...tableRows.map(({ ID }) => ID) + ]; + } + } + + setSelectedRows(rows: TableState['selectedRows']) { + this.selectedRows = rows; + } + + addStreamingRows(rows: TableState['streamingRows']) { + this.streamingRows = { ...this.streamingRows, ...rows }; + } + + delStreamingRows(rowIDs: string[]) { + this.streamingRows = Object.fromEntries( + Object.entries(this.streamingRows).filter(([rowId]) => !rowIDs.includes(rowId)) + ); + } + + setColumnSettings(value: TableState['columnSettings']) { + this.columnSettings = $state.snapshot(value); + } + + setRenamingCol(value: string | null) { + this.renamingCol = value; + } + + setDeletingCol(value: string | null) { + this.deletingCol = value; + } + + reset() { + this.templateCols = ''; + this.colSizes = {}; + this.resizingCol = null; + this.editingCell = null; + this.selectedRows = []; + this.streamingRows = {}; + this.columnSettings = { + isOpen: false, + column: null + }; + this.renamingCol = null; + this.deletingCol = null; + } +} + +const tableStateContextKey = 'tableState'; +export function setTableState() { + return setContext(tableStateContextKey, new TableState()); +} + +export function getTableState() { + return getContext(tableStateContextKey); +} + +export class TableRowsState { + rows = $state(undefined); + loading = $state(false); + + setRows(rows: GenTableRow[] | undefined) { + this.rows = rows; + this.loading = false; + } + + /** Adds a row at the beginning of the array */ + addRow(row: GenTableRow) { + this.rows = [row, ...(this.rows ?? [])]; + } + + /** Removes a row */ + deleteRow(rowID: string) { + this.rows = this.rows?.filter((row) => row.ID !== rowID); + } + + /** Updates a row */ + updateRow(rowID: string, data: GenTableRow) { + this.rows = this.rows?.map((row) => { + if (row.ID === rowID) { + return { ...row, ...data }; + } + return row; + }); + } + + /** Set cell value */ + setCell({ rowID, columnID }: { rowID: string; columnID: string }, value: any) { + this.rows = this.rows?.map((row) => { + if (row.ID === rowID) { + if (columnID === 'ID' || columnID === 'Updated at') { + return { ...row, [columnID]: value }; + } else { + return { ...row, [columnID]: { value: value } }; + } + } + return row; + }); + } + + /** Streaming prep, clears outputs */ + clearOutputs(tableData: GenTable, rowIDs: string[], columnIDs?: string[]) { + this.rows = this.rows?.map((row) => { + if (rowIDs.includes(row.ID)) { + return { + ...row, + ...Object.fromEntries( + Object.entries(row).map(([key, value]) => { + if (key === 'ID' || key === 'Updated at' || (columnIDs && !columnIDs.includes(key))) { + return [key, value as string]; + } else { + return [ + key, + { + value: tableData.cols.find((col) => col.id == key)?.gen_config + ? '' + : (value as { value: any }).value + } + ]; + } + }) + ) + }; + } + return row; + }); + } + + /** Stream to cell */ + stream(rowID: string, colID: string, value: any) { + this.rows = this.rows?.map((row) => { + if (row.ID === rowID) { + return { + ...row, + [colID]: { + value: (row[colID]?.value ?? '') + value + } + }; + } + return row; + }); + } + + /** Revert to original value */ + revert( + originalValues: { + id: string; + value: GenTableRow; + }[] + ) { + this.rows = this.rows?.map((row) => { + const originalRow = originalValues.find((o) => o.id === row.ID); + if (originalRow) { + return originalRow.value; + } + return row; + }); + } +} + +const tableRowsStateContextKey = 'tableRowsState'; +export function setTableRowsState() { + return setContext(tableRowsStateContextKey, new TableRowsState()); +} + +export function getTableRowsState() { + return getContext(tableRowsStateContextKey); +} diff --git a/services/app/src/lib/components/tables/tablesStore.ts b/services/app/src/lib/components/tables/tablesStore.ts deleted file mode 100644 index 8365f15..0000000 --- a/services/app/src/lib/components/tables/tablesStore.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { writable } from 'svelte/store'; -import { persisted } from 'svelte-persisted-store'; -import { serializer } from '$lib/utils'; -import type { GenTable, GenTableCol, GenTableRow } from '$lib/types'; - -export const tableState = createTableStore(); -export const genTableRows = createGenTableRows(); -export const pastActionTables = writable[]>([]); -export const pastKnowledgeTables = writable[]>([]); -export const pastChatAgents = writable[]>([]); -export const chatTableMode = persisted<'chat' | 'table'>('table_mode', 'table', { - serializer, - storage: 'session' -}); - -interface TableState { - templateCols: string; - colSizes: Record; - resizingCol: { columnID: string; diffX: number } | null; - editingCell: { rowID: string; columnID: string } | null; - selectedRows: string[]; - streamingRows: Record; - columnSettings: { - isOpen: boolean; - column: GenTableCol | null; - }; - renamingCol: string | null; - deletingCol: string | null; -} - -function createTableStore() { - const defaultValue = { - templateCols: '', - colSizes: {}, - resizingCol: null, - editingCell: null, - selectedRows: [], - streamingRows: {}, - columnSettings: { - isOpen: false, - column: null - }, - renamingCol: null, - deletingCol: null - } satisfies TableState; - const { subscribe, set, update } = writable(defaultValue); - - return { - subscribe, - set, - setTemplateCols: (columns: GenTableCol[]) => - update((state) => ({ - ...state, - templateCols: columns - .filter((col) => col.id !== 'ID' && col.id !== 'Updated at') - .map((col) => { - const colSize = state.colSizes[col.id]; - if (colSize) return `${colSize}px`; - else return 'minmax(320px, 1fr)'; - }) - .join(' ') - })), - setColSize: (colID: string, value: number) => - update((state) => { - const obj = structuredClone(state); - obj.colSizes[colID] = value; - return obj; - }), - setResizingCol: (value: TableState['resizingCol']) => - update((state) => ({ ...state, resizingCol: value })), - setEditingCell: (cell: TableState['editingCell']) => - update((state) => ({ ...state, editingCell: cell })), - toggleRowSelection: (rowID: string) => - update((state) => ({ - ...state, - selectedRows: state.selectedRows.includes(rowID) - ? state.selectedRows.filter((id) => id !== rowID) - : [...state.selectedRows, rowID] - })), - selectAllRows: (tableRows: GenTableRow[]) => - update((state) => ({ - ...state, - selectedRows: tableRows.every((row) => state.selectedRows.includes(row.ID)) - ? state.selectedRows.filter((i) => !tableRows?.some(({ ID }) => ID === i)) - : [ - ...state.selectedRows.filter((i) => !tableRows?.some(({ ID }) => ID === i)), - ...tableRows.map(({ ID }) => ID) - ] - })), - setSelectedRows: (rows: TableState['selectedRows']) => - update((state) => ({ ...state, selectedRows: rows })), - addStreamingRows: (rows: TableState['streamingRows']) => - update((state) => ({ ...state, streamingRows: { ...state.streamingRows, ...rows } })), - delStreamingRows: (rowIDs: string[]) => - update((state) => ({ - ...state, - streamingRows: Object.fromEntries( - Object.entries(state.streamingRows).filter(([rowId]) => !rowIDs.includes(rowId)) - ) - })), - setColumnSettings: (value: TableState['columnSettings']) => - update((state) => ({ ...state, columnSettings: value })), - setRenamingCol: (value: string | null) => update((state) => ({ ...state, renamingCol: value })), - setDeletingCol: (value: string | null) => update((state) => ({ ...state, deletingCol: value })), - reset: () => set(defaultValue) - }; -} - -function createGenTableRows() { - const { subscribe, set, update } = writable(undefined); - - return { - subscribe, - set, - /** Adds a row at the beginning of the array */ - addRow: (row: GenTableRow) => - update((rows) => { - if (rows) { - return [row, ...rows]; - } else { - return rows; - } - }), - /** Removes a row */ - deleteRow: (rowID: string) => - update((rows) => { - if (rows) { - return rows.filter((row) => row.ID !== rowID); - } else { - return rows; - } - }), - /** Updates a row */ - updateRow: (rowID: string, data: GenTableRow) => - update((rows) => - rows?.map((row) => { - if (row.ID === rowID) { - return { - ...row, - ...data - }; - } - return row; - }) - ), - /** Set cell value */ - setCell: ({ rowID, columnID }: { rowID: string; columnID: string }, value: any) => - update((rows) => - rows?.map((row) => { - if (row.ID === rowID) { - if (columnID === 'ID' || columnID === 'Updated at') { - return { - ...row, - [columnID]: value - }; - } else { - return { - ...row, - [columnID]: { - value: value - } - }; - } - } - return row; - }) - ), - /** Streaming prep, clears outputs */ - clearOutputs: (tableData: GenTable, rowIDs: string[], columnIDs?: string[]) => - update((rows) => - rows?.map((row) => { - if (rowIDs.includes(row.ID)) { - return { - ...row, - ...Object.fromEntries( - Object.entries(row).map(([key, value]) => { - if ( - key === 'ID' || - key === 'Updated at' || - (columnIDs && !columnIDs.includes(key)) - ) { - return [key, value as string]; - } else { - return [ - key, - { - value: tableData.cols.find((col) => col.id == key)?.gen_config - ? '' - : (value as { value: any }).value - } - ]; - } - }) - ) - }; - } - return row; - }) - ), - /** Stream to cell */ - stream: (rowID: string, colID: string, value: any) => - update((rows) => - rows?.map((row) => { - if (row.ID === rowID) { - return { - ...row, - [colID]: { - value: (row[colID]?.value ?? '') + value - } - }; - } - return row; - }) - ), - /** Revert to original value */ - revert: ( - originalValues: { - id: string; - value: GenTableRow; - }[] - ) => - update((rows) => - rows?.map((row) => { - const originalRow = originalValues.find((o) => o.id === row.ID); - if (originalRow) { - return originalRow.value; - } - return row; - }) - ) - }; -} diff --git a/services/app/src/lib/components/ui/alert/alert-description.svelte b/services/app/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..ef74aa4 --- /dev/null +++ b/services/app/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,16 @@ + + +
    + {@render children?.()} +
    diff --git a/services/app/src/lib/components/ui/alert/alert-title.svelte b/services/app/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..12ec9fc --- /dev/null +++ b/services/app/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,25 @@ + + +
    + {@render children?.()} +
    diff --git a/services/app/src/lib/components/ui/alert/alert.svelte b/services/app/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..8b4528f --- /dev/null +++ b/services/app/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,39 @@ + + + + + diff --git a/services/app/src/lib/components/ui/alert/index.ts b/services/app/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..97e21b4 --- /dev/null +++ b/services/app/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/services/app/src/lib/components/ui/badge/badge.svelte b/services/app/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..da26104 --- /dev/null +++ b/services/app/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/services/app/src/lib/components/ui/badge/index.ts b/services/app/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/services/app/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/services/app/src/lib/components/ui/button/button.svelte b/services/app/src/lib/components/ui/button/button.svelte old mode 100644 new mode 100755 index 270d111..4db80bd --- a/services/app/src/lib/components/ui/button/button.svelte +++ b/services/app/src/lib/components/ui/button/button.svelte @@ -1,34 +1,101 @@ + + - - {#if loading} - - {/if} - - - +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/services/app/src/lib/components/ui/button/index.ts b/services/app/src/lib/components/ui/button/index.ts old mode 100644 new mode 100755 index 48181d3..fb585d7 --- a/services/app/src/lib/components/ui/button/index.ts +++ b/services/app/src/lib/components/ui/button/index.ts @@ -1,53 +1,17 @@ -import Root from './button.svelte'; -import { tv, type VariantProps } from 'tailwind-variants'; -import type { Button as ButtonPrimitive } from 'bits-ui'; - -const buttonVariants = tv({ - base: 'inline-flex items-center justify-center text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-full disabled:pointer-events-none disabled:opacity-50', - variants: { - variant: { - default: - 'text-[#FCFCFD] bg-[#BF416E] hover:bg-[#950048] focus-visible:bg-[#950048] active:bg-[#7A003B]', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: - 'text-[#BF416E] bg-transparent hover:bg-[#BF416E]/[0.025] focus-visible:bg-[#BF416E]/[0.025] active:bg-[#BF416E]/5 border border-[#BF416E]', - 'outline-neutral': - 'text-text bg-transparent hover:bg-[#F9FAFB] data-dark:hover:bg-white/[0.1] active:bg-[#F2F4F7] data-dark:bg-[#0D0E11] data-dark:hover:bg-white/[0.1] border border-[#DDD] data-dark:border-[#42464E]', - action: 'bg-[#F2F4F7] hover:bg-[#E4E7EC] text-black rounded-md', - warning: 'bg-warning hover:bg-warning/80 text-black', - ghost: 'hover:bg-[#F2F4F7] hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline' - }, - size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10' - } - }, - defaultVariants: { - variant: 'default', - size: 'default' - } -}); - -type Variant = VariantProps['variant']; -type Size = VariantProps['size']; - -type Props = ButtonPrimitive.Props & { - variant?: Variant; - size?: Size; -}; - -type Events = ButtonPrimitive.Events; +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; export { Root, - type Props, - type Events, + type ButtonProps as Props, // Root as Button, - type Props as ButtonProps, - type Events as ButtonEvents, - buttonVariants + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, }; diff --git a/services/app/src/lib/components/ui/calendar/calendar-cell.svelte b/services/app/src/lib/components/ui/calendar/calendar-cell.svelte new file mode 100644 index 0000000..3c065a5 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-day.svelte b/services/app/src/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 0000000..d5e802a --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,30 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-grid-body.svelte b/services/app/src/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 0000000..8cd86de --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,12 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-grid-head.svelte b/services/app/src/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 0000000..333edc4 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,12 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-grid-row.svelte b/services/app/src/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 0000000..9032236 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-grid.svelte b/services/app/src/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 0000000..1d7edb5 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-head-cell.svelte b/services/app/src/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 0000000..dd5e55f --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-header.svelte b/services/app/src/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 0000000..e64feae --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-heading.svelte b/services/app/src/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 0000000..5d57a50 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,12 @@ + + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-months.svelte b/services/app/src/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 0000000..4cd0ed7 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
    diff --git a/services/app/src/lib/components/ui/calendar/calendar-next-button.svelte b/services/app/src/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 0000000..8581a43 --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,28 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/services/app/src/lib/components/ui/calendar/calendar-prev-button.svelte b/services/app/src/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 0000000..0ad629e --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,28 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/services/app/src/lib/components/ui/calendar/calendar.svelte b/services/app/src/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 0000000..e05c46e --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,61 @@ + + + + + {#snippet children({ months, weekdays })} + + + + + + + {#each months as month (month)} + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + + + {/each} + + {/each} + + + {/each} + + {/snippet} + diff --git a/services/app/src/lib/components/ui/calendar/index.ts b/services/app/src/lib/components/ui/calendar/index.ts new file mode 100644 index 0000000..ab257ab --- /dev/null +++ b/services/app/src/lib/components/ui/calendar/index.ts @@ -0,0 +1,30 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + // + Root as Calendar, +}; diff --git a/services/app/src/lib/components/ui/checkbox/checkbox.svelte b/services/app/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..ca81843 --- /dev/null +++ b/services/app/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,35 @@ + + + + {#snippet children({ checked, indeterminate })} +
    + {#if indeterminate} + + {:else} + + {/if} +
    + {/snippet} +
    diff --git a/services/app/src/lib/components/ui/checkbox/index.ts b/services/app/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/services/app/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/services/app/src/lib/components/ui/dialog/dialog-actions.svelte b/services/app/src/lib/components/ui/dialog/dialog-actions.svelte old mode 100644 new mode 100755 index 9c5afb7..625fdfb --- a/services/app/src/lib/components/ui/dialog/dialog-actions.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-actions.svelte @@ -1,8 +1,13 @@
    - + {@render children?.()}
    diff --git a/services/app/src/lib/components/ui/dialog/dialog-content.svelte b/services/app/src/lib/components/ui/dialog/dialog-content.svelte old mode 100644 new mode 100755 index a440cb2..8ad83c4 --- a/services/app/src/lib/components/ui/dialog/dialog-content.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,33 +1,41 @@ - + - + {@render children?.()} + diff --git a/services/app/src/lib/components/ui/dialog/dialog-description.svelte b/services/app/src/lib/components/ui/dialog/dialog-description.svelte old mode 100644 new mode 100755 index e1d796a..bc048e4 --- a/services/app/src/lib/components/ui/dialog/dialog-description.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-description.svelte @@ -2,15 +2,15 @@ import { Dialog as DialogPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; - type $$Props = DialogPrimitive.DescriptionProps; - - let className: $$Props["class"] = undefined; - export { className as class }; + let { + ref = $bindable(null), + class: className, + ...restProps + }: DialogPrimitive.DescriptionProps = $props(); - - + bind:ref + class={cn("text-muted-foreground text-sm", className)} + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/dialog/dialog-footer.svelte b/services/app/src/lib/components/ui/dialog/dialog-footer.svelte old mode 100644 new mode 100755 index 6f6e589..91ecaba --- a/services/app/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-footer.svelte @@ -1,16 +1,20 @@
    - + {@render children?.()}
    diff --git a/services/app/src/lib/components/ui/dialog/dialog-header.svelte b/services/app/src/lib/components/ui/dialog/dialog-header.svelte old mode 100644 new mode 100755 index 33877fa..fab7e72 --- a/services/app/src/lib/components/ui/dialog/dialog-header.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-header.svelte @@ -1,32 +1,37 @@
    - + {@render children?.()} + Close -
    +
    diff --git a/services/app/src/lib/components/ui/dialog/dialog-overlay.svelte b/services/app/src/lib/components/ui/dialog/dialog-overlay.svelte old mode 100644 new mode 100755 index ff264c0..9e40bf2 --- a/services/app/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -1,21 +1,19 @@ diff --git a/services/app/src/lib/components/ui/dialog/dialog-portal.svelte b/services/app/src/lib/components/ui/dialog/dialog-portal.svelte old mode 100644 new mode 100755 index eb5d0a5..38b451f --- a/services/app/src/lib/components/ui/dialog/dialog-portal.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-portal.svelte @@ -1,8 +1,14 @@ - - + + {@render children?.()} diff --git a/services/app/src/lib/components/ui/dialog/dialog-root.svelte b/services/app/src/lib/components/ui/dialog/dialog-root.svelte deleted file mode 100644 index 656af2b..0000000 --- a/services/app/src/lib/components/ui/dialog/dialog-root.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - { - if ( - //@ts-ignore - document.getElementById('upload-tab-global')?.contains(e.target) || - //@ts-ignore - document.querySelector('[data-sonner-toaster]')?.contains(e.target) - ) - e.preventDefault(); - }} - bind:open - {...$$restProps} -> - - diff --git a/services/app/src/lib/components/ui/dialog/dialog-title.svelte b/services/app/src/lib/components/ui/dialog/dialog-title.svelte old mode 100644 new mode 100755 index 06574f3..9cf592c --- a/services/app/src/lib/components/ui/dialog/dialog-title.svelte +++ b/services/app/src/lib/components/ui/dialog/dialog-title.svelte @@ -2,15 +2,15 @@ import { Dialog as DialogPrimitive } from "bits-ui"; import { cn } from "$lib/utils.js"; - type $$Props = DialogPrimitive.TitleProps; - - let className: $$Props["class"] = undefined; - export { className as class }; + let { + ref = $bindable(null), + class: className, + ...restProps + }: DialogPrimitive.TitleProps = $props(); - - + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/dialog/index.ts b/services/app/src/lib/components/ui/dialog/index.ts old mode 100644 new mode 100755 index e839343..fd0bb41 --- a/services/app/src/lib/components/ui/dialog/index.ts +++ b/services/app/src/lib/components/ui/dialog/index.ts @@ -1,16 +1,17 @@ -import { Dialog as DialogPrimitive } from 'bits-ui'; +import { Dialog as DialogPrimitive } from "bits-ui"; -const Trigger = DialogPrimitive.Trigger; +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Actions from "./dialog-actions.svelte"; -import Root from './dialog-root.svelte'; -import Title from './dialog-title.svelte'; -import Portal from './dialog-portal.svelte'; -import Footer from './dialog-footer.svelte'; -import Header from './dialog-header.svelte'; -import Overlay from './dialog-overlay.svelte'; -import Content from './dialog-content.svelte'; -import Description from './dialog-description.svelte'; -import Actions from './dialog-actions.svelte'; +const Root = DialogPrimitive.Root; +const Trigger = DialogPrimitive.Trigger; +const Close = DialogPrimitive.Close; +const Portal = DialogPrimitive.Portal; export { Root, @@ -22,6 +23,7 @@ export { Overlay, Content, Description, + Close, Actions, // Root as Dialog, @@ -33,5 +35,6 @@ export { Overlay as DialogOverlay, Content as DialogContent, Description as DialogDescription, + Close as DialogClose, Actions as DialogActions }; diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte old mode 100644 new mode 100755 index cbca3c5..3f1575a --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,35 +1,40 @@ - - - - - - + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte old mode 100644 new mode 100755 index 2c05927..fdbaa47 --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,27 +1,26 @@ - - - + + + diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..84d5cca --- /dev/null +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,19 @@ + + + diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte old mode 100644 new mode 100755 index 9cc73fd..70a5236 --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,32 +1,23 @@ - - + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte old mode 100644 new mode 100755 index 43f1527..9837d5a --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,19 +1,23 @@ - - - + {@render children?.()} +
    diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte old mode 100644 new mode 100755 index 1c74ae1..c04aa6a --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -3,9 +3,15 @@ type $$Props = DropdownMenuPrimitive.RadioGroupProps; - export let value: $$Props["value"] = undefined; + interface Props { + value?: $$Props["value"]; + children?: import('svelte').Snippet; + [key: string]: any + } + + let { value = $bindable(undefined), children, ...rest }: Props = $props(); - - + + {@render children?.()} diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte old mode 100644 new mode 100755 index 4e6e3be..bcb960d --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -1,35 +1,30 @@ - - - - - - + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte old mode 100644 new mode 100755 index 48d016a..32fac4b --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,14 +1,16 @@ diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte old mode 100644 new mode 100755 index 880d9b4..053e2a2 --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -1,13 +1,20 @@ - - + + {@render children?.()} diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte old mode 100644 new mode 100755 index ff20507..0bb6eea --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -1,30 +1,19 @@ - - + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte old mode 100644 new mode 100755 index 942e577..be175ad --- a/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/services/app/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -1,32 +1,28 @@ - - + {@render children?.()} + diff --git a/services/app/src/lib/components/ui/dropdown-menu/index.ts b/services/app/src/lib/components/ui/dropdown-menu/index.ts old mode 100644 new mode 100755 index c1749e9..40c4502 --- a/services/app/src/lib/components/ui/dropdown-menu/index.ts +++ b/services/app/src/lib/components/ui/dropdown-menu/index.ts @@ -1,48 +1,50 @@ import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; import Item from "./dropdown-menu-item.svelte"; import Label from "./dropdown-menu-label.svelte"; -import Content from "./dropdown-menu-content.svelte"; -import Shortcut from "./dropdown-menu-shortcut.svelte"; import RadioItem from "./dropdown-menu-radio-item.svelte"; import Separator from "./dropdown-menu-separator.svelte"; -import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; import SubContent from "./dropdown-menu-sub-content.svelte"; import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; -import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; const Sub = DropdownMenuPrimitive.Sub; const Root = DropdownMenuPrimitive.Root; const Trigger = DropdownMenuPrimitive.Trigger; const Group = DropdownMenuPrimitive.Group; +const RadioGroup = DropdownMenuPrimitive.RadioGroup; export { - Sub, - Root, - Item, - Label, - Group, - Trigger, - Content, - Shortcut, - Separator, - RadioItem, - SubContent, - SubTrigger, - RadioGroup, CheckboxItem, - // + Content, Root as DropdownMenu, - Sub as DropdownMenuSub, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + GroupHeading as DropdownMenuGroupHeading, Item as DropdownMenuItem, Label as DropdownMenuLabel, - Group as DropdownMenuGroup, - Content as DropdownMenuContent, - Trigger as DropdownMenuTrigger, - Shortcut as DropdownMenuShortcut, + RadioGroup as DropdownMenuRadioGroup, RadioItem as DropdownMenuRadioItem, Separator as DropdownMenuSeparator, - RadioGroup as DropdownMenuRadioGroup, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, SubContent as DropdownMenuSubContent, SubTrigger as DropdownMenuSubTrigger, - CheckboxItem as DropdownMenuCheckboxItem, + Trigger as DropdownMenuTrigger, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, }; diff --git a/services/app/src/lib/components/ui/input-otp/index.ts b/services/app/src/lib/components/ui/input-otp/index.ts new file mode 100644 index 0000000..e9ae273 --- /dev/null +++ b/services/app/src/lib/components/ui/input-otp/index.ts @@ -0,0 +1,15 @@ +import Root from "./input-otp.svelte"; +import Group from "./input-otp-group.svelte"; +import Slot from "./input-otp-slot.svelte"; +import Separator from "./input-otp-separator.svelte"; + +export { + Root, + Group, + Slot, + Separator, + Root as InputOTP, + Group as InputOTPGroup, + Slot as InputOTPSlot, + Separator as InputOTPSeparator, +}; diff --git a/services/app/src/lib/components/ui/input-otp/input-otp-group.svelte b/services/app/src/lib/components/ui/input-otp/input-otp-group.svelte new file mode 100644 index 0000000..7ef58a5 --- /dev/null +++ b/services/app/src/lib/components/ui/input-otp/input-otp-group.svelte @@ -0,0 +1,16 @@ + + +
    + {@render children?.()} +
    diff --git a/services/app/src/lib/components/ui/input-otp/input-otp-separator.svelte b/services/app/src/lib/components/ui/input-otp/input-otp-separator.svelte new file mode 100644 index 0000000..8e99e58 --- /dev/null +++ b/services/app/src/lib/components/ui/input-otp/input-otp-separator.svelte @@ -0,0 +1,19 @@ + + +
    + {#if children} + {@render children?.()} + {:else} + + {/if} +
    diff --git a/services/app/src/lib/components/ui/input-otp/input-otp-slot.svelte b/services/app/src/lib/components/ui/input-otp/input-otp-slot.svelte new file mode 100644 index 0000000..f5c6035 --- /dev/null +++ b/services/app/src/lib/components/ui/input-otp/input-otp-slot.svelte @@ -0,0 +1,30 @@ + + + + {cell.char} + {#if cell.hasFakeCaret} +
    + +
    + {/if} +
    diff --git a/services/app/src/lib/components/ui/input-otp/input-otp.svelte b/services/app/src/lib/components/ui/input-otp/input-otp.svelte new file mode 100644 index 0000000..8b59b3f --- /dev/null +++ b/services/app/src/lib/components/ui/input-otp/input-otp.svelte @@ -0,0 +1,22 @@ + + + diff --git a/services/app/src/lib/components/ui/input/index.ts b/services/app/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/services/app/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/services/app/src/lib/components/ui/input/input.svelte b/services/app/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..dae6b9a --- /dev/null +++ b/services/app/src/lib/components/ui/input/input.svelte @@ -0,0 +1,46 @@ + + +{#if type === 'file'} + +{:else} + +{/if} diff --git a/services/app/src/lib/components/ui/label/index.ts b/services/app/src/lib/components/ui/label/index.ts old mode 100644 new mode 100755 diff --git a/services/app/src/lib/components/ui/label/label.svelte b/services/app/src/lib/components/ui/label/label.svelte old mode 100644 new mode 100755 index 2a7d479..f251d98 --- a/services/app/src/lib/components/ui/label/label.svelte +++ b/services/app/src/lib/components/ui/label/label.svelte @@ -1,21 +1,26 @@ - + {@render children?.()} + {#if required} + * + {/if} diff --git a/services/app/src/lib/components/ui/pagination/index.ts b/services/app/src/lib/components/ui/pagination/index.ts old mode 100644 new mode 100755 diff --git a/services/app/src/lib/components/ui/pagination/pagination-content.svelte b/services/app/src/lib/components/ui/pagination/pagination-content.svelte old mode 100644 new mode 100755 index 9279558..6ba3cd3 --- a/services/app/src/lib/components/ui/pagination/pagination-content.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-content.svelte @@ -1,13 +1,16 @@ -
      - +
        + {@render children?.()}
      diff --git a/services/app/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/services/app/src/lib/components/ui/pagination/pagination-ellipsis.svelte old mode 100644 new mode 100755 index fe064c3..e2155e1 --- a/services/app/src/lib/components/ui/pagination/pagination-ellipsis.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-ellipsis.svelte @@ -1,19 +1,22 @@ diff --git a/services/app/src/lib/components/ui/pagination/pagination-item.svelte b/services/app/src/lib/components/ui/pagination/pagination-item.svelte old mode 100644 new mode 100755 index 009ad17..09c1076 --- a/services/app/src/lib/components/ui/pagination/pagination-item.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-item.svelte @@ -1,13 +1,14 @@ -
    • - +
    • + {@render children?.()}
    • diff --git a/services/app/src/lib/components/ui/pagination/pagination-link.svelte b/services/app/src/lib/components/ui/pagination/pagination-link.svelte old mode 100644 new mode 100755 index ebec229..4a41b47 --- a/services/app/src/lib/components/ui/pagination/pagination-link.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-link.svelte @@ -1,35 +1,36 @@ +{#snippet Fallback()} + {page.value} +{/snippet} + - {page.value} - + children={children || Fallback} + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/pagination/pagination-next-button.svelte b/services/app/src/lib/components/ui/pagination/pagination-next-button.svelte old mode 100644 new mode 100755 index 02a43cf..84eee90 --- a/services/app/src/lib/components/ui/pagination/pagination-next-button.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-next-button.svelte @@ -1,10 +1,31 @@ - - - +{#snippet Fallback()} + Next + +{/snippet} + + diff --git a/services/app/src/lib/components/ui/pagination/pagination-prev-button.svelte b/services/app/src/lib/components/ui/pagination/pagination-prev-button.svelte old mode 100644 new mode 100755 index 23f0c04..2b1a991 --- a/services/app/src/lib/components/ui/pagination/pagination-prev-button.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination-prev-button.svelte @@ -1,10 +1,31 @@ - - - +{#snippet Fallback()} + + Previous +{/snippet} + + diff --git a/services/app/src/lib/components/ui/pagination/pagination.svelte b/services/app/src/lib/components/ui/pagination/pagination.svelte old mode 100644 new mode 100755 index 1cbcce3..4cdc9b1 --- a/services/app/src/lib/components/ui/pagination/pagination.svelte +++ b/services/app/src/lib/components/ui/pagination/pagination.svelte @@ -3,31 +3,23 @@ import { cn } from "$lib/utils.js"; - type $$Props = PaginationPrimitive.Props; - type $$Events = PaginationPrimitive.Events; - - let className: $$Props["class"] = undefined; - export let count: $$Props["count"] = 0; - export let perPage: $$Props["perPage"] = 10; - export let page: $$Props["page"] = 1; - export let siblingCount: $$Props["siblingCount"] = 1; - export { className as class }; - - $: currentPage = page; + let { + ref = $bindable(null), + class: className, + count = 0, + perPage = 10, + page = $bindable(1), + siblingCount = 1, + ...restProps + }: PaginationPrimitive.RootProps = $props(); - - + {...restProps} +/> diff --git a/services/app/src/lib/components/ui/popover/index.ts b/services/app/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..63aecf9 --- /dev/null +++ b/services/app/src/lib/components/ui/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from "bits-ui"; +import Content from "./popover-content.svelte"; +const Root = PopoverPrimitive.Root; +const Trigger = PopoverPrimitive.Trigger; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, +}; diff --git a/services/app/src/lib/components/ui/popover/popover-content.svelte b/services/app/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..d2fbace --- /dev/null +++ b/services/app/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/services/app/src/lib/components/ui/range-calendar/index.ts b/services/app/src/lib/components/ui/range-calendar/index.ts new file mode 100644 index 0000000..d949b05 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/index.ts @@ -0,0 +1,32 @@ +import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui"; +import Root from "./range-calendar.svelte"; +import Cell from "./range-calendar-cell.svelte"; +import Day from "./range-calendar-day.svelte"; +import Grid from "./range-calendar-grid.svelte"; +import Header from "./range-calendar-header.svelte"; +import Months from "./range-calendar-months.svelte"; +import GridRow from "./range-calendar-grid-row.svelte"; +import Heading from "./range-calendar-heading.svelte"; +import HeadCell from "./range-calendar-head-cell.svelte"; +import NextButton from "./range-calendar-next-button.svelte"; +import PrevButton from "./range-calendar-prev-button.svelte"; + +const GridHead = RangeCalendarPrimitive.GridHead; +const GridBody = RangeCalendarPrimitive.GridBody; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + // + Root as RangeCalendar, +}; diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-cell.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-cell.svelte new file mode 100644 index 0000000..596bd71 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-day.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-day.svelte new file mode 100644 index 0000000..09650e5 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-day.svelte @@ -0,0 +1,34 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte new file mode 100644 index 0000000..3286b2a --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-grid.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-grid.svelte new file mode 100644 index 0000000..7379a71 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte new file mode 100644 index 0000000..3c5b869 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-head-cell.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-header.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-header.svelte new file mode 100644 index 0000000..be2bc82 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-header.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-heading.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-heading.svelte new file mode 100644 index 0000000..a39e4e2 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-months.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-months.svelte new file mode 100644 index 0000000..4cd0ed7 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-months.svelte @@ -0,0 +1,20 @@ + + +
      + {@render children?.()} +
      diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte new file mode 100644 index 0000000..c627c60 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-next-button.svelte @@ -0,0 +1,28 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte new file mode 100644 index 0000000..b457412 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar-prev-button.svelte @@ -0,0 +1,28 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/services/app/src/lib/components/ui/range-calendar/range-calendar.svelte b/services/app/src/lib/components/ui/range-calendar/range-calendar.svelte new file mode 100644 index 0000000..f255351 --- /dev/null +++ b/services/app/src/lib/components/ui/range-calendar/range-calendar.svelte @@ -0,0 +1,57 @@ + + + + {#snippet children({ months, weekdays })} + + + + + + + {#each months as month (month)} + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + + + {/each} + + {/each} + + + {/each} + + {/snippet} + diff --git a/services/app/src/lib/components/ui/select/index.ts b/services/app/src/lib/components/ui/select/index.ts old mode 100644 new mode 100755 index 327541c..f31b8ae --- a/services/app/src/lib/components/ui/select/index.ts +++ b/services/app/src/lib/components/ui/select/index.ts @@ -1,34 +1,34 @@ import { Select as SelectPrimitive } from "bits-ui"; -import Label from "./select-label.svelte"; +import GroupHeading from "./select-group-heading.svelte"; import Item from "./select-item.svelte"; import Content from "./select-content.svelte"; import Trigger from "./select-trigger.svelte"; import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; const Root = SelectPrimitive.Root; const Group = SelectPrimitive.Group; -const Input = SelectPrimitive.Input; -const Value = SelectPrimitive.Value; export { Root, Group, - Input, - Label, + GroupHeading, Item, - Value, Content, Trigger, Separator, + ScrollDownButton, + ScrollUpButton, // Root as Select, Group as SelectGroup, - Input as SelectInput, - Label as SelectLabel, + GroupHeading as SelectGroupHeading, Item as SelectItem, - Value as SelectValue, Content as SelectContent, Trigger as SelectTrigger, Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, }; diff --git a/services/app/src/lib/components/ui/select/select-content.svelte b/services/app/src/lib/components/ui/select/select-content.svelte old mode 100644 new mode 100755 index f4148df..56916ef --- a/services/app/src/lib/components/ui/select/select-content.svelte +++ b/services/app/src/lib/components/ui/select/select-content.svelte @@ -1,39 +1,39 @@ - -
      - -
      -
      + + + + + {@render children?.()} + + + + diff --git a/services/app/src/lib/components/ui/select/select-group-heading.svelte b/services/app/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..7984bef --- /dev/null +++ b/services/app/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/services/app/src/lib/components/ui/select/select-item.svelte b/services/app/src/lib/components/ui/select/select-item.svelte old mode 100644 new mode 100755 index 92c46ed..2acc257 --- a/services/app/src/lib/components/ui/select/select-item.svelte +++ b/services/app/src/lib/components/ui/select/select-item.svelte @@ -1,60 +1,37 @@ - {#if labelSelected} - {#if disabled} - - - + {#snippet children({ selected, highlighted })} + + {#if selected && !restProps.disabled} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} {:else} - - - - - + {label || value} {/if} - {/if} - - {label ? label : value} - + {/snippet} diff --git a/services/app/src/lib/components/ui/select/select-label.svelte b/services/app/src/lib/components/ui/select/select-label.svelte deleted file mode 100644 index d966450..0000000 --- a/services/app/src/lib/components/ui/select/select-label.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/services/app/src/lib/components/ui/select/select-scroll-down-button.svelte b/services/app/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..c17d5d1 --- /dev/null +++ b/services/app/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/services/app/src/lib/components/ui/select/select-scroll-up-button.svelte b/services/app/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..8ba08c0 --- /dev/null +++ b/services/app/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/services/app/src/lib/components/ui/select/select-separator.svelte b/services/app/src/lib/components/ui/select/select-separator.svelte old mode 100644 new mode 100755 index bc518e6..38a3ab0 --- a/services/app/src/lib/components/ui/select/select-separator.svelte +++ b/services/app/src/lib/components/ui/select/select-separator.svelte @@ -1,11 +1,13 @@ - + diff --git a/services/app/src/lib/components/ui/select/select-trigger.svelte b/services/app/src/lib/components/ui/select/select-trigger.svelte old mode 100644 new mode 100755 index 8714e0b..64908f1 --- a/services/app/src/lib/components/ui/select/select-trigger.svelte +++ b/services/app/src/lib/components/ui/select/select-trigger.svelte @@ -1,23 +1,27 @@ span]:line-clamp-1', + 'flex h-10 w-full min-w-full items-center justify-between gap-8 rounded-lg border-transparent bg-[#F2F4F7] px-3 py-2 pl-3 pr-2 text-sm transition-colors placeholder:text-muted-foreground hover:bg-[#e1e2e6] disabled:cursor-not-allowed disabled:opacity-100 data-[placeholder]:italic data-[placeholder]:text-muted-foreground data-dark:bg-[#42464e] [&>span]:line-clamp-1', className )} - {...$$restProps} - let:builder - on:click - on:keydown + {...restProps} > - + {@render children?.()} + {#if showArrow} + + {/if} diff --git a/services/app/src/lib/components/ui/separator/index.ts b/services/app/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/services/app/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/services/app/src/lib/components/ui/separator/separator.svelte b/services/app/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..839494d --- /dev/null +++ b/services/app/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,22 @@ + + + diff --git a/services/app/src/lib/components/ui/skeleton/index.ts b/services/app/src/lib/components/ui/skeleton/index.ts old mode 100644 new mode 100755 diff --git a/services/app/src/lib/components/ui/skeleton/skeleton.svelte b/services/app/src/lib/components/ui/skeleton/skeleton.svelte old mode 100644 new mode 100755 index 5a6f269..4089b49 --- a/services/app/src/lib/components/ui/skeleton/skeleton.svelte +++ b/services/app/src/lib/components/ui/skeleton/skeleton.svelte @@ -1,15 +1,17 @@
      diff --git a/services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte b/services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte old mode 100644 new mode 100755 index 59249ea..c51a611 --- a/services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte +++ b/services/app/src/lib/components/ui/sonner/CustomToastDesc.svelte @@ -3,28 +3,32 @@ import CheckDoneIcon from '$lib/icons/CheckDoneIcon.svelte'; import CopyIcon from '$lib/icons/CopyIcon.svelte'; - export let description: string; - export let requestID: string; + interface Props { + description: string; + requestID: string; + } - let requestIDCopied = false; - let requestIDCopiedTimeout: ReturnType; + let { description, requestID }: Props = $props(); + + let requestIDCopied = $state(false); + let requestIDCopiedTimeout: ReturnType | undefined = $state();
      -

      {description}

      +

      {description}

      {#if requestID}
      {requestID} - {#if !$page.data.hideBreadcrumbs} + {#if !page.data.hideBreadcrumbs} {/if} + + {#if !page.data.hideUserDetailsBtn} + + {/if}
      - + {@render children?.()}
      - + {#if page.data.rightDock} + + {/if}
    @@ -174,7 +214,7 @@ {#if $showLoadingOverlay}
    diff --git a/services/app/src/routes/(main)/BreadcrumbsBar.svelte b/services/app/src/routes/(main)/BreadcrumbsBar.svelte old mode 100644 new mode 100755 index 6bc9d4c..2a11e97 --- a/services/app/src/routes/(main)/BreadcrumbsBar.svelte +++ b/services/app/src/routes/(main)/BreadcrumbsBar.svelte @@ -1,182 +1,279 @@
    - {#if PUBLIC_IS_LOCAL === 'false'} + {#if page.url.pathname.startsWith('/system')} +
    + + System +
    + {:else if page.url.pathname.startsWith('/chat')} +
    + + JamAI Chat +
    + {:else} - - + + {#snippet child({ props })} + + {/snippet} - {#each $page.data.userData?.member_of ?? [] as org} - { - if (org?.organization_id !== $activeOrganization?.organization_id) { - $activeOrganization = org; - await tick(); - if ($page.route.id?.includes('/project/[project_id]')) { - goto('/project'); - } else { - invalidate('layout:root'); +

    Organization

    + + {#each (page.data.user as User)?.org_memberships ?? [] as orgMembership} + {@const org = (page.data.user as User)?.organizations.find( + (org) => org.id === orgMembership.organization_id + )} + {#if org} + { + if (org?.id !== $activeOrganization?.id) { + activeOrganization.setOrgCookie(org.id); + if (page.route.id?.includes('/project/[project_id]')) { + goto('/project'); + } else { + invalidate('layout:root'); + } } - } - }} - class="flex items-center gap-1 text-xs cursor-pointer {$activeOrganization?.organization_id === - org.organization_id - ? 'bg-[#F7F7F7]' - : ''} rounded-sm" - > - - {org.organization_name} + }} + class="flex cursor-pointer items-center gap-1 text-xs {$activeOrganization?.id === + org.id + ? '!bg-[#D0F7FB]' + : ''} rounded-sm" + > + + {org.name} - - + +
    + {/if} {/each}
    - - - - New Organization - + + {#snippet child({ props })} + + {@render joinOrgIcon('h-4 w-4')} + {m['breadcrumbs.org_join_btn']()} + + {/snippet} + + + {#snippet child({ props })} +
    + + {m['breadcrumbs.org_create_btn']()} + + {/snippet} - {:else} -
    - - Default Organization -
    {/if} - / - {#if $page.route.id?.startsWith('/(main)/project')} + + {#if !page.url.pathname.startsWith('/chat') && $activeOrganization?.id === PUBLIC_ADMIN_ORGANIZATION_ID} + + {/if} + + {#if page.route.id?.startsWith('/(main)/project')} - - Projects + + {m['project.heading']()} - {:else if $page.url.pathname.startsWith('/organization')} + {:else if page.url.pathname.startsWith('/organization')}
    - + Organization
    - {:else if $page.url.pathname.startsWith('/home')} + {:else if page.url.pathname.startsWith('/analytics')}
    - - Home + + Analytics
    - {:else if $page.route.id?.endsWith('/template')} -
    + {:else if page.url.pathname.startsWith('/template')} + Discover -
    - {:else if $page.route.id?.startsWith('/(main)/template/[template_id]')} + + {/if} + + {#if page.route.id?.startsWith('/(main)/(cloud)/template/[template_id]')} + / - - + + - {$page.data?.templateData?.data?.name ?? $page.params.template_id} + {page.data?.templateData?.data?.name ?? page.params.template_id} - {/if} - - {#if $page.route.id?.startsWith('/(main)/project/[project_id]')} + {:else if page.route.id?.startsWith('/(main)/project/[project_id]')} / - + {$activeProject?.name ?? - ($loadingProjectData.loading ? 'Loading...' : $page.params.project_id)} + ($loadingProjectData.loading ? 'Loading...' : page.params.project_id)} + {:else if page.route.id?.startsWith('/(main)/template/[template_id]')} + + + + + {page.data?.templateData?.data?.name ?? page.params.template_id} + {/if} - {#if $page.route.id?.endsWith('/action-table/[table_id]')} + {#if page.route.id?.endsWith('/action-table/[table_id]')} /
    - {$page.params.table_id} + {page.params.table_id}
    - {:else if $page.route.id?.endsWith('/knowledge-table/[table_id]')} + {:else if page.route.id?.endsWith('/knowledge-table/[table_id]')} /
    - {$page.params.table_id} + {page.params.table_id}
    - {:else if $page.route.id?.endsWith('/chat-table/[table_id]')} + {:else if page.route.id?.endsWith('/chat-table/[table_id]')} /
    - {$page.params.table_id} + {page.params.table_id}
    {/if}
    +{#snippet joinOrgIcon(className = '')} + + + + + + + +{/snippet} + diff --git a/services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/+page.ts b/services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/+page.ts new file mode 100644 index 0000000..a4d609b --- /dev/null +++ b/services/app/src/routes/(main)/chat/[project_id]/[conversation_id]/+page.ts @@ -0,0 +1,5 @@ +export async function load() { + return { + hideUserDetailsBtn: true + }; +} diff --git a/services/app/src/routes/(main)/chat/chat.svelte.ts b/services/app/src/routes/(main)/chat/chat.svelte.ts new file mode 100644 index 0000000..87c65f1 --- /dev/null +++ b/services/app/src/routes/(main)/chat/chat.svelte.ts @@ -0,0 +1,989 @@ +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import { env as publicEnv } from '$env/dynamic/public'; +import logger from '$lib/logger'; +import type { + ChatReferences, + ChatThreads, + Conversation, + GenTable, + GenTableStreamEvent +} from '$lib/types'; +import { waitForElement } from '$lib/utils'; +import { tick } from 'svelte'; +import { v4 as uuidv4 } from 'uuid'; + +import { CustomToastDesc, toast } from '$lib/components/ui/sonner'; +import { fileColumnFiletypes } from '$lib/constants'; +import axios from 'axios'; + +const { PUBLIC_JAMAI_URL } = publicEnv; + +export class ChatState { + // Agents + agents: Record = $state({}); + + // Conversations + fetchController: AbortController | null = null; + conversations: Conversation[] = $state([]); + loadingConvsError: { status: number; message: string } | null = $state(null); + isLoadingConvs = $state(true); + isLoadingMoreConvs = $state(false); + moreConvsFinished = false; + currentOffsetConvs = 0; + private limitConvs = 50; + searchQuery = $state(''); + isLoadingSearch = $state(false); + + // Actual chat + agent = $state< + (Omit & { agent_id: string }) | null + >(null); + conversation: Conversation | null = $state(null); + loadingConversation: any = $state(true); + messages: ChatThreads['threads'] = $state({}); + loadingMessages: any = $state(true); + chatWindow: HTMLDivElement | null = $state(null); + chatForm: HTMLFormElement | null = $state(null); + chat: HTMLTextAreaElement | null = $state(null); + chatMessage = $state(''); + editingContent: { + rowID: string; + columnID: string; + fileColumns: Record; + } | null = $state(null); + generationStatus: string[] | null = $state(null); + isLoadingMoreMessages = $state(false); + moreMessagesFinished = false; + currentOffsetMessages = 0; + private limitMessages = 10; + fileColumns = $derived( + (!page.params.conversation_id ? this.agent : this.conversation)?.cols.filter( + (col) => col.dtype === 'image' || col.dtype === 'audio' || col.dtype === 'document' + ) ?? [] + ); + uploadColumns: Record = $state({}); + loadedStreams: Record> = $state({}); + latestStreams: Record> = $state({}); + loadedReferences: Record> | null = null; + + private getConvController: AbortController | null = null; + private getMessagesController: AbortController | null = null; + + async getConversation() { + if (!page.params.project_id || !page.params.conversation_id) return; + + this.getConvController?.abort('Duplicate'); + this.getConvController = new AbortController(); + + try { + const response = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/conversations?${new URLSearchParams([ + ['conversation_id', page.params.conversation_id] + ])}`, + { + headers: { + 'x-project-id': page.params.project_id + }, + signal: this.getConvController.signal + } + ); + const responseBody = await response.json(); + + if (response.ok) { + this.conversation = responseBody; + this.loadingConversation = false; + } else { + this.loadingConversation = responseBody; + logger.error('CHAT_GET_CONV', responseBody); + toast.error('Failed to load conversation', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + } + } catch (err) { + //* don't show abort errors in browser + if (err !== 'Duplicate') { + console.error(err); + } + } + } + + async getMessages(scroll = true) { + if (!page.params.project_id || !page.params.conversation_id) return; + + this.getMessagesController?.abort('Duplicate'); + this.getMessagesController = new AbortController(); + + try { + const searchParams = new URLSearchParams([ + ['conversation_id', page.params.conversation_id], + ['offset', this.currentOffsetMessages.toString()], + ['limit', this.limitMessages.toString()], + ['order_ascending', 'false'] + // ['organization_id', $activeOrganization.id] + ]); + + const response = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/conversations/threads?${searchParams}`, + { + headers: { + 'x-project-id': page.params.project_id + }, + signal: this.getMessagesController.signal + } + ); + const responseBody = await response.json(); + + this.currentOffsetMessages += this.limitMessages; + + if (response.ok) { + this.messages = responseBody.threads; + + this.moreMessagesFinished = true; + + //! Old paginated response + /* if (responseBody.items.length) { + if (this.chatWindow && !scroll) { + this.chatWindow.scrollTop += 1; + } + this.messages = [...responseBody.items.reverse(), ...this.messages]; + } else { + this.moreMessagesFinished = true; + } */ + this.loadingMessages = false; + } else { + this.loadingMessages = responseBody; + logger.error('CHAT_GET_MESSAGES', responseBody); + toast.error('Failed to load messages', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + } + + if (scroll) { + await tick(); + if (this.messages.length) { + await waitForElement('[data-testid=chat-message]'); + } + await this.scrollChatToBottom(); + } + } catch (err) { + //* don't show abort errors in browser + if (err !== 'Duplicate') { + console.error(err); + } + } + } + + async sendMessage() { + if ( + this.generationStatus || + (!this.chatMessage.trim() && Object.values(chatState.uploadColumns).every((col) => !col.uri)) + ) + return; + + const cachedPrompt = this.chatMessage; + const cachedFiles = structuredClone($state.snapshot(this.uploadColumns)); + this.chatMessage = ''; + this.uploadColumns = {}; + + if (this.chat) this.chat.style.height = '3rem'; + + //? Get agent threads + if (!page.params.conversation_id) { + if (!this.agent) return; + const agentThreadRes = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/conversations/threads?${new URLSearchParams([ + ['conversation_id', this.agent.agent_id] + ])}`, + { + headers: { + 'x-project-id': page.params.project_id ?? page.url.searchParams.get('project_id') + } + } + ); + const agentThreadBody = await agentThreadRes.json(); + + if (agentThreadRes.ok) { + this.messages = Object.fromEntries( + Object.entries((agentThreadBody as ChatThreads).threads).map(([outCol, thread]) => [ + outCol, + { + ...thread, + thread: [ + ...thread.thread, + { + row_id: uuidv4(), + role: 'user', + content: [ + { + type: 'text' as const, + text: cachedPrompt + }, + ...Object.entries(cachedFiles).map(([uploadColumn, val]) => ({ + type: 'input_s3' as const, + uri: val.uri, + column_name: uploadColumn + })) + ], + name: null, + user_prompt: cachedPrompt, + references: null + } + ] + } + ]) + ); + } else { + logger.error('CHAT_CONV_GETAGENT', agentThreadBody); + toast.error('Failed to send message', { + id: agentThreadBody.message || JSON.stringify(agentThreadBody), + description: CustomToastDesc as any, + componentProps: { + description: agentThreadBody.message || JSON.stringify(agentThreadBody), + requestID: agentThreadBody.request_id + } + }); + return; + } + } else { + //? Add user message to the chat + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([outCol, thread]) => [ + outCol, + { + ...thread, + thread: [ + ...thread.thread, + { + row_id: uuidv4(), + role: 'user', + content: [ + { + type: 'text' as const, + text: cachedPrompt + }, + ...Object.entries(cachedFiles).map(([uploadColumn, val]) => ({ + type: 'input_s3' as const, + uri: val.uri, + column_name: uploadColumn + })) + ], + name: null, + user_prompt: cachedPrompt, + references: null + } + ] + } + ]) + ); + } + + this.generationStatus = ['new']; + this.loadedStreams = { + new: Object.fromEntries( + (page.params.conversation_id ? this.conversation! : this.agent!).cols + .map((col) => + col.gen_config?.object === 'gen_config.llm' && col.gen_config.multi_turn + ? [[col.id, []]] + : [] + ) + .flat() + ) + }; + this.latestStreams = { + new: Object.fromEntries( + (page.params.conversation_id ? this.conversation! : this.agent!).cols + .map((col) => + col.gen_config?.object === 'gen_config.llm' && col.gen_config.multi_turn + ? [[col.id, '']] + : [] + ) + .flat() + ) + }; + + //? Show user message + await tick(); + if (this.chatWindow) this.chatWindow.scrollTop = this.chatWindow.scrollHeight; + + //? Send message to the server + const apiUrl = page.params.conversation_id + ? '/api/owl/conversations/messages' + : '/api/owl/conversations'; + const response = await fetch(`${PUBLIC_JAMAI_URL}${apiUrl}`, { + method: 'POST', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + 'x-project-id': page.params.project_id ?? page.url.searchParams.get('project_id') + }, + body: JSON.stringify({ + data: { + User: cachedPrompt, + ...Object.fromEntries( + Object.entries(cachedFiles).map(([uploadColumn, val]) => [uploadColumn, val.uri]) + ) + }, + agent_id: page.params.conversation_id ? undefined : this.agent?.agent_id, + conversation_id: page.params.conversation_id || undefined + }) + }); + + if (response.status != 200) { + const responseBody = await response.json(); + logger.error(this.conversation ? 'CHAT_MESSAGE_ADD' : 'CHAT_CONV_CREATE', responseBody); + toast.error('Failed to add message', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([outCol, thread]) => [ + outCol, + { + ...thread, + thread: thread.thread.slice(0, -1) + } + ]) + ); + this.chatMessage = cachedPrompt; + this.uploadColumns = cachedFiles; + } else { + const { row_id } = await this.parseStream( + response.body!.pipeThrough(new TextDecoderStream()).getReader(), + true + ); + + this.loadedStreams = Object.fromEntries( + Object.entries(this.loadedStreams).map(([row, colStreams]) => [ + row, + Object.fromEntries( + Object.entries(colStreams).map(([col, streams]) => [ + col, + [...streams, this.latestStreams[row][col]] + ]) + ) + ]) + ); + + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([outCol, thread]) => { + const loadedStreamCol = this.loadedStreams.new[outCol]; + const colReferences = this.loadedReferences?.new?.[outCol] ?? null; + const userPrompt = thread.thread.at(-1)!; + + return [ + outCol, + { + ...thread, + thread: [ + ...thread.thread.slice(0, -1), + { + ...userPrompt, + row_id + }, + { + row_id, + role: 'assistant', + content: [ + { + type: 'text', + text: loadedStreamCol.join('') + } + ], + name: null, + user_prompt: null, + references: colReferences + } + ] + } + ]; + }) + ); + + this.getMessages(); + if (apiUrl === '/api/owl/conversations') { + chatState.refetchConversations(); + } + } + + this.generationStatus = null; + this.loadedStreams = {}; + this.latestStreams = {}; + this.loadedReferences = {}; + } + + async handleSaveFile(files: File[], editing = false) { + const formData = new FormData(); + formData.append('file', files[0]); + + const nextAvailableCol = this.fileColumns.find( + (col) => + !(editing ? this.editingContent?.fileColumns ?? {} : this.uploadColumns)[col.id]?.uri && + fileColumnFiletypes + .filter(({ type }) => col.dtype === type) + .map(({ ext }) => ext) + .includes('.' + (files[0].name.split('.').pop() ?? '').toLowerCase()) + ); + if (!nextAvailableCol) + return alert('No more files of this type can be uploaded: all columns filled.'); + + if (editing) { + if (this.editingContent) { + this.editingContent.fileColumns[nextAvailableCol.id] = { + uri: 'loading', + url: '' + }; + } + } else { + this.uploadColumns[nextAvailableCol.id] = { + uri: 'loading', + url: '' + }; + } + + try { + const uploadRes = await axios.post(`${PUBLIC_JAMAI_URL}/api/owl/files/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'x-project-id': page.url.searchParams.get('project_id') ?? page.params.project_id + } + }); + + if (uploadRes.status !== 200) { + logger.error('CHAT_FILE_UPLOAD', { + file: files[0].name, + response: uploadRes.data + }); + alert( + 'Failed to upload file: ' + + (uploadRes.data.message || JSON.stringify(uploadRes.data)) + + `\nRequest ID: ${uploadRes.data.request_id}` + ); + return; + } else { + const urlResponse = await fetch(`/api/owl/files/url/thumb`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-project-id': page.params.project_id + }, + body: JSON.stringify({ + uris: [uploadRes.data.uri] + }) + }); + const urlBody = await urlResponse.json(); + + if (urlResponse.ok) { + if (editing) { + if (this.editingContent) { + this.editingContent.fileColumns[nextAvailableCol.id] = { + uri: uploadRes.data.uri, + url: urlBody.urls[0] + }; + } + } else { + this.uploadColumns[nextAvailableCol.id] = { + uri: uploadRes.data.uri, + url: urlBody.urls[0] + }; + } + } else { + if (editing) { + if (this.editingContent) { + this.editingContent.fileColumns[nextAvailableCol.id] = { + uri: uploadRes.data.uri, + url: '' + }; + } + } else { + this.uploadColumns[nextAvailableCol.id] = { uri: uploadRes.data.uri, url: '' }; + } + toast.error('Failed to retrieve thumbnail', { + id: urlBody.message || JSON.stringify(urlBody), + description: CustomToastDesc as any, + componentProps: { + description: urlBody.message || JSON.stringify(urlBody), + requestID: urlBody.request_id + } + }); + } + } + } catch (err) { + if (!(err instanceof axios.CanceledError && err.code == 'ERR_CANCELED')) { + //@ts-expect-error AxiosError + logger.error('CHAT_FILE_UPLOAD', err?.response?.data); + alert( + 'Failed to upload file: ' + + //@ts-expect-error AxiosError + (err?.response?.data.message || JSON.stringify(err?.response?.data)) + + //@ts-expect-error AxiosError + `\nRequest ID: ${err?.response?.data?.request_id}` + ); + } + } + } + + async regenMessage(rowID: string) { + if (this.generationStatus) return; + + const cachedMessages = $state.snapshot(this.messages); + + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([outCol, thread]) => { + return [ + outCol, + { + ...thread, + thread: thread.thread.map((v) => + v.row_id === rowID && v.role === 'assistant' + ? { ...v, row_id: rowID, content: '', references: null } + : v + ) + } + ]; + }) + ); + + const longestThreadCol = Object.keys(chatState.messages).reduce( + (a, b) => + Array.isArray(chatState.messages[b].thread) && + (!a || chatState.messages[b].thread.length > chatState.messages[a].thread.length) + ? b + : a, + '' + ); + const rowsToRegen = this.messages[longestThreadCol].thread + .filter((m) => m.role !== 'User') + .slice(this.messages[longestThreadCol].thread.findIndex((m) => m.row_id === rowID)) + .map((m) => m.row_id); + this.loadedStreams = Object.fromEntries( + rowsToRegen.map((row) => [ + row, + Object.fromEntries( + this.conversation!.cols.map((col) => + col.gen_config?.object === 'gen_config.llm' && col.gen_config.multi_turn + ? [[col.id, []]] + : [] + ).flat() + ) + ]) + ); + this.latestStreams = Object.fromEntries( + rowsToRegen.map((row) => [ + row, + Object.fromEntries( + this.conversation!.cols.map((col) => + col.gen_config?.object === 'gen_config.llm' && col.gen_config.multi_turn + ? [[col.id, '']] + : [] + ).flat() + ) + ]) + ); + + this.generationStatus = rowsToRegen; + + //? Show user message + await tick(); + + //? Send message to the server + const response = await fetch(`${PUBLIC_JAMAI_URL}/api/owl/conversations/messages/regen`, { + method: 'POST', + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + 'x-project-id': page.params.project_id + }, + body: JSON.stringify({ + conversation_id: page.params.conversation_id, + row_id: rowID + }) + }); + + if (response.status != 200) { + const responseBody = await response.json(); + logger.error('CHAT_MESSAGE_REGEN', responseBody); + toast.error('Failed to regen message response', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + this.messages = cachedMessages; + } else { + await this.parseStream(response.body!.pipeThrough(new TextDecoderStream()).getReader()); + + this.loadedStreams = Object.fromEntries( + Object.entries(this.loadedStreams).map(([row, colStreams]) => [ + row, + Object.fromEntries( + Object.entries(colStreams).map(([col, streams]) => [ + col, + [...streams, this.latestStreams[row][col]] + ]) + ) + ]) + ); + + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([outCol, thread]) => { + return [ + outCol, + { + ...thread, + thread: [ + ...thread.thread.map((v) => + v.row_id === rowID && v.role === 'assistant' + ? { + ...v, + content: this.loadedStreams[v.row_id]?.[outCol]?.join('') ?? v.content, + references: this.loadedReferences?.[v.row_id]?.[outCol] ?? v.references + } + : v + ) + ] + } + ]; + }) + ); + + this.getMessages(); + } + + this.generationStatus = null; + this.loadedStreams = {}; + this.latestStreams = {}; + this.loadedReferences = {}; + } + + async saveEditedContent(newContent: Record) { + if (!this.editingContent || this.generationStatus) return; + + // const editingMessage = this.messages.find((m) => m.ID === this.editingContent?.rowID)!; + const response = await fetch(`${PUBLIC_JAMAI_URL}/api/owl/conversations/messages`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'x-project-id': page.params.project_id + }, + body: JSON.stringify({ + conversation_id: page.params.conversation_id, + row_id: this.editingContent.rowID, + data: newContent + }) + }); + const responseBody = await response.json(); + + if (response.ok) { + if (this.editingContent.columnID === 'User') { + this.messages = Object.fromEntries( + Object.entries(this.messages).map(([column, thread]) => [ + column, + { + ...thread, + thread: thread.thread.map((m) => + m.row_id === this.editingContent?.rowID && m.role === 'user' + ? { + ...m, + content: Object.entries(newContent).map(([col, val]) => + col === 'User' + ? { type: 'text', text: newContent.User } + : { type: 'input_s3', uri: val, column_name: col } + ), + user_prompt: newContent.User + } + : m + ) + } + ]) + ); + } else { + this.messages = { + [this.editingContent.columnID]: { + ...this.messages[this.editingContent.columnID], + thread: this.messages[this.editingContent.columnID].thread.map((v) => + v.row_id === this.editingContent?.rowID + ? { + ...v, + content: Object.entries(newContent).map(([col, val]) => + col === this.editingContent?.columnID + ? { type: 'text', text: newContent[this.editingContent.columnID] } + : { type: 'input_s3', uri: val, column_name: col } + ) + } + : v + ) + } + }; + } + // editingMessage[this.editingContent.columnID] = newContent; + this.editingContent = null; + } else { + logger.error('CHAT_MESSAGE_EDIT', responseBody); + toast.error('Failed to edit message', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + } + + this.getMessages(); + } + + resetChat() { + this.conversation = null; + this.loadingConversation = true; + this.messages = {}; + this.loadingMessages = true; + this.currentOffsetMessages = 0; + this.moreMessagesFinished = false; + this.uploadColumns = {}; + } + + private async parseStream(reader: ReadableStreamDefaultReader, newMessage = false) { + let rowID = ''; + let buffer = ''; + let renderCount = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const { value, done } = await reader.read(); + if (done) break; + + buffer += value; + const lines = buffer.split('\n'); //? Split by \n to handle collation + buffer = lines.pop() || ''; + + let parsedEvent: + | { event: 'metadata'; data: Conversation } + | { event: undefined; data: GenTableStreamEvent } + | undefined = undefined; + for (const line of lines) { + if (line === '') { + if (parsedEvent) { + if (parsedEvent.event) { + if (parsedEvent.event === 'metadata') { + if (!page.params.conversation_id) { + goto( + `/chat/${page.url.searchParams.get('project_id')}/${encodeURIComponent(parsedEvent.data.conversation_id)}` + ); + setTimeout(() => chatState.refetchConversations(), 5000); + } + + this.conversation = parsedEvent.data; + } + } else if (parsedEvent.data.object === 'gen_table.completion.chunk') { + if (parsedEvent.data.choices[0].finish_reason) { + switch (parsedEvent.data.choices[0].finish_reason) { + case 'error': { + logger.error('CHAT_MESSAGE_ADDSTREAM', parsedEvent.data); + console.error('STREAMING_ERROR', parsedEvent.data); + alert( + `Error while streaming: ${parsedEvent.data.choices[0].message.content}` + ); + break; + } + } + } else { + rowID = parsedEvent.data.row_id; + const streamDataRowID = newMessage ? 'new' : rowID; + + if (this.loadedStreams[streamDataRowID][parsedEvent.data.output_column_name]) { + if (renderCount++ >= 20) { + this.loadedStreams[streamDataRowID][parsedEvent.data.output_column_name] = [ + ...this.loadedStreams[streamDataRowID][parsedEvent.data.output_column_name], + this.latestStreams[streamDataRowID][parsedEvent.data.output_column_name] + + (parsedEvent.data.choices[0]?.message?.content ?? '') + ]; + this.latestStreams[streamDataRowID][parsedEvent.data.output_column_name] = ''; + } else { + this.latestStreams[streamDataRowID][parsedEvent.data.output_column_name] += + parsedEvent.data.choices[0]?.message?.content ?? ''; + } + } + + this.scrollChatToBottom(); + } + } else if (parsedEvent.data.object === 'gen_table.references') { + this.loadedReferences = { + ...(this.loadedReferences ?? {}), + [parsedEvent.data.row_id]: { + ...((this.loadedReferences ?? {})[parsedEvent.data.row_id] ?? {}), + [parsedEvent.data.output_column_name]: + parsedEvent.data as unknown as ChatReferences + } + }; + } else { + console.warn('Unknown event data:', parsedEvent.data); + } + } else { + console.warn('Unknown event object:', parsedEvent); + } + } else if (line.startsWith('data: ')) { + if (line.slice(6) === '[DONE]') break; + //@ts-expect-error missing type + parsedEvent = { ...(parsedEvent ?? {}), data: JSON.parse(line.slice(6)) }; + } else if (line.startsWith('event: ')) { + //@ts-expect-error missing type + parsedEvent = { ...(parsedEvent ?? {}), event: line.slice(7) }; + } + } + } catch (err) { + logger.error('CHAT_MESSAGE_ADDSTREAM', err); + console.error(err); + break; + } + } + + return { row_id: rowID }; + } + + async getConversations() { + if (!page.params.project_id && !page.url.searchParams.has('project_id')) return; + + this.fetchController?.abort('Duplicate'); + this.fetchController = new AbortController(); + + try { + // autoAnimateController?.disable(); + this.isLoadingMoreConvs = true; + + const searchParams = new URLSearchParams([ + ['offset', this.currentOffsetConvs.toString()], + ['limit', this.limitConvs.toString()], + ['order_by', 'updated_at'], + ['order_ascending', 'false'] + // ['organization_id', $activeOrganization.id] + ]); + + if (this.searchQuery.trim() !== '') { + searchParams.append('search_query', this.searchQuery.trim()); + } + + const response = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/conversations/list?${searchParams}`, + { + credentials: 'same-origin', + signal: this.fetchController.signal, + headers: { + 'x-project-id': page.params.project_id || page.url.searchParams.get('project_id')! + } + } + ); + this.currentOffsetConvs += this.limitConvs; + + if (response.status == 200) { + const moreProjects = await response.json(); + if (moreProjects.items.length) { + this.conversations = [...this.conversations, ...moreProjects.items]; + } else { + //* Finished loading oldest conversation + this.moreConvsFinished = true; + } + } else { + const responseBody = await response.json(); + console.error(responseBody); + toast.error('Failed to fetch conversations', { + id: responseBody?.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody?.message || JSON.stringify(responseBody), + requestID: responseBody?.request_id + } + }); + this.loadingConvsError = { + status: response.status, + message: responseBody + }; + } + + this.isLoadingMoreConvs = false; + } catch (err) { + //* don't show abort errors in browser + if (err !== 'Duplicate') { + console.error(err); + } + } + } + + async editConversationTitle( + newTitle: string, + conversationID: string, + projectID: string, + successCb: () => void + ) { + const response = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/conversations/title?${new URLSearchParams([ + ['conversation_id', conversationID], + ['title', newTitle ?? ''] + ])}`, + { + method: 'PATCH', + headers: { + 'x-project-id': projectID + } + } + ); + const responseBody = await response.json(); + + if (response.ok) { + successCb(); + } else { + logger.error('CHAT_TITLE_EDIT', responseBody); + toast.error('Failed to edit conversation title', { + id: responseBody.message || JSON.stringify(responseBody), + description: CustomToastDesc as any, + componentProps: { + description: responseBody.message || JSON.stringify(responseBody), + requestID: responseBody.request_id + } + }); + } + } + + async refetchConversations() { + this.fetchController?.abort('Duplicate'); + this.conversations = []; + this.currentOffsetConvs = 0; + this.moreConvsFinished = false; + await tick(); + this.getConversations(); + this.isLoadingSearch = false; + } + + async scrollChatToBottom() { + if (!browser || !this.chatWindow) return; + + if ( + this.chatWindow.scrollHeight - this.chatWindow.clientHeight - this.chatWindow.scrollTop < + 100 || + !this.generationStatus + ) { + await tick(); + await tick(); + this.chatWindow.scrollTop = this.chatWindow.scrollHeight; + } + } +} + +export const chatState = new ChatState(); diff --git a/services/app/src/routes/(main)/organization/+layout.server.ts b/services/app/src/routes/(main)/organization/+layout.server.ts new file mode 100755 index 0000000..24ca8f2 --- /dev/null +++ b/services/app/src/routes/(main)/organization/+layout.server.ts @@ -0,0 +1,141 @@ +import { env } from '$env/dynamic/private'; +import logger from '$lib/logger'; +import { getPrices } from '$lib/server/nodeCache'; +import { error } from '@sveltejs/kit'; +import Stripe from 'stripe'; + +const stripe = new Stripe(env.OWL_STRIPE_API_KEY); + +export async function load({ cookies, depends, locals, parent }) { + depends('layout:settings'); + const data = await parent(); + const { user, organizationData } = data; + + const prices = await getPrices(locals.user?.id); + + if (!data.ossMode && !prices) { + throw error(500, 'Failed to get prices'); + } + + if (data.ossMode || !env.OWL_STRIPE_API_KEY || !locals.user) { + return { + prices, + billing_info: [ + { data: null, status: 401 }, + { data: null, status: 401 } + ], + payment_methods: { data: null, status: 401 } + }; + } + + if (!user || !organizationData || !organizationData.stripe_id) { + return { + prices, + billing_info: [ + { data: null, status: 401 }, + { data: null, status: 401 } + ], + payment_methods: { data: null, status: 401 } + }; + // throw error(500, 'Failed to get organization data'); + } + + //? check if user is in organization + const activeOrganizationId = cookies.get('activeOrganizationId'); + if (!user.org_memberships.find((org) => org.organization_id === activeOrganizationId)) { + throw error(403, 'Unauthorized'); + } + + const getSubscription = async () => { + try { + const subscription = await stripe.subscriptions.list({ + customer: organizationData.stripe_id!, + expand: ['data.latest_invoice.payment_intent.latest_charge'] + }); + + if (subscription.data.length > 0) { + return { data: subscription.data[0], status: 200 }; + } else { + return { data: null, status: 404, error: 'No subscription found' }; + } + } catch (err) { + if ((err as any).type === 'StripeInvalidRequestError' && (err as any).statusCode === 404) { + return { data: null, status: 404, error: 'No subscription found' }; + } else { + logger.error('SETTINGS_SUBSCRIPTION_GET', err); + return { data: null, status: 500, error: err }; + } + } + }; + + const getInvoice: () => Promise<{ + data: Stripe.Response | null; + status: number; + error?: any; + }> = async () => { + try { + const invoice = await stripe.invoices.retrieveUpcoming({ + customer: organizationData.stripe_id! + }); + return { data: invoice, status: 200 }; + } catch (err) { + if ((err as any).type === 'StripeInvalidRequestError' && (err as any).statusCode === 404) { + return { data: null, status: 404, error: 'No invoice found' }; + } else { + logger.error('SETTINGS_INVOICE_GET', err); + return { data: null, status: 500, error: err }; + } + } + }; + + const getBillingInfo = () => { + return [getSubscription(), getInvoice()] as const; + }; + + const getPaymentMethods = async (): Promise<{ + data: Stripe.PaymentMethod[] | null; + status: number; + error?: any; + }> => { + //? Get payment methods for customer + try { + const paymentMethods = await stripe.customers.listPaymentMethods(organizationData.stripe_id!); + return { data: paymentMethods.data, status: 200 }; + } catch (err) { + logger.error('SETTINGS_PAYMENTMETHODS_GET', err); + return { data: null, status: 500, error: err }; + } + }; + + const getCustomer = async (): Promise<{ + data: Stripe.Customer | null; + status: number; + error?: any; + }> => { + try { + const customer = await stripe.customers.retrieve(organizationData.stripe_id!); + if (customer.deleted) { + return { data: null, status: 404, error: 'Customer not found' }; + } + return { data: customer, status: 200 }; + } catch (err) { + logger.error('SETTINGS_CUSTOMER_GET', err); + return { data: null, status: 500, error: err }; + } + }; + + const isAdmin = + locals.user.org_memberships.find((org) => org.organization_id === activeOrganizationId) + ?.role === 'ADMIN'; + return { + prices, + billing_info: isAdmin + ? getBillingInfo() + : [ + { data: null, status: 403 }, + { data: null, status: 403 } + ], + payment_methods: isAdmin ? getPaymentMethods() : { data: null, status: 403 }, + customer: isAdmin ? await getCustomer() : { data: null, status: 403 } + }; +} diff --git a/services/app/src/routes/(main)/organization/+layout.svelte b/services/app/src/routes/(main)/organization/+layout.svelte new file mode 100755 index 0000000..e1c3c97 --- /dev/null +++ b/services/app/src/routes/(main)/organization/+layout.svelte @@ -0,0 +1,93 @@ + + + moveHighlighter(page.url.pathname)} /> + +
    +
    +
    + +

    Organization

    +
    + +
    + {#each links.filter((l) => !l.exclude) as { title, href }, index (href)} + + {title} + + {/each} + +
    +
    +
    + + {@render children?.()} +
    diff --git a/services/app/src/routes/(main)/organization/+page.server.ts b/services/app/src/routes/(main)/organization/+page.server.ts new file mode 100755 index 0000000..b5b5d9b --- /dev/null +++ b/services/app/src/routes/(main)/organization/+page.server.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + throw redirect(302, '/organization/general'); +} diff --git a/services/app/src/routes/(main)/organization/general/+page.server.ts b/services/app/src/routes/(main)/organization/general/+page.server.ts new file mode 100755 index 0000000..dbc013d --- /dev/null +++ b/services/app/src/routes/(main)/organization/general/+page.server.ts @@ -0,0 +1,205 @@ +import { env } from '$env/dynamic/private'; +import logger, { APIError } from '$lib/logger.js'; +import type { User } from '$lib/types.js'; +import { fail } from '@sveltejs/kit'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const actions = { + update: async ({ cookies, fetch, locals, request }) => { + const data = await request.formData(); + const organization_name = data.get('organization_name'); + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (typeof organization_name !== 'string' || organization_name.trim() === '') { + return fail(400, new APIError('Invalid organization name').getSerializable()); + } + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const userApiRes = await fetch( + `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', locals.user.id]])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + const userApiBody = (await userApiRes.json()) as User; + if (userApiRes.ok) { + const targetOrg = userApiBody.org_memberships.find( + (org) => org.organization_id === activeOrganizationId + ); + if (!targetOrg || targetOrg.role !== 'ADMIN') { + return fail(403, new APIError('Forbidden').getSerializable()); + } + } else { + logger.error('ORG_UPDATE_USERGET', userApiBody); + return fail( + userApiRes.status, + new APIError('Failed to get user', userApiBody as any).getSerializable() + ); + } + + const updateOrgRes = await fetch( + `${OWL_URL}/api/v2/organizations?${new URLSearchParams([['organization_id', activeOrganizationId]])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': locals.user.id, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: organization_name + }) + } + ); + + const updateOrgBody = await updateOrgRes.json(); + if (!updateOrgRes.ok) { + logger.error('ORG_UPDATE_UPDATE', updateOrgBody); + return fail( + updateOrgRes.status, + new APIError('Failed to update organization', updateOrgBody as any).getSerializable() + ); + } else { + return updateOrgBody; + } + }, + + leave: async ({ cookies, fetch, locals }) => { + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const userApiRes = await fetch( + `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', locals.user.id]])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + const userApiBody = (await userApiRes.json()) as User; + if (userApiRes.ok) { + const targetOrg = userApiBody.org_memberships.find( + (org) => org.organization_id === activeOrganizationId + ); + if (!targetOrg) { + return fail(403, new APIError('Forbidden').getSerializable()); + } + } else { + logger.error('ORG_LEAVE_USERGET', userApiBody); + return fail( + userApiRes.status, + new APIError('Failed to get user', userApiBody as any).getSerializable() + ); + } + + const leaveOrgRes = await fetch( + `${OWL_URL}/api/v2/organizations/members?${new URLSearchParams([ + ['user_id', locals.user.id], + ['organization_id', activeOrganizationId] + ])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + + const leaveOrgBody = await leaveOrgRes.json(); + if (!leaveOrgRes.ok) { + logger.error('ORG_LEAVE_DELETE', leaveOrgBody); + return fail( + leaveOrgRes.status, + new APIError('Failed to leave organization', leaveOrgBody as any).getSerializable() + ); + } else { + return leaveOrgBody; + } + }, + + delete: async ({ cookies, fetch, locals }) => { + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const userApiRes = await fetch( + `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', locals.user.id]])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + const userApiBody = (await userApiRes.json()) as User; + if (userApiRes.ok) { + const targetOrg = userApiBody.org_memberships.find( + (org) => org.organization_id === activeOrganizationId + ); + if (!targetOrg || targetOrg.role !== 'ADMIN') { + return fail(403, new APIError('Forbidden').getSerializable()); + } + } else { + logger.error('ORG_DELETE_USERGET', userApiBody); + return fail( + userApiRes.status, + new APIError('Failed to get user', userApiBody as any).getSerializable() + ); + } + + const deleteOrgRes = await fetch( + `${OWL_URL}/api/v2/organizations?${new URLSearchParams([['organization_id', activeOrganizationId]])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + + const deleteOrgBody = await deleteOrgRes.json(); + if (!deleteOrgRes.ok) { + logger.error('ORG_DELETE_DELETE', deleteOrgBody); + return fail( + deleteOrgRes.status, + new APIError('Failed to delete organization', deleteOrgBody as any).getSerializable() + ); + } else { + return deleteOrgBody; + } + } +}; diff --git a/services/app/src/routes/(main)/organization/general/+page.svelte b/services/app/src/routes/(main)/organization/general/+page.svelte new file mode 100755 index 0000000..20cd7de --- /dev/null +++ b/services/app/src/routes/(main)/organization/general/+page.svelte @@ -0,0 +1,354 @@ + + + + General - Organization + + +
    +

    YOUR ORGANIZATION

    + +
    +
    +
    +

    Organization Name

    + {$activeOrganization?.name ?? ''} +
    + + + + +
    + +
    +

    Organization ID

    +
    + {$activeOrganization?.id ?? ''} + +
    +
    +
    + +
    +

    ORGANIZATION REMOVAL

    + +

    + Leaving this organization will remove you from it + + + , while deleting it will permanently remove all data associated with it. + {#snippet deniedMessage()} + . + {/snippet} + + +

    + +
    + + + + + +
    +
    +
    + + (editOrgName = $activeOrganization?.name ?? '')} +> + + Edit organization name + + + { + isLoadingEditOrgName = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error updating organization details', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } + + await update({ reset: false }); + isLoadingEditOrgName = false; + isEditingOrgName = false; + }; + }} + method="POST" + action="?/update" + class="w-full grow overflow-auto" + > +
    + + + +
    + + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    + + + + + + Close + + + +
    { + isLoadingLeaveOrg = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error leaving organization', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else { + return location.reload(); + } + + isLoadingLeaveOrg = false; + update({ reset: false, invalidateAll: false }); + }; + }} + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + method="POST" + action="?/leave" + class="flex flex-col items-start gap-2 p-8 pb-10" + > + +

    Are you sure?

    +

    + Do you really want to leave organization + + `{$activeOrganization?.name}` + ? +

    + + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    + + { + if (!e) { + confirmOrgName = ''; + } + }} +> + + Delete organization + + +
    { + isLoadingDeleteOrg = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error deleting organization', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else { + return location.reload(); + } + + isLoadingDeleteOrg = false; + update({ reset: false, invalidateAll: false }); + }; + }} + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + method="POST" + action="?/delete" + class="w-full grow overflow-auto" + > +
    +

    + Do you really want to delete organization + + `{$activeOrganization?.name}` + ? This process cannot be undone. +

    + +
    + + + +
    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/organization/secrets/(components)/DeleteExtKeyDialog.svelte b/services/app/src/routes/(main)/organization/secrets/(components)/DeleteExtKeyDialog.svelte new file mode 100644 index 0000000..eb29cff --- /dev/null +++ b/services/app/src/routes/(main)/organization/secrets/(components)/DeleteExtKeyDialog.svelte @@ -0,0 +1,111 @@ + + + isDeletingExtKey.open, + (v) => (isDeletingExtKey = { ...isDeletingExtKey, open: v })} +> + + + + Close + + +
    + +

    Are you sure?

    +

    + Do you really want to delete API key + + `{PROVIDERS[isDeletingExtKey.value ?? ''] || isDeletingExtKey.value}` + ? This process cannot be undone. +

    +
    + + +
    { + loadingDeleteExtKey = true; + + if (!formData.get('provider') || !organizationData) { + cancel(); + } else { + Object.keys(organizationData.external_keys).forEach((key) => { + if (key !== formData.get('provider')?.toString()) { + formData.append(key, organizationData.external_keys[key]); + } + }); + + formData.delete('provider'); + } + + return async ({ update, result }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error('Error deleting external key', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + isDeletingExtKey = { ...isDeletingExtKey, open: false }; + } + + loadingDeleteExtKey = false; + update({ reset: false }); + }; + }} + action="?/update-external-keys" + class="flex gap-2 overflow-x-auto overflow-y-hidden" + > + + + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/organization/secrets/(components)/EditExtKeyDialog.svelte b/services/app/src/routes/(main)/organization/secrets/(components)/EditExtKeyDialog.svelte new file mode 100644 index 0000000..61646cf --- /dev/null +++ b/services/app/src/routes/(main)/organization/secrets/(components)/EditExtKeyDialog.svelte @@ -0,0 +1,180 @@ + + + isEditingExtKey.open, (v) => (isEditingExtKey = { ...isEditingExtKey, open: v })} +> + + + {isEditingExtKey.value ? 'Edit' : 'Add'} API Key + + +
    { + loadingEditExtKey = true; + + if (!formData.get('provider') || !organizationData) { + cancel(); + } else { + formData.set( + formData.get('provider')?.toString()!, + formData.get('external_key')?.toString() ?? '' + ); + + Object.keys(organizationData.external_keys).forEach((key) => { + if (key !== formData.get('provider')?.toString()) { + formData.append(key, organizationData.external_keys[key]); + } + }); + + formData.delete('provider'); + formData.delete('external_key'); + } + + return async ({ update, result }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error('Error updating external keys', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + isEditingExtKey = { ...isEditingExtKey, open: false }; + } + + loadingEditExtKey = false; + update({ reset: false }); + }; + }} + action="?/update-external-keys" + class="flex grow flex-col gap-3 overflow-auto py-3" + > +
    + + {#if customProvider} +
    + + +
    + {:else} +
    + { + selectedProvider = value; + }} + > + + {PROVIDERS[selectedProvider] || 'Select a Provider'} + + + + {#each Object.entries(PROVIDERS) as [key, value]} + {value} + {/each} + + + + +
    + {/if} +
    + +
    + + +
    +
    + + +
    + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/organization/secrets/(components)/index.ts b/services/app/src/routes/(main)/organization/secrets/(components)/index.ts new file mode 100644 index 0000000..e1e10c8 --- /dev/null +++ b/services/app/src/routes/(main)/organization/secrets/(components)/index.ts @@ -0,0 +1,4 @@ +import DeleteExtKeyDialog from './DeleteExtKeyDialog.svelte'; +import EditExtKeyDialog from './EditExtKeyDialog.svelte'; + +export { DeleteExtKeyDialog, EditExtKeyDialog }; diff --git a/services/app/src/routes/(main)/organization/secrets/+page.server.ts b/services/app/src/routes/(main)/organization/secrets/+page.server.ts new file mode 100755 index 0000000..3a89945 --- /dev/null +++ b/services/app/src/routes/(main)/organization/secrets/+page.server.ts @@ -0,0 +1,55 @@ +import { env } from '$env/dynamic/private'; +import { APIError } from '$lib/logger.js'; +import { fail } from '@sveltejs/kit'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const actions = { + 'update-external-keys': async ({ cookies, fetch, locals, request }) => { + const activeOrganizationId = cookies.get('activeOrganizationId'); + + const data = await request.formData(); + const externalKeys: Record = {}; + for (const [key, value] of data.entries()) { + externalKeys[key] = (value as string).trim(); + } + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const updateExternalKeysRes = await fetch( + `${OWL_URL}/api/v2/organizations?${new URLSearchParams([['organization_id', activeOrganizationId]])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': locals.user.id, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + external_keys: externalKeys + }) + } + ); + + const updateExternalKeysBody = await updateExternalKeysRes.json(); + if (!updateExternalKeysRes.ok) { + return fail( + updateExternalKeysRes.status, + new APIError('Failed to update external keys', updateExternalKeysBody).getSerializable() + ); + } else { + return updateExternalKeysBody; + } + } +}; diff --git a/services/app/src/routes/(main)/organization/secrets/+page.svelte b/services/app/src/routes/(main)/organization/secrets/+page.svelte new file mode 100755 index 0000000..37ab060 --- /dev/null +++ b/services/app/src/routes/(main)/organization/secrets/+page.svelte @@ -0,0 +1,138 @@ + + + + Secrets - Organization + + +
    +
    +

    EXTERNAL API KEYS

    + + +
    +
    +
    +
    Provider
    +
    API Key
    +
    +
    +
    + + {#if Object.keys(organizationData?.external_keys ?? {}).length > 0} + {@const extKeys = Object.keys(organizationData?.external_keys ?? {})} +
    + {#each extKeys as provider} +
    +
    +

    + {PROVIDERS[provider] ?? provider} +

    +
    + +
    + +
    + +
    + + + +
    + +
    + {/each} +
    + {:else} +
    +
    +
    +

    No external keys have been added to this organization

    +
    +
    +
    + {/if} +
    + +
    + +
    + + {#snippet deniedMessage()} +
    +

    You need to be an Admin to manage external keys in your organization

    +
    + {/snippet} +
    +
    +
    + + + diff --git a/services/app/src/routes/(main)/organization/team/+page.server.ts b/services/app/src/routes/(main)/organization/team/+page.server.ts new file mode 100755 index 0000000..e229fc2 --- /dev/null +++ b/services/app/src/routes/(main)/organization/team/+page.server.ts @@ -0,0 +1,262 @@ +import { env } from '$env/dynamic/private'; +import { userRoles } from '$lib/constants.js'; +import logger, { APIError } from '$lib/logger.js'; +import { fail } from '@sveltejs/kit'; + +const { ORIGIN, OWL_SERVICE_KEY, OWL_URL, RESEND_API_KEY } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const actions = { + invite: async ({ cookies, fetch, locals, request }) => { + const data = await request.formData(); + const user_email = data.get('user_email'); + const user_role = data.get('user_role'); + const valid_days = data.get('valid_days'); + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (typeof user_email !== 'string' || user_email.trim() === '') { + return fail(400, new APIError('Invalid user email').getSerializable()); + } + + if ( + typeof user_role !== 'string' || + user_role.trim() === '' || + !userRoles.includes(user_role as (typeof userRoles)[number]) + ) { + return fail(400, new APIError('Invalid user role').getSerializable()); + } + + if ( + typeof valid_days !== 'string' || + valid_days.trim() === '' || + isNaN(Number(valid_days)) || + Number(valid_days) <= 0 + ) { + return fail(400, new APIError('Invalid valid days').getSerializable()); + } + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const getInviteToken = await fetch( + `${OWL_URL}/api/v2/organizations/invites?${new URLSearchParams({ + user_email: user_email.trim(), + organization_id: activeOrganizationId, + role: user_role, + valid_days + })}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + const inviteToken = await getInviteToken.json(); + + if (!getInviteToken.ok) { + if (![403].includes(getInviteToken.status)) { + logger.error('ORGTEAM_INVITE_TOKEN', inviteToken); + } + return fail( + getInviteToken.status, + new APIError('Failed to get invite token', inviteToken as any).getSerializable() + ); + } + + if (RESEND_API_KEY) { + const sendEmailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: 'JamAI Base ', + to: user_email, + subject: 'You have been invited to join an organization on JamAI Base', + html: getInviteEmailBody(locals.user.email, inviteToken.id) + }) + }); + + if (!sendEmailRes.ok) { + logger.error('ORGTEAM_INVITE_EMAIL', await sendEmailRes.json()); + return fail(sendEmailRes.status, new APIError('Failed to send email').getSerializable()); + } + } + + return RESEND_API_KEY ? { ok: true } : inviteToken; + }, + + update: async ({ cookies, fetch, locals, request }) => { + const data = await request.formData(); + const user_id = data.get('user_id'); + const user_role = data.get('user_role'); + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (typeof user_id !== 'string' || user_id.trim() === '') { + return fail(400, new APIError('Invalid user ID').getSerializable()); + } + if ( + typeof user_role !== 'string' || + user_role.trim() === '' || + !userRoles.includes(user_role as (typeof userRoles)[number]) + ) { + return fail(400, new APIError('Invalid user role').getSerializable()); + } + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const updateRoleRes = await fetch( + `${OWL_URL}/api/v2/organizations/members/role?${new URLSearchParams([ + ['user_id', user_id], + ['organization_id', activeOrganizationId], + ['role', user_role] + ])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + + const updateRoleBody = await updateRoleRes.json(); + if (!updateRoleRes.ok) { + logger.error('ORGTEAM_UPDATE_ROLE', updateRoleBody); + return fail( + updateRoleRes.status, + new APIError('Failed to update role', updateRoleBody as any).getSerializable() + ); + } else { + return updateRoleBody; + } + }, + + remove: async ({ cookies, fetch, locals, request }) => { + const data = await request.formData(); + const user_id = data.get('user_id'); + const activeOrganizationId = cookies.get('activeOrganizationId'); + + if (typeof user_id !== 'string' || user_id.trim() === '') { + return fail(400, new APIError('Invalid user ID').getSerializable()); + } + + if (!activeOrganizationId) { + return fail(400, new APIError('No active organization').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const leaveOrgRes = await fetch( + `${OWL_URL}/api/v2/organizations/members?${new URLSearchParams([ + ['user_id', user_id], + ['organization_id', activeOrganizationId] + ])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + } + ); + + const leaveOrgBody = await leaveOrgRes.json(); + if (!leaveOrgRes.ok) { + logger.error('ORGTEAM_REMOVE_REMOVE', leaveOrgBody); + return fail( + leaveOrgRes.status, + new APIError('Failed to remove user', leaveOrgBody as any).getSerializable() + ); + } else { + return leaveOrgBody; + } + } +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const getInviteEmailBody = (inviterEmail: string, inviteToken: string) => ` + + + + +
    + + + + +
    +
    +

    + JamAI Logo +

    + +

    ${inviterEmail} has invited you to join their organization on JamAI Base

    + +

    You have been invited to join an organization on JamAI Base. Click the link below to accept the invitation:

    + +

    Join JamAI Base

    + +

    This link will expire in 7 days.

    + +
    + Thanks! +
    + + JamAI Base + +

    +
    +

    + If you did not make this request, you can ignore this mail. +

    +
    +
    +
    + +`; diff --git a/services/app/src/routes/(main)/organization/team/+page.svelte b/services/app/src/routes/(main)/organization/team/+page.svelte new file mode 100755 index 0000000..fa73e6d --- /dev/null +++ b/services/app/src/routes/(main)/organization/team/+page.svelte @@ -0,0 +1,340 @@ + + + + Team - Organization + + +
    +
    +
    +

    ORGANIZATION MEMBERS

    + + + + +
    + +
    +
    +
    +
    No.
    +
    Name
    +
    Email
    +
    Role
    +
    Created at
    +
    +
    +
    + +
    + {#each organizationMembers.data ?? [] as user, index} + {@const userOrgCreatedAt = new Date(user.created_at).toLocaleString(undefined, { + day: '2-digit', + month: 'short', + year: isThisYear(new Date(user.created_at)) ? undefined : 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit' + })} +
    +
    + {index + 1} +
    +
    + + {user.user.name} + + + {user.user_id} + +
    +
    + + {user.user.email ?? ''} + +
    +
    + + {lowerCase(user.role)} + +
    +
    + + {userOrgCreatedAt} + +
    +
    + + + + + +
    + +
    +
    + {/each} +
    +
    +
    +
    + + + + !!editingUser, () => (editingUser = null)}> + + Edit user role + + +
    { + isLoadingEdit = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error updating user role', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else { + editingUser = null; + } + + isLoadingEdit = false; + update({ reset: false }); + }; + }} + method="POST" + action="?/update" + class="w-full grow overflow-auto" + > +
    + + +
    + + + + + + {#snippet children()} + + {selectedUserRole} + + {/snippet} + + + {#each userRoles as roleType} + + {roleType} + + {/each} + + +
    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    + + !!deletingUser, + (v) => { + if (!v) { + deletingUser = null; + } + }} +> + + + + Close + + +
    + +

    Are you sure?

    +

    + Do you really want to remove user + + `{deletingUser?.user_id}` + ? +

    +
    + + +
    { + isLoadingDelete = true; + + return async ({ result, update }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error('Error removing user', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + deletingUser = null; + } + + isLoadingDelete = false; + update({ reset: false }); + }; + }} + method="POST" + action="?/remove" + class="flex gap-2 overflow-x-auto overflow-y-hidden" + > + + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/organization/team/+page.ts b/services/app/src/routes/(main)/organization/team/+page.ts new file mode 100644 index 0000000..44229e0 --- /dev/null +++ b/services/app/src/routes/(main)/organization/team/+page.ts @@ -0,0 +1,34 @@ +import { env } from '$env/dynamic/public'; +import logger from '$lib/logger'; +import type { OrgMemberRead } from '$lib/types'; + +const { PUBLIC_JAMAI_URL } = env; + +export const load = async ({ fetch, parent }) => { + const data = await parent(); + + const getOrgMembers = async () => { + const activeOrganizationId = data.organizationData?.id; + if (!activeOrganizationId) { + return { error: 400, message: 'No active organization' }; + } + + const orgMembersRes = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/organizations/members/list?${new URLSearchParams([['organization_id', activeOrganizationId]])}` + ); + const orgMembersBody = await orgMembersRes.json(); + + if (!orgMembersRes.ok) { + logger.error('ORGTEAM_MEMBERS_ERROR', orgMembersBody); + return { error: orgMembersRes.status, message: orgMembersBody }; + } else { + return { + data: orgMembersBody.items as OrgMemberRead[] + }; + } + }; + return { + ...data, + organizationMembers: await getOrgMembers() + }; +}; diff --git a/services/app/src/routes/(main)/organization/team/OrgInviteDialog.svelte b/services/app/src/routes/(main)/organization/team/OrgInviteDialog.svelte new file mode 100755 index 0000000..268135a --- /dev/null +++ b/services/app/src/routes/(main)/organization/team/OrgInviteDialog.svelte @@ -0,0 +1,188 @@ + + + + + Invite user + + +
    { + isLoadingInvite = true; + + return async ({ result, update }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error('Error inviting user to organization', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + await update({ reset: false }); + email = ''; + isInvitingUser = false; + if ((result.data as any).id) { + showCodeDialog = (result.data as any).id; + } else { + toast.success('Invite email sent!', { id: 'invite-sent' }); + } + } + + isLoadingInvite = false; + }; + }} + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + method="POST" + action="?/invite" + class="flex grow flex-col gap-3 overflow-auto py-3" + > +
    + + +
    + +
    + + + + + {selectedUserRoleInvite} + + + + {#each userRoles as roleType} + + {roleType} + + {/each} + + +
    + +
    + + + + + {inviteValidity} days + + + + {#each ['1', '2', '3', '4', '5', '6', '7'] as daysValid} + + {daysValid} days + + {/each} + + +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    + + !!showCodeDialog, () => (showCodeDialog = null)}> + + Invite code + +
    +
    +

    Invitation Code:

    +
    + {showCodeDialog} + +
    +

    Share this code with the user you want to invite.

    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/organization/usage/+page.svelte b/services/app/src/routes/(main)/organization/usage/+page.svelte new file mode 100755 index 0000000..b827aee --- /dev/null +++ b/services/app/src/routes/(main)/organization/usage/+page.svelte @@ -0,0 +1,77 @@ + + + + Usage - Organization + + +
    +
    +

    QUOTAS

    + +
    + {#if organizationData} + {#each Object.keys(organizationData.quotas) as key} + {@const productQuota = + organizationData.price_plan?.products[key as keyof PriceRes['products']]} + {#if productQuota} +
    + + {productQuota.name} + + + {parseQuotas(organizationData.quotas[key].usage)} + {productQuota.unit} + + + + +
    + + Balance:
    + + {parseQuotas( + organizationData.quotas[key].quota - organizationData.quotas[key].usage + )} + {productQuota.unit} + +
    + + Quota:
    + + {organizationData.quotas[key].quota} + {productQuota.unit} + +
    +
    +
    + {/if} + {/each} + {/if} +
    +
    +
    diff --git a/services/app/src/routes/(main)/project/+layout.svelte b/services/app/src/routes/(main)/project/+layout.svelte old mode 100644 new mode 100755 index 88642b2..28383b9 --- a/services/app/src/routes/(main)/project/+layout.svelte +++ b/services/app/src/routes/(main)/project/+layout.svelte @@ -1,56 +1,61 @@ - +{@render children?.()} diff --git a/services/app/src/routes/(main)/project/+layout.ts b/services/app/src/routes/(main)/project/+layout.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/+page.svelte b/services/app/src/routes/(main)/project/+page.svelte old mode 100644 new mode 100755 index bd9ff03..eb36728 --- a/services/app/src/routes/(main)/project/+page.svelte +++ b/services/app/src/routes/(main)/project/+page.svelte @@ -1,9 +1,10 @@ - Projects + {m['project.heading']()} -
    -
    +
    +
    - -

    Projects

    -
    - -
    - { - //@ts-expect-error Generic type - debouncedSearchProjects(e.target?.value ?? ''); - }} - bind:value={searchQuery} - type="search" - placeholder="Search Project" - class="pl-8 h-9 w-[16rem] placeholder:not-italic placeholder:text-[#98A2B3] bg-[#F2F4F7] rounded-full" - > - - {#if isLoadingSearch} -
    - -
    - {:else} - - {/if} -
    -
    +

    {m['project.heading']()}

    -
    -
    +
    +
    + +
    + +
    + + Join Project +
    + + + +
    + +
    + + Browse Templates +
    -
    -
    -

    All Projects

    +
    +
    +

    {m['project.subheading']()}

    + +
    +
    + + {#snippet leading()} + {#if isLoadingSearch} +
    + +
    + {:else} + + {/if} + {/snippet} +
    +
    - + +
    {#if !loadingProjectsError}
    {#if isLoadingProjects} {#each Array(8) as _} {/each} {:else} {#each orgProjects ?? [] as project (project.id)} ($activeProject = project)} - href="/project/{project.id}" + onclick={() => ($activeProject = project)} + href="/project/{encodeURIComponent(project.id)}" title={project.id} - class="flex flex-col bg-white data-dark:bg-[#42464E] border border-[#E5E5E5] data-dark:border-[#333] rounded-lg hover:-translate-y-0.5 hover:shadow-float transition-[transform,box-shadow]" + class="flex flex-col rounded-lg border border-[#E5E5E5] bg-white transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-float data-dark:border-[#333] data-dark:bg-[#42464E]" > -
    +
    - - + + - + {project.name}
    - - + + {#snippet child({ props })} + + {/snippet} - + (isEditingProjectName = project)} + onclick={() => (isEditingProjectName = project)} class="text-[#344054] data-[highlighted]:text-[#344054]" > - - Rename project + + {m['project.settings_rename']()} - - handleExportProject(project.id)} - class="text-[#344054] data-[highlighted]:text-[#344054]" - > - - Export project - + + {#snippet children({ handleExportProject })} + handleExportProject(project.id)} + class="text-[#344054] data-[highlighted]:text-[#344054]" + > + + {m['project.settings_export']()} + + {/snippet} (isDeletingProject = project.id)} + onclick={() => (isDeletingProject = project.id)} class="text-destructive data-[highlighted]:text-destructive" > - - Delete project + + {m['project.settings_delete']()} @@ -426,16 +406,16 @@
    - Last updated - - {new Date(project.updated_at).toLocaleString(undefined, { + {m['project.updated_at']()} + + {new Date(project.updated_at).toLocaleString(getLocale(), { month: 'long', day: 'numeric', year: 'numeric' @@ -448,18 +428,18 @@ {/if} {#if isLoadingMoreProjects} -
    +
    {/if}
    {:else} -
    +
    {loadingProjectsError.status}

    {JSON.stringify(loadingProjectsError.message)}

    diff --git a/services/app/src/routes/(main)/project/ExportProjectButton.svelte b/services/app/src/routes/(main)/project/ExportProjectButton.svelte old mode 100644 new mode 100755 index ff295a6..6c2b1ac --- a/services/app/src/routes/(main)/project/ExportProjectButton.svelte +++ b/services/app/src/routes/(main)/project/ExportProjectButton.svelte @@ -1,19 +1,34 @@ - +{@render children?.({ handleExportProject })} diff --git a/services/app/src/routes/(main)/project/ProjectDialogs.svelte b/services/app/src/routes/(main)/project/ProjectDialogs.svelte old mode 100644 new mode 100755 index 939ec5d..c855d2e --- a/services/app/src/routes/(main)/project/ProjectDialogs.svelte +++ b/services/app/src/routes/(main)/project/ProjectDialogs.svelte @@ -1,92 +1,113 @@ - { - if (!e) { - isEditingProjectName = null; - } - }} -> + !!isEditingProjectName, () => (isEditingProjectName = null)}> - Edit project name + {m['project.edit.heading']()} - +
    -
    - +
    +
    - +
    - - - + + {#snippet child({ props })} + + {/snippet} +
    - + isAddingProject, + (v) => { + isAddingProject = v; + page.url.searchParams.delete('new'); + history.replaceState(history.state, '', page.url); + }} +> - New project + {m['project.create.heading']()} - +
    -
    - Project name* +
    + - +
    - +
    - - - + + {#snippet child({ props })} + + {/snippet} +
    @@ -238,10 +270,9 @@ !!isDeletingProject, (v) => (isDeletingProject = null)} onOpenChange={(e) => { if (!e) { - isDeletingProject = null; confirmProjectName = ''; } }} @@ -251,32 +282,37 @@ data-testid="delete-project-dialog" class="max-h-[90vh] w-[clamp(0px,35rem,100%)]" > - Delete project + {m['project.delete.heading']()} - +
    event.key === 'Enter' && event.preventDefault()} - on:submit={handleDeleteProject} - class="grow w-full overflow-auto" + id="deleteProjectForm" + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + onsubmit={handleDeleteProject} + class="w-full grow overflow-auto" > -
    -

    - Do you really want to delete project - - `{targetProject?.name ?? isDeletingProject}` - ? This process cannot be undone. +

    +

    + {@html m['project.delete.text_content']({ + project_name: escapeHtmlText(targetProject?.name ?? isDeletingProject ?? '') + })}

    -
    - - Enter project {targetProject?.name ? 'name' : 'ID'} to confirm +
    + + {m['project.delete.text_confirm']({ + confirm_text: targetProject?.name ? 'name' : 'ID' + })}
    @@ -284,20 +320,21 @@
    - - - + + {#snippet child({ props })} + + {/snippet} +
    diff --git a/services/app/src/routes/(main)/project/[project_id]/(components)/ActionsDropdown.svelte b/services/app/src/routes/(main)/project/[project_id]/(components)/ActionsDropdown.svelte old mode 100644 new mode 100755 index 71605be..7160c32 --- a/services/app/src/routes/(main)/project/[project_id]/(components)/ActionsDropdown.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(components)/ActionsDropdown.svelte @@ -1,14 +1,14 @@ - - + + {#snippet child({ props })} + + {/snippet} - - - Order by - Created - - -
    - { - const query = new URLSearchParams($page.url.searchParams.toString()); - query.set('asc', '1'); - goto(`?${query.toString()}`, { replaceState: true }); - }} - class="z-10 transition-colors ease-in-out rounded-full px-4 py-1 w-full text-center {$page.url.searchParams.get( - 'asc' - ) === '1' - ? 'text-[#667085]' - : 'text-[#98A2B3]'} cursor-pointer" - > - Ascending - - - { - const query = new URLSearchParams($page.url.searchParams.toString()); - query.delete('asc'); - goto(`?${query.toString()}`, { replaceState: true }); - }} - class="z-10 transition-colors ease-in-out rounded-full px-4 py-1 w-full text-center {$page.url.searchParams.get( - 'asc' - ) !== '1' - ? 'text-[#667085]' - : 'text-[#98A2B3]'} cursor-pointer" - > - Descending - -
    -
    - - - - {#if tableType !== 'chat' || !tableData?.parent_id} - - (isAddingColumn = { type: 'input', showDialog: true })}> - - - Add - input -
    - column -
    -
    - (isAddingColumn = { type: 'output', showDialog: true })}> - - - Add - output -
    - column -
    -
    -
    - - - {/if} - - - + + Import rows - - + + Export rows (.csv) - - - - Export table - + + {#snippet children({ handleExportTable })} + + + Export table + + {/snippet} @@ -317,10 +248,10 @@ (isDeletingTable = $page.params.table_id)} - class="text-[#D92D20] hover:!text-[#D92D20] data-[highlighted]:text-[#D92D20] hover:!bg-[#FEF3F2] data-[highlighted]:bg-[#FEF3F2]" + onclick={() => (isDeletingTable = page.params.table_id)} + class="text-[#D92D20] hover:!bg-[#FEF3F2] hover:!text-[#D92D20] data-[highlighted]:bg-[#FEF3F2] data-[highlighted]:text-[#D92D20]" > - + Delete table @@ -333,7 +264,7 @@ bind:isDeletingTable deletedCb={(success) => { if (success) { - goto(`/project/${$page.params.project_id}/${tableType}-table`); + goto(`/project/${page.params.project_id}/${tableType}-table`); } }} /> diff --git a/services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte b/services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte old mode 100644 new mode 100755 index 604ef67..a24d3a1 --- a/services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(components)/ExportTableButton.svelte @@ -1,32 +1,42 @@ - +{@render children?.({ handleExportTable })} diff --git a/services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte b/services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte old mode 100644 new mode 100755 index 978dc54..9bd4260 --- a/services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(components)/GenerateButton.svelte @@ -1,28 +1,40 @@ diff --git a/services/app/src/routes/(main)/project/[project_id]/(components)/index.ts b/services/app/src/routes/(main)/project/[project_id]/(components)/index.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddColumnDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddColumnDialog.svelte old mode 100644 new mode 100755 index 4042e3d..dc64f9b --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddColumnDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/AddColumnDialog.svelte @@ -1,9 +1,7 @@ { - if (!e) { - isAddingColumn = { ...isAddingColumn, showDialog: false }; - } - }} + bind:open={() => isAddingColumn.showDialog, + (v) => (isAddingColumn = { ...isAddingColumn, showDialog: v })} > New {isAddingColumn.type} column -
    - +
    +
    -
    - Data type* - - { - if (v) { - selectedDatatype = v.value; - } - }} - > - - + {/snippet} - + {#each Object.keys(genTableDTypes).filter((dtype) => (isAddingColumn.type === 'output' || !dtype.endsWith('_code')) && (isAddingColumn.type === 'input' || dtype.startsWith('str') || dtype === 'file_code')) as dType} {genTableDTypes[dType]} @@ -200,40 +194,32 @@ {#if isAddingColumn.type == 'output'} {#if !selectedDatatype.endsWith('_code')} -
    - Models +
    + { - selectedModel = model; - const modelDetails = $modelsAvailable.find((val) => val.id == model); if (modelDetails && parseInt(maxTokens) > modelDetails.context_length) { maxTokens = modelDetails.context_length.toString(); } }} - buttonText={($modelsAvailable.find((model) => model.id == selectedModel)?.name ?? - selectedModel) || - 'Select model'} - class="bg-[#F2F4F7] data-dark:bg-[#42464e] hover:bg-[#e1e2e6] border-transparent" + class="border-transparent bg-[#F2F4F7] hover:bg-[#e1e2e6] data-dark:bg-[#42464e]" />
    -
    -
    - +
    +
    + { + onchange={(e) => { const value = parseFloat(e.currentTarget.value); if (isNaN(value)) { @@ -246,22 +232,20 @@ temperature = value.toFixed(2); } }} - class="px-3 py-2 text-sm 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" + class="rounded-md border border-transparent bg-[#F2F4F7] px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" />
    -
    - +
    + { + onchange={(e) => { const value = parseInt(e.currentTarget.value); const model = $modelsAvailable.find((model) => model.id == selectedModel); @@ -275,7 +259,7 @@ maxTokens = value.toString(); } }} - class="px-3 py-2 text-sm 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" + class="rounded-md border border-transparent bg-[#F2F4F7] px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" />
    -
    - +
    + { + onchange={(e) => { const value = parseFloat(e.currentTarget.value); if (isNaN(value)) { @@ -309,22 +291,17 @@ topP = value.toFixed(3); } }} - class="px-3 py-2 text-sm 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" + class="rounded-md border border-transparent bg-[#F2F4F7] px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:border-[#d5607c] focus-visible:shadow-[0_0_0_1px_#FFD8DF] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-dark:bg-[#42464e] data-dark:focus-visible:border-[#5b7ee5]" />
    -
    - +
    +
    -
    - +
    + -
    - Columns: +
    + Columns: {#each usableColumns as column}
    -
    -
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ColumnMatchDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ColumnMatchDialog.svelte old mode 100644 new mode 100755 index 391bb2f..fe10386 --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ColumnMatchDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ColumnMatchDialog.svelte @@ -1,7 +1,6 @@ !!isMatchingImportCols, () => (isMatchingImportCols = null)} onOpenChange={(e) => { if (!e) { - isMatchingImportCols = null; match = []; } }} @@ -87,46 +91,50 @@ Drag columns to match -
    +
    - Source file + {#snippet leading()} + Source file + {/snippet} - - - + {#snippet listItem({ + item: col, + itemIndex: index, + dragStart, + dragMove, + dragOver, + dragEnd, + draggingItem: draggingColumn, + draggingItemIndex: draggingColumnIndex + })} + +
    dragStart(e, col, index, !!col.name)} - on:drag={dragMove} - on:dragover|preventDefault={(e) => dragOver(e, index)} - on:dragend={dragEnd} - on:touchstart={(e) => dragStart(e, col, index, !!col.name)} - on:touchmove={dragMove} - on:touchend={dragEnd} + onclick={(e) => e.stopPropagation()} + ondragstart={(e) => dragStart(e, col, index, !!col.name)} + ondrag={dragMove} + ondragover={(e) => { + e.preventDefault(); + dragOver(e, index); + }} + ondragend={dragEnd} + ontouchstart={(e) => dragStart(e, col, index, !!col.name)} + ontouchmove={dragMove} + ontouchend={dragEnd} draggable={!!col.name} - class="flex items-center gap-2 px-2 h-[40px] bg-white data-dark:bg-[#42464E] {col.name - ? 'border cursor-grab hover:shadow-float' + class="flex h-[40px] items-center gap-2 bg-white px-2 data-dark:bg-[#42464E] {col.name + ? 'cursor-grab border hover:shadow-float' : ''} border-[#E4E7EC] data-dark:border-[#333] {draggingColumn?.id === col.id ? 'opacity-0' : include.includes(col.id) && index + 1 <= filterTableCols.length ? draggingColumnIndex === null && col.name ? 'hover:shadow-float' : '' - : 'opacity-60'} transition-shadow rounded touch-none" + : 'opacity-60'} touch-none rounded transition-shadow" >
    -
    + {/snippet} - - {#if dragMouseCoords && draggingColumn} - + {#snippet draggedItem({ dragMouseCoords, draggingItem: draggingColumn })} + + {#if dragMouseCoords && draggingColumn}
    {draggingColumn.name}
    -
    - {/if} -
    + {/if} + + {/snippet}
      {#each match as col, index}
    - Table + Table {#each filterTableCols as col} {@const colType = !col.gen_config ? 'input' : 'output'}
    - + {colType} {col.dtype} {col.id} @@ -238,23 +243,23 @@ type="submit" loading={isLoadingImport} disabled={isLoadingImport} - class="hidden relative grow px-6 rounded-full" + class="relative hidden grow px-6" />
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteDialogs.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteDialogs.svelte old mode 100644 new mode 100755 index 45186ea..ba2072d --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteDialogs.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteDialogs.svelte @@ -1,9 +1,8 @@ - { - if (!e) { - tableState.setDeletingCol(null); - } - }} -> + !!tableState.deletingCol, () => tableState.setDeletingCol(null)}> - Close - +
    -

    Are you sure?

    -

    +

    Are you sure?

    +

    Do you really want to drop column - - `{$tableState.deletingCol}` + + `{tableState.deletingCol}` ? This process cannot be undone.

    - - - + + {#snippet child({ props })} + + {/snippet} + @@ -144,31 +146,24 @@ - { - if (!e) { - isDeletingRow = null; - } - }} -> + !!isDeletingRow, () => (isDeletingRow = null)}> - Close - +
    -

    Are you sure?

    -

    +

    Are you sure?

    +

    Do you really want to delete these row(s)? This process cannot be undone.

    @@ -189,17 +184,17 @@
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteTableDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteTableDialog.svelte old mode 100644 new mode 100755 index 1f3f6f5..2b3f8e3 --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteTableDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/DeleteTableDialog.svelte @@ -2,14 +2,13 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; import toUpper from 'lodash/toUpper'; import { goto } from '$app/navigation'; - import { page } from '$app/stores'; - import { Dialog as DialogPrimitive } from 'bits-ui'; + import { page } from '$app/state'; import logger from '$lib/logger'; import { pastActionTables, pastKnowledgeTables, pastChatAgents - } from '$lib/components/tables/tablesStore'; + } from '$lib/components/tables/tablesState.svelte'; import { toast, CustomToastDesc } from '$lib/components/ui/sonner'; import { Button } from '$lib/components/ui/button'; @@ -17,23 +16,28 @@ import DialogCloseIcon from '$lib/icons/DialogCloseIcon.svelte'; import CloseIcon from '$lib/icons/CloseIcon.svelte'; - export let tableType: 'action' | 'knowledge' | 'chat'; - export let isDeletingTable: string | null; - export let deletedCb: ((success: boolean, deletedTableID?: string) => any) | undefined = - undefined; + interface Props { + tableType: 'action' | 'knowledge' | 'chat'; + isDeletingTable: string | null; + deletedCb?: ((success: boolean, deletedTableID?: string) => any) | undefined; + } + + let { tableType, isDeletingTable = $bindable(), deletedCb = undefined }: Props = $props(); - let isLoading = false; + let isLoading = $state(false); async function handleDeleteTable() { if (isLoading || !isDeletingTable) return; isLoading = true; const response = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/${tableType}/${isDeletingTable}`, + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/${tableType}?${new URLSearchParams([ + ['table_id', isDeletingTable] + ])}`, { method: 'DELETE', headers: { - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id } } ); @@ -56,9 +60,9 @@ switch (tableType) { case 'action': { $pastActionTables = $pastActionTables.filter((t) => t.id !== isDeletingTable); - if ($page.params.table_id === isDeletingTable) { + if (page.params.table_id === isDeletingTable) { goto( - `/project/${$page.params.project_id}/action-table/${$pastActionTables[0]?.id || ''}` + `/project/${page.params.project_id}/action-table/${$pastActionTables[0]?.id || ''}` ); } break; @@ -67,9 +71,9 @@ $pastKnowledgeTables = $pastKnowledgeTables.filter( (table) => table.id !== isDeletingTable ); - if ($page.params.table_id === isDeletingTable) { + if (page.params.table_id === isDeletingTable) { goto( - `/project/${$page.params.project_id}/knowledge-table/${$pastKnowledgeTables[0]?.id || ''}` + `/project/${page.params.project_id}/knowledge-table/${$pastKnowledgeTables[0]?.id || ''}` ); } break; @@ -89,33 +93,26 @@ } - { - if (!e) { - isDeletingTable = null; - } - }} -> + !!isDeletingTable, () => (isDeletingTable = null)}> - Close - +
    -

    Are you sure?

    -

    +

    Are you sure?

    +

    Do you really want to delete table - + `{isDeletingTable}` ? This process cannot be undone.

    @@ -123,17 +120,17 @@
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte old mode 100644 new mode 100755 index 6e42763..6f2fe55 --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/ImportTableDialog.svelte @@ -2,26 +2,28 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; import toUpper from 'lodash/toUpper'; import axios, { CanceledError } from 'axios'; - import { Dialog as DialogPrimitive } from 'bits-ui'; - import { page } from '$app/stores'; import logger from '$lib/logger'; import { toast } from 'svelte-sonner'; import InputText from '$lib/components/InputText.svelte'; + import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; import DocumentFilledIcon from '$lib/icons/DocumentFilledIcon.svelte'; - import { tick } from 'svelte'; - export let isImportingTable: File | null; - export let tableType: 'action' | 'knowledge' | 'chat'; - export let refetchTables: () => Promise; + interface Props { + isImportingTable: File | null; + tableType: 'action' | 'knowledge' | 'chat'; + refetchTables: () => Promise; + } + + let { isImportingTable = $bindable(), tableType, refetchTables }: Props = $props(); - let form: HTMLFormElement; - let isLoading = false; - let uploadProgress: number | null = null; + let isLoading = $state(false); + let uploadProgress: number | null = $state(null); async function handleImportTable(e: SubmitEvent & { currentTarget: HTMLFormElement }) { + e.preventDefault(); if (!isImportingTable) return; const tableId = new FormData(e.currentTarget).get('table_id') as string; @@ -39,12 +41,11 @@ isLoading = true; try { const uploadRes = await axios.post( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/${tableType}/import`, + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/${tableType}/import`, formData, { headers: { - 'Content-Type': 'multipart/form-data', - 'x-project-id': $page.params.project_id + 'Content-Type': 'multipart/form-data' }, onUploadProgress: (progressEvent) => { if (!progressEvent.total) return; @@ -93,30 +94,27 @@ } - { - if (!e) { - isImportingTable = null; - } - }} -> - + !!isImportingTable, () => (isImportingTable = null)}> + Import table
    -
    - Table ID* +
    + {#if isImportingTable} -
    +
    -

    +

    {isImportingTable.name}

    @@ -147,17 +145,17 @@ {#if uploadProgress}
    {:else} @@ -165,31 +163,21 @@
    {/if} - - -
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/RenameTableDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/RenameTableDialog.svelte old mode 100644 new mode 100755 index 76dd002..01808b4 --- a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/RenameTableDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/RenameTableDialog.svelte @@ -2,42 +2,50 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; import toUpper from 'lodash/toUpper'; import { goto } from '$app/navigation'; - import { page } from '$app/stores'; - import { Dialog as DialogPrimitive } from 'bits-ui'; + import { page } from '$app/state'; import { pastActionTables, pastChatAgents, pastKnowledgeTables - } from '$lib/components/tables/tablesStore'; + } from '$lib/components/tables/tablesState.svelte'; import logger from '$lib/logger'; import InputText from '$lib/components/InputText.svelte'; import { toast, CustomToastDesc } from '$lib/components/ui/sonner'; + import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; - export let tableType: 'action' | 'knowledge' | 'chat'; - export let isEditingTableID: string | null; - export let editedCb: ((success: boolean, tableID?: string) => any) | undefined = undefined; + interface Props { + tableType: 'action' | 'knowledge' | 'chat'; + isEditingTableID: string | null; + editedCb?: ((success: boolean, tableID?: string) => any) | undefined; + } + + let { tableType, isEditingTableID = $bindable(), editedCb = undefined }: Props = $props(); - let form: HTMLFormElement; - let isLoadingSaveEdit = false; + let isLoadingSaveEdit = $state(false); async function handleSaveTableID( e: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement } ) { - const editedTableID = e.currentTarget.getElementsByTagName('input')[0].value.trim(); + e.preventDefault(); + if (!isEditingTableID) return; + const editedTableID = e.currentTarget.getElementsByTagName('input')[0].value.trim(); if (isEditingTableID === editedTableID) return; isLoadingSaveEdit = true; const response = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/${tableType}/rename/${isEditingTableID}/${editedTableID}`, + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/${tableType}/rename?${new URLSearchParams([ + ['table_id_src', isEditingTableID], + ['table_id_dst', editedTableID] + ])}`, { method: 'POST', headers: { - 'x-project-id': $page.params.project_id + 'x-project-id': page.params.project_id } } ); @@ -85,8 +93,8 @@ $pastActionTables = $pastActionTables; } - if ($page.params.table_id === isEditingTableID) { - goto(`/project/${$page.params.project_id}/action-table/${editedTableID}`); + if (page.params.table_id === isEditingTableID) { + goto(`/project/${page.params.project_id}/action-table/${editedTableID}`); } break; } @@ -101,8 +109,8 @@ $pastKnowledgeTables = $pastKnowledgeTables; } - if ($page.params.table_id === isEditingTableID) { - goto(`/project/${$page.params.project_id}/knowledge-table/${editedTableID}`); + if (page.params.table_id === isEditingTableID) { + goto(`/project/${page.params.project_id}/knowledge-table/${editedTableID}`); } break; } @@ -117,8 +125,8 @@ $pastChatAgents = $pastChatAgents; } - if ($page.params.table_id === isEditingTableID) { - goto(`/project/${$page.params.project_id}/chat-table/${editedTableID}`); + if (page.params.table_id === isEditingTableID) { + goto(`/project/${page.params.project_id}/chat-table/${editedTableID}`); } break; } @@ -135,45 +143,32 @@ } - { - if (!e) { - isEditingTableID = null; - } - }} -> + !!isEditingTableID, () => (isEditingTableID = null)}> Edit table ID - -
    -
    - Table ID* + + +
    + - +
    - - -
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/(dialogs)/index.ts b/services/app/src/routes/(main)/project/[project_id]/(dialogs)/index.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/[project_id]/+layout.server.ts b/services/app/src/routes/(main)/project/[project_id]/+layout.server.ts new file mode 100644 index 0000000..ed1f0ab --- /dev/null +++ b/services/app/src/routes/(main)/project/[project_id]/+layout.server.ts @@ -0,0 +1,44 @@ +import { env } from '$env/dynamic/private'; +import logger from '$lib/logger.js'; +import type { ProjectMemberRead } from '$lib/types.js'; + +const { OWL_URL, OWL_SERVICE_KEY /* RESEND_API_KEY */ } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export async function load({ cookies, locals }) { + //TODO: Paginate this + const getProjectMembers = async () => { + const activeProjectId = cookies.get('activeProjectId'); + + if (!activeProjectId) { + return { error: 400, message: 'No active project' }; + } + + const response = await fetch( + `${OWL_URL}/api/v2/projects/members/list?${new URLSearchParams([ + ['project_id', activeProjectId] + ])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user?.id ?? '' + } + } + ); + const responseBody = await response.json(); + + if (!response.ok) { + logger.error('ORGMEMBER_LIST_ERROR', responseBody, locals.user?.id); + return { error: response.status, message: responseBody }; + } + + return { data: responseBody.items as ProjectMemberRead[] }; + }; + + return { + projectMembers: getProjectMembers() + }; +} diff --git a/services/app/src/routes/(main)/project/[project_id]/+layout.svelte b/services/app/src/routes/(main)/project/[project_id]/+layout.svelte old mode 100644 new mode 100755 index 9c37acb..2709ed7 --- a/services/app/src/routes/(main)/project/[project_id]/+layout.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/+layout.svelte @@ -1,7 +1,7 @@ -
    -
    -
    - - - +
    +
    +
    + -

    +

    {$activeProject?.name ?? ($loadingProjectData.loading ? 'Loading...' - : $loadingProjectData.error ?? $page.params.project_id)} + : $loadingProjectData.error ?? page.params.project_id)}

    - - + + {#snippet child({ props })} + + {/snippet} - + (isEditingProjectName = $activeProject)} + onclick={() => (isEditingProjectName = $activeProject)} class="text-[#344054] data-[highlighted]:text-[#344054]" > - + Rename project { - navigator.clipboard.writeText($page.params.project_id ?? ''); + onclick={() => { + navigator.clipboard.writeText(page.params.project_id ?? ''); toast.success('Project ID copied to clipboard', { id: 'project-id-copied' }); }} + class="text-[#344054] data-[highlighted]:text-[#344054]" > (isDeletingProject = $page.params.project_id)} + onclick={() => (isDeletingProject = page.params.project_id)} class="text-destructive data-[highlighted]:text-destructive" > - + Delete project - - + + + {/snippet}
    - - Action Table - - - - Knowledge Table - - - - Chat Table - + {#each tabItems as { title, href, route }} + + {title} + + {/each}
    @@ -165,7 +185,7 @@ />
    - + {@render children?.()}
    {#if $activeProject} diff --git a/services/app/src/routes/(main)/project/[project_id]/+page.ts b/services/app/src/routes/(main)/project/[project_id]/+page.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/[project_id]/action-table/(dialogs)/AddTableDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/action-table/(dialogs)/AddTableDialog.svelte old mode 100644 new mode 100755 index 8567d04..19940a0 --- a/services/app/src/routes/(main)/project/[project_id]/action-table/(dialogs)/AddTableDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/action-table/(dialogs)/AddTableDialog.svelte @@ -1,19 +1,25 @@ - + New action table -
    -
    - - Table ID* - +
    +
    +
    -
    -

    Columns

    +
    +

    Table Columns

    - {#if columns.length === 0} -

    No columns added

    - {:else} +
    -
    - - - - Column ID* - - - - Data Type* - - - - Output* - - - -
    - - - + + + + + + + + +
    + + + {#snippet listItem({ + item: column, + itemIndex: index, + dragStart, + dragMove, + dragOver, + dragEnd, + draggingItem: draggingColumn + })} +
  • (columns.length === index + 1 ? null : dragOver(e, index))} + style="grid-template-columns: 30px minmax(0, 1.2fr) repeat(2, minmax(0, 1fr)) auto;" + class="grid gap-2 {draggingColumn?.drag_id == column.drag_id ? 'opacity-0' : ''}" > -
  • dragOver(e, index)} - style="grid-template-columns: 30px repeat(2, minmax(0, 1fr)) 50px 40px;" - class="grid gap-2 {draggingColumn?.drag_id == column.drag_id ? 'opacity-0' : ''}" + + +
    + { + if (columns.length === index + 1) { + if (columns[index].col_type === null) columns[index].col_type = 'Input'; + columns[index].dtype = + 'str' /* genTableColDTypes[column.col_type ?? 'Input'][0] */; + columns = [...columns, newColDefault()]; + } + }} + placeholder="New column" + class="h-[38px] border border-[#E4E7EC] {columns.length === index + 1 + ? 'bg-[#F9FAFB]' + : 'bg-white data-dark:bg-[#42464e]'}" + /> +
    + +
    + { + columns[index].col_type = v as keyof typeof genTableColTypes; + columns[index].gen_config = genTableColTypes[columns[index].col_type]; + if ( + !genTableColDTypes[column.col_type ?? 'Input'].includes( + columns[index].dtype + ) + ) { + columns[index].dtype = + 'str' /* genTableColDTypes[column.col_type ?? 'Input'][0] */; + } + + if (columns.length === index + 1) { + columns = [...columns, newColDefault()]; + } + }} > - - - -
    - -
    - -
    - { - if (v) { - columns[index].dtype = v.value; - if (columns[index].gen_config) { - columns[index].gen_config = v.value.endsWith('_code') - ? CODE_GEN_CONFIG_DEFAULT - : LLM_GEN_CONFIG_DEFAULT; - } - } - }} + - - - - - {#each Object.keys(genTableDTypes).filter((dtype) => (column.gen_config || !dtype.endsWith('_code')) && (!column.gen_config || dtype.startsWith('str') || dtype === 'file_code')) as dType} - - {genTableDTypes[dType]} - - {/each} - - -
    - -
    - { - if (e.detail.value) { - columns[index].gen_config = columns[index].dtype.endsWith('_code') - ? CODE_GEN_CONFIG_DEFAULT - : LLM_GEN_CONFIG_DEFAULT; - - if (!['str', 'image'].includes(columns[index].dtype)) { - columns[index].dtype = 'str'; - } - } else { - columns[index].gen_config = null; - - if (columns[index].dtype.endsWith('_code')) { - columns[index].dtype = 'str'; - } - } - }} - checked={!!column.gen_config} - class="h-5 w-5 [&>svg]:translate-x-[1px]" - /> -
    - -
    + +
    + { + columns[index].dtype = v; + + if (columns.length === index + 1) { + columns[index].col_type = (Object.entries(genTableColDTypes).find( + ([colType, dTypes]) => dTypes.includes(v) + )?.[0] ?? 'Input') as keyof typeof genTableColDTypes; + columns = [...columns, newColDefault()]; + } + }} > - - -
  • - - - - {#if dragMouseCoords && draggingColumn} - -
  • - - -
    - -
    - -
    - -
    - -
    - -
    + {genTableDTypes[dType]} + + {/each} + + +
  • + + +

    }mw*hAEn*f=SdkAME;7<%z))F|#wZOvjc-VmLO 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 z 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%0DKoFJTp5iu#ZEl3#nOWrXHA2#eM$Oqw0Cy;de%)I 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

    F@EgOR8gQW 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%0A|*gpCLd@_%D;K>guxiVek`=vM^i? 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

  • + {/snippet} + {#snippet draggedItem({ dragMouseCoords, draggingItem: draggingColumn })} + + {#if dragMouseCoords && draggingColumn} +
  • + + +
    + +
    + +
    -
  • -
    + + +
    + +
    + + + {/if} - - - - {/if} + + {/snippet} + + -
    +
    - +
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/action-table/+page.svelte b/services/app/src/routes/(main)/project/[project_id]/action-table/+page.svelte old mode 100644 new mode 100755 index c0b8218..0685259 --- a/services/app/src/routes/(main)/project/[project_id]/action-table/+page.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/action-table/+page.svelte @@ -3,9 +3,9 @@ import { onMount } from 'svelte'; import debounce from 'lodash/debounce'; import Trash_2 from 'lucide-svelte/icons/trash-2'; - import { page } from '$app/stores'; + import { page } from '$app/state'; import { aTableSort as sortOptions } from '$globalStore'; - import { pastActionTables } from '$lib/components/tables/tablesStore'; + import { pastActionTables } from '$lib/components/tables/tablesState.svelte'; import logger from '$lib/logger'; import AddTableDialog from './(dialogs)/AddTableDialog.svelte'; @@ -27,16 +27,17 @@ import SortAlphabetIcon from '$lib/icons/SortAlphabetIcon.svelte'; import ImportIcon from '$lib/icons/ImportIcon.svelte'; import ExportIcon from '$lib/icons/ExportIcon.svelte'; + import InputText from '$lib/components/InputText.svelte'; + import SearchIcon from '$lib/icons/SearchIcon.svelte'; - export let data; - $: ({ userData } = data); - - let windowWidth: number; + let { data } = $props(); + let { user } = $derived(data); let fetchController: AbortController | null = null; - let loadingATablesError: { status: number; message: string; org_id: string } | null = null; - let isLoadingATables = true; - let isLoadingMoreATables = false; + let loadingATablesError: { status: number; message: string; org_id: string } | null = + $state(null); + let isLoadingATables = $state(true); + let isLoadingMoreATables = $state(false); let moreATablesFinished = false; //FIXME: Bandaid fix for infinite loop caused by loading circle let currentOffset = 0; const limit = 50; @@ -45,14 +46,14 @@ { id: 'updated_at', title: 'Date modified', Icon: SortByIcon } ]; - let searchQuery = ''; + let searchQuery = $state(''); let searchController: AbortController | null = null; - let isLoadingSearch = false; + let isLoadingSearch = $state(false); - let isAddingTable = false; - let isEditingTableID: string | null = null; - let isDeletingTable: string | null = null; - let isImportingTable: File | null = null; + let isAddingTable = $state(false); + let isEditingTableID: string | null = $state(null); + let isDeletingTable: string | null = $state(null); + let isImportingTable: File | null = $state(null); onMount(() => { getActionTables(); @@ -75,7 +76,7 @@ offset: currentOffset.toString(), limit: limit.toString(), order_by: $sortOptions.orderBy, - order_descending: $sortOptions.order === 'asc' ? 'false' : 'true', + order_ascending: $sortOptions.order === 'asc' ? 'true' : 'false', search_query: searchQuery.trim() } as Record; @@ -84,13 +85,10 @@ } const response = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/action?` + new URLSearchParams(searchParams), + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/action/list?` + new URLSearchParams(searchParams), { credentials: 'same-origin', - signal: fetchController.signal, - headers: { - 'x-project-id': $page.params.project_id - } + signal: fetchController.signal } ); currentOffset += limit; @@ -157,17 +155,14 @@ try { const response = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/action?${new URLSearchParams({ + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/action/list?${new URLSearchParams({ limit: limit.toString(), order_by: $sortOptions.orderBy, - order_descending: $sortOptions.order === 'asc' ? 'false' : 'true', + order_ascending: $sortOptions.order === 'asc' ? 'true' : 'false', search_query: q })}`, { - signal: searchController.signal, - headers: { - 'x-project-id': $page.params.project_id - } + signal: searchController.signal } ); currentOffset = limit; @@ -241,129 +236,127 @@ Action Table - - {#if !loadingATablesError} -
    +
    -
    - - - -
    - - + + +
    + + + +
    {#if isLoadingATables} {#each Array(12) as _} {/each} {:else} {#each $pastActionTables as actionTable (actionTable.id)} -
    +
    - - + + {actionTable.id}
    - - + + {#snippet child({ props })} + + {/snippet} - + (isEditingTableID = actionTable.id)} + onclick={() => (isEditingTableID = actionTable.id)} class="text-[#344054] data-[highlighted]:text-[#344054]" > - + Rename table - - - - Export table - + + {#snippet children({ handleExportTable })} + + + Export table + + {/snippet} (isDeletingTable = actionTable.id)} + onclick={() => (isDeletingTable = actionTable.id)} class="text-destructive data-[highlighted]:text-destructive" > - + Delete table @@ -378,7 +371,7 @@ day: 'numeric', year: 'numeric' })} - class="font-medium text-xs text-[#98A2B3] data-dark:text-[#C9C9C9] line-clamp-1" + class="line-clamp-1 text-xs font-medium text-[#98A2B3] data-dark:text-[#C9C9C9]" > Last updated @@ -394,25 +387,23 @@ {/each} {#if isLoadingMoreATables} -
    +
    {/if} {/if}
    -{:else if loadingATablesError.status === 404 && loadingATablesError.org_id && userData?.member_of.find((org) => org.organization_id === loadingATablesError?.org_id)} - {@const projectOrg = userData?.member_of.find( - (org) => org.organization_id === loadingATablesError?.org_id - )} +{:else if loadingATablesError.status === 404 && loadingATablesError.org_id && user?.org_memberships.find((org) => org.organization_id === loadingATablesError?.org_id)} + {@const projectOrg = user?.organizations.find((org) => org.id === loadingATablesError?.org_id)} {:else} -
    +
    {loadingATablesError.status}

    {loadingATablesError.message}

    diff --git a/services/app/src/routes/(main)/project/[project_id]/action-table/+page.ts b/services/app/src/routes/(main)/project/[project_id]/action-table/+page.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page.ts b/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page.ts old mode 100644 new mode 100755 index 6120f4c..d6ab9e2 --- a/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page.ts +++ b/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page.ts @@ -1,13 +1,14 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; -import { error } from '@sveltejs/kit'; -import logger from '$lib/logger.js'; import { actionRowsPerPage } from '$lib/constants.js'; +import logger from '$lib/logger.js'; import type { GenTable, GenTableRow } from '$lib/types.js'; +import { error } from '@sveltejs/kit'; export const load = async ({ depends, fetch, params, parent, url }) => { depends('action-table:slug'); await parent(); const page = parseInt(url.searchParams.get('page') ?? '1'); + const orderBy = url.searchParams.get('sort_by'); const orderAsc = parseInt(url.searchParams.get('asc') ?? '0'); if (!params.table_id) { @@ -16,11 +17,7 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const getTable = async () => { const tableDataRes = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/action/${params.table_id}?` + - new URLSearchParams({ - offset: '0', - limit: '1' - }), + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/action?${new URLSearchParams([['table_id', params.table_id]])}`, { headers: { 'x-project-id': params.project_id @@ -30,7 +27,7 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const tableDataBody = await tableDataRes.json(); if (!tableDataRes.ok) { - if (tableDataRes.status !== 404 && tableDataRes.status !== 422) { + if (![403, 404, 422].includes(tableDataRes.status)) { logger.error('ACTIONTBL_TBL_GET', tableDataBody); } return { error: tableDataRes.status, message: tableDataBody }; @@ -42,13 +39,22 @@ export const load = async ({ depends, fetch, params, parent, url }) => { }; const getRows = async () => { + const q = url.searchParams.get('q'); + + const searchParams = new URLSearchParams([ + ['table_id', params.table_id], + ['offset', ((page - 1) * actionRowsPerPage).toString()], + ['limit', actionRowsPerPage.toString()], + ['order_by', orderBy ?? 'ID'], + ['order_ascending', orderAsc === 1 ? 'true' : 'false'] + ]); + + if (q) { + searchParams.set('search_query', q); + } + const tableRowsRes = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/action/${params.table_id}/rows?` + - new URLSearchParams({ - offset: ((page - 1) * actionRowsPerPage).toString(), - limit: actionRowsPerPage.toString(), - order_descending: orderAsc === 1 ? 'false' : 'true' - }), + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/action/rows/list?${searchParams}`, { headers: { 'x-project-id': params.project_id @@ -58,7 +64,7 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const tableRowsBody = await tableRowsRes.json(); if (!tableRowsRes.ok) { - if (tableRowsRes.status !== 404 && tableRowsRes.status !== 422) { + if (![403, 404, 422].includes(tableRowsRes.status)) { logger.error('ACTIONTBL_TBL_GETROWS', tableRowsBody); } return { error: tableRowsRes.status, message: tableRowsBody }; diff --git a/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte b/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte old mode 100644 new mode 100755 index 6fa7ae4..0b961d7 --- a/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/action-table/[table_id]/+page@project.svelte @@ -1,17 +1,18 @@ - {$page.params.table_id} - Action Table + {page.params.table_id} - Action Table
    - - - - + + + - {#await table} - {$page.params.table_id} + {#await data.table} + {page.params.table_id} {:then { data }} {data ? data.id : tableError?.error === 404 ? 'Not found' : 'Failed to load'} {/await}
    -
    - {#if tableLoaded || (tableData && $genTableRows)} - - +
    + {#if tableLoaded || (tableData && tableRowsState.rows)}
    button>div]:bg-[#E4E7EC]'} transition-[opacity,grid-template-columns]" + : 'opacity-80 [&>button>div]:bg-[#E4E7EC] [&_*]:!text-[#98A2B3] [&_button]:bg-[#E4E7EC]'} transition-[opacity,grid-template-columns]" > @@ -233,17 +232,56 @@ Get Code --> - - {:else} - - - + {/if}
    - +
    + {#if tableLoaded || (tableData && tableRowsState.rows)} +
    + + + +
    + +
    + +
    + + + {:else} + + + + {/if} +
    + + {#if !tableError} diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddAgentDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddAgentDialog.svelte old mode 100644 new mode 100755 index caad262..d5be18b --- a/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddAgentDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddAgentDialog.svelte @@ -1,9 +1,8 @@ - + New agent -
    -
    - +
    +
    + - +
    -
    - Models* +
    + model.id == selectedModel)?.name ?? - selectedModel) || - 'Select model'} class="{!selectedModel ? 'italic text-muted-foreground' - : ''} bg-[#F2F4F7] data-dark:bg-[#42464e] hover:bg-[#e1e2e6] border-transparent" + : ''} border-transparent bg-[#F2F4F7] hover:bg-[#e1e2e6] data-dark:bg-[#42464e]" />
    - - - + + {#snippet child({ props })} + + {/snippet} + diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddConversationDialog.svelte b/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddConversationDialog.svelte old mode 100644 new mode 100755 index 75ce94f..fa5c3aa --- a/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddConversationDialog.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/chat-table/(dialogs)/AddConversationDialog.svelte @@ -1,30 +1,41 @@ Chat Table - - -
    +
    {#if !loadingAgentsError}
    -

    Agents

    +

    Agents

    scrollHandler(e, 'agent'), 300)} - class="grow flex flex-col gap-1 overflow-auto" + onscroll={debounce((e) => scrollHandler(e, 'agent'), 300)} + class="flex grow flex-col gap-1 overflow-auto" > {#if isLoadingCAgents} {#each Array(6) as _} {/each} {:else} {#each $pastChatAgents as chatTable (chatTable.id)} {/each} {#if isLoadingMoreCAgents} -
    +
    {/if} @@ -474,23 +469,23 @@
    scrollHandler(e, 'filtered'), 300)} - class="flex flex-col gap-1 pl-6 @2xl:pl-0.5 supports-[not(container-type:inline-size)]:lg:pl-0.5 pr-6 py-2.5 @2xl:py-4 supports-[not(container-type:inline-size)]:lg:py-4" + onscroll={debounce((e) => scrollHandler(e, 'filtered'), 300)} + class="flex flex-col gap-1 py-2.5 pl-6 pr-6 @2xl:py-4 @2xl:pl-0.5 supports-[not(container-type:inline-size)]:lg:py-4 supports-[not(container-type:inline-size)]:lg:pl-0.5" > {#if filterByAgent} -
    +
    -
    - - - -
    - - + + +
    + + + +
    scrollHandler(e, 'filtered'), 300)} + onscroll={debounce((e) => scrollHandler(e, 'filtered'), 300)} style="grid-auto-rows: 120px;" - class="grow grid grid-cols-[repeat(auto-fill,_minmax(300px,_1fr))] grid-flow-row gap-3 pt-1 px-1 h-1 overflow-auto [scrollbar-gutter:stable]" + class="grid h-1 grow grid-flow-row grid-cols-[repeat(auto-fill,_minmax(300px,_1fr))] gap-3 overflow-auto px-1 pt-1 [scrollbar-gutter:stable]" > {#if isLoadingFilteredConv} -
    +
    {:else} {#each filteredConversations as chatTable}
    -
    +
    - - + + {chatTable.id}
    - - + + {#snippet child({ props })} + + {/snippet} - + (isEditingTableID = chatTable.id)} + onclick={() => (isEditingTableID = chatTable.id)} class="text-[#344054] data-[highlighted]:text-[#344054]" > - + Rename table - - - - Export table - + + {#snippet children({ handleExportTable })} + + + Export table + + {/snippet} (isDeletingTable = chatTable.id)} + onclick={() => (isDeletingTable = chatTable.id)} class="text-destructive data-[highlighted]:text-destructive" > - + Delete table @@ -704,7 +697,7 @@ day: 'numeric', year: 'numeric' })} - class="font-medium text-xs text-[#98A2B3] data-dark:text-[#C9C9C9] line-clamp-1" + class="line-clamp-1 text-xs font-medium text-[#98A2B3] data-dark:text-[#C9C9C9]" > Last updated @@ -722,7 +715,7 @@ style="background-color: {mappedColors ? mappedColors.bg : '#E3F2FD'}; color: {mappedColors ? mappedColors.text : '#0295FF'};" - class="w-min px-1 py-0.5 text-xs font-medium whitespace-nowrap rounded-[0.1875rem] select-none" + class="w-min select-none whitespace-nowrap rounded-[0.1875rem] px-1 py-0.5 text-xs font-medium" > {chatTable.parent_id} @@ -732,31 +725,29 @@ {/each} {#if isLoadingMoreFilteredConv} -
    +
    {/if} {/if}
    {:else} - + Select agent to filter conversations {/if}
    - {:else if loadingAgentsError.status === 404 && loadingAgentsError.org_id && userData?.member_of.find((org) => org.organization_id === loadingAgentsError?.org_id)} - {@const projectOrg = userData?.member_of.find( - (org) => org.organization_id === loadingAgentsError?.org_id - )} + {:else if loadingAgentsError.status === 404 && loadingAgentsError.org_id && user?.org_memberships?.find((org) => org.organization_id === loadingAgentsError?.org_id)} + {@const projectOrg = user?.organizations.find((org) => org.id === loadingAgentsError?.org_id)} {:else} -
    +
    {loadingAgentsError.status}

    {loadingAgentsError.message}

    diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/+page.ts b/services/app/src/routes/(main)/project/[project_id]/chat-table/+page.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page.ts b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page.ts old mode 100644 new mode 100755 index 41e13c9..e4ab3b3 --- a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page.ts +++ b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page.ts @@ -1,13 +1,14 @@ import { PUBLIC_JAMAI_URL } from '$env/static/public'; -import { error } from '@sveltejs/kit'; -import logger from '$lib/logger.js'; import { chatRowsPerPage } from '$lib/constants.js'; +import logger from '$lib/logger.js'; import type { GenTable, GenTableRow } from '$lib/types.js'; +import { error } from '@sveltejs/kit'; export const load = async ({ depends, fetch, params, parent, url }) => { depends('chat-table:slug'); await parent(); const page = parseInt(url.searchParams.get('page') ?? '1'); + const orderBy = url.searchParams.get('sort_by'); const orderAsc = parseInt(url.searchParams.get('asc') ?? '0'); if (!params.table_id) { @@ -16,11 +17,8 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const getTable = async () => { const tableDataRes = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/chat/${params.table_id}?` + - new URLSearchParams({ - offset: '0', - limit: '1' - }), + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/chat?` + + new URLSearchParams([['table_id', params.table_id]]), { headers: { 'x-project-id': params.project_id @@ -30,7 +28,7 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const tableDataBody = await tableDataRes.json(); if (!tableDataRes.ok) { - if (tableDataRes.status !== 404 && tableDataRes.status !== 422) { + if (![403, 404, 422].includes(tableDataRes.status)) { logger.error('CHATTBL_TBL_GET', tableDataBody); } return { error: tableDataRes.status, message: tableDataBody }; @@ -42,13 +40,22 @@ export const load = async ({ depends, fetch, params, parent, url }) => { }; const getRows = async () => { + const q = url.searchParams.get('q'); + + const searchParams = new URLSearchParams([ + ['table_id', params.table_id], + ['offset', ((page - 1) * chatRowsPerPage).toString()], + ['limit', chatRowsPerPage.toString()], + ['order_by', orderBy ?? 'ID'], + ['order_ascending', orderAsc === 1 ? 'true' : 'false'] + ]); + + if (q) { + searchParams.set('search_query', q); + } + const tableRowsRes = await fetch( - `${PUBLIC_JAMAI_URL}/api/v1/gen_tables/chat/${params.table_id}/rows?` + - new URLSearchParams({ - offset: ((page - 1) * chatRowsPerPage).toString(), - limit: chatRowsPerPage.toString(), - order_descending: orderAsc === 1 ? 'false' : 'true' - }), + `${PUBLIC_JAMAI_URL}/api/owl/gen_tables/chat/rows/list?${searchParams}`, { headers: { 'x-project-id': params.project_id @@ -58,7 +65,7 @@ export const load = async ({ depends, fetch, params, parent, url }) => { const tableRowsBody = await tableRowsRes.json(); if (!tableRowsRes.ok) { - if (tableRowsRes.status !== 404 && tableRowsRes.status !== 422) { + if (![403, 404, 422].includes(tableRowsRes.status)) { logger.error('CHATTBL_TBL_GETROWS', tableRowsBody); } return { error: tableRowsRes.status, message: tableRowsBody }; diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte old mode 100644 new mode 100755 index 5ad4b95..a5e7980 --- a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/+page@project.svelte @@ -1,17 +1,22 @@ - {$page.params.table_id} - Chat Table + {page.params.table_id} - Chat Table
    -
    - - - - +
    + + - {#await table} - {$page.params.table_id} + {#await data.table} + {page.params.table_id} {:then { data }} {data ? data.id : tableError?.error === 404 ? 'Not found' : 'Failed to load'} {/await} +
    -
    - {#if tableLoaded || (tableData && $genTableRows)} -
    - {#if $chatTableMode != 'chat'} - + {#if tableLoaded || (tableData && tableRowsState.rows)} +
    + {#if $chatTableMode != 'chat'} +
    + - {/if} -
    - -
    - {#if $chatTableMode != 'chat'} -
    - - - -
    - {:else} + - {/if} - - { - // Prevent toggling if streaming - if (generationStatus || Object.keys($tableState.streamingRows).length) { - return false; - } else return true; - }} - checked={$chatTableMode == 'table'} - on:checkedChange={(e) => { - if (e.detail.value) { - $chatTableMode = 'table'; - } else { - $chatTableMode = 'chat'; - } - }} - /> - - -
    - {:else} - -
    - - - -
    - {/if} -
    +
    + {:else} + + {/if} + + { + // Prevent toggling if streaming + if (generationStatus || Object.keys(tableState.streamingRows).length) { + return false; + } else return true; + }} + checked={$chatTableMode == 'table'} + on:checkedChange={(e) => { + if (e.detail.value) { + $chatTableMode = 'table'; + } else { + $chatTableMode = 'chat'; + } + }} + /> +
    + {:else} +
    + + +
    + {/if}
    + {#if $chatTableMode == 'table'} +
    + {#if tableLoaded || (tableData && tableRowsState.rows)} +
    + + + +
    + +
    + +
    + + + {:else} + + + + {/if} +
    + {/if} + {#if $chatTableMode == 'chat'} - + {:else} - + {#if !tableError} @@ -388,9 +389,7 @@ filterByAgent={tableData?.parent_id ?? ''} refetchTables={async (tableID) => { threadLoaded = false; - await goto( - `${$page.url.pathname.substring(0, $page.url.pathname.lastIndexOf('/'))}/${tableID}` - ); + await goto(`${page.url.pathname.substring(0, page.url.pathname.lastIndexOf('/'))}/${tableID}`); }} /> diff --git a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatMode.svelte b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatMode.svelte old mode 100644 new mode 100755 index 40e20bb..39fea75 --- a/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatMode.svelte +++ b/services/app/src/routes/(main)/project/[project_id]/chat-table/[table_id]/ChatMode.svelte @@ -1,18 +1,30 @@ (isResizing = false)} - on:click={handleCustomBtnClick} - on:keydown={(e) => { + onmousemove={handleResize} + onmouseup={() => (isResizing = false)} + onclick={handleCustomBtnClick} + onkeydown={(e) => { if ( //@ts-ignore e.target.tagName !== 'INPUT' && @@ -326,241 +496,258 @@ bind:this={chatWindow} data-testid="chat-window" id="chat-window" - class="@container/chat relative grow flex flex-col gap-4 pt-6 pb-16 overflow-x-hidden overflow-y-auto [scrollbar-gutter:stable]" + class="relative flex grow flex-col gap-4 overflow-y-auto overflow-x-hidden pt-6 [scrollbar-gutter:stable]" > {#if threadLoaded} - {@const displayedLoadedStreams = Object.keys(loadedStreams).filter((colID) => { - // Filter out columns to display - const col = tableData?.cols?.find((col) => col.id === colID); - return ( - col?.gen_config?.object === 'gen_config.llm' && - col.gen_config.multi_turn && - /\${User}/g.test(col.gen_config.prompt ?? '') - ); - })} - - {#each thread as threadItem, index} - {@const nonErrorMessage = threadItem.find((v) => 'content' in v && v.role === 'user')} - {@const messages = nonErrorMessage ? [nonErrorMessage] : threadItem} - {@const messagesWithContent = messages.filter( - (v) => ('content' in v && v.content?.trim()) || 'error' in v - )} + {@const multiturnCols = Object.keys(tableThread)} + {@const longestThreadColLen = tableThread[longestThreadCol]?.thread?.length ?? 0} + {#each Array(longestThreadColLen).fill('') as _, index}
    - {#each messages as message} - {@const { column_id } = message} - {#if 'content' in message} - {@const { content, role } = message} - {#if content?.trim()} - {#if role == 'assistant'} -
    -
    - + +
    + +
    +
    + {/if} + {/each} +
    + {/if} +

    - {content} + {threadItem.user_prompt}

    -
    + {/if}
    - {/if} - {/if} - {:else if messages.every((v) => ('content' in v && v.role === 'assistant') || 'error' in v)} -
    -
    - +
    - + {:else if threadItem.role === 'assistant'} + {@const isEditingCell = false}
    1 + ? 'min-w-full @5xl/chat:min-w-[50%] supports-[not(container-type:inline-size)]:xl:min-w-[50%]' + : '', + multiturnCols.length == 1 + ? '@5xl/chat:pr-[20%] supports-[not(container-type:inline-size)]:xl:pr-[20%]' + : 'last:pr-3 @2xl/chat:last:pr-6 @4xl/chat:last:pr-20 @5xl/chat:last:pr-0 supports-[not(container-type:inline-size)]:last:pr-6 supports-[not(container-type:inline-size)]:lg:last:pr-20 supports-[not(container-type:inline-size)]:xl:last:pr-0' + )} > -
    - - {message.error} - + {#if threadItem.row_id !== generationStatus} +
    +
    + + {column} + +
    + +
    + +
    +
    +
    1} + class="group relative max-w-full scroll-my-2 self-start rounded-xl bg-[#F2F4F7] p-4 text-text data-dark:bg-[#5B7EE5]" > -

    - {message.message.message || JSON.stringify(message)} -

    + {#if isEditingCell} + + {:else if typeof threadItem.content === 'string'} +

    + {#if showRawTexts} + {threadItem.content} + {:else} + {@const rawHtml = converter.makeHtml(threadItem.content)} + {@html rawHtml} + {/if} +

    + {:else} + {@const textContent = threadItem.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('')} + +

    + {#if showRawTexts} + {textContent} + {:else} + {@const rawHtml = converter.makeHtml(textContent)} + {@html rawHtml} + {/if} +

    + {/if}
    -
    + + {#if isEditingCell} + + {/if} + {:else} + {@render generatingMessages(column)} + {/if}
    -
    - {:else} - empty block + {/if} {/if} {/each}
    {/each} -
    - {#each displayedLoadedStreams as key} - {@const loadedStream = loadedStreams[key]} - {@const latestStream = latestStreams[key] ?? ''} -
    -
    - - output - - str - - -
    -
    - -
    -
    - - - {key} - -
    - -
    -

    - {@html converter.makeHtml(loadedStream.join(''))} - {latestStream} - - {#if loadedStream.length === 0 && latestStream === ''} - - {/if} -

    -
    -
    - {/each} -
    + {#if generationStatus === 'new'} + {@render generatingMessages()} + {/if} {:else} -
    +
    {/if} @@ -568,52 +755,52 @@
    -
    - + +
    @@ -632,6 +819,83 @@
    --> + + + +{#snippet generatingMessages(columnID?: string)} + {#if columnID} + {#each displayedLoadedStreams as key} + {#if key === columnID} + {@const loadedStream = loadedStreams[key]} + {@const latestStream = latestStreams[key] ?? ''} +
    + + {key} + +
    + +
    1} + class="group relative max-w-full scroll-my-2 self-start rounded-xl bg-[#F2F4F7] p-4 text-text data-dark:bg-[#5B7EE5]" + > +

    + {@html converter.makeHtml(loadedStream.join(''))} + {latestStream} + + {#if loadedStream.length === 0 && latestStream === ''} + + {/if} +

    +
    + {/if} + {/each} + {:else} +
    + {#each displayedLoadedStreams as key} + {@const loadedStream = loadedStreams[key]} + {@const latestStream = latestStreams[key] ?? ''} +
    1 + ? 'min-w-full @5xl/chat:min-w-[50%] supports-[not(container-type:inline-size)]:xl:min-w-[50%]' + : '', + displayedLoadedStreams.length == 1 + ? '@5xl/chat:pr-[20%] supports-[not(container-type:inline-size)]:xl:pr-[20%]' + : '' + )} + > +
    + + {key} + +
    + +
    1} + class="group relative max-w-full scroll-my-2 self-start rounded-xl bg-[#F2F4F7] p-4 text-text data-dark:bg-[#5B7EE5]" + > +

    + {@html converter.makeHtml(loadedStream.join(''))} + {latestStream} + + {#if loadedStream.length === 0 && latestStream === ''} + + {/if} +

    +
    +
    + {/each} +
    + {/if} +{/snippet} + + + +
    + + + + +
    +
    +

    + JamAI Logo +

    + +

    ${inviterEmail} has invited you to join their project on JamAI Base

    + +

    You have been invited to join a project on JamAI Base. Click the link below to accept the invitation:

    + +

    Join JamAI Base

    + +

    This link will expire in 7 days.

    + +
    + Thanks! +
    + + JamAI Base + +

    +
    +

    + If you did not make this request, you can ignore this mail. +

    +
    +
    +
    + +`; diff --git a/services/app/src/routes/(main)/project/[project_id]/members/+page.svelte b/services/app/src/routes/(main)/project/[project_id]/members/+page.svelte new file mode 100644 index 0000000..895c7bb --- /dev/null +++ b/services/app/src/routes/(main)/project/[project_id]/members/+page.svelte @@ -0,0 +1,187 @@ + + + + Project Members + + +
    +
    +
    +
    + +
    + +
    + + + + +
    + +
    +
    + + + + Name + Member + Role + + + + + {#await data.projectMembers} + {#each Array(6) as _} + + + + + + {/each} + {:then projectMembers} + {#if projectMembers.data} + {@const filteredMembers = filterMembers(projectMembers.data)} + {#if filteredMembers.length > 0} + {#each filteredMembers as member} + + +
    +
    +
    + {member.user.name?.charAt(0).toUpperCase() || '?'} +
    +
    +
    +
    {member.user.name}
    +
    {member.user.email}
    +
    +
    +
    + + {formatDistanceToNow(new Date(member.created_at), { addSuffix: true })} + + + + • + {member.role} + + + + + + + + (isEditingUser = member)} + > + Edit role + + (isRemovingUser = { open: true, value: member })} + > + Remove member + + + + +
    + {/each} + {:else} +
    +
    + No members found +
    +
    + {/if} + {:else} +
    +
    + Error fetching members + + {projectMembers?.message.message || JSON.stringify(projectMembers?.message)} + +
    +
    + {/if} + {/await} +
    +
    +
    +
    +
    + + + + diff --git a/services/app/src/routes/(main)/project/[project_id]/members/+page.ts b/services/app/src/routes/(main)/project/[project_id]/members/+page.ts new file mode 100644 index 0000000..f1162a7 --- /dev/null +++ b/services/app/src/routes/(main)/project/[project_id]/members/+page.ts @@ -0,0 +1,35 @@ +import { env } from '$env/dynamic/public'; +import logger from '$lib/logger'; +import type { OrgMemberRead } from '$lib/types'; + +const { PUBLIC_JAMAI_URL } = env; + +export const load = async ({ fetch, parent, data }) => { + const parentData = await parent(); + + const getOrgMembers = async () => { + const activeOrganizationId = parentData.organizationData?.id; + if (!activeOrganizationId) { + return { error: 400, message: 'No active organization' }; + } + + const orgMembersRes = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/organizations/members/list?${new URLSearchParams([['organization_id', activeOrganizationId]])}` + ); + const orgMembersBody = await orgMembersRes.json(); + + if (!orgMembersRes.ok) { + logger.error('PROJTEAM_ORGMEMBERS_ERROR', orgMembersBody); + return { error: orgMembersRes.status, message: orgMembersBody }; + } else { + return { + data: orgMembersBody.items as OrgMemberRead[] + }; + } + }; + + return { + ...data, + organizationMembers: await getOrgMembers() + }; +}; diff --git a/services/app/src/routes/(main)/project/[project_id]/overview/+page.svelte b/services/app/src/routes/(main)/project/[project_id]/overview/+page.svelte new file mode 100644 index 0000000..a971f17 --- /dev/null +++ b/services/app/src/routes/(main)/project/[project_id]/overview/+page.svelte @@ -0,0 +1,43 @@ + + + + Overview + + +
    +
    +
    Placeholder picture
    + +

    {$activeProject?.name}

    + +
    + {#each $activeProject?.tags ?? [] as tag} + {tag} + {/each} +
    + + + +
    +
    + + + {#await data.projectMembers} + 0 + {:then projectMembers} + {projectMembers.data?.length} + {/await} + + members +
    +
    +
    + +
    Description
    +
    diff --git a/services/app/src/routes/(main)/settings/+layout.svelte b/services/app/src/routes/(main)/settings/+layout.svelte old mode 100644 new mode 100755 index 5ed2f8b..94d0780 --- a/services/app/src/routes/(main)/settings/+layout.svelte +++ b/services/app/src/routes/(main)/settings/+layout.svelte @@ -1,10 +1,15 @@ - moveHighlighter($page.url.pathname)} /> + moveHighlighter(page.url.pathname)} />
    -

    Account Settings

    +

    Account Settings

    -
    +
    -
    diff --git a/services/app/src/routes/(main)/settings/+layout.ts b/services/app/src/routes/(main)/settings/+layout.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/(main)/settings/+page.ts b/services/app/src/routes/(main)/settings/+page.ts old mode 100644 new mode 100755 index 3538a56..2482054 --- a/services/app/src/routes/(main)/settings/+page.ts +++ b/services/app/src/routes/(main)/settings/+page.ts @@ -1,10 +1,5 @@ -import { PUBLIC_IS_LOCAL } from '$env/static/public'; import { redirect } from '@sveltejs/kit'; export function load() { - if (PUBLIC_IS_LOCAL === 'false') { - return redirect(302, '/settings/account'); - } else { - throw redirect(302, '/'); - } + return redirect(302, '/settings/account'); } diff --git a/services/app/src/routes/(main)/settings/account/(components)/ChangePasswordDialog.svelte b/services/app/src/routes/(main)/settings/account/(components)/ChangePasswordDialog.svelte new file mode 100644 index 0000000..774b51a --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/(components)/ChangePasswordDialog.svelte @@ -0,0 +1,135 @@ + + + + + Change password + +
    { + loadingChangePW = true; + + return async ({ update, result }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } + + loadingChangePW = false; + isChangingPW = false; + signOut(); + await update(); + }; + }} + method="POST" + action="?/change-password" + class="grow overflow-auto" + > +
    +
    + + + +
    + +
    + + + +
    + +
    + + + +
    +
    +
    + + +
    + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/settings/account/(components)/CreatePATDialog.svelte b/services/app/src/routes/(main)/settings/account/(components)/CreatePATDialog.svelte new file mode 100644 index 0000000..1e8234d --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/(components)/CreatePATDialog.svelte @@ -0,0 +1,165 @@ + + + + + Create PAT + + +
    { + isLoadingCreatePAT = true; + + return async ({ result, update }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error('Error creating PAT', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + isCreatingPAT = false; + } + + isLoadingCreatePAT = false; + update({ reset: false }); + }; + }} + method="POST" + action="?/create-pat" + class="h-full w-full grow overflow-auto" + > +
    + + + +
    + +
    + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + + +
    + +
    + + + + +
    + {#if selectedProject} + {selectedProjectData?.name ?? ''}  â€“  + + {selectedProjectOrg?.name ?? selectedProjectData?.organization_id} + + {:else} + Optional + {/if} +
    +
    + + {#each user?.projects ?? [] as project} + {@const projectOrg = (user?.organizations ?? []).find( + (o) => project.organization_id === o.id + )} + + {project.name}  â€“  + + {projectOrg?.name ?? project.organization_id} + + + {/each} + +
    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/settings/account/(components)/DeleteAccountDialog.svelte b/services/app/src/routes/(main)/settings/account/(components)/DeleteAccountDialog.svelte new file mode 100644 index 0000000..57a7188 --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/(components)/DeleteAccountDialog.svelte @@ -0,0 +1,101 @@ + + + { + if (!e) { + confirmEmail = ''; + } + }} +> + + Delete account + + +
    { + isLoadingDeleteAccount = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error deleting account', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else { + return location.reload(); + } + + isLoadingDeleteAccount = false; + update({ reset: false, invalidateAll: false }); + }; + }} + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + method="POST" + action="?/delete-account" + class="w-full grow overflow-auto" + > +
    +

    + Do you really want to delete your account + + `{user?.email}` + ? This process cannot be undone. +

    + +
    + + + +
    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/settings/account/(components)/DeletePATDialog.svelte b/services/app/src/routes/(main)/settings/account/(components)/DeletePATDialog.svelte new file mode 100644 index 0000000..506dafd --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/(components)/DeletePATDialog.svelte @@ -0,0 +1,89 @@ + + + !!isDeletingPAT, () => (isDeletingPAT = null)}> + + + + Close + + +
    + +

    Are you sure?

    +

    + Do you really want to remove PAT + + `{isDeletingPAT}` + ? +

    +
    + + +
    { + isLoadingDeletePAT = true; + + return async ({ result, update }) => { + if (result.type !== 'success') { + //@ts-ignore + const data = result.data; + toast.error('Error deleting PAT', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else { + isDeletingPAT = null; + } + + isLoadingDeletePAT = false; + update({ reset: false }); + }; + }} + method="POST" + action="?/delete-pat" + class="flex gap-2 overflow-x-auto overflow-y-hidden" + > + + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/settings/account/(components)/index.ts b/services/app/src/routes/(main)/settings/account/(components)/index.ts new file mode 100644 index 0000000..451c358 --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/(components)/index.ts @@ -0,0 +1,6 @@ +import ChangePasswordDialog from './ChangePasswordDialog.svelte'; +import CreatePatDialog from './CreatePATDialog.svelte'; +import DeleteAccountDialog from './DeleteAccountDialog.svelte'; +import DeletePatDialog from './DeletePATDialog.svelte'; + +export { ChangePasswordDialog, CreatePatDialog, DeleteAccountDialog, DeletePatDialog }; diff --git a/services/app/src/routes/(main)/settings/account/+page.server.ts b/services/app/src/routes/(main)/settings/account/+page.server.ts new file mode 100755 index 0000000..288ce66 --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/+page.server.ts @@ -0,0 +1,236 @@ +import { env } from '$env/dynamic/private'; +import logger, { APIError } from '$lib/logger.js'; +import type { PATRead } from '$lib/types.js'; +import { fail, redirect } from '@sveltejs/kit'; +import { ManagementClient } from 'auth0'; + +const { + AUTH0_CLIENT_ID, + AUTH0_ISSUER_BASE_URL, + AUTH0_MGMTAPI_CLIENT_ID, + AUTH0_MGMTAPI_CLIENT_SECRET, + OWL_SERVICE_KEY, + OWL_URL +} = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +const management = new ManagementClient({ + domain: AUTH0_ISSUER_BASE_URL?.replace('https://', ''), + clientId: AUTH0_MGMTAPI_CLIENT_ID, + clientSecret: AUTH0_MGMTAPI_CLIENT_SECRET +}); + +export async function load({ locals }) { + //TODO: Infinite scroll this + const getPats = async () => { + const patListRes = await fetch(`${OWL_URL}/api/v2/pats/list`, { + headers: { + ...headers, + 'x-user-id': locals.user?.id ?? '' + } + }); + const patListBody = await patListRes.json(); + + if (!patListRes.ok) { + logger.error('PAT_LIST_ERROR', patListBody); + return { error: patListRes.status, message: patListBody }; + } else { + return { + data: patListBody.items as PATRead[] + }; + } + }; + + return { + pats: await getPats() + }; +} + +export const actions = { + 'change-password': async ({ locals, request }) => { + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + if (locals.auth0Mode) { + try { + const pwChangeRes = await management.tickets.changePassword({ + user_id: locals.user.sub, + client_id: AUTH0_CLIENT_ID, + ttl_sec: 0, + mark_email_as_verified: false, + includeEmailInRedirect: true + }); + if (pwChangeRes.status !== 200 && pwChangeRes.status !== 201) { + return fail( + pwChangeRes.status, + new APIError('Failed to change password', pwChangeRes as any).getSerializable() + ); + } else { + throw redirect(303, pwChangeRes.data.ticket); + } + } catch (err) { + //@ts-expect-error library throws error for redirects??? + if (err?.status === 303) { + //@ts-expect-error see above + throw redirect(303, err.location); + } else { + logger.error('PASSWORD_CHANGE_CHANGE', err); + return fail(500, new APIError('Failed to change password', err as any).getSerializable()); + } + } + } else { + try { + const data = await request.formData(); + const password = data.get('password'); + const new_password = data.get('new_password'); + + if ( + !password || + typeof password !== 'string' || + !new_password || + typeof new_password !== 'string' + ) { + return fail(400, new APIError('Invalid form data').getSerializable()); + } + + const response = await fetch(`${OWL_URL}/api/v2/auth/login/password`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + 'x-user-id': locals.user.id + }, + body: JSON.stringify({ + email: locals.user.email, + password, + new_password + }) + }); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('PASSWORD_CHANGE_ERROR', responseData, locals.user.email || locals.user.id); + return fail( + response.status, + new APIError('Failed to change password', responseData).getSerializable() + ); + } + + return responseData; + } catch (err) { + logger.error('PASSWORD_CHANGE_ERROR', err); + return fail(500, new APIError('Failed to change password', err as any).getSerializable()); + } + } + }, + + 'create-pat': async ({ locals, request }) => { + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const data = await request.formData(); + const patName = data.get('pat_name'); + const patExpiry = data.get('pat_expiry'); + const patProject = data.get('pat_project'); + + if ( + typeof patName !== 'string' || + typeof patExpiry !== 'string' || + typeof patProject !== 'string' + ) { + return fail(400, new APIError('Invalid form data').getSerializable()); + } + + const patCreateRes = await fetch(`${OWL_URL}/api/v2/pats`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user?.id, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: patName, + expiry: patExpiry || null, + project_id: patProject || null + }) + }); + const patCreateBody = await patCreateRes.json(); + + if (patCreateRes.ok) { + return patCreateBody; + } else { + return fail( + patCreateRes.status, + new APIError('Failed to create PAT', patCreateBody as any).getSerializable() + ); + } + }, + + 'delete-pat': async ({ locals, request }) => { + const data = await request.formData(); + const key = data.get('key'); + + if (typeof key !== 'string' || key.trim() === '') { + return fail(400, new APIError('Invalid PAT').getSerializable()); + } + + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const patDeleteRes = await fetch( + `${OWL_URL}/api/v2/pats?${new URLSearchParams([['pat_id', key]])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user?.id + } + } + ); + const patDeleteBody = await patDeleteRes.json(); + + if (patDeleteRes.ok) { + return patDeleteBody; + } else { + return fail( + patDeleteRes.status, + new APIError('Failed to delete PAT', patDeleteBody as any).getSerializable() + ); + } + }, + + 'delete-account': async ({ locals }) => { + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + const deleteUserRes = await fetch(`${OWL_URL}/api/v2/users`, { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id + } + }); + + const deleteUserBody = await deleteUserRes.json(); + if (!deleteUserRes.ok) { + logger.error('USER_DELETE_DELETE', deleteUserBody); + return fail( + deleteUserRes.status, + new APIError('Failed to delete account', deleteUserBody as any).getSerializable() + ); + } else { + return deleteUserBody; + } + } +}; diff --git a/services/app/src/routes/(main)/settings/account/+page.svelte b/services/app/src/routes/(main)/settings/account/+page.svelte new file mode 100755 index 0000000..ab08bcd --- /dev/null +++ b/services/app/src/routes/(main)/settings/account/+page.svelte @@ -0,0 +1,264 @@ + + + + Account - Settings + + +
    +

    ACCOUNT

    + +
    +
    + {#if user?.picture_url} + User Avatar + {:else} + + {(user?.name ?? 'Default User').charAt(0)} + + {/if} +
    + +
    + + {user?.name} + +
    +
    + +
    +
    +

    User ID

    + {user?.id ?? ''} +
    + +
    +

    Email

    + {user?.email ?? ''} +
    +
    + + + + {#if data.auth0Mode} + +
    { + isLoadingChangePassword = true; + + return async ({ result, update }) => { + if (result.type !== 'redirect') { + //@ts-ignore + const data = result.data; + toast.error('Error getting password reset link', { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } + + isLoadingChangePassword = false; + update({ reset: false, invalidateAll: false }); + }; + }} + onkeydown={(event) => event.key === 'Enter' && event.preventDefault()} + method="POST" + action="?/change-password" + class="mb-8 flex w-full flex-col gap-3" + > +

    PASSWORD

    + + +
    + {:else} +
    +

    PASSWORD

    + + +
    + {/if} + +
    +

    PERSONAL ACCESS TOKEN

    + +
    +
    +
    +
    Name
    +
    Key
    +
    Project
    +
    Expiry
    +
    +
    + + {#if (pats.data ?? []).length > 0} +
    + {#each pats.data ?? [] as apiKey} +
    +
    +

    + {apiKey.name} +

    +
    + +
    + + + + + +
    + +
    +

    + {apiKey.project_id || '-'} +

    +
    + +
    + {apiKey.expiry ? new Date(apiKey.expiry).toLocaleString() : '-'} +
    + + +
    + {/each} +
    + {:else} +
    +
    +
    +

    No PATs have been created for this user

    +
    +
    +
    + {/if} +
    + +
    + +
    +
    + +
    +

    ACCOUNT REMOVAL

    + +

    + Delete your account permanently. +

    + +
    + +
    +
    + + +
    + + + + + diff --git a/services/app/src/routes/(main)/settings/theme/page.svelte b/services/app/src/routes/(main)/settings/theme/page.svelte old mode 100644 new mode 100755 index 8cbee89..7e5b350 --- a/services/app/src/routes/(main)/settings/theme/page.svelte +++ b/services/app/src/routes/(main)/settings/theme/page.svelte @@ -18,7 +18,7 @@
    + {/snippet} + + +
    + + + diff --git a/services/app/src/routes/(main)/system/models/(components)/AddModelConfigDialog.svelte b/services/app/src/routes/(main)/system/models/(components)/AddModelConfigDialog.svelte new file mode 100644 index 0000000..576a3ce --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/AddModelConfigDialog.svelte @@ -0,0 +1,498 @@ + + + + + + Add Model Config + + +
    +
    +

    SUGGESTED MODELS

    +
    + {#if data.modelPresets.data} +
    + {#each data.modelPresets.data.filter((p) => p.deployments[0].routing_id) as config} +
    +
    +
    {config.name}
    +

    {config.id}

    +
    + config.id === selectedSuggestedConfig?.id, + (v) => { + currentStep = 0; + if (v) { + selectedSuggestedConfig = config; + modelType = config.type; + selectedCapabilities = config.capabilities; + modelIcon = (config?.meta?.icon as string) || undefined; + } else { + selectedSuggestedConfig = null; + modelType = undefined; + selectedCapabilities = []; + modelIcon = undefined; + } + }} + class="border-gray-400 data-[state=checked]:bg-[#1B748A]" + /> +
    + {/each} +
    + {/if} +
    +
    +
    { + if (!modelType) { + toast.error('Model type is required'); + cancel(); + return; + } + + if (selectedCapabilities.length === 0) { + toast.error('At least one capability is required'); + cancel(); + return; + } + + loading = true; + if (modelIcon) { + formData.set('icon', modelIcon); + } + if (baseTier) { + formData.set('base_tier_id', baseTier); + } + formData.set('type', modelType); + formData.set('capabilities', JSON.stringify(selectedCapabilities)); + + const languages = formData.get('languages'); + if (languages) { + formData.set('languages', JSON.stringify(stringToArray(languages.toString()))); + } + + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + open = false; + toast.success('Model config added successfully', { + id: 'add-model-config-success' + }); + selectedCapabilities = []; + } + + loading = false; + await update({ invalidateAll: false }); + await invalidate('system:models'); + }; + }} + action="?/add-model-config" + class="flex w-3/5 flex-col space-y-6 overflow-y-auto p-1 pr-0" + > +
    +
    + {#each steps as _, i} +
    +
    + {i + 1} +
    + {#if i < steps.length - 1} +
    + {/if} +
    + {/each} +
    +
    + {#each steps as step, i} +
    + {step} +
    + {/each} +
    +
    + +
    + + + + + +
    +
    +
    + + +
    + + {#snippet child({ props })} + + {/snippet} + + {#if currentStep > 0} + + {/if} + {#if currentStep < steps.length - 1} + + {:else} + + {/if} +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/DeleteDeploymentDialog.svelte b/services/app/src/routes/(main)/system/models/(components)/DeleteDeploymentDialog.svelte new file mode 100644 index 0000000..4a205bb --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/DeleteDeploymentDialog.svelte @@ -0,0 +1,96 @@ + + + + + + + Close + + +
    + +

    Are you sure?

    +

    + Do you really want to delete deployment + + `{deployment?.name || deployment?.id}` + ? This process cannot be undone. +

    +
    + + +
    { + loading = true; + + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + open = false; + toast.success('Model deployment deleted successfully', { + id: 'delete-deployment-success' + }); + + update({ invalidateAll: false }); + invalidate('system:models'); + invalidate('system:modelsslug'); + } + + loading = false; + }; + }} + action="/system/models?/delete-deployment" + class="flex gap-2 overflow-x-auto overflow-y-hidden" + > + + + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/DeleteModelConfigDialog.svelte b/services/app/src/routes/(main)/system/models/(components)/DeleteModelConfigDialog.svelte new file mode 100644 index 0000000..c705bde --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/DeleteModelConfigDialog.svelte @@ -0,0 +1,96 @@ + + + open.open, (v) => (open = { ...open, open: v })}> + + + + Close + + +
    + +

    Are you sure?

    +

    + Do you really want to delete model + + `{open.value?.name || open.value?.id}` + ? This process cannot be undone. +

    +
    + + +
    { + loading = true; + + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + open = { ...open, open: false }; + toast.success('Model config deleted successfully', { + id: 'delete-model-config-success' + }); + } + + await update({ invalidateAll: false }); + if (page.params.model_id) { + await goto('/system/models'); + } + invalidate('system:models'); + loading = false; + }; + }} + action="/system/models?/delete-model-config" + class="flex gap-2 overflow-x-auto overflow-y-hidden" + > + + + + {#snippet child({ props })} + + {/snippet} + + +
    +
    +
    +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/DeploymentDetails.svelte b/services/app/src/routes/(main)/system/models/(components)/DeploymentDetails.svelte new file mode 100644 index 0000000..25c0c68 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/DeploymentDetails.svelte @@ -0,0 +1,208 @@ + + +
    { + loading = true; + formData.set('provider', selectedProvider); + + return async ({ update, result }) => { + if (result.type === 'failure') { + const data = result.data as any; + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + isEditing = false; + toast.success('Model deployment updated successfully', { + id: 'edit-deployment-success' + }); + } + + loading = false; + update({ invalidateAll: false }); + invalidate('system:models'); + invalidate('system:modelsslug'); + deployment.refetch(); + }; + }} + action="/system/models?/edit-deployment" + class="grow overflow-y-scroll py-1" +> +
    +
    + + {#if isEditing} + + {:else} +
    {deployment.data?.model_id}
    + {/if} +
    + +
    + + {#if isEditing} + + {:else} +
    {deployment.data?.name}
    + {/if} +
    + + + + +
    + + {#if isEditing} + {#await (page.data as LayoutData).providers} + + {:then providers} + + + {PROVIDERS[selectedProvider] || selectedProvider || 'Select a provider'} + + + + {#if providers.data} + {#each providers.data.filter((p) => p !== '') as provider} + + {PROVIDERS[provider] || provider} + + {/each} + {/if} + + + {/await} + {:else} +
    + {PROVIDERS[deployment.data?.provider ?? ''] || deployment.data?.provider || '-'} +
    + {/if} +
    + +
    + + {#if isEditing} + + {:else} +
    {deployment.data?.routing_id || '-'}
    + {/if} +
    + +
    + + {#if isEditing} + + {:else} +
    {deployment.data?.api_base || '-'}
    + {/if} +
    + +
    + + {#if isEditing} + + {:else} +
    {deployment.data?.weight || 1}
    + {/if} +
    +
    +
    + +{#if !isEditing} +
    + +
    +{:else} +
    + + +
    +{/if} diff --git a/services/app/src/routes/(main)/system/models/(components)/DeploymentManagement.svelte b/services/app/src/routes/(main)/system/models/(components)/DeploymentManagement.svelte new file mode 100644 index 0000000..1c680f7 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/DeploymentManagement.svelte @@ -0,0 +1,131 @@ + + +
    +

    + Cloud Deployment Management +

    + +
    +
    + + + + Endpoint Name + Model + Model ID + API Base + Actions + + + + {#await data.deployments} + {#each Array(4) as _} + + + + + + {/each} + {:then deployments} + {#if deployments.data} + {#if deployments.data.length > 0} + {#each deployments.data as deployment} + + {deployment.name} + {deployment.model?.name} + {deployment.model_id} + + {deployment.api_base} + +
    + + +
    +
    +
    + {/each} + + + {:else} + + +
    +
    + No cloud deployments found + Deploy a model to see it listed here +
    +
    +
    +
    + {/if} + {:else} +
    +
    + Error loading deployments + + {deployments?.error.message || JSON.stringify(deployments?.error)} + +
    +
    + {/if} + {/await} +
    +
    +
    +
    + + {#if selectedEditDeploymentId} + + {/if} + +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/ManageDeploymentDialog.svelte b/services/app/src/routes/(main)/system/models/(components)/ManageDeploymentDialog.svelte new file mode 100644 index 0000000..19dd575 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/ManageDeploymentDialog.svelte @@ -0,0 +1,108 @@ + + + + + +
    +

    Model Deployment

    + + {#await deployment} +

    Loading...

    + {:then deployment} + {deployment.data?.name} + {/await} +
    +
    +
    + +
    +
    + +
    + + + {#await deployment} +
    + +
    + {:then deployment} + {#if deployment.data} +
    + {#if activeTab === 'details'} + + {/if} +
    + {:else} +
    +
    + Error loading deployment +

    + {deployment?.error?.message || JSON.stringify(deployment.error)} +

    +
    +
    + {/if} + {/await} +
    +
    +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/ModelCatalogue.svelte b/services/app/src/routes/(main)/system/models/(components)/ModelCatalogue.svelte new file mode 100644 index 0000000..13988cb --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/ModelCatalogue.svelte @@ -0,0 +1,344 @@ + + +
    +

    Model Catalogue

    +
    +
    +
    + +
    + +
    + +
    + +
    +
    + {#each ['all', ...Object.keys(MODEL_TYPES)] as modelType} + + {/each} +
    + + {#await data.modelConfigs then modelConfigs} + {#if modelConfigs.data} + {@const filteredConfigs = filterModelConfigs( + modelConfigs.data, + searchQuery, + $modelConfigSort.filter + )} + + {#snippet children({ pages, currentPage })} + + + + {#snippet child({ props })} + + {/snippet} + + + {#each pages as page (page.key)} + {#if page.type === 'ellipsis'} + + + + {:else} + {@const pageFontSize = + 99 % page.value === 99 + ? 999 % page.value === 999 + ? 'text-[0.6rem]' + : 'text-xs' + : 'text-sm'} + + + {#snippet child({ props })} + + {/snippet} + + + {/if} + {/each} + + + {#snippet child({ props })} + + {/snippet} + + + + {/snippet} + + {/if} + {/await} +
    + +
    + {#await data.modelConfigs} + {#each Array(6) as _} + + {/each} + {:then modelConfigs} + {#if modelConfigs.data} + {@const { filteredConfigs, paginatedConfigs } = getPaginatedModelConfigs( + modelConfigs.data, + searchQuery, + $modelConfigSort.filter, + currentPage, + itemsPerPage + )} + + {#if filteredConfigs.length === 0} +
    +

    + {$modelConfigSort.filter === 'all' + ? 'No model config found.' + : `No ${MODEL_TYPES[$modelConfigSort.filter] ?? $modelConfigSort.filter} models found in the catalogue.`} +

    +
    + {:else} + {#each paginatedConfigs as modelConfig (modelConfig.id)} + + {/each} + {/if} + {:else} +
    +

    + {modelConfigs?.error.message || JSON.stringify(modelConfigs?.error)} +

    +
    + {/if} + {/await} +
    +
    + + + + + diff --git a/services/app/src/routes/(main)/system/models/(components)/ModelConfigCard.svelte b/services/app/src/routes/(main)/system/models/(components)/ModelConfigCard.svelte new file mode 100644 index 0000000..937331f --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/ModelConfigCard.svelte @@ -0,0 +1,117 @@ + + + + +
    { + goto(`/system/models/${encodeURIComponent(modelConfig.id)}`, { state: { page: currentPage } }); + }} + oncontextmenu={(e) => { + e.preventDefault(); + menuOpen = !menuOpen; + }} + class="flex h-full cursor-pointer flex-col justify-start space-y-2 overflow-auto rounded-xl border border-[#E4E7EC] bg-white p-4 transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-float" + class:!bg-[#FFF8EA]={!modelConfig.deployments.length} +> +
    + +
    +
    +

    + {modelConfig.name} +

    +

    + {modelConfig.id} + +

    +
    +
    + {modelConfig.type} +
    + {#if modelConfig.deployments.length} +
    + {modelConfig.deployments.length} + {modelConfig.deployments.length === 1 ? 'deployment' : 'deployments'} +
    + {:else} +
    + No Deployment +
    + {/if} +
    +
    + +
    + + + + + + + goto(`/system/models/${encodeURIComponent(modelConfig.id)}?edit=true`)} + class="text-[#344054] data-[highlighted]:text-[#344054]" + > + + Edit + + (deleteOpen = { open: true, value: modelConfig })} + class="text-destructive data-[highlighted]:text-destructive" + > + + Delete + + + +
    +
    diff --git a/services/app/src/routes/(main)/system/models/(components)/index.ts b/services/app/src/routes/(main)/system/models/(components)/index.ts new file mode 100755 index 0000000..bf3a223 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/(components)/index.ts @@ -0,0 +1,19 @@ +import AddDeploymentDialog from './AddDeploymentDialog.svelte'; +import AddModelConfigDialog from './AddModelConfigDialog.svelte'; +import DeleteDeploymentDialog from './DeleteDeploymentDialog.svelte'; +import DeleteModelConfigDialog from './DeleteModelConfigDialog.svelte'; +import DeploymentManagement from './DeploymentManagement.svelte'; +import ManageDeploymentDialog from './ManageDeploymentDialog.svelte'; +import ModelCatalogue from './ModelCatalogue.svelte'; +import ModelConfigCard from './ModelConfigCard.svelte'; + +export { + AddDeploymentDialog, + AddModelConfigDialog, + DeleteDeploymentDialog, + DeleteModelConfigDialog, + DeploymentManagement, + ManageDeploymentDialog, + ModelCatalogue, + ModelConfigCard +}; diff --git a/services/app/src/routes/(main)/system/models/+layout.server.ts b/services/app/src/routes/(main)/system/models/+layout.server.ts new file mode 100644 index 0000000..07faa0e --- /dev/null +++ b/services/app/src/routes/(main)/system/models/+layout.server.ts @@ -0,0 +1,32 @@ +import { env } from '$env/dynamic/private'; +import logger from '$lib/logger.js'; + +const { OWL_URL, OWL_SERVICE_KEY } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export async function load({ locals }) { + const getProviders = async () => { + const response = await fetch(`${OWL_URL}/api/v2/models/deployments/providers/cloud`, { + headers: { + ...headers, + 'x-user-id': locals.user?.id || '' + } + }); + + const responseBody = await response.json(); + + if (!response.ok) { + logger.error('PROVIDERS_GET_ERROR', responseBody, locals.user?.id); + return { error: response.status, message: responseBody }; + } + + return { data: responseBody as string[] }; + }; + + return { + providers: getProviders() + }; +} diff --git a/services/app/src/routes/(main)/system/models/+layout.ts b/services/app/src/routes/(main)/system/models/+layout.ts new file mode 100644 index 0000000..247304c --- /dev/null +++ b/services/app/src/routes/(main)/system/models/+layout.ts @@ -0,0 +1,77 @@ +import { PUBLIC_JAMAI_URL } from '$env/static/public'; +import { activeOrganization } from '$globalStore'; +import logger from '$lib/logger.js'; +import type { ModelConfig, ModelDeployment } from '$lib/types.js'; +import { get } from 'svelte/store'; + +export const ssr = false; + +export async function load({ data, depends, fetch }) { + depends('system:models'); + + //TODO: Maybe paginate this + const getModelConfigs = async () => { + const activeOrg = get(activeOrganization); + + const limit = 1000; + const offset = 0; + const response = await fetch( + `${PUBLIC_JAMAI_URL}/api/owl/models/configs/list?${new URLSearchParams({ + organization_id: activeOrg?.id ?? '', + offset: offset.toString(), + limit: limit.toString() + })}` + ); + const responseBody = await response.json(); + + if (!response.ok) { + logger.error('MODELCONFIGS_GET_ERROR', responseBody); + return { data: null, error: responseBody as any, status: response.status }; + } + + return { data: responseBody.items as ModelConfig[] }; + }; + + const getDeployments = async () => { + const limit = 1000; + const offset = 0; + const response = await fetch( + `/api/owl/models/deployments/list?${new URLSearchParams([ + ['offset', offset.toString()], + ['limit', limit.toString()] + ])}` + ); + const responseBody = await response.json(); + + if (!response.ok) { + logger.error('DEPLOYMENTS_GET_ERROR', data); + return { data: null, error: responseBody as any, status: response.status }; + } + + return { data: responseBody.items as ModelDeployment[] }; + }; + + const getModelPresets = async () => { + const response = await fetch( + 'https://raw.githubusercontent.com/EmbeddedLLM/JamAIBase/refs/heads/main/services/api/src/owl/configs/preset_models.json', + { + method: 'GET' + } + ); + + if (!response.ok) { + const error = await response.text(); + logger.error('MODELPRESETS_GET_ERROR', error); + return { error: response.status, message: 'Failed to fetch model presets' }; + } + + return { data: (await response.json()) as ModelConfig[] }; + }; + + return { + ...data, + modelConfigs: getModelConfigs(), + deployments: getDeployments(), + modelPresets: await getModelPresets() + }; +} diff --git a/services/app/src/routes/(main)/system/models/+page.server.ts b/services/app/src/routes/(main)/system/models/+page.server.ts new file mode 100644 index 0000000..87e4bb5 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/+page.server.ts @@ -0,0 +1,279 @@ +import { env } from '$env/dynamic/private'; +import { PUBLIC_ADMIN_ORGANIZATION_ID } from '$env/static/public'; +import logger, { APIError } from '$lib/logger.js'; +import { error, fail } from '@sveltejs/kit'; + +const { OWL_URL, OWL_SERVICE_KEY } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export async function load({ cookies, locals }) { + if ( + cookies.get('activeOrganizationId') !== PUBLIC_ADMIN_ORGANIZATION_ID || + !locals.user?.org_memberships.find( + (org) => org.organization_id === PUBLIC_ADMIN_ORGANIZATION_ID + ) + ) { + throw error(404, 'Not found'); + } +} + +export const actions = { + 'add-model-config': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + const data: Record = {}; + + try { + for (const [key, value] of formData.entries()) { + // Skip empty values + if (value === '' || value === null || value === undefined) continue; + + // Handle value conversion + const numValue = Number(value); + if (!isNaN(numValue)) { + data[key] = numValue; + } else { + data[key] = value; + } + } + + data['capabilities'] = JSON.parse(formData.get('capabilities') as string); + if (data['languages']) { + data['languages'] = JSON.parse(formData.get('languages') as string); + } + if (data.provisioned_to !== undefined && data.provisioned_to !== null) { + data.provisioned_to = String(data.provisioned_to); + } + + if (data['icon']) { + if (!data['meta']) { + data['meta'] = {}; + } + data['meta']['icon'] = data['icon']; + delete data['icon']; + } + + const response = await fetch(`${OWL_URL}/api/v2/models/configs`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user.id || '', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('MODELCONFIG_ADD_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to create model config', responseData).getSerializable() + ); + } + + return responseData; + } catch (error) { + logger.error('MODELCONFIG_ADD_ERROR', error, locals.user.id); + return fail( + 500, + new APIError('Failed to create model config', error as any).getSerializable() + ); + } + }, + 'delete-model-config': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + try { + const model_id = formData.get('model_id')?.toString(); + + if (!model_id || typeof model_id !== 'string' || model_id.trim() === '') { + return fail(400, new APIError('Model ID (type string) is required').getSerializable()); + } + + const response = await fetch( + `${OWL_URL}/api/v2/models/configs?${new URLSearchParams([['model_id', model_id]])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id || '' + } + } + ); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('MODELCONFIG_DELETE_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to delete model config', responseData).getSerializable() + ); + } + + return responseData; + } catch (error: any) { + logger.error('MODELCONFIG_DELETE_ERROR', error, locals.user.id); + return fail( + 500, + new APIError('Failed to delete model config', error as any).getSerializable() + ); + } + }, + + 'add-deployment': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + const data: Record = {}; + + try { + for (const [key, value] of formData.entries()) { + // Skip empty values + if (value === '' || value === null || value === undefined) continue; + + // Handle value conversion + const numValue = Number(value); + if (!isNaN(numValue)) { + data[key] = numValue; + } else { + data[key] = value; + } + } + + const response = await fetch(`${OWL_URL}/api/v2/models/deployments/cloud`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user.id || '', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('DEPLOYMENT_ADD_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to create deployment', responseData as any).getSerializable() + ); + } + + return responseData; + } catch (error) { + logger.error('DEPLOYMENT_ADD_ERROR', error, locals.user.id); + return fail(500, new APIError('Failed to create deployment', error as any).getSerializable()); + } + }, + 'edit-deployment': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + const data: Record = {}; + + try { + for (const [key, value] of formData.entries()) { + // Skip empty values + if (value === '' || value === null || value === undefined) continue; + + // Handle value conversion + const numValue = Number(value); + if (!isNaN(numValue)) { + data[key] = numValue; + } else { + data[key] = value; + } + } + + const response = await fetch( + `${OWL_URL}/api/v2/models/deployments?${new URLSearchParams([['deployment_id', data.id]])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': locals.user.id || '', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + } + ); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('DEPLOYMENT_EDIT_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to edit deployment', responseData).getSerializable() + ); + } + + return responseData; + } catch (error) { + logger.error('DEPLOYMENT_EDIT_ERROR', error, locals.user.id); + return fail(500, new APIError('Failed to edit deployment', error as any).getSerializable()); + } + }, + 'delete-deployment': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + try { + const deployment_id = formData.get('deployment_id')?.toString(); + + if (!deployment_id || typeof deployment_id !== 'string' || deployment_id.trim() === '') { + return fail(400, new APIError('Deployment ID (type string) is required').getSerializable()); + } + + const response = await fetch( + `${OWL_URL}/api/v2/models/deployments?${new URLSearchParams([['deployment_id', deployment_id]])}`, + { + method: 'DELETE', + headers: { + ...headers, + 'x-user-id': locals.user.id || '' + } + } + ); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('DEPLOYMENT_DELETE_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to delete deployment', responseData).getSerializable() + ); + } + + return responseData; + } catch (error) { + logger.error('DEPLOYMENT_DELETE_ERROR', error, locals.user.id); + return fail(500, new APIError('Failed to delete deployment', error as any).getSerializable()); + } + } +}; diff --git a/services/app/src/routes/(main)/system/models/+page.svelte b/services/app/src/routes/(main)/system/models/+page.svelte new file mode 100644 index 0000000..a4531c2 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/+page.svelte @@ -0,0 +1,56 @@ + + + + Model Setup + + +
    + + + +
    + + + + { + if (!v) { + page.url.searchParams.delete('onboarding'); + goto(`?${page.url.searchParams}`, { invalidate: [] }); + } + }} +> + +

    Start deploying your first model

    + +
    +
    diff --git a/services/app/src/routes/(main)/system/models/[model_id]/(components)/CloudDeployments.svelte b/services/app/src/routes/(main)/system/models/[model_id]/(components)/CloudDeployments.svelte new file mode 100644 index 0000000..4be96ab --- /dev/null +++ b/services/app/src/routes/(main)/system/models/[model_id]/(components)/CloudDeployments.svelte @@ -0,0 +1,106 @@ + + + + + + Endpoint Name + API Base + Provider + Routing ID + Actions + + + + + + + + + {#if model.deployments?.length} + {@const cloudDeployments = model.deployments.flat()} + {#if cloudDeployments.length > 0} + {#each cloudDeployments as deployment} + + {deployment.name} + {deployment.api_base} + + {PROVIDERS[deployment.provider] || deployment.provider} + + {deployment.routing_id} + +
    + + +
    +
    +
    + {/each} + {:else} + + + No cloud deployments found + + {/if} + {/if} +
    +
    + + + +{#if selectedEditDeploymentId} + +{/if} + diff --git a/services/app/src/routes/(main)/system/models/[model_id]/(components)/ModelDetails.svelte b/services/app/src/routes/(main)/system/models/[model_id]/(components)/ModelDetails.svelte new file mode 100644 index 0000000..6b7f3d4 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/[model_id]/(components)/ModelDetails.svelte @@ -0,0 +1,513 @@ + + +
    +
    { + if (!modelType) { + toast.error('Model type is required', { + id: 'model-type-required' + }); + cancel(); + return; + } + + if (selectedCapabilities.length === 0) { + toast.error('At least one capability is required', { + id: 'capability-required' + }); + cancel(); + return; + } + + loading = true; + if (modelIcon) { + formData.set('icon', modelIcon); + } + + formData.set('type', modelType); + formData.set('capabilities', JSON.stringify(selectedCapabilities)); + + const languages = formData.get('languages'); + if (languages) { + formData.set('languages', JSON.stringify(stringToArray(languages.toString()))); + } + + const allowed_orgs = formData.get('allowed_orgs'); + if (allowed_orgs) { + formData.set('allowed_orgs', JSON.stringify(stringToArray(allowed_orgs.toString()))); + } + + const blocked_orgs = formData.get('blocked_orgs'); + if (blocked_orgs) { + formData.set('blocked_orgs', JSON.stringify(stringToArray(blocked_orgs.toString()))); + } + + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + toast.success('Model config updated successfully', { + id: 'edit-model-config-success' + }); + isEditing = false; + + page.url.searchParams.delete('edit'); + goto( + `/system/models/${encodeURIComponent(formData.get('id')?.toString()!)}?${page.url.searchParams}`, + { + invalidate: ['system:models', 'system:modelsslug'], + replaceState: true + } + ); + } + + update({ invalidateAll: false }); + loading = false; + }; + }} + action="?/edit-model-config" + class="px-5 py-5" + class:space-y-5={!isEditing} + > +
    +
    +

    Basic Information

    +
    + {#if isEditing} +
    + + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + + {#if modelIcon} +
    + + {modelIcon} +
    + {:else} + Select model icon + {/if} +
    + + {#each Object.keys(modelLogos) as icon} + + + {icon} + + {/each} + +
    +
    +
    + +
    + +
    + + + {MODEL_TYPES[modelType ?? ''] || 'Select model type'} + + + {#each Object.keys(MODEL_TYPES) as type} + {MODEL_TYPES[type]} + {/each} + + +
    +
    + +
    + +
    + {#each MODEL_CAPABILITIES as capability} + + {/each} +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    + {:else} +
    +
    +

    Model Name

    +

    {model.name}

    +
    +
    +

    Model ID

    +

    {model.id}

    +
    +
    +

    Model Type

    +
    +

    {model.type}

    +
    +
    +
    +

    Capabilities

    +

    + {model.capabilities.map((cap) => capitalize(cap)).join(', ')} +

    +
    +
    +

    Priority

    +

    {model.priority}

    +
    +
    +

    Owned By

    +

    {model.owned_by}

    +
    +
    + {/if} +
    + +
    +
    +

    Model Specification

    +
    + {#if isEditing} +
    +
    + +
    + +
    +
    + {#if modelType === MODEL_TYPES.embed.toLowerCase()} +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + {/if} +
    + +
    + +
    +
    +
    + {:else} +
    +
    +

    Context Length

    +

    {model.context_length}

    +
    +
    +

    Languages

    +

    {model.languages.join(', ')}

    +
    +
    + {/if} +
    + +
    +
    +

    Cost Configuration

    +
    + {#if isEditing} +
    + {#if modelType === MODEL_TYPES.llm.toLowerCase()} +
    + +
    + +
    +
    +
    + +
    + +
    +
    + {/if} + {#if modelType === MODEL_TYPES.embed.toLowerCase()} +
    + +
    + +
    +
    + {/if} + {#if modelType === MODEL_TYPES.rerank.toLowerCase()} +
    + +
    + +
    +
    + {/if} +
    + {:else} +
    +
    +

    Cost in USD per million input tokens

    +

    {model.llm_input_cost_per_mtoken}

    +
    +
    +

    Cost in USD per million output tokens

    +

    {model.llm_output_cost_per_mtoken}

    +
    +
    + {/if} +
    + +
    +
    +

    Access Control

    +
    + {#if isEditing} +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + {:else} +
    +
    +

    Allowed Orgs

    +

    {model.allowed_orgs.join(', ')}

    +
    +
    +

    Blocked Orgs

    +

    {model.blocked_orgs.join(', ')}

    +
    +
    +

    Is Private

    +
    + +
    +
    +
    + {/if} +
    +
    + {#if !isEditing} +
    + + + +
    + {:else} +
    + + +
    + {/if} +
    + + diff --git a/services/app/src/routes/(main)/system/models/[model_id]/(components)/index.ts b/services/app/src/routes/(main)/system/models/[model_id]/(components)/index.ts new file mode 100644 index 0000000..36f0313 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/[model_id]/(components)/index.ts @@ -0,0 +1,4 @@ +import CloudDeployments from './CloudDeployments.svelte'; +import ModelDetails from './ModelDetails.svelte'; + +export { CloudDeployments, ModelDetails }; diff --git a/services/app/src/routes/(main)/system/models/[model_id]/+page.server.ts b/services/app/src/routes/(main)/system/models/[model_id]/+page.server.ts new file mode 100644 index 0000000..dd38a52 --- /dev/null +++ b/services/app/src/routes/(main)/system/models/[model_id]/+page.server.ts @@ -0,0 +1,125 @@ +import { env } from '$env/dynamic/private'; +import { PUBLIC_ADMIN_ORGANIZATION_ID } from '$env/static/public'; +import logger, { APIError } from '$lib/logger.js'; +import type { ModelConfig } from '$lib/types.js'; +import { error, fail } from '@sveltejs/kit'; + +const { OWL_URL, OWL_SERVICE_KEY } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export async function load({ cookies, locals, depends, params }) { + depends('system:modelsslug'); + + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + if ( + cookies.get('activeOrganizationId') !== PUBLIC_ADMIN_ORGANIZATION_ID || + !locals.user?.org_memberships.find( + (org) => org.organization_id === PUBLIC_ADMIN_ORGANIZATION_ID + ) + ) { + throw error(404, 'Not found'); + } + + const getModelConfig = async () => { + const response = await fetch( + `${OWL_URL}/api/v2/models/configs?${new URLSearchParams([['model_id', params.model_id]])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user!.id || '' + } + } + ); + + const data = await response.json(); + + if (!response.ok) { + logger.error('MODELCONFIG_GET_ERROR', data, locals.user!.id); + return { data: null, status: response.status, error: data as any }; + } + + return { data: data as ModelConfig, status: response.status }; + }; + + return { + modelConfig: getModelConfig() + }; +} + +export const actions = { + 'edit-model-config': async function ({ locals, request }) { + if (!locals.user) { + return error(401, 'Unauthorized'); + } + + const formData = await request.formData(); + + const data: Record = {}; + + try { + for (const [key, value] of formData.entries()) { + // Skip empty values + if (value === '' || value === null || value === undefined) continue; + + // Handle value conversion + const numValue = Number(value); + if (!isNaN(numValue)) { + data[key] = numValue; + } else { + data[key] = value; + } + } + + data['capabilities'] = JSON.parse((formData.get('capabilities') as string) || '[]'); + + data['languages'] = JSON.parse((formData.get('languages') as string) || '[]'); + + data['allowed_orgs'] = JSON.parse((formData.get('allowed_orgs') as string) || '[]'); + data['blocked_orgs'] = JSON.parse((formData.get('blocked_orgs') as string) || '[]'); + + data['owned_by'] = (formData.get('owned_by') as string) || ''; + + if (data['icon']) { + if (!data['meta']) { + data['meta'] = {}; + } + data['meta']['icon'] = data['icon']; + delete data['icon']; + } + + const response = await fetch( + `${OWL_URL}/api/v2/models/configs?${new URLSearchParams([['model_id', data.model_id]])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': locals.user.id || '', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + } + ); + + const responseData = await response.json(); + + if (!response.ok) { + logger.error('MODELCONFIG_EDIT_ERROR', responseData, locals.user.id); + return fail( + response.status, + new APIError('Failed to edit model config', responseData).getSerializable() + ); + } + + return responseData; + } catch (error) { + logger.error('MODELCONFIG_EDIT_ERROR', error, locals.user.id); + return fail(500, new APIError('Failed to edit model config', error as any).getSerializable()); + } + } +}; diff --git a/services/app/src/routes/(main)/system/models/[model_id]/+page.svelte b/services/app/src/routes/(main)/system/models/[model_id]/+page.svelte new file mode 100644 index 0000000..eab37ef --- /dev/null +++ b/services/app/src/routes/(main)/system/models/[model_id]/+page.svelte @@ -0,0 +1,97 @@ + + + + Model Setup - {page.params.model_id} + + +
    + {#await data.modelConfig} +
    +
    +
    +
    +
    +
    + {:then modelConfig} + {#if modelConfig.data} +
    + +
    + + +

    + {modelConfig.data.name} + + {MODEL_TYPES[modelConfig.data.type] ?? modelConfig.data.type} + +

    +
    + + +
    +
    + + + + +
    + +
    + {#if activeTab === 'details'} + + {:else if activeTab === 'cloud'} + + + {/if} +
    +
    +
    + {/if} + {/await} +
    diff --git a/services/app/src/routes/+error.svelte b/services/app/src/routes/+error.svelte old mode 100644 new mode 100755 index a632a37..04a061f --- a/services/app/src/routes/+error.svelte +++ b/services/app/src/routes/+error.svelte @@ -1,20 +1,20 @@ - {$page.error.message} + {page.error.message}
    - {$page.status} + {page.status}
    -

    {$page.error.message}

    +

    {page.error.message}

    diff --git a/services/app/src/routes/+layout.server.ts b/services/app/src/routes/+layout.server.ts old mode 100644 new mode 100755 index 0ec5ec8..01df0eb --- a/services/app/src/routes/+layout.server.ts +++ b/services/app/src/routes/+layout.server.ts @@ -1,23 +1,28 @@ -import { PUBLIC_IS_LOCAL, PUBLIC_IS_SPA } from '$env/static/public'; -import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; -import { error, redirect } from '@sveltejs/kit'; -import { getPrices } from '$lib/server/nodeCache.js'; +import { env } from '$env/dynamic/private'; +import { PUBLIC_IS_SPA } from '$env/static/public'; import logger from '$lib/logger.js'; -import type { OrganizationReadRes, PriceRes, UserRead } from '$lib/types.js'; +import type { OrganizationReadRes } from '$lib/types.js'; +import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoadEvent } from './$types.js'; interface Data { - prices: PriceRes | undefined; user: App.Locals['user']; - userData?: UserRead; dockOpen: boolean; rightDockOpen: boolean; activeOrganizationId?: string; organizationData?: OrganizationReadRes; + OWL_STRIPE_PUBLISHABLE_KEY: string; } +const { + OWL_SERVICE_KEY, + OWL_URL, + OWL_STRIPE_PUBLISHABLE_KEY_LIVE, + OWL_STRIPE_PUBLISHABLE_KEY_TEST +} = env; + const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` + Authorization: `Bearer ${OWL_SERVICE_KEY}` }; export const prerender = PUBLIC_IS_SPA !== 'true' ? false : 'auto'; @@ -44,8 +49,6 @@ export const load: (event: LayoutServerLoadEvent) => Promise = async ({ }); } - const prices = await getPrices(); - const showDock = cookies.get('dockOpen') === 'true'; const showRightDock = cookies.get('rightDockOpen') === 'true'; @@ -56,206 +59,111 @@ export const load: (event: LayoutServerLoadEvent) => Promise = async ({ cookies.set('rightDockOpen', 'false', { path: '/', httpOnly: false }); } - if (PUBLIC_IS_LOCAL === 'false') { + if (!url.pathname.startsWith('/login') && !url.pathname.startsWith('/register')) { if (!locals.user!.email_verified && !url.pathname.startsWith('/verify-email')) { throw redirect( 302, - `/verify-email${url.searchParams.size > 0 ? `?${url.searchParams.toString()}` : ''}` + `/verify-email${url.searchParams.size > 0 ? `?${url.searchParams}` : ''}` ); } - if (locals.user!.email_verified) { + if (locals.user?.email_verified) { let activeOrganizationId = cookies.get('activeOrganizationId'); - const userApiRes = await fetch( - `${JAMAI_URL}/api/admin/backend/v1/users/${locals.user!.sub}`, - { - headers - } - ); - if (userApiRes.status === 404) { - const userUpsertRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users`, { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: locals.user!.sub, - name: - locals.user!.email === locals.user!.name ? locals.user!.nickname : locals.user!.name, - description: '', - email: locals.user!.email - }) - }); - const userUpsertBody = (await userUpsertRes.json()) as UserRead; - - if (userUpsertRes.ok) { - //? Redirect to create org if no orgs - if ( - userUpsertBody.member_of.length === 0 && - !url.pathname.startsWith('/new-organization') && - !url.pathname.startsWith('/accept-invite') - ) { - throw redirect(302, '/new-organization'); - } - - //? Set org ID if not set or if it's not in the list of orgs - if ( - !activeOrganizationId || - !userUpsertBody.member_of.find((org) => org.organization_id === activeOrganizationId) - ) { - cookies.set('activeOrganizationId', userUpsertBody.member_of[0].organization_id, { - path: '/', - sameSite: 'strict', - maxAge: 604800, - httpOnly: false, - secure: false - }); - - activeOrganizationId = cookies.get('activeOrganizationId'); - } - - const orgData = await getOrganizationData(activeOrganizationId!); - const userRoleInOrg = orgData?.members?.find( - (user) => user.user_id === locals.user?.sub - )?.role; - - //* Obfuscate external keys if not admin - if (orgData && userRoleInOrg !== 'admin') { - if (orgData.external_keys) { - orgData.external_keys = Object.fromEntries( - Object.entries(orgData.external_keys).map(([key, value]) => [ - key, - value.trim() === '' ? '' : '********' - ]) - ); - } - delete orgData.members; - - //* Obfuscate credit - orgData.credit = orgData.credit > 0 ? 1 : 0; - orgData.credit_grant = orgData.credit_grant > 0 ? 1 : 0; - - //* Remove JamAI api keys if not member - if (userRoleInOrg !== 'member') { - delete orgData.api_keys; - } - } - - return { - prices, - user: locals.user, - userData: userUpsertBody, - dockOpen: cookies.get('dockOpen') === 'true', - rightDockOpen: cookies.get('rightDockOpen') === 'true', - activeOrganizationId, - organizationData: orgData - }; - } else { - logger.error('APP_USER_UPSERT', userUpsertBody); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - throw error(userUpsertRes.status, userUpsertBody as any); - } - } else if (userApiRes.ok) { - const userApiBody = (await userApiRes.json()) as UserRead; - - //? Redirect to create org if no orgs - if ( - userApiBody.member_of.length === 0 && - !url.pathname.startsWith('/new-organization') && - !url.pathname.startsWith('/accept-invite') - ) { - throw redirect(302, '/new-organization'); - } - - //? Set org ID if not set or if it's not in the list of orgs - if ( - !activeOrganizationId || - !userApiBody.member_of.find((org) => org.organization_id === activeOrganizationId) - ) { - cookies.set('activeOrganizationId', userApiBody.member_of[0]?.organization_id, { - path: '/', - sameSite: 'strict', - maxAge: 604800, - httpOnly: false, - secure: false - }); - - activeOrganizationId = cookies.get('activeOrganizationId'); - } - - const orgData = await getOrganizationData(activeOrganizationId!); - const userRoleInOrg = orgData?.members?.find( - (user) => user.user_id === locals.user?.sub - )?.role; + //? Redirect to create org if no orgs + if ( + locals.user?.org_memberships.length === 0 && + !url.pathname.startsWith('/new-organization') && + !url.pathname.startsWith('/join-organization') + ) { + throw redirect(302, '/new-organization'); + } - //* Obfuscate external keys if not admin - if (orgData && userRoleInOrg !== 'admin') { - if (orgData.external_keys) { - orgData.external_keys = Object.fromEntries( - Object.entries(orgData.external_keys).map(([key, value]) => [ - key, - value.trim() === '' ? '' : '********' - ]) - ); - } - delete orgData.members; + //? Set org ID if not set or if it's not in the list of orgs + if ( + locals.user?.org_memberships.length !== 0 && + (!activeOrganizationId || + !locals.user?.org_memberships.find((org) => org.organization_id === activeOrganizationId)) + ) { + cookies.set('activeOrganizationId', locals.user!.org_memberships[0].organization_id!, { + path: '/', + sameSite: 'strict', + maxAge: 604800, + httpOnly: false, + secure: false + }); - //* Obfuscate credit - orgData.credit = orgData.credit > 0 ? 1 : 0; - orgData.credit_grant = orgData.credit_grant > 0 ? 1 : 0; + activeOrganizationId = cookies.get('activeOrganizationId'); + } - //* Remove JamAI api keys if not member - if (userRoleInOrg !== 'member') { - delete orgData.api_keys; - } + const orgData = await getOrganizationData(activeOrganizationId!); + const userRoleInOrg = locals.user?.org_memberships.find( + (org) => org.organization_id === activeOrganizationId + )?.role; + + //* Obfuscate external keys if not admin + if (orgData && userRoleInOrg !== 'ADMIN') { + if (orgData.external_keys) { + orgData.external_keys = Object.fromEntries( + Object.entries(orgData.external_keys).map(([key, value]) => [ + key, + value.trim() === '' ? '' : '********' + ]) + ); } - return { - prices, - user: locals.user, - userData: userApiBody, - dockOpen: cookies.get('dockOpen') === 'true', - rightDockOpen: cookies.get('rightDockOpen') === 'true', - activeOrganizationId, - organizationData: orgData - }; - } else { - logger.error('APP_USER_GET', await userApiRes.json()); - //FIXME: Throw error if user API fails, maybe? - return { - prices, - user: locals.user, - dockOpen: cookies.get('dockOpen') === 'true', - rightDockOpen: cookies.get('rightDockOpen') === 'true' - }; - throw error(userApiRes.status, await userApiRes.json()); + //* Obfuscate credit + orgData.credit = orgData.credit > 0 ? 1 : 0; + orgData.credit_grant = orgData.credit_grant > 0 ? 1 : 0; } + + return { + user: locals.user, + dockOpen: cookies.get('dockOpen') === 'true', + rightDockOpen: cookies.get('rightDockOpen') === 'true', + activeOrganizationId, + organizationData: orgData, + ossMode: locals.ossMode, + auth0Mode: locals.auth0Mode, + OWL_STRIPE_PUBLISHABLE_KEY: + OWL_STRIPE_PUBLISHABLE_KEY_LIVE || OWL_STRIPE_PUBLISHABLE_KEY_TEST || '' + }; } else { return { - prices, user: locals.user, dockOpen: cookies.get('rightDockOpen') === 'true', - rightDockOpen: cookies.get('rightDockOpen') === 'true' + rightDockOpen: cookies.get('rightDockOpen') === 'true', + ossMode: locals.ossMode, + auth0Mode: locals.auth0Mode, + OWL_STRIPE_PUBLISHABLE_KEY: + OWL_STRIPE_PUBLISHABLE_KEY_LIVE || OWL_STRIPE_PUBLISHABLE_KEY_TEST || '' }; } } else { return { - prices, user: locals.user, activeOrganizationId: 'default', dockOpen: cookies.get('rightDockOpen') === 'true', - rightDockOpen: cookies.get('rightDockOpen') === 'true' + rightDockOpen: cookies.get('rightDockOpen') === 'true', + ossMode: locals.ossMode, + auth0Mode: locals.auth0Mode, + OWL_STRIPE_PUBLISHABLE_KEY: + OWL_STRIPE_PUBLISHABLE_KEY_LIVE || OWL_STRIPE_PUBLISHABLE_KEY_TEST || '' }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function getOrganizationData(orgId: string): Promise { if (!orgId) return undefined; - const orgInfoRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/organizations/${orgId}`, { - headers - }); + const orgInfoRes = await fetch( + `${OWL_URL}/api/v2/organizations?${new URLSearchParams([['organization_id', orgId]])}`, + { + headers: { + ...headers, + 'x-user-id': locals.user?.id ?? '' + } + } + ); const orgInfoBody = (await orgInfoRes.json()) as OrganizationReadRes; if (!orgInfoRes.ok) { diff --git a/services/app/src/routes/+layout.svelte b/services/app/src/routes/+layout.svelte old mode 100644 new mode 100755 index 63551c1..3e0f28a --- a/services/app/src/routes/+layout.svelte +++ b/services/app/src/routes/+layout.svelte @@ -8,49 +8,66 @@ import '@fontsource-variable/roboto-flex'; import { showDock, showRightDock, preferredTheme, activeOrganization } from '$globalStore'; - import { Toaster } from '$lib/components/ui/sonner'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { CustomToastDesc, toast, Toaster } from '$lib/components/ui/sonner'; let timeout: NodeJS.Timeout; NProgress.configure({ showSpinner: false }); - beforeNavigate(() => (timeout = setTimeout(() => NProgress.start(), 250))); - afterNavigate(() => { - clearTimeout(timeout); - NProgress.done(); - }); + // beforeNavigate(() => (timeout = setTimeout(() => NProgress.start(), 250))); + // afterNavigate(() => { + // clearTimeout(timeout); + // NProgress.done(); + // }); - export let data; - $: ({ dockOpen, rightDockOpen, userData, activeOrganizationId } = data); + let { data, children } = $props(); + let { dockOpen, rightDockOpen, user, activeOrganizationId } = $derived(data); //* Initialize showDock using cookie store - $: $showDock = dockOpen; - $: $showRightDock = rightDockOpen; - - $: if (browser) { - document.cookie = `dockOpen=${$showDock}; path=/; sameSite=Lax`; - document.cookie = `rightDockOpen=${$showRightDock}; path=/; sameSite=Lax`; - } - - $: if (browser) { - if ($preferredTheme == 'SYSTEM') { - document.documentElement.setAttribute( - 'data-theme', - window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - ); - } else { - document.documentElement.setAttribute( - 'data-theme', - $preferredTheme == 'LIGHT' ? 'light' : 'dark' - ); + // svelte-ignore state_referenced_locally (mimic run function) + $showDock = dockOpen; + $effect.pre(() => { + $showDock = dockOpen; + }); + // svelte-ignore state_referenced_locally (mimic run function) + $showRightDock = rightDockOpen; + $effect.pre(() => { + $showRightDock = rightDockOpen; + }); + + $effect(() => { + if (browser) { + document.cookie = `dockOpen=${$showDock}; path=/; sameSite=Lax`; + document.cookie = `rightDockOpen=${$showRightDock}; path=/; sameSite=Lax`; } - } + }); - $: if (activeOrganizationId) { - $activeOrganization = - userData?.member_of?.find((org) => org.organization_id === activeOrganizationId) ?? null; - } - $: if (browser && $activeOrganization) { - document.cookie = `activeOrganizationId=${$activeOrganization?.organization_id}; path=/; max-age=604800; samesite=strict`; - } + $effect(() => { + if (browser) { + if ($preferredTheme == 'SYSTEM') { + document.documentElement.setAttribute( + 'data-theme', + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + } else { + document.documentElement.setAttribute( + 'data-theme', + $preferredTheme == 'LIGHT' ? 'light' : 'dark' + ); + } + } + }); + + $effect(() => { + if (activeOrganizationId) { + $activeOrganization = + user?.organizations?.find((org) => org.id === activeOrganizationId) ?? null; + } + }); + // $effect(() => { + // if (browser && $activeOrganization) { + // document.cookie = `activeOrganizationId=${$activeOrganization?.id}; path=/; max-age=604800; samesite=strict`; + // } + // }); onMount(() => { //* Reflect changes to user preference for immediately @@ -85,6 +102,53 @@ // document.startViewTransition(switchTheme); // } // } + + // function switchTheme(e: KeyboardEvent) { + // const target = e.target as HTMLElement; + // if ( + // target.tagName == 'INPUT' || + // target.tagName == 'TEXTAREA' || + // target.getAttribute('contenteditable') == 'true' + // ) + // return; + + // if (!e.ctrlKey && !e.shiftKey && !e.metaKey) { + // switch (e.key) { + // case 'e': + // toast.error('Test', { + // duration: Number.POSITIVE_INFINITY, + // description: CustomToastDesc as any, + // componentProps: { + // description: 'Error desc here', + // requestID: 'Request ID here' + // } + // }); + // break; + // case 's': + // toast.success('Test', { + // duration: Number.POSITIVE_INFINITY, + // description: CustomToastDesc as any, + // componentProps: { + // description: 'Error desc here', + // requestID: 'Request ID here' + // } + // }); + // break; + // case 'i': + // toast.info('Test', { + // duration: Number.POSITIVE_INFINITY, + // description: CustomToastDesc as any, + // componentProps: { + // description: 'Error desc here', + // requestID: 'Request ID here' + // } + // }); + // break; + // default: + // break; + // } + // } + // } @@ -113,4 +177,6 @@ - + + {@render children?.()} + diff --git a/services/app/src/routes/+page.ts b/services/app/src/routes/+page.ts old mode 100644 new mode 100755 diff --git a/services/app/src/routes/_layout.ts b/services/app/src/routes/_layout.ts old mode 100644 new mode 100755 index 5f4a9d3..d8eff40 --- a/services/app/src/routes/_layout.ts +++ b/services/app/src/routes/_layout.ts @@ -1,10 +1,10 @@ -import { PUBLIC_IS_LOCAL, PUBLIC_IS_SPA } from '$env/static/public'; +import { PUBLIC_IS_SPA } from '$env/static/public'; //@ts-expect-error missing types export async function load({ parent }) { - await parent(); + const data = await parent(); - if (PUBLIC_IS_LOCAL !== 'false' && PUBLIC_IS_SPA === 'true') { + if (data.ossMode && PUBLIC_IS_SPA === 'true') { return { activeOrganizationId: 'default', dockOpen: true, diff --git a/services/app/src/routes/api/admin/org/v1/projects/+server.ts b/services/app/src/routes/api/admin/org/v1/projects/+server.ts deleted file mode 100644 index c75e6f3..0000000 --- a/services/app/src/routes/api/admin/org/v1/projects/+server.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { PUBLIC_IS_LOCAL } from '$env/static/public'; -import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; -import { json } from '@sveltejs/kit'; -import { projectIDPattern } from '$lib/constants.js'; -import logger, { APIError } from '$lib/logger.js'; -import type { UserRead } from '$lib/types.js'; - -const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` -}; - -export const GET = async ({ cookies, locals, url }) => { - const activeOrganizationId = cookies.get('activeOrganizationId'); - - if (PUBLIC_IS_LOCAL === 'false') { - if (!activeOrganizationId) { - return json(new APIError('No active organization'), { status: 400 }); - } - - //* Verify user perms - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find( - (org) => org.organization_id === activeOrganizationId - ); - if (!targetOrg) { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_LIST_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - const searchParams = new URLSearchParams({ organization_id: activeOrganizationId ?? '' }); - url.searchParams.forEach((value, key) => { - if (key === 'organization_id' && PUBLIC_IS_LOCAL === 'false') return; - searchParams.set(key, value); - }); - - const projectsListRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects?${searchParams}`, { - headers - }); - const projectsListBody = await projectsListRes.json(); - - if (!projectsListRes.ok) { - logger.error('PROJECT_LIST_LIST', projectsListBody); - return json(new APIError('Failed to get projects list', projectsListBody), { - status: projectsListRes.status - }); - } else { - return json(projectsListBody); - } -}; - -export const POST = async ({ cookies, fetch, locals, request }) => { - const activeOrganizationId = cookies.get('activeOrganizationId'); - - const { name: project_name } = await request.json(); - if (!project_name || typeof project_name !== 'string' || project_name.trim() === '') { - return json(new APIError('Invalid project name'), { status: 400 }); - } - - if (!projectIDPattern.test(project_name)) { - return json( - new APIError( - 'Project name must contain only alphanumeric characters and underscores/hyphens/spaces/periods, and start and end with alphanumeric characters, between 2 and 100 characters.' - ), - { status: 400 } - ); - } - - if (PUBLIC_IS_LOCAL === 'false') { - //* Verify user perms - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find( - (org) => org.organization_id === activeOrganizationId - ); - if (!targetOrg || (targetOrg.role !== 'admin' && targetOrg.role !== 'member')) { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_CREATE_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - const createProjectRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects`, { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: project_name, - organization_id: activeOrganizationId - }) - }); - - const createProjectBody = await createProjectRes.json(); - if (!createProjectRes.ok) { - logger.error('PROJECT_CREATE_CREATE', createProjectBody); - return json(new APIError('Failed to create project', createProjectBody), { - status: createProjectRes.status - }); - } else { - return json(createProjectBody); - } -}; - -export const PATCH = async ({ locals, request }) => { - const { id: projectId, name: project_name } = await request.json(); - if (!project_name || typeof project_name !== 'string' || project_name.trim() === '') { - return json(new APIError('Invalid project name'), { status: 400 }); - } - - if (PUBLIC_IS_LOCAL === 'false') { - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const projectApiRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects/${projectId}`, { - headers - }); - const projectApiBody = await projectApiRes.json(); - - if (!projectApiRes.ok) { - logger.error('PROJECT_PATCH_GETPROJ', projectApiBody); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find( - (org) => org.organization_id === projectApiBody.organization_id - ); - if (!targetOrg || targetOrg.role !== 'admin') { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_PATCH_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - const patchProjectRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects`, { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: projectId, - name: project_name - }) - }); - - const patchProjectBody = await patchProjectRes.json(); - if (!patchProjectRes.ok) { - logger.error('PROJECT_PATCH_PATCH', patchProjectBody); - return json(new APIError('Failed to update project', patchProjectBody as any), { - status: patchProjectRes.status - }); - } else { - return json({ ok: true }); - } -}; diff --git a/services/app/src/routes/api/admin/org/v1/projects/[project_id]/+server.ts b/services/app/src/routes/api/admin/org/v1/projects/[project_id]/+server.ts deleted file mode 100644 index dfbe582..0000000 --- a/services/app/src/routes/api/admin/org/v1/projects/[project_id]/+server.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { PUBLIC_IS_LOCAL } from '$env/static/public'; -import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; -import { json } from '@sveltejs/kit'; -import logger, { APIError } from '$lib/logger.js'; -import type { Project, UserRead } from '$lib/types.js'; - -const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` -}; - -export const GET = async ({ locals, params }) => { - if (PUBLIC_IS_LOCAL === 'false') { - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - } - - const projectRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects/${params.project_id}`, { - headers - }); - const projectBody = await projectRes.json(); - - if (projectRes.ok) { - if ( - PUBLIC_IS_LOCAL !== 'false' || - (projectBody as Project).organization.members?.find( - (user) => user.user_id === locals.user?.sub - ) - ) { - return json(projectBody); - } else { - return json(new APIError('Project not found'), { status: 404 }); - } - } else { - return json(new APIError('Failed to get project', projectBody as any), { - status: projectRes.status - }); - } -}; - -export const DELETE = async ({ locals, params }) => { - const projectId = params.project_id; - - if (PUBLIC_IS_LOCAL === 'false') { - //* Verify user perms - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const projectApiRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects/${projectId}`, { - headers - }); - const projectApiBody = await projectApiRes.json(); - - if (!projectApiRes.ok) { - logger.error('PROJECT_DELETE_GETPROJ', projectApiBody); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find( - (org) => org.organization_id === projectApiBody.organization_id - ); - if (!targetOrg || targetOrg.role !== 'admin') { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_DELETE_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - const deleteProjectRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects/${projectId}`, { - method: 'DELETE', - headers - }); - - const deleteProjectBody = await deleteProjectRes.json(); - if (!deleteProjectRes.ok) { - logger.error('PROJECT_DELETE_DELETE', deleteProjectBody); - return json(new APIError('Failed to delete project', deleteProjectBody as any), { - status: deleteProjectRes.status - }); - } else { - return json({ ok: true }); - } -}; diff --git a/services/app/src/routes/api/admin/org/v1/projects/[project_id]/export/+server.ts b/services/app/src/routes/api/admin/org/v1/projects/[project_id]/export/+server.ts deleted file mode 100644 index 4c0671f..0000000 --- a/services/app/src/routes/api/admin/org/v1/projects/[project_id]/export/+server.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { PUBLIC_IS_LOCAL } from '$env/static/public'; -import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; -import { json } from '@sveltejs/kit'; -import logger, { APIError } from '$lib/logger.js'; -import type { UserRead } from '$lib/types.js'; - -const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` -}; - -export const GET = async ({ locals, params }) => { - const projectId = params.project_id; - - if (PUBLIC_IS_LOCAL === 'false') { - //* Verify user perms - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const projectApiRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects/${projectId}`, { - headers - }); - const projectApiBody = await projectApiRes.json(); - - if (!projectApiRes.ok) { - logger.error('PROJECT_EXPORT_GETPROJ', projectApiBody); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find( - (org) => org.organization_id === projectApiBody.organization_id - ); - if (!targetOrg) { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_EXPORT_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - const exportProjectRes = await fetch( - `${JAMAI_URL}/api/admin/org/v1/projects/${projectId}/export`, - { - headers - } - ); - - if (!exportProjectRes.ok) { - const exportProjectBody = await exportProjectRes.json(); - logger.error('PROJECT_EXPORT_EXPORT', exportProjectBody); - return json(new APIError('Failed to export project', exportProjectBody as any), { - status: exportProjectRes.status - }); - } else { - return exportProjectRes; - } -}; diff --git a/services/app/src/routes/api/admin/org/v1/projects/import/[organization_id]/+server.ts b/services/app/src/routes/api/admin/org/v1/projects/import/[organization_id]/+server.ts deleted file mode 100644 index cb15352..0000000 --- a/services/app/src/routes/api/admin/org/v1/projects/import/[organization_id]/+server.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { PUBLIC_IS_LOCAL } from '$env/static/public'; -import { JAMAI_URL, JAMAI_SERVICE_KEY } from '$env/static/private'; -import { json } from '@sveltejs/kit'; -import axios from 'axios'; -import logger, { APIError } from '$lib/logger.js'; -import type { UserRead } from '$lib/types.js'; - -const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` -}; - -export const POST = async ({ locals, params, request }) => { - const organizationId = params.organization_id; - - if (PUBLIC_IS_LOCAL === 'false') { - //* Verify user perms - if (!locals.user) { - return json(new APIError('Unauthorized'), { status: 401 }); - } - - const userApiRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${locals.user.sub}`, { - headers - }); - const userApiBody = (await userApiRes.json()) as UserRead; - - if (userApiRes.ok) { - const targetOrg = userApiBody.member_of.find((org) => org.organization_id === organizationId); - if (!targetOrg || targetOrg.role === 'guest') { - return json(new APIError('Forbidden'), { status: 403 }); - } - } else { - logger.error('PROJECT_IMPORT_GETUSER', userApiBody); - return json(new APIError('Failed to get user info', userApiBody as any), { - status: userApiRes.status - }); - } - } - - try { - const importProjectRes = await axios.post( - `${JAMAI_URL}/api/admin/org/v1/projects/import/${organizationId}`, - await request.formData(), - { - headers: { - ...headers, - 'Content-Type': 'multipart/form-data' - } - } - ); - if (importProjectRes.status != 200) { - logger.error('PROJECT_IMPORT_IMPORT', importProjectRes.data); - return json(new APIError('Failed to import project', importProjectRes.data as any), { - status: importProjectRes.status - }); - } else { - return new Response(importProjectRes.data); - } - } catch (err) { - //@ts-expect-error AxiosError - logger.error('PROJECT_IMPORT_IMPORT', err?.response?.data); - //@ts-expect-error AxiosError - return json(new APIError('Failed to import project', err?.response?.data), { - status: 500 - }); - } -}; diff --git a/services/app/src/routes/api/log/+server.ts b/services/app/src/routes/api/log/+server.ts old mode 100644 new mode 100755 index 07c20bc..5fe2960 --- a/services/app/src/routes/api/log/+server.ts +++ b/services/app/src/routes/api/log/+server.ts @@ -1,6 +1,6 @@ +import { enumerateObj } from '$lib/utils.js'; import { json } from '@sveltejs/kit'; import { z } from 'zod'; -import { enumerateObj } from '$lib/utils.js'; export const POST = async ({ request, locals }) => { const body = await request.json().catch(() => null); @@ -27,31 +27,31 @@ export const POST = async ({ request, locals }) => { switch (type) { case 'error': console.error( - `Logged from client (${locals.user?.sub ?? 'Unknown'}): ${event}\n`, + `Logged from client (${locals.user?.id ?? 'Unknown'}): ${event}\n`, stringMessage ); break; case 'warn': console.warn( - `Logged from client (${locals.user?.sub ?? 'Unknown'}): ${event}\n`, + `Logged from client (${locals.user?.id ?? 'Unknown'}): ${event}\n`, stringMessage ); break; case 'info': console.info( - `Logged from client (${locals.user?.sub ?? 'Unknown'}): ${event}\n`, + `Logged from client (${locals.user?.id ?? 'Unknown'}): ${event}\n`, stringMessage ); break; case 'log': console.log( - `Logged from client (${locals.user?.sub ?? 'Unknown'}): ${event}\n`, + `Logged from client (${locals.user?.id ?? 'Unknown'}): ${event}\n`, stringMessage ); break; default: console.log( - `Logged from client (${locals.user?.sub ?? 'Unknown'}): ${event}\n`, + `Logged from client (${locals.user?.id ?? 'Unknown'}): ${event}\n`, stringMessage ); break; diff --git a/services/app/src/routes/api/v2/projects/export/+server.ts b/services/app/src/routes/api/v2/projects/export/+server.ts new file mode 100755 index 0000000..3bada5e --- /dev/null +++ b/services/app/src/routes/api/v2/projects/export/+server.ts @@ -0,0 +1,35 @@ +import { env } from '$env/dynamic/private'; +import logger, { APIError } from '$lib/logger.js'; +import { json } from '@sveltejs/kit'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const GET = async ({ locals, url }) => { + const projectId = url.searchParams.get('project_id'); + + //* Verify user perms + if (!locals.user) { + return json(new APIError('Unauthorized'), { status: 401 }); + } + + const exportProjectRes = await fetch( + `${OWL_URL}/api/v2/projects/export?${new URLSearchParams([['project_id', projectId ?? '']])}`, + { + headers + } + ); + + if (!exportProjectRes.ok) { + const exportProjectBody = await exportProjectRes.json(); + logger.error('PROJECT_EXPORT_EXPORT', exportProjectBody); + return json(new APIError('Failed to export project', exportProjectBody as any), { + status: exportProjectRes.status + }); + } else { + return exportProjectRes; + } +}; diff --git a/services/app/src/routes/api/v2/projects/import/parquet/+server.ts b/services/app/src/routes/api/v2/projects/import/parquet/+server.ts new file mode 100755 index 0000000..2a496d2 --- /dev/null +++ b/services/app/src/routes/api/v2/projects/import/parquet/+server.ts @@ -0,0 +1,45 @@ +import { env } from '$env/dynamic/private'; +import logger, { APIError } from '$lib/logger.js'; +import { json } from '@sveltejs/kit'; +import axios from 'axios'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const POST = async ({ locals, request }) => { + //* Verify user perms + if (!locals.user) { + return json(new APIError('Unauthorized'), { status: 401 }); + } + + try { + const importProjectRes = await axios.post( + `${OWL_URL}/api/v2/projects/import/parquet`, + await request.formData(), + { + headers: { + ...headers, + 'Content-Type': 'multipart/form-data' + } + } + ); + if (importProjectRes.status != 200) { + logger.error('PROJECT_IMPORT_IMPORT', importProjectRes.data); + return json(new APIError('Failed to import project', importProjectRes.data as any), { + status: importProjectRes.status + }); + } else { + return new Response(importProjectRes.data); + } + } catch (err) { + //@ts-expect-error AxiosError + logger.error('PROJECT_IMPORT_IMPORT', err?.response?.data); + //@ts-expect-error AxiosError + return json(new APIError('Failed to import project', err?.response?.data), { + status: 500 + }); + } +}; diff --git a/services/app/src/routes/join-organization/+page.server.ts b/services/app/src/routes/join-organization/+page.server.ts new file mode 100755 index 0000000..125a132 --- /dev/null +++ b/services/app/src/routes/join-organization/+page.server.ts @@ -0,0 +1,80 @@ +import { env } from '$env/dynamic/private'; +import logger from '$lib/logger.js'; +import { error, redirect } from '@sveltejs/kit'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const load = async ({ locals, url, parent }) => { + await parent(); + const token = url.searchParams.get('token'); + + if (token) { + if (!locals.user) { + throw error(401, 'Unauthorized'); + } + + const inviteUserRes = await fetch( + `${OWL_URL}/api/v2/organizations/members?${new URLSearchParams([ + ['user_id', locals.user.id], + ['invite_code', token] + ])}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user?.id ?? '' + } + } + ); + + const inviteUserBody = await inviteUserRes.json(); + if (!inviteUserRes.ok) { + if (inviteUserRes.status !== 404) { + logger.error('INVITEORG_TOKEN_ERROR', inviteUserBody); + } + throw error(inviteUserRes.status, inviteUserBody.message || JSON.stringify(inviteUserBody)); + } else { + throw redirect(302, '/'); + } + } +}; + +export const actions = { + /** Form actions method of invite */ + // default: async function ({ locals, request }) { + // if (!locals.user) { + // return error(401, 'Unauthorized'); + // } + // const formdata = await request.formData(); + // const code = formdata.get('code'); + // if (!code || typeof code !== 'string' || code.trim() === '') { + // return fail(400, new APIError('Code (type string) is required').getSerializable()); + // } + // const response = await fetch( + // `${OWL_URL}/api/v2/organizations/members?${new URLSearchParams([ + // ['user_id', locals.user?.id ?? ''], + // ['invite_code', code] + // ])}`, + // { + // method: 'POST', + // headers: { + // ...headers, + // // 'x-user-id': locals.user.id || '', + // 'Content-Type': 'application/json' + // } + // } + // ); + // const responseBody = await response.json(); + // if (response.ok) { + // return responseBody?.organization; + // } + // return fail( + // response.status, + // new APIError('Failed to join organization', responseBody).getSerializable() + // ); + // } +}; diff --git a/services/app/src/routes/join-organization/+page.svelte b/services/app/src/routes/join-organization/+page.svelte new file mode 100644 index 0000000..85bd8db --- /dev/null +++ b/services/app/src/routes/join-organization/+page.svelte @@ -0,0 +1,112 @@ + + + + Join Organization + + +
    + + +
    +
    +

    Join Organization

    +

    + Enter the code provided by your organization administrator +

    +
    + +
    { + loading = true; + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + errorMessage = data?.err_message?.message || ''; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + activeOrganization.setOrgCookie(data?.data?.id ?? $activeOrganization?.id); + await goto('/'); + } + loading = false; + await update(); + }; + }} + class="mt-10 space-y-6" + > +
    + + {#snippet children({ cells })} + + {#each cells as cell (cell)} + + {/each} + + {/snippet} + + + {#if errorMessage} + + {errorMessage} + + {/if} +
    +
    + +
    +

    Don't have a code? Contact your organization administrator for Invitation.

    +
    + +
    + + +
    +
    +
    diff --git a/services/app/src/routes/join-project/+page.server.ts b/services/app/src/routes/join-project/+page.server.ts new file mode 100755 index 0000000..318a266 --- /dev/null +++ b/services/app/src/routes/join-project/+page.server.ts @@ -0,0 +1,80 @@ +import { env } from '$env/dynamic/private'; +import logger from '$lib/logger.js'; +import { error, redirect } from '@sveltejs/kit'; + +const { OWL_SERVICE_KEY, OWL_URL } = env; + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export const load = async ({ locals, url, parent }) => { + await parent(); + const token = url.searchParams.get('token'); + + if (token) { + if (!locals.user) { + throw error(401, 'Unauthorized'); + } + + const inviteUserRes = await fetch( + `${OWL_URL}/api/v2/projects/members?${new URLSearchParams([ + ['user_id', locals.user.id], + ['invite_code', token] + ])}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user?.id ?? '' + } + } + ); + + const inviteUserBody = await inviteUserRes.json(); + if (!inviteUserRes.ok) { + if (inviteUserRes.status !== 404) { + logger.error('INVITEPROJ_TOKEN_ERROR', inviteUserBody); + } + throw error(inviteUserRes.status, inviteUserBody.message || JSON.stringify(inviteUserBody)); + } else { + throw redirect(302, '/'); + } + } +}; + +export const actions = { + /** Form actions method of invite */ + // default: async function ({ locals, request }) { + // if (!locals.uer) { + // return error(401, 'Unauthorized'); + // } + // const formdata = await request.formData(); + // const code = formdata.get('code'); + // if (!code || typeof code !== 'string' || code.trim() === '') { + // return fail(400, new APIError('Code (type string) is required').getSerializable()); + // } + // const response = await fetch( + // `${OWL_URL}/api/v2/organizations/members?${new URLSearchParams([ + // ['user_id', locals.user?.id ?? ''], + // ['invite_code', code] + // ])}`, + // { + // method: 'POST', + // headers: { + // ...headers, + // // 'x-user-id': locals.user.id || '', + // 'Content-Type': 'application/json' + // } + // } + // ); + // const responseBody = await response.json(); + // if (response.ok) { + // return responseBody?.organization; + // } + // return fail( + // response.status, + // new APIError('Failed to join organization', responseBody).getSerializable() + // ); + // } +}; diff --git a/services/app/src/routes/join-project/+page.svelte b/services/app/src/routes/join-project/+page.svelte new file mode 100644 index 0000000..490cb91 --- /dev/null +++ b/services/app/src/routes/join-project/+page.svelte @@ -0,0 +1,110 @@ + + + + Join Organization + + +
    + + +
    +
    +

    Join Project

    +

    + Enter the code provided by your project administrator +

    +
    + +
    { + loading = true; + return async ({ update, result }) => { + //@ts-ignore + const data = result.data; + errorMessage = data?.err_message?.message || ''; + if (result.type === 'failure') { + toast.error(data.error, { + id: data?.err_message?.message || JSON.stringify(data), + description: CustomToastDesc as any, + componentProps: { + description: data?.err_message?.message || JSON.stringify(data), + requestID: data?.err_message?.request_id ?? '' + } + }); + } else if (result.type === 'success') { + activeOrganization.setOrgCookie(data?.data?.id ?? $activeOrganization?.id); + await goto('/'); + } + loading = false; + await update(); + }; + }} + class="mt-10 space-y-6" + > +
    + + {#snippet children({ cells })} + + {#each cells as cell (cell)} + + {/each} + + {/snippet} + + + {#if errorMessage} + + {errorMessage} + + {/if} +
    +
    + +
    +

    Don't have a code? Contact your project administrator for Invitation.

    +
    + +
    + + +
    +
    +
    diff --git a/services/app/src/routes/login/+page.svelte b/services/app/src/routes/login/+page.svelte new file mode 100644 index 0000000..aa0ecd8 --- /dev/null +++ b/services/app/src/routes/login/+page.svelte @@ -0,0 +1,138 @@ + + + + Log in | JamAI Base + + +
    +
    +
    + +

    Welcome back

    +
    + +
    +
    +
    + + +
    + +
    + + + +
    +
    + + {#if error} + + {error} + + {/if} + + +
    + +
    + Don't have an account? + +
    +
    +
    diff --git a/services/app/src/routes/login/auth-errors.ts b/services/app/src/routes/login/auth-errors.ts new file mode 100644 index 0000000..f8adaf0 --- /dev/null +++ b/services/app/src/routes/login/auth-errors.ts @@ -0,0 +1,22 @@ +export const AUTH_ERROR_MESSAGES = { + invalid_credentials: 'Invalid email or password', + default: 'An error occurred during authentication', + user_exists: 'An account with this email already exists', + user_not_found: 'No user found with the email provided', + weak_password: 'Password should be at least 8 characters long', + email_verification: 'Please verify your email address', + account_disabled: 'Your account has been disabled', + rate_limit: 'Too many attempts. Please try again later', + invalid_token: 'Your session has expired. Please sign in again', + server_error: 'Server error. Please try again later' +}; + +// Type to ensure the code is one of the keys in AUTH_ERROR_MESSAGES +export type TCode = keyof typeof AUTH_ERROR_MESSAGES; + +export const getAuthErrorMessage = (code?: string | null): string => { + if (code && code in AUTH_ERROR_MESSAGES) { + return AUTH_ERROR_MESSAGES[code as TCode]; + } + return AUTH_ERROR_MESSAGES.default; +}; diff --git a/services/app/src/routes/new-organization/+page.server.ts b/services/app/src/routes/new-organization/+page.server.ts new file mode 100755 index 0000000..bd0a407 --- /dev/null +++ b/services/app/src/routes/new-organization/+page.server.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; + +export async function load({ locals }) { + if (!locals.user) { + throw redirect(302, '/'); + } +} diff --git a/services/app/src/routes/new-organization/+page.svelte b/services/app/src/routes/new-organization/+page.svelte new file mode 100755 index 0000000..a4bba32 --- /dev/null +++ b/services/app/src/routes/new-organization/+page.svelte @@ -0,0 +1,190 @@ + + + + Create new organization + + +
    + + +
    + {#if (user?.org_memberships ?? []).length > 0} + Create a new organization + {:else} + Welcome,
    let's get you ready! + {/if} +
    + +
    + +
    +
    + + + + + + Your organization's display name. You can change this later. + +
    + + +
    + +
    + +
    +
    + + +
    + {#if (user?.org_memberships ?? []).length > 0} + + {:else} + + {/if} + + +
    +
    +
    +
    +
    + + diff --git a/services/app/src/routes/register/+page.svelte b/services/app/src/routes/register/+page.svelte new file mode 100644 index 0000000..5aa20ad --- /dev/null +++ b/services/app/src/routes/register/+page.svelte @@ -0,0 +1,177 @@ + + + + Sign Up | JamAI Base + + +
    +
    +
    + +

    Create account

    +
    + +
    +
    +
    + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + + +
    +
    + + {#if error} + + {error} + + {/if} + + +
    + +
    + Already have an account? + +
    +
    +
    diff --git a/services/app/src/routes/register/auth-errors.ts b/services/app/src/routes/register/auth-errors.ts new file mode 100644 index 0000000..697b0d4 --- /dev/null +++ b/services/app/src/routes/register/auth-errors.ts @@ -0,0 +1,22 @@ +export const AUTH_ERROR_MESSAGES = { + invalid_credentials: 'Invalid username or password', + default: 'An error occurred during authentication', + user_exists: 'An account with this username already exists', + user_not_found: 'No user found with the username provided', + weak_password: 'Password should be at least 8 characters long', + email_verification: 'Please verify your email address', + account_disabled: 'Your account has been disabled', + rate_limit: 'Too many attempts. Please try again later', + invalid_token: 'Your session has expired. Please sign in again', + server_error: 'Server error. Please try again later' +}; + +// Type to ensure the code is one of the keys in AUTH_ERROR_MESSAGES +export type TCode = keyof typeof AUTH_ERROR_MESSAGES; + +export const getAuthErrorMessage = (code?: string | null): string => { + if (code && code in AUTH_ERROR_MESSAGES) { + return AUTH_ERROR_MESSAGES[code as TCode]; + } + return AUTH_ERROR_MESSAGES.default; +}; diff --git a/services/app/src/routes/verify-email/+page.server.ts b/services/app/src/routes/verify-email/+page.server.ts new file mode 100755 index 0000000..30b79e6 --- /dev/null +++ b/services/app/src/routes/verify-email/+page.server.ts @@ -0,0 +1,301 @@ +import { env } from '$env/dynamic/private'; +import { emailCodeCooldownSecs } from '$lib/constants.js'; +import logger, { APIError } from '$lib/logger.js'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { ManagementClient } from 'auth0'; + +const { + AUTH0_CLIENT_ID, + AUTH0_ISSUER_BASE_URL, + AUTH0_MGMTAPI_CLIENT_ID, + AUTH0_MGMTAPI_CLIENT_SECRET, + ORIGIN, + OWL_SERVICE_KEY, + OWL_URL, + RESEND_API_KEY +} = env; + +const management = new ManagementClient({ + domain: AUTH0_ISSUER_BASE_URL?.replace('https://', '') ?? '', + clientId: AUTH0_MGMTAPI_CLIENT_ID ?? '', + clientSecret: AUTH0_MGMTAPI_CLIENT_SECRET ?? '' +}); + +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +export async function load({ locals, url }) { + if (!locals.user || locals.user.email_verified) { + throw redirect(302, '/'); + } + + const token = url.searchParams.get('token'); + + if (token) { + const verifyUserRes = await fetch( + `${OWL_URL}/api/v2/users/verify/email?${new URLSearchParams([['verification_code', token]])}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': locals.user.id ?? '' + } + } + ); + + const verifyUserBody = await verifyUserRes.json(); + if (!verifyUserRes.ok) { + if (verifyUserRes.status !== 404) { + logger.error('VERIFYEMAIL_LOAD_TOKEN', verifyUserBody); + } + throw error(verifyUserRes.status, verifyUserBody.message || JSON.stringify(verifyUserBody)); + } else { + throw redirect(302, '/'); + } + } + + const listCodesRes = await fetch( + `${OWL_URL}/api/v2/users/verify/email/code/list?${new URLSearchParams([ + ['limit', '1'], + ['search_query', locals.user.email], + ['search_columns', 'user_email'] + ])}`, + { + headers: { + ...headers, + 'x-user-id': '0' + } + } + ); + const listCodesBody = await listCodesRes.json(); + + if ( + listCodesRes.ok && + (!listCodesBody.items[0] || + new Date(listCodesBody.items[0]?.expiry).getTime() < new Date().getTime()) + ) { + const sendCodeRes = await fetch( + `${OWL_URL}/api/v2/users/verify/email/code?${new URLSearchParams([ + ['user_email', locals.user.email], + ['valid_days', '1'] + ])}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': '0' + } + } + ); + const sendCodeBody = await sendCodeRes.json(); + + if (!sendCodeRes.ok) { + logger.error('VERIFYEMAIL_LOAD_GETCODE', sendCodeBody); + } else { + const sendEmailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: 'JamAI Base ', + to: locals.user.email, + subject: 'Verify your JamAI Base email address', + html: getVerificationEmailBody(sendCodeBody.id) + }) + }); + + if (!sendEmailRes.ok) { + logger.error('VERIFYEMAIL_LOAD_SENDCODE', await sendEmailRes.json()); + } + } + } +} + +export const actions = { + 'resend-verification-email': async ({ locals }) => { + //* Verify user perms + if (!locals.user) { + return fail(401, new APIError('Unauthorized').getSerializable()); + } + + if (locals.auth0Mode) { + try { + const resendEmailRes = await management.jobs.verifyEmail({ + user_id: locals.user.sub!, + client_id: AUTH0_CLIENT_ID + }); + if (resendEmailRes.status !== 200 && resendEmailRes.status !== 201) { + logger.error('VERIFY_RESEND_EMAIL', resendEmailRes.data); + return fail( + resendEmailRes.status, + new APIError( + 'Failed to resend verification email', + resendEmailRes.data as any + ).getSerializable() + ); + } else { + return resendEmailRes.data; + } + } catch (err) { + logger.error('VERIFY_RESEND_EMAILERR', err); + return fail( + 500, + new APIError('Failed to resend verification email', err as any).getSerializable() + ); + } + } else { + try { + //? Check if resend cooldown is up + const response = await fetch( + `${OWL_URL}/api/v2/users/verify/email/code/list?${new URLSearchParams([ + ['limit', '1'], + ['search_query', locals.user!.email], + ['search_columns', 'user_email'] + ])}`, + { + headers: { + ...headers, + 'x-user-id': '0' + } + } + ); + const responseBody = await response.json(); + + if (response.ok) { + if ( + new Date().getTime() - new Date(responseBody.items[0]?.created_at).getTime() > + emailCodeCooldownSecs * 1000 + ) { + const sendCodeRes = await fetch( + `${OWL_URL}/api/v2/users/verify/email/code?${new URLSearchParams([ + ['user_email', locals.user.email], + ['valid_days', '1'] + ])}`, + { + method: 'POST', + headers: { + ...headers, + 'x-user-id': '0' + } + } + ); + const sendCodeBody = await sendCodeRes.json(); + + if (!sendCodeRes.ok) { + logger.error('VERIFYEMAIL_RESEND_GETCODE', sendCodeBody); + } else { + const sendEmailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: 'JamAI Base ', + to: locals.user.email, + subject: 'Verify your JamAI Base email address', + html: getVerificationEmailBody(sendCodeBody.id) + }) + }); + + if (!sendEmailRes.ok) { + logger.error('VERIFYEMAIL_RESEND_SENDCODE', await sendEmailRes.json()); + } + } + } else { + return fail( + 403, + new APIError( + 'Too many resend verification email requests, please wait.' + ).getSerializable() + ); + } + } else { + logger.error('VERIFY_RESEND_LISTCODE', responseBody); + return fail( + 500, + new APIError('Failed to resend verification email', responseBody).getSerializable() + ); + } + } catch (err) { + logger.error('VERIFY_RESEND_EMAILERR', err); + return fail( + 500, + new APIError('Failed to resend verification email', err as any).getSerializable() + ); + } + } + } +}; + +const getVerificationEmailBody = (verificationToken: string) => ` + + + + +
    + + + + +
    +
    +

    + JamAI Logo +

    +

    Verify your email address

    +

    Welcome to JamAI Base! To complete your account setup, please verify your email address by clicking the link below:

    + + + + +
    + + Verify Email Address + +
    +

    If the button above doesn't work, you can copy and paste this link into your browser:

    +

    ${ORIGIN}/verify-email?token=${verificationToken}

    +

    This verification link will expire in 24 hours.

    +
    + Thanks! +
    + JamAI Base +

    +
    +

    + If you did not make this request, you can ignore this mail. +

    +
    +
    +
    + +`; diff --git a/services/app/src/routes/verify-email/+page.svelte b/services/app/src/routes/verify-email/+page.svelte new file mode 100755 index 0000000..de5f18c --- /dev/null +++ b/services/app/src/routes/verify-email/+page.svelte @@ -0,0 +1,89 @@ + + + + Verify your email + + +
    +
    +
    +
    + {#if user?.picture_url} + User Avatar + {:else} + + {((page.data.user as User).name ?? 'Default User').charAt(0)} + + {/if} +
    + {user?.email} +
    + +

    Verify your email

    +

    + We've sent you an email with a link to verify your email address. Please check your inbox and + click the link to continue. +

    + +
    +
    { + if (emailResent) { + cancel(); + } else { + isLoading = true; + } + + return async ({ result, update }) => { + if (result.type !== 'success') { + toast.error('Error resending verification email', { + //@ts-ignore + id: result.data?.err_message?.message || JSON.stringify(result.data), + description: CustomToastDesc as any, + componentProps: { + //@ts-ignore + description: result.data?.err_message?.message || JSON.stringify(result.data) + } + }); + } else { + emailResent = true; + } + + isLoading = false; + update({ reset: result.type === 'success', invalidateAll: false }); + }; + }} + method="POST" + action="?/resend-verification-email" + > + +
    + + +
    + +

    + Verification email sent, please check your inbox. +

    +
    +
    diff --git a/services/app/src/showdown-theme.css b/services/app/src/showdown-theme.css old mode 100644 new mode 100755 diff --git a/services/app/static/favicon.ico b/services/app/static/favicon.ico old mode 100644 new mode 100755 diff --git a/services/app/static/favicon.png b/services/app/static/favicon.png old mode 100644 new mode 100755 diff --git a/services/app/static/jamai-onboarding-bg.svg b/services/app/static/jamai-onboarding-bg.svg old mode 100644 new mode 100755 diff --git a/services/app/static/logo.png b/services/app/static/logo.png old mode 100644 new mode 100755 diff --git a/services/app/svelte.config.js b/services/app/svelte.config.js old mode 100644 new mode 100755 diff --git a/services/app/tailwind.config.js b/services/app/tailwind.config.js old mode 100644 new mode 100755 index 07dd773..d1718ff --- a/services/app/tailwind.config.js +++ b/services/app/tailwind.config.js @@ -93,10 +93,25 @@ const config = { '100%': { opacity: '0' } + }, + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--bits-accordion-content-height)' } + }, + 'accordion-up': { + from: { height: 'var(--bits-accordion-content-height)' }, + to: { height: '0' } + }, + 'caret-blink': { + '0%,70%,100%': { opacity: '1' }, + '20%,50%': { opacity: '0' } } }, animation: { - blink: 'blink 1060ms steps(1) infinite' + blink: 'blink 1060ms steps(1) infinite', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'caret-blink': 'caret-blink 1.25s ease-out infinite' } } }, diff --git a/services/app/tests/auth.setup.ts b/services/app/tests/auth.setup.ts old mode 100644 new mode 100755 index d3babec..b7da7c5 --- a/services/app/tests/auth.setup.ts +++ b/services/app/tests/auth.setup.ts @@ -1,11 +1,12 @@ +import { test as setup } from '@playwright/test'; import 'dotenv/config'; import { existsSync } from 'fs'; -import { test as setup } from '@playwright/test'; +const ossMode = !process.env.OWL_SERVICE_KEY; const authFile = 'playwright/.auth/user.json'; setup('authenticate', async ({ browser, page }) => { - if (process.env.PUBLIC_IS_LOCAL === 'false') { + if (!ossMode) { if (existsSync(authFile)) { await page.close(); const context = await browser.newContext({ storageState: authFile }); @@ -16,13 +17,11 @@ setup('authenticate', async ({ browser, page }) => { const isCredentialsValid = !/.*\/(login)/.test(page.url()); if (!isCredentialsValid) { - await page.getByLabel('Email address').fill(process.env.TEST_ACC_EMAIL!); - await page.getByLabel('Password').fill(process.env.TEST_ACC_PW!); - await page.getByRole('button', { name: 'Continue', exact: true }).click(); + await page.getByPlaceholder('Username').fill(process.env.TEST_USER_USERNAME!); + await page.getByPlaceholder('Password').fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole('button', { name: 'Login', exact: true }).click(); } - await page.goto('/'); - await page.waitForURL(/.*\/(project|new-organization)/); await page.context().storageState({ path: authFile }); diff --git a/services/app/tests/fixtures/sample-csv.csv b/services/app/tests/fixtures/sample-csv.csv old mode 100644 new mode 100755 diff --git a/services/app/tests/fixtures/sample-data.json b/services/app/tests/fixtures/sample-data.json new file mode 100644 index 0000000..2522cd8 --- /dev/null +++ b/services/app/tests/fixtures/sample-data.json @@ -0,0 +1,122 @@ +{ + "admin": { + "email": "admin@example.com", + "name": "Sue Dough", + "username": "admin", + "password": "password" + }, + + "test": { + "email": "test@example.com", + "name": "Deepak Gurr", + "username": "test-user", + "password": "password" + }, + + "chat_model": { + "meta": { + "icon": "openai" + }, + "id": "openai/gpt-4o", + "name": "OpenAI GPT-4o", + "type": "llm", + "context_length": 1047576, + "capabilities": ["chat", "image"], + "languages": ["en", "fr"], + "llm_input_cost_per_mtoken": 2.5, + "llm_output_cost_per_mtoken": 10.0 + }, + "chat_model_deployment": { + "model_id": "openai/gpt-4o", + "name": "OpenAI GPT-4o", + "provider": "openai", + "routing_id": "openai/gpt-4o", + "api_base": "" + }, + + "embedding_model": { + "meta": { + "icon": "openai" + }, + "id": "openai/text-embedding-3-small-1536", + "name": "OpenAI Text Embedding 3 Small (1536-dim)", + "type": "embed", + "context_length": 8192, + "capabilities": ["embed"], + "languages": ["en", "fr"], + "embedding_size": 1536, + "embedding_cost_per_mtoken": 0.022 + }, + "embedding_model_deployment": { + "model_id": "openai/text-embedding-3-small-1536", + "name": "OpenAI Text Embedding 3 Small (1536-dim)", + "provider": "openai", + "routing_id": "openai/text-embedding-3-small", + "api_base": "" + }, + + "pro_plan": { + "name": "Pro", + "stripe_price_id_live": "a", + "stripe_price_id_test": "a", + "flat_cost": 0, + "credit_grant": 10000, + "max_users": 20, + "products": { + "llm_tokens": { + "name": "LLM Tokens", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "tokens" + }, + "embedding_tokens": { + "name": "Embedding Tokens", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "Tokens" + }, + "reranker_searches": { + "name": "Reranker Searches", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "Searches" + }, + "db_storage": { + "name": "DB Storage", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "bytes" + }, + "file_storage": { + "name": "File Storage", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "bytes" + }, + "egress": { + "name": "Egress", + "included": { + "unit_cost": 0, + "up_to": 100000 + }, + "tiers": [], + "unit": "bytes" + } + } + } +} diff --git a/services/app/tests/fixtures/sample-doc.txt b/services/app/tests/fixtures/sample-doc.txt old mode 100644 new mode 100755 diff --git a/services/app/tests/fixtures/sample-img.jpg b/services/app/tests/fixtures/sample-img.jpg old mode 100644 new mode 100755 diff --git a/services/app/tests/main.setup.ts b/services/app/tests/main.setup.ts old mode 100644 new mode 100755 index 66d58ea..a9c1329 --- a/services/app/tests/main.setup.ts +++ b/services/app/tests/main.setup.ts @@ -1,36 +1,288 @@ -import 'dotenv/config'; +import type { GenTableCol, ModelConfig } from '$lib/types'; import { test as setup } from '@playwright/test'; -import type { AvailableModel, GenTableCol } from '$lib/types'; +import 'dotenv/config'; +import { readFileSync } from 'fs'; +import Stripe from 'stripe'; -const { JAMAI_URL, JAMAI_SERVICE_KEY, TEST_ACC_USERID } = process.env; +const { OWL_URL, OWL_SERVICE_KEY, OWL_STRIPE_API_KEY } = process.env; +const stripe = new Stripe(OWL_STRIPE_API_KEY!); -setup('create org and tables', async () => { - const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` - }; +const testDataFile = 'tests/fixtures/sample-data.json'; +const headers = { + Authorization: `Bearer ${OWL_SERVICE_KEY}` +}; + +//TODO: Clean slate tests with teardown +setup.skip('create users', async () => { + const users = JSON.parse(readFileSync(testDataFile, 'utf-8')); + // const getUserRes = await fetch( + // `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', '0']])}`, + // { + // headers + // } + // ); + // const getUserBody = await getUserRes.json(); + + // if (!getUserRes.ok) { + // if (getUserRes.status !== 404) throw { code: 'get_user', ...getUserBody }; + + // } + const createAdminRes = await fetch(`${OWL_URL}/api/v2/auth/register/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(users.admin) + }); + const createAdminBody = await createAdminRes.json(); + + if (!createAdminRes.ok) throw { code: 'create_admin_user', ...createAdminBody }; + + const createTestUserRes = await fetch(`${OWL_URL}/api/v2/auth/register/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(users.test) + }); + const createTestUserBody = await createTestUserRes.json(); + + if (!createTestUserRes.ok) throw { code: 'create_test_user', ...createTestUserBody }; + + process.env.TEST_ADMIN_ID = createAdminBody.id; + process.env.TEST_USER_ID = createTestUserBody.id; + + // Verify accounts + const verifyAdminRes = await fetch(`${OWL_URL}/api/v2/users`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: process.env.TEST_ADMIN_ID!, + email_verified: true + }) + }); + if (!verifyAdminRes.ok) throw { code: 'verify_admin_user', ...(await verifyAdminRes.json()) }; + + const verifyTestUserRes = await fetch(`${OWL_URL}/api/v2/users`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: process.env.TEST_USER_ID!, + email_verified: true + }) + }); + if (!verifyTestUserRes.ok) + throw { code: 'verify_test_user', ...(await verifyTestUserRes.json()) }; +}); + +setup.skip('add model config and deployment', async () => { + // const modelPresetsRes = await fetch( + // 'https://raw.githubusercontent.com/EmbeddedLLM/JamAIBase/refs/heads/main/services/api/src/owl/configs/preset_models.json', + // { + // method: 'GET' + // } + // ); + + // if (!modelPresetsRes.ok) { + // const error = await modelPresetsRes.text(); + // throw { code: '', status: modelPresetsRes.status, message: error }; + // } + + // const modelPresetsBody = (await modelPresetsRes.json()) as ModelConfig[]; + + const models = JSON.parse(readFileSync(testDataFile, 'utf-8')); + + const createModelConfigs = await Promise.allSettled([ + createModelConfig(models.chat_table), + createModelConfig(models.embedding_model) + ]); + + if (createModelConfigs.some((val) => val.status === 'rejected')) { + const rejected = await Promise.all( + createModelConfigs.flatMap((val, index) => + val.status === 'rejected' ? { index, ...val.reason } : [] + ) + ); + throw { code: 'create_model_configs', rejected }; + } + + const createModelDeployments = await Promise.allSettled([ + createModelDeployment(models.chat_model_deployment), + createModelDeployment(models.embedding_model_deployment) + ]); + + if (createModelDeployments.some((val) => val.status === 'rejected')) { + const rejected = await Promise.all( + createModelDeployments.flatMap((val, index) => + val.status === 'rejected' ? { index, ...val.reason } : [] + ) + ); + throw { code: 'create_model_deployments', rejected }; + } + + async function createModelConfig(body: any) { + const createModelConfigRes = await fetch(`${OWL_URL}/api/v2/models/configs`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (!createModelConfigRes.ok) throw await createModelConfigRes.json(); + } - const createOrgRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/organizations`, { + async function createModelDeployment(body: any) { + const createModelDeploymentRes = await fetch(`${OWL_URL}/api/v2/models/deployment/cloud`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (!createModelDeploymentRes.ok) throw await createModelDeploymentRes.json(); + } +}); + +setup.skip('create price plans', async () => { + const prices = JSON.parse(readFileSync(testDataFile, 'utf-8')); + + const createPlans = await Promise.allSettled([createPricePlan(prices.pro_plan)]); + + if (createPlans.some((val) => val.status === 'rejected')) { + const rejected = await Promise.all( + createPlans.flatMap((val, index) => + val.status === 'rejected' ? { index, ...val.reason } : [] + ) + ); + throw { code: 'create_price_plan', rejected }; + } + + process.env.TEST_PRO_PLAN_ID = + createPlans[0].status === 'fulfilled' ? createPlans[0].value.id : null; + + async function createPricePlan(body: any) { + const createPricePlanRes = await fetch(`${OWL_URL}/api/v2/prices/plans`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + const createPricePlanBody = await createPricePlanRes.json(); + + if (!createPricePlanRes.ok) throw createPricePlanBody; + return createPricePlanBody; + } +}); + +setup.skip('create admin org', async () => { + const createOrgRes = await fetch(`${OWL_URL}/api/v2/organizations`, { + method: 'POST', + headers: { + ...headers, + 'x-user-id': process.env.TEST_ADMIN_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'admin-org', + currency: 'USD' + }) + }); + const createOrgBody = await createOrgRes.json(); + + if (!createOrgRes.ok) throw { code: 'create_org', ...createOrgBody }; + + process.env.TEST_ADMIN_ORGID = createOrgBody.id; +}); + +setup('create org and tables', async () => { + const createOrgRes = await fetch(`${OWL_URL}/api/v2/organizations`, { method: 'POST', headers: { ...headers, + 'x-user-id': process.env.TEST_USER_ID!, 'Content-Type': 'application/json' }, body: JSON.stringify({ - creator_user_id: TEST_ACC_USERID, name: 'test-org', - tier: 'team' + currency: 'USD' }) }); const createOrgBody = await createOrgRes.json(); - console.log(createOrgBody); + if (!createOrgRes.ok) throw { code: 'create_org', ...createOrgBody }; const organizationId = createOrgBody.id; - const createProjectRes = await fetch(`${JAMAI_URL}/api/admin/org/v1/projects`, { + //stripe add payment method and subscribe plan + const paymentMethod = await stripe.paymentMethods.create({ + type: 'card', + card: { + token: 'tok_visa' + } + }); + await stripe.paymentMethods.attach(paymentMethod.id, { + customer: createOrgBody.stripe_id + }); + await stripe.customers.update(createOrgBody.stripe_id, { + invoice_settings: { + default_payment_method: paymentMethod.id + } + }); + + const changeOrgPlanRes = await fetch( + `${OWL_URL}/api/v2/organizations/plan?${new URLSearchParams([ + ['organization_id', organizationId], + ['price_plan_id', process.env.TEST_TEAM_PLAN_ID!] + ])}`, + { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': process.env.TEST_USER_ID! + } + } + ); + const changeOrgPlanBody = await changeOrgPlanRes.json(); + + if (!changeOrgPlanRes.ok) throw { code: 'change_org_plan', ...changeOrgPlanBody }; + + // await stripe.paymentIntents.confirm(changeOrgPlanBody.payment_intent_id); + + // Add credits + const addCreditsRes = await fetch(`${OWL_URL}/api/v2/organizations`, { + method: 'PATCH', + headers: { + ...headers, + 'x-user-id': process.env.TEST_USER_ID!, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: organizationId, + credit: 10000 + }) + }); + if (!addCreditsRes.ok) throw { code: 'add_org_credits', ...(await addCreditsRes.json()) }; + + const createProjectRes = await fetch(`${OWL_URL}/api/v2/projects`, { method: 'POST', headers: { ...headers, + 'x-user-id': process.env.TEST_USER_ID!, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -43,6 +295,8 @@ setup('create org and tables', async () => { const projectId = createProjectBody.id; + const models = JSON.parse(readFileSync(testDataFile, 'utf-8')); + const createTestTables = await Promise.allSettled([ createTable('action', 'test-action-table', [ { @@ -59,7 +313,7 @@ setup('create org and tables', async () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'anthropic/claude-3-haiku-20240307', + model: models.chat_model.id, multi_turn: false } } @@ -67,7 +321,7 @@ setup('create org and tables', async () => { createTable('action', 'test-action-table-file', [ { id: 'Input', - dtype: 'file', + dtype: 'image', vlen: 0, index: true, gen_config: null @@ -79,7 +333,7 @@ setup('create org and tables', async () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'openai/gpt-4o', + model: models.chat_model.id, multi_turn: false } } @@ -101,7 +355,7 @@ setup('create org and tables', async () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'anthropic/claude-3-haiku-20240307', + model: models.chat_model.id, multi_turn: true } } @@ -125,7 +379,7 @@ setup('create org and tables', async () => { if (createConvs.some((val) => val.status === 'rejected')) { const rejected = await Promise.all( createConvs.flatMap((val, index) => - val.status === 'rejected' ? { index, ...val.reason } : [] + val.status === 'rejected' ? { index, ...(val.reason ? val.reason : { reason: val }) } : [] ) ); throw { code: 'create_test_convs', rejected }; @@ -136,36 +390,39 @@ setup('create org and tables', async () => { tableName: string, cols: GenTableCol[] ) { + await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 3000))); + let embeddingModel; if (tableType === 'knowledge') { const modelsRes = await fetch( - `${JAMAI_URL}/api/v1/models?${new URLSearchParams({ - capabilities: 'embed' - })}`, + `${OWL_URL}/api/v2/organizations/models/catalogue?${new URLSearchParams([['organization_id', organizationId]])}`, { headers: { ...headers, - 'x-project-id': projectId + 'x-user-id': process.env.TEST_USER_ID! } } ); const modelsBody = await modelsRes.json(); - if (!modelsRes.ok) throw { code: 'list_models', ...modelsBody }; + if (!modelsRes.ok) throw modelsBody; - embeddingModel = (modelsBody.data as AvailableModel[])[0].id; + embeddingModel = (modelsBody.items as ModelConfig[]).find((m) => + m.capabilities.includes('embed') + )?.id; } - const response = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const response = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', + 'x-user-id': process.env.TEST_USER_ID!, 'x-project-id': projectId }, body: JSON.stringify({ id: tableName, - version: '0.3.0', + version: '0.5.0', cols, embedding_model: tableType === 'knowledge' ? embeddingModel : undefined }) @@ -181,14 +438,16 @@ setup('create org and tables', async () => { async function createConv(parent: string, name: string) { const response = await fetch( - `${JAMAI_URL}/api/v1/gen_tables/chat/duplicate/${parent}?${new URLSearchParams({ - create_as_child: 'true', - table_id_dst: name - })}`, + `${OWL_URL}/api/v2/gen_tables/chat/duplicate?${new URLSearchParams([ + ['table_id_src', parent], + ['table_id_dst', name], + ['create_as_child', 'true'] + ])}`, { method: 'POST', headers: { ...headers, + 'x-user-id': process.env.TEST_USER_ID!, 'x-project-id': projectId } } diff --git a/services/app/tests/main.teardown.ts b/services/app/tests/main.teardown.ts old mode 100644 new mode 100755 index db2b085..aa98415 --- a/services/app/tests/main.teardown.ts +++ b/services/app/tests/main.teardown.ts @@ -1,39 +1,46 @@ +import type { User } from '$lib/types'; +import { test as teardown } from '@playwright/test'; import 'dotenv/config'; import fs from 'fs'; -import { test as teardown } from '@playwright/test'; -import type { UserRead } from '$lib/types'; -const { JAMAI_URL, JAMAI_SERVICE_KEY, TEST_ACC_USERID } = process.env; +const { OWL_URL, OWL_SERVICE_KEY, TEST_USER_ID } = process.env; teardown('delete setup', async () => { const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` + Authorization: `Bearer ${OWL_SERVICE_KEY}` }; - const userInfoRes = await fetch(`${JAMAI_URL}/api/admin/backend/v1/users/${TEST_ACC_USERID}`, { - headers - }); + const userInfoRes = await fetch( + `${OWL_URL}/api/v2/users?${new URLSearchParams([['user_id', TEST_USER_ID!]])}`, + { + headers: { + ...headers, + 'x-user-id': process.env.TEST_USER_ID! + } + } + ); const userInfoBody = await userInfoRes.json(); - if (!userInfoRes.ok) throw new Error(userInfoBody); + if (!userInfoRes.ok) throw JSON.stringify(userInfoBody); - const testOrgs = (userInfoBody as UserRead).member_of.filter((org) => - /test-org/.test(org.organization_name) - ); + const testOrgs = (userInfoBody as User).organizations.filter((org) => /test-org/.test(org.name)); if (testOrgs.length === 0) { console.warn('Playwright test organization not found, skipping delete step'); } else { for (const testOrg of testOrgs) { const deleteOrgRes = await fetch( - `${JAMAI_URL}/api/admin/backend/v1/organizations/${testOrg?.organization_id}`, + `${OWL_URL}/api/v2/organizations?${new URLSearchParams([['organization_id', testOrg.id]])}`, { method: 'DELETE', - headers + headers: { + ...headers, + 'x-user-id': process.env.TEST_USER_ID! + } } ); if (!deleteOrgRes.ok) { const deleteOrgBody = await deleteOrgRes.json(); - throw new Error(deleteOrgBody); + throw JSON.stringify(deleteOrgBody); } } } diff --git a/services/app/tests/pages/layout.page.ts b/services/app/tests/pages/layout.page.ts old mode 100644 new mode 100755 index 8ab7b1f..1dcbb17 --- a/services/app/tests/pages/layout.page.ts +++ b/services/app/tests/pages/layout.page.ts @@ -1,5 +1,7 @@ -import 'dotenv/config'; import { expect, type Locator, type Page } from '@playwright/test'; +import 'dotenv/config'; + +const ossMode = !process.env.OWL_SERVICE_KEY; /** Layout with breadcrumbs */ export class LayoutPage { @@ -12,7 +14,7 @@ export class LayoutPage { } async switchOrganization(organizationName: string) { - if (process.env.PUBLIC_IS_LOCAL === 'false') { + if (!ossMode) { const orgSelector = this.page.getByTestId('org-selector'); await expect(async () => { await this.selectOrgBtn.click(); diff --git a/services/app/tests/pages/project.page.ts b/services/app/tests/pages/project.page.ts old mode 100644 new mode 100755 index be32c0c..9ec6c65 --- a/services/app/tests/pages/project.page.ts +++ b/services/app/tests/pages/project.page.ts @@ -1,14 +1,16 @@ -import 'dotenv/config'; import { expect, type Page } from '@playwright/test'; +import 'dotenv/config'; import { LayoutPage } from './layout.page'; +const ossMode = !process.env.OWL_SERVICE_KEY; + export class ProjectPage extends LayoutPage { constructor(page: Page) { super(page); } async goto() { - if (process.env.PUBLIC_IS_LOCAL === 'false') { + if (!ossMode) { await this.page.goto('/'); await this.page.waitForURL(/.*\/project/); } else { @@ -18,7 +20,7 @@ export class ProjectPage extends LayoutPage { } async gotoProject(projectName: string) { - if (process.env.PUBLIC_IS_LOCAL === 'false') { + if (!ossMode) { await this.page .locator('a', { has: this.page.getByText(projectName, { exact: true }) }) .click(); diff --git a/services/app/tests/pages/table.page.ts b/services/app/tests/pages/table.page.ts old mode 100644 new mode 100755 index a768373..89ffaf2 --- a/services/app/tests/pages/table.page.ts +++ b/services/app/tests/pages/table.page.ts @@ -1,5 +1,5 @@ -import 'dotenv/config'; import { expect, type Locator, type Page } from '@playwright/test'; +import 'dotenv/config'; import { LayoutPage } from './layout.page'; /** Only to be instantiated in a project */ @@ -190,7 +190,7 @@ export class TablePage extends LayoutPage { } /** Add column */ - async addColumn(type: 'input' | 'output', datatype: 'str' | 'file' = 'str') { + async addColumn(type: 'input' | 'output', datatype: 'str' | 'image' = 'str') { await this.actionsBtn.click(); await this.page .getByTestId('table-actions-dropdown') @@ -205,9 +205,9 @@ export class TablePage extends LayoutPage { await newColDialog.getByLabel('Column ID').fill(`transient-${type}-column`); await newColDialog.getByTestId('datatype-select-btn').click(); if (type === 'input') { - await newColDialog - .getByTestId('datatype-select-btn') - .locator('div[role="option"]', { hasText: datatype === 'str' ? 'Text' : 'File' }) + await this.page + .getByTestId('datatype-select-list') + .locator('div[role="option"]', { hasText: datatype === 'str' ? 'Text' : 'Image' }) .click(); } if (type === 'output') { diff --git a/services/app/tests/pages/tableList.page.ts b/services/app/tests/pages/tableList.page.ts old mode 100644 new mode 100755 diff --git a/services/app/tests/tableList.spec.ts b/services/app/tests/tableList.spec.ts old mode 100644 new mode 100755 index f9c1a31..62b3d32 --- a/services/app/tests/tableList.spec.ts +++ b/services/app/tests/tableList.spec.ts @@ -76,7 +76,7 @@ test.describe('Knowledge Table', () => { await modal.waitFor({ state: 'visible' }); await modal.locator('input[name="table_id"]').fill('transient-test-knowledge-table'); await modal.getByTestId('model-select-btn').click(); - await modal.getByTestId('model-select-btn').locator('div[role="option"]').first().click(); + await page.getByTestId('model-select-list').locator('div[role="option"]').first().click(); await modal.locator('button:has-text("Create"):visible').click(); await modal.waitFor({ state: 'hidden' }); @@ -128,7 +128,11 @@ test.describe('Chat Table', () => { await modal.waitFor({ state: 'visible' }); await modal.locator('input[name="agent-id"]').fill('transient-test-chat-agent'); await modal.locator('button[title="Select model"]').click(); - await modal.locator('div[role="option"]:visible').first().click(); + await page + .getByTestId('model-select-list') + .locator('div[role="option"]:visible') + .first() + .click(); await modal.locator('button:has-text("Add"):visible').click(); await modal.waitFor({ state: 'hidden' }); @@ -169,7 +173,7 @@ test.describe('Chat Table', () => { await modal.waitFor({ state: 'visible' }); await modal.locator('input[name="conversation-id"]').fill('transient-test-chat-conv'); await modal.locator('button[title="Select Chat Agent"]').click(); - await modal + await page .locator('div[role="option"]:visible', { has: page.getByText('transient-test-agent', { exact: true }) }) diff --git a/services/app/tests/tables/actionTable.spec.ts b/services/app/tests/tables/actionTable.spec.ts old mode 100644 new mode 100755 index 514391d..b7eedc1 --- a/services/app/tests/tables/actionTable.spec.ts +++ b/services/app/tests/tables/actionTable.spec.ts @@ -1,13 +1,13 @@ -import 'dotenv/config'; -import { expect, test as base } from '@playwright/test'; import { faker } from '@faker-js/faker'; +import { test as base, expect } from '@playwright/test'; +import 'dotenv/config'; import { ProjectPage } from '../pages/project.page'; -import { TableListPage } from '../pages/tableList.page'; import { TablePage } from '../pages/table.page'; +import { TableListPage } from '../pages/tableList.page'; -const { JAMAI_URL, JAMAI_SERVICE_KEY } = process.env; +const { OWL_URL, OWL_SERVICE_KEY } = process.env; const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` + Authorization: `Bearer ${OWL_SERVICE_KEY}` }; const test = base.extend<{ tablePage: TablePage; fileTablePage: TablePage }>({ @@ -125,7 +125,7 @@ test.describe('Action Table Page Basic', () => { const tableType = 'action'; const tableName = 'test-action-table'; - const deleteTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableName}`, { + const deleteTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}/${tableName}`, { method: 'DELETE', headers: { ...headers, @@ -137,7 +137,7 @@ test.describe('Action Table Page Basic', () => { throw await deleteTableRes.json(); } - const createTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const createTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, @@ -146,7 +146,7 @@ test.describe('Action Table Page Basic', () => { }, body: JSON.stringify({ id: tableName, - version: '0.3.0', + version: '0.5.0', cols: [ { id: 'Input', @@ -162,7 +162,7 @@ test.describe('Action Table Page Basic', () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'anthropic/claude-3-haiku-20240307', + model: 'openai/gpt-4o-mini', multi_turn: false } } @@ -252,7 +252,7 @@ test.describe('Action Table Page with File Col', () => { const tableType = 'action'; const tableName = 'test-action-table-file'; - const deleteTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableName}`, { + const deleteTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}/${tableName}`, { method: 'DELETE', headers: { ...headers, @@ -264,7 +264,7 @@ test.describe('Action Table Page with File Col', () => { throw await deleteTableRes.json(); } - const createTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const createTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, @@ -273,7 +273,7 @@ test.describe('Action Table Page with File Col', () => { }, body: JSON.stringify({ id: tableName, - version: '0.3.0', + version: '0.5.0', cols: [ { id: 'Input', diff --git a/services/app/tests/tables/chatTable.spec.ts b/services/app/tests/tables/chatTable.spec.ts old mode 100644 new mode 100755 index 6a10a8d..bef945e --- a/services/app/tests/tables/chatTable.spec.ts +++ b/services/app/tests/tables/chatTable.spec.ts @@ -1,13 +1,13 @@ -import 'dotenv/config'; -import { expect, test as base } from '@playwright/test'; import { faker } from '@faker-js/faker'; +import { test as base, expect } from '@playwright/test'; +import 'dotenv/config'; import { ProjectPage } from '../pages/project.page'; -import { TableListPage } from '../pages/tableList.page'; import { TablePage } from '../pages/table.page'; +import { TableListPage } from '../pages/tableList.page'; -const { JAMAI_URL, JAMAI_SERVICE_KEY } = process.env; +const { OWL_URL, OWL_SERVICE_KEY } = process.env; const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` + Authorization: `Bearer ${OWL_SERVICE_KEY}` }; const test = base.extend<{ tablePage: TablePage; agentTablePage: TablePage }>({ @@ -164,7 +164,7 @@ test.describe('Chat Table Page Basic', () => { const tableName = 'test-chat-conv'; const tableParent = 'temp-chat-agent'; - const deleteTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableName}`, { + const deleteTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}/${tableName}`, { method: 'DELETE', headers: { ...headers, @@ -177,7 +177,7 @@ test.describe('Chat Table Page Basic', () => { } //* Temp chat agent in case original has been changed - const createTempAgentRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const createTempAgentRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, @@ -186,7 +186,7 @@ test.describe('Chat Table Page Basic', () => { }, body: JSON.stringify({ id: tableParent, - version: '0.3.0', + version: '0.5.0', cols: [ { id: 'User', @@ -202,7 +202,7 @@ test.describe('Chat Table Page Basic', () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'anthropic/claude-3-haiku-20240307', + model: 'openai/gpt-4o-mini', multi_turn: true } } @@ -216,9 +216,10 @@ test.describe('Chat Table Page Basic', () => { //* Duplicate agent to chat conv const dupeTableRes = await fetch( - `${JAMAI_URL}/api/v1/gen_tables/chat/duplicate/${tableParent}?${new URLSearchParams({ - create_as_child: 'true', - table_id_dst: tableName + `${OWL_URL}/api/v2/gen_tables/chat/duplicate?${new URLSearchParams({ + table_id_src: tableParent, + table_id_dst: tableName, + create_as_child: 'true' })}`, { method: 'POST', @@ -234,7 +235,7 @@ test.describe('Chat Table Page Basic', () => { } const deleteTempAgentRes = await fetch( - `${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableParent}`, + `${OWL_URL}/api/v2/gen_tables/${tableType}/${tableParent}`, { method: 'DELETE', headers: { @@ -260,7 +261,7 @@ test.describe('Chat Table Page with File Col', () => { test.describe('Column create, rename, reorder, delete', () => { test('can add new input column', async ({ agentTablePage }) => { - await agentTablePage.addColumn('input', 'file'); + await agentTablePage.addColumn('input', 'image'); }); test('can add new output column', async ({ agentTablePage }) => { @@ -326,7 +327,7 @@ test.describe('Chat Table Page with File Col', () => { const tableType = 'chat'; const tableName = 'test-chat-agent'; - const deleteTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableName}`, { + const deleteTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}/${tableName}`, { method: 'DELETE', headers: { ...headers, @@ -338,7 +339,7 @@ test.describe('Chat Table Page with File Col', () => { throw await deleteTableRes.json(); } - const createTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const createTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, @@ -347,7 +348,7 @@ test.describe('Chat Table Page with File Col', () => { }, body: JSON.stringify({ id: tableName, - version: '0.3.0', + version: '0.5.0', cols: [ { id: 'User', @@ -363,7 +364,7 @@ test.describe('Chat Table Page with File Col', () => { index: true, gen_config: { object: 'gen_config.llm', - model: 'anthropic/claude-3-haiku-20240307', + model: 'openai/gpt-4o-mini', multi_turn: true } } diff --git a/services/app/tests/tables/knowledgeTable.spec.ts b/services/app/tests/tables/knowledgeTable.spec.ts old mode 100644 new mode 100755 index 8dbcfca..0b4b970 --- a/services/app/tests/tables/knowledgeTable.spec.ts +++ b/services/app/tests/tables/knowledgeTable.spec.ts @@ -1,12 +1,12 @@ +import { test as base, expect } from '@playwright/test'; import 'dotenv/config'; -import { expect, test as base } from '@playwright/test'; import { ProjectPage } from '../pages/project.page'; -import { TableListPage } from '../pages/tableList.page'; import { TablePage } from '../pages/table.page'; +import { TableListPage } from '../pages/tableList.page'; -const { JAMAI_URL, JAMAI_SERVICE_KEY } = process.env; +const { OWL_URL, OWL_SERVICE_KEY } = process.env; const headers = { - Authorization: `Bearer ${JAMAI_SERVICE_KEY}` + Authorization: `Bearer ${OWL_SERVICE_KEY}` }; const test = base.extend<{ tablePage: TablePage; fileTablePage: TablePage }>({ @@ -197,7 +197,7 @@ test.describe('Knowledge Table Page', () => { const tableType = 'knowledge'; const tableName = 'test-knowledge-table'; - const deleteTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}/${tableName}`, { + const deleteTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}/${tableName}`, { method: 'DELETE', headers: { ...headers, @@ -211,7 +211,7 @@ test.describe('Knowledge Table Page', () => { //* Get embedding model const modelsRes = await fetch( - `${JAMAI_URL}/api/v1/models?${new URLSearchParams({ + `${OWL_URL}/api/v2/models?${new URLSearchParams({ capabilities: 'embed' })}`, { @@ -227,7 +227,7 @@ test.describe('Knowledge Table Page', () => { throw modelsBody; } - const createTableRes = await fetch(`${JAMAI_URL}/api/v1/gen_tables/${tableType}`, { + const createTableRes = await fetch(`${OWL_URL}/api/v2/gen_tables/${tableType}`, { method: 'POST', headers: { ...headers, @@ -236,7 +236,7 @@ test.describe('Knowledge Table Page', () => { }, body: JSON.stringify({ id: tableName, - version: '0.3.0', + version: '0.5.0', cols: [], embedding_model: modelsBody.data[0].id }) diff --git a/services/app/tsconfig.json b/services/app/tsconfig.json old mode 100644 new mode 100755 diff --git a/services/app/vite.config.ts b/services/app/vite.config.ts old mode 100644 new mode 100755 index 9629400..a48c877 --- a/services/app/vite.config.ts +++ b/services/app/vite.config.ts @@ -1,8 +1,10 @@ -import 'dotenv/config'; +import { paraglideVitePlugin } from '@inlang/paraglide-js'; import { sveltekit } from '@sveltejs/kit/vite'; -import type { ProxyOptions, ViteDevServer } from 'vite'; +import 'dotenv/config'; import express from 'express'; import expressOpenIdConnect from 'express-openid-connect'; +import type { ProxyOptions, ViteDevServer } from 'vite'; +import devtoolsJson from 'vite-plugin-devtools-json'; import { defineConfig } from 'vitest/config'; const proxy: Record = { @@ -14,35 +16,33 @@ const proxy: Record = { function expressPlugin() { const app = express(); - if (process.env.PUBLIC_IS_LOCAL === 'false') { - app.use( - expressOpenIdConnect.auth({ - authorizationParams: { - response_type: 'code', - scope: 'openid profile email offline_access' - }, - authRequired: false, - auth0Logout: true, - baseURL: `http://localhost:5173`, - clientID: process.env.AUTH0_CLIENT_ID, - clientSecret: process.env.AUTH0_CLIENT_SECRET, - issuerBaseURL: process.env.AUTH0_ISSUER_BASE_URL, - secret: process.env.AUTH0_SECRET, - attemptSilentLogin: false, - routes: { - login: false - } - }) - ); - app.get('/login', (req, res) => { - res.oidc.login({ - returnTo: (typeof req.query.returnTo === 'string' ? req.query.returnTo : '/') || '/' - }); - }); - app.get('/dev-profile', (req, res) => { - res.json(req.oidc.user ?? {}); + app.use( + expressOpenIdConnect.auth({ + authorizationParams: { + response_type: 'code', + scope: 'openid profile email offline_access' + }, + authRequired: false, + auth0Logout: true, + baseURL: `http://localhost:5173`, + clientID: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + issuerBaseURL: process.env.AUTH0_ISSUER_BASE_URL, + secret: process.env.AUTH0_SECRET, + attemptSilentLogin: false, + routes: { + login: false + } + }) + ); + app.get('/login', (req, res) => { + res.oidc.login({ + returnTo: (typeof req.query.returnTo === 'string' ? req.query.returnTo : '/') || '/' }); - } + }); + app.get('/dev-profile', (req, res) => { + res.json(req.oidc.user ?? {}); + }); return { name: 'express-plugin', @@ -67,7 +67,16 @@ export default defineConfig({ target: 'esnext' } }, - plugins: [process.env.PUBLIC_IS_LOCAL === 'false' && expressPlugin(), sveltekit()], + plugins: [ + devtoolsJson(), + paraglideVitePlugin({ + project: './project.inlang', + outdir: './src/lib/paraglide', + strategy: ['cookie', 'baseLocale'] + }), + !!process.env.OWL_SERVICE_KEY && !!process.env.AUTH0_CLIENT_SECRET && expressPlugin(), + sveltekit() + ], test: { include: ['src/**/*.{test,spec}.{js,ts}'] } diff --git a/services/docio/.env b/services/docio/.env deleted file mode 100644 index dbd0994..0000000 --- a/services/docio/.env +++ /dev/null @@ -1,2 +0,0 @@ -DOCIO_PORT=6979 -DOCIO_WORKERS=2 \ No newline at end of file diff --git a/services/docio/MANIFEST.in b/services/docio/MANIFEST.in deleted file mode 100644 index 5b0063f..0000000 --- a/services/docio/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -exclude tests/**/* \ No newline at end of file diff --git a/services/docio/README.md b/services/docio/README.md deleted file mode 100644 index a027370..0000000 --- a/services/docio/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Document IO (DocIO) - -This is a package and a service that helps to parse file. Currently it support parsing of - -- txt -- md -- pdf - -## Compile Windows Executable File - -1. Create python virtual environment. -2. `cd services\docio`. -3. Install the python dependencies in the python virtual environment. PowerShell: `.\scripts\SetupWinExeEnv.ps1`. -4. Generate Python executable file. PowerShell: `.\scripts\GenerateWinExe.ps1`. The generate output can be found in `dist`. diff --git a/services/docio/docio.spec b/services/docio/docio.spec deleted file mode 100644 index f028833..0000000 --- a/services/docio/docio.spec +++ /dev/null @@ -1,70 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -import sys -import docio -from pathlib import Path - -# Increase the recursion limit -sys.setrecursionlimit(sys.getrecursionlimit() * 5) - -# Print the path of the Python executable -print(sys.executable) - -from PyInstaller.utils.hooks import collect_all - -binaries_list = [] - -datas_list = [] - -hiddenimports_list = ['multipart', 'torch'] - -def add_package(package_name): - datas, binaries, hiddenimports = collect_all(package_name) - datas_list.extend(datas) - binaries_list.extend(binaries) - hiddenimports_list.extend(hiddenimports) - -add_package('pypdfium2') -add_package('pypdfium2_raw') -add_package('docio') - -a = Analysis( - [Path('src/docio/entrypoints/api.py').as_posix()], - pathex=[], - binaries=binaries_list, - datas=datas_list, - hiddenimports=hiddenimports_list, - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='docio', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='docio', -) diff --git a/services/docio/pyproject.toml b/services/docio/pyproject.toml deleted file mode 100644 index e905b26..0000000 --- a/services/docio/pyproject.toml +++ /dev/null @@ -1,153 +0,0 @@ -# See https://gitlab.liris.cnrs.fr/pagoda/tools/mkdocs_template/-/blob/master/user_config/pyproject.toml - -# ----------------------------------------------------------------------------- -# Pytest configuration -# https://docs.pytest.org/en/latest/customize.html?highlight=pyproject#pyproject-toml - -[tool.pytest.ini_options] -log_cli = true -asyncio_mode = "auto" -# log_cli_level = "DEBUG" -# addopts = "--cov=docio --doctest-modules" -testpaths = ["tests"] -filterwarnings = [ - "ignore::DeprecationWarning:tensorflow.*", - "ignore::DeprecationWarning:tensorboard.*", - "ignore::DeprecationWarning:matplotlib.*", - "ignore::DeprecationWarning:flatbuffers.*", -] - -# ----------------------------------------------------------------------------- -# Ruff configuration -# https://docs.astral.sh/ruff/ - -[tool.ruff] -line-length = 99 -indent-width = 4 -target-version = "py310" -extend-include = [".pyi?$", ".ipynb"] -extend-exclude = ["archive/*"] -respect-gitignore = true - -[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 -# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html - -[build-system] -# setuptools-scm considers all files tracked by git to be data files -requires = ["setuptools>=62.0", "setuptools-scm"] -build-backend = "setuptools.build_meta" - -[project] -name = "docio" -description = "DocIO service for PDF loading and parsing." -readme = "README.md" -requires-python = "~=3.10" -# keywords = ["one", "two"] -license = { text = "Proprietary" } -classifiers = [ # https://pypi.org/classifiers/ - "Development Status :: 3 - Alpha", - "Programming Language :: Python :: 3 :: Only", - "Intended Audience :: Information Technology", - "Operating System :: Unix", -] -dependencies = [ - "accelerate~=0.28", - "fastapi~=0.110.0", - "gunicorn~=21.2.0", - "jamaibase>=0.0.1", - "langchain-community~=0.0.25", - "langchain~=0.1.10", - "loguru~=0.7.2", - "matplotlib", - "pandas~=2.2.2", - "pdfplumber~=0.10.4", # pdfplumber - "pydantic-settings>=2.2.1", - "pydantic~=2.6.3", - "pypdfium2~=4.27.0", - "python-multipart", - "s3fs", - "timm", - "torch~=2.2.0", - "transformers>=4.38.2", - "unstructured-client @ git+https://github.com/EmbeddedLLM/unstructured-python-client.git@fix-nested-asyncio-conflict-with-uvloop#egg=unstructured-client", - "unstructured~=0.14.9", - "uvicorn[standard]~=0.27.1", -] # Sort your dependencies https://sortmylist.com/ -dynamic = ["version"] - -[project.optional-dependencies] -lint = ["ruff~=0.5.7"] -test = [ - "flaky~=3.7.0", - "mypy~=1.5.1", - "openai~=1.9.0", - "pytest-cov~=4.1.0", - "pytest~=7.4.2", -] -docs = [ - "furo~=2023.9.10", # Sphinx theme (nice looking, with dark mode) - "myst-parser~=2.0.0", - "sphinx-autobuild~=2021.3.14", - "sphinx-copybutton~=0.5.2", - "sphinx~=7.2.6", - "sphinx_rtd_theme~=1.3.0", # Sphinx theme -] -build = [ - "build", - "twine", -] # https://realpython.com/pypi-publish-python-package/#build-your-package -all = [ - "docio[lint,test,docs,build]", # https://hynek.me/articles/python-recursive-optional-dependencies/ -] - -# [project.scripts] -# docio = "docio.scripts.example:main_cli" - -[tool.setuptools.dynamic] -version = { attr = "docio.version.__version__" } - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -docio = ["**/*.json"] diff --git a/services/docio/scripts/GenerateWinExe.ps1 b/services/docio/scripts/GenerateWinExe.ps1 deleted file mode 100644 index f055907..0000000 --- a/services/docio/scripts/GenerateWinExe.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -if (Test-Path -Path ".\dist") { - Remove-Item -Path ".\dist" -Recurse -Force -} - -Get-Command python -pyinstaller .\docio.spec -Copy-Item -Path .\.env -Destination .\dist\docio\ \ No newline at end of file diff --git a/services/docio/scripts/SetupWinExeEnv.ps1 b/services/docio/scripts/SetupWinExeEnv.ps1 deleted file mode 100644 index 81ebd38..0000000 --- a/services/docio/scripts/SetupWinExeEnv.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -cd ..\..\clients\python -pip install --no-cache . -cd ..\..\services\docio -pip install --no-cache . -pip install --no-cache pyinstaller \ No newline at end of file diff --git a/services/docio/scripts/validate_exe.py b/services/docio/scripts/validate_exe.py deleted file mode 100644 index 7cf332e..0000000 --- a/services/docio/scripts/validate_exe.py +++ /dev/null @@ -1,39 +0,0 @@ -from mimetypes import guess_type -from pathlib import Path - -import httpx - - -def get_local_uri(): - return [ - Path("../../clients/python/tests/files/txt/weather.txt").as_posix(), - Path("../../clients/python/tests/files/pdf/ca-warn-report.pdf").as_posix(), - Path("README.md").as_posix(), - ] - - -def test_file_loader_api(file_uri: str): - # Guess the MIME type of the file based on its extension - mime_type, _ = guess_type(file_uri) - if mime_type is None: - mime_type = "application/octet-stream" # Default MIME type - - # Extract the filename from the file path - filename = file_uri.split("/")[-1] - - # Open the file in binary mode - with open(file_uri, "rb") as f: - response = httpx.post( - "http://127.0.0.1:6979/api/docio/v1/load_file", - files={ - "file": (filename, f, mime_type), - }, - timeout=None, - ) - - assert response.status_code == 200 - - -if __name__ == "__main__": - for file_url in get_local_uri(): - test_file_loader_api(file_uri=file_url) diff --git a/services/docio/src/docio/config.py b/services/docio/src/docio/config.py deleted file mode 100644 index ad25008..0000000 --- a/services/docio/src/docio/config.py +++ /dev/null @@ -1,21 +0,0 @@ -LOGS = { - "stderr": { - "level": "INFO", - "serialize": False, - "backtrace": False, - "diagnose": True, - "enqueue": True, - "catch": True, - }, - "logs/docio.log": { - "level": "INFO", - "serialize": False, - "backtrace": False, - "diagnose": True, - "enqueue": True, - "catch": True, - "rotation": "50 MB", - "delay": False, - "watch": False, - }, -} diff --git a/services/docio/src/docio/entrypoints/api.py b/services/docio/src/docio/entrypoints/api.py deleted file mode 100644 index 27c242b..0000000 --- a/services/docio/src/docio/entrypoints/api.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -API server. - -```shell -$ python -m docio.entrypoints.api -$ CUDA_VISIBLE_DEVICES=1 WORKERS=2 python -m docio.entrypoints.api -``` -""" - -from fastapi import FastAPI, Request, Response, status -from fastapi.encoders import jsonable_encoder -from fastapi.responses import ORJSONResponse -from loguru import logger -from pydantic_settings import BaseSettings, SettingsConfigDict - -from docio.routers import loader -from docio.utils.logging import replace_logging_handlers, setup_logger_sinks - - -class Config(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore", cli_parse_args=True - ) - docio_port: int = 6979 - docio_host: str = "0.0.0.0" - docio_workers: int = 2 - service: str | None = None - prefix: str = "/api/docio" - - -config = Config() -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) - - -app = FastAPI( - logger=logger, - openapi_url=f"{config.prefix}/openapi.json", - docs_url=f"{config.prefix}/docs", - redoc_url=f"{config.prefix}/redoc", -) -services = { - "loader": (loader.router, ["Document loader"]), -} -if config.service: - try: - router, tags = services[config.service] - except KeyError: - logger.error(f"Invalid service '{config.service}', choose from: {list(services.keys())}") - raise - app.include_router(router, prefix=config.prefix, tags=tags) -else: - # Mount everything - for router, tags in services.values(): - app.include_router(router, prefix=config.prefix, tags=tags) - - -@app.on_event("startup") -async def startup(): - # Temporary for backwards compatibility - logger.info(f"Using configuration: {config}") - - -# Order of handler does not matter -@app.exception_handler(FileNotFoundError) -async def file_not_found_exc_handler(request: Request, exc: FileNotFoundError): - return ORJSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={"detail": [{"type": "file_not_found", "msg": str(exc)}]}, - ) - - -@app.exception_handler(Exception) -async def exception_handler(request: Request, exc: Exception): - return ORJSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content=jsonable_encoder( - [ - { - "type": "unexpected_error", - "msg": f"Encountered error: {exc!r}", - } - ] - ), - ) - - -@app.get("/health") -async def health() -> Response: - """Health check.""" - return Response(status_code=200) - - -if __name__ == "__main__": - import os - - import uvicorn - - if os.name == "nt": - import asyncio - from multiprocessing import freeze_support - - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - freeze_support() - logger.info("The system is Windows.") - else: - logger.info("The system is not Windows.") - - uvicorn.run( - "docio.entrypoints.api:app", - reload=False, - host=config.docio_host, - port=config.docio_port, - workers=config.docio_workers, - ) diff --git a/services/docio/src/docio/langchain/jsonloader.py b/services/docio/src/docio/langchain/jsonloader.py deleted file mode 100644 index 72a9bd6..0000000 --- a/services/docio/src/docio/langchain/jsonloader.py +++ /dev/null @@ -1,141 +0,0 @@ -import json -from pathlib import Path -from typing import Any, Callable, Dict, Iterator, Optional, Union - -from langchain_community.document_loaders.base import BaseLoader -from langchain_core.documents import Document - - -class JSONLoader(BaseLoader): - """Load a `JSON` file generically.""" - - def __init__( - self, - file_path: Union[str, Path], - content_key: Optional[str] = None, - metadata_func: Optional[Callable[[Dict, Dict], Dict]] = None, - text_content: bool = True, - json_lines: bool = False, - ): - """Initialize the JSONLoader. - - Args: - file_path (Union[str, Path]): The path to the JSON or JSON Lines file. - content_key (str): The key to use to extract the content from - the JSON if the result is a list of objects (dict). - This should be a simple string key. - metadata_func (Callable[Dict, Dict]): A function that takes in the JSON - object and the default metadata and returns a dict of the updated metadata. - text_content (bool): Boolean flag to indicate whether the content is in - string format, default to True. - json_lines (bool): Boolean flag to indicate whether the input is in - JSON Lines format. - """ - self.file_path = Path(file_path).resolve() - self._content_key = content_key - self._metadata_func = metadata_func - self._text_content = text_content - self._json_lines = json_lines - - def lazy_load(self) -> Iterator[Document]: - """Load and return documents from the JSON file.""" - index = 0 - if self._json_lines: - with self.file_path.open(encoding="utf-8") as f: - for line in f: - line = line.strip() - if line: - for doc in self._parse(line, index): - yield doc - index += 1 - else: - for doc in self._parse(self.file_path.read_text(encoding="utf-8"), index): - yield doc - index += 1 - - def _parse(self, content: str, index: int) -> Iterator[Document]: - """Convert given content to documents.""" - data = json.loads(content) - - # Perform some validation - # This is not a perfect validation, but it should catch most cases - # and prevent the user from getting a cryptic error later on. - if self._content_key is not None: - self._validate_content_key(data) - if self._metadata_func is not None: - self._validate_metadata_func(data) - - # If the data is a dictionary, treat it as a single document - if isinstance(data, dict): - text = self._get_text(sample=data) - metadata = self._get_metadata( - sample=data, source=str(self.file_path), seq_num=index + 1 - ) - yield Document(page_content=text, metadata=metadata) - else: - for i, sample in enumerate(data, index + 1): - text = self._get_text(sample=sample) - metadata = self._get_metadata(sample=sample, source=str(self.file_path), seq_num=i) - yield Document(page_content=text, metadata=metadata) - - def _get_text(self, sample: Any) -> str: - """Convert sample to string format""" - if self._content_key is not None: - content = sample[self._content_key] - else: - content = sample - - if self._text_content and not isinstance(content, str): - raise ValueError( - f"Expected page_content is string, got {type(content)} instead. \ - Set `text_content=False` if the desired input for \ - `page_content` is not a string" - ) - - # In case the text is None, set it to an empty string - elif isinstance(content, str): - return content - elif isinstance(content, dict): - return json.dumps(content, ensure_ascii=False) if content else "" - else: - return str(content) if content is not None else "" - - def _get_metadata(self, sample: Dict[str, Any], **additional_fields: Any) -> Dict[str, Any]: - """ - Return a metadata dictionary base on the existence of metadata_func - :param sample: single data payload - :param additional_fields: key-word arguments to be added as metadata values - :return: - """ - if self._metadata_func is not None: - return self._metadata_func(sample, additional_fields) - else: - return additional_fields - - def _validate_content_key(self, data: Any) -> None: - """Check if a content key is valid""" - - sample = data[0] if isinstance(data, list) else data - if not isinstance(sample, dict): - raise ValueError( - f"Expected the JSON to result in a list of objects (dict), \ - so sample must be a dict but got `{type(sample)}`" - ) - - if sample.get(self._content_key) is None: - raise ValueError( - f"Expected the JSON to result in a list of objects (dict) \ - with the key `{self._content_key}`" - ) - - def _validate_metadata_func(self, data: Any) -> None: - """Check if the metadata_func output is valid""" - - sample = data[0] if isinstance(data, list) else data - if self._metadata_func is not None: - sample_metadata = self._metadata_func(sample, {}) - if not isinstance(sample_metadata, dict): - raise ValueError( - f"Expected the metadata_func to return a dict but got \ - `{type(sample_metadata)}`" - ) diff --git a/services/docio/src/docio/langchain/pdfplumber.py b/services/docio/src/docio/langchain/pdfplumber.py deleted file mode 100644 index 5d3acc8..0000000 --- a/services/docio/src/docio/langchain/pdfplumber.py +++ /dev/null @@ -1,515 +0,0 @@ -import warnings -from typing import Any, Iterable, Iterator, Mapping, Sequence - -import matplotlib.pyplot as plt -import numpy as np -import pdfplumber.page -import torch -from langchain.document_loaders.base import BaseBlobParser -from langchain.document_loaders.blob_loaders import Blob -from langchain.document_loaders.pdf import BasePDFLoader -from pydantic_settings import BaseSettings, SettingsConfigDict -from transformers import DetrFeatureExtractor, TableTransformerForObjectDetection - -from docio.protocol import Document - - -class Config(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - docio_device: str = "cpu" - - -config = Config() - -_PDF_FILTER_WITH_LOSS = ["DCTDecode", "DCT", "JPXDecode"] -_PDF_FILTER_WITHOUT_LOSS = [ - "LZWDecode", - "LZW", - "FlateDecode", - "Fl", - "ASCII85Decode", - "A85", - "ASCIIHexDecode", - "AHx", - "RunLengthDecode", - "RL", - "CCITTFaxDecode", - "CCF", - "JBIG2Decode", -] - -# Define colors for visualization -COLORS = [ - [0.000, 0.447, 0.741], - [0.850, 0.325, 0.098], - [0.929, 0.694, 0.125], - [0.494, 0.184, 0.556], - [0.466, 0.674, 0.188], - [0.301, 0.745, 0.933], -] - - -def extract_from_images_with_rapidocr( - images: Sequence[Iterable[np.ndarray] | bytes], -) -> str: - try: - from rapidocr_onnxruntime import RapidOCR - except ImportError as e: - raise ImportError( - "`rapidocr-onnxruntime` package not found, please install it with " - "`pip install rapidocr-onnxruntime`" - ) from e - ocr = RapidOCR() - text = "" - for img in images: - result, _ = ocr(img) - if result: - result = [text[1] for text in result] - text += "\n".join(result) - return text - - -class PDFPlumberParser(BaseBlobParser): - """Parse `PDF` with `PDFPlumber`.""" - - def __init__( - self, - text_kwargs: Mapping[str, Any] | None = None, - dedupe: bool = False, - extract_images: bool = False, - table_detection_conf: float = 0.7, - ) -> None: - """Initialize the parser. - - Args: - text_kwargs: Keyword arguments to pass to ``pdfplumber.Page.extract_text()`` - dedupe: Avoiding the error of duplicate characters if `dedupe=True`. - """ - self.text_kwargs = text_kwargs or {} - self.dedupe = dedupe - self.extract_images = extract_images - - self.feature_extractor = DetrFeatureExtractor() - self.model = TableTransformerForObjectDetection.from_pretrained( - "microsoft/table-transformer-detection", device_map=config.docio_device - ) - - self.table_detection_conf = table_detection_conf - - def lazy_parse(self, blob: Blob) -> Iterator[Document]: - """Lazily parse the blob.""" - import pdfplumber - - with blob.as_bytes_io() as file_path: - doc = pdfplumber.open(file_path) # open document - - yield from [ - Document( - page_content=self._process_page_content(page) - + "\n" - + self._extract_images_from_page(page), - metadata=dict( - { - "source": blob.source, - "file_path": blob.source, - "page": page.page_number - 1, - "total_pages": len(doc.pages), - }, - **{ - k: doc.metadata[k] - for k in doc.metadata - if type(doc.metadata[k]) in [str, int] - }, - ), - ) - for page in doc.pages - if page.chars != [] # to skip blank page (or page without any text) - ] - - def _table_bbox_results(self, pil_img, model, scores, labels, boxes, save_file=None): - """ - model.config.id2label: { - 0: "table", - 1: "table column", - 2: "table row", - 3: "table column header", - 4: "table projected row header", - 5: "table spanning cell", - } - """ - - # Create a figure for visualization - plt.figure(figsize=(16, 10)) - # plt.figure(figsize=(160, 100)) - plt.imshow(pil_img) - - # Get the current axis - ax = plt.gca() - - # Repeat the COLORS list multiple times for visualization - colors = COLORS * 100 - - table_bboxes = [] - - # Iterate through scores, labels, boxes, and colors for visualization - for score, label, (xmin, ymin, xmax, ymax), c in zip( - scores.tolist(), - labels.tolist(), - boxes.tolist(), - colors, - strict=True, - ): - # Add a rectangle to the image for the detected object's bounding box - ax.add_patch( - plt.Rectangle( - (xmin, ymin), - xmax - xmin, - ymax - ymin, - fill=False, - color=c, - linewidth=3, - ) - ) - table_bboxes.append((xmin, ymin, xmax, ymax)) - - # Prepare the text for the label and score - # print(f"label: {label}, score: {score:0.2f}") - text = f"{model.config.id2label[label]}: {score:0.2f}" - - # Add the label and score text to the image - ax.text(xmin, ymin, text, fontsize=15, bbox=dict(facecolor="yellow", alpha=0.5)) - - # Turn off the axis - plt.axis("off") - - # Display the visualization - # plt.show() - if save_file: - plt.savefig(save_file, bbox_inches="tight") - - plt.close() # close the plt - - return table_bboxes - - def _process_page_content(self, page: pdfplumber.page.Page) -> str: - image = page.to_image().original - - width, height = image.size - # print(f"width, height: {width, height}") # (596, 808) - - encoding = self.feature_extractor(image, return_tensors="pt").to(config.docio_device) - # Get the keys of the encoding dictionary - # keys = encoding.keys() - # print(f"keys: {keys}") - - # with torch.no_grad(): # to onnx - with torch.inference_mode(): # to onnx - outputs = self.model(**encoding) - - # Post-process the object detection outputs using the feature extractor - results = self.feature_extractor.post_process_object_detection( - outputs, threshold=self.table_detection_conf, target_sizes=[(height, width)] - )[0] - - # save_detection_file = os.path.join( - # save_detection_dir, f"{pdf_file.split('/')[-1][:-4]}_p{i+1:03d}.png" - # ) - - # print(f"model.config.id2label: {model.config.id2label}") - - # table_bboxes = self._table_bbox_results( - # image, - # self.model, - # results["scores"], - # results["labels"], - # results["boxes"], - # # save_detection_file, - # ) - table_bboxes = results["boxes"].tolist() - # PDF Parsing - pages_chars = [] - pages_words = [] - char_widths = [] - char_heights = [] - sizes = [] - full_text = "" - for c in page.chars: - char_widths.append(c["width"]) - char_heights.append(c["height"]) - sizes.append(c["size"]) - - charW_med = np.median(np.array(char_widths)) - # charW_avg = np.sum(np.array(char_widths)) / len(char_widths) - # print(f"char_width_median: {charW_med}") - # print(f"char_width_avg: {charW_avg}") - - charH_med = np.median(np.array(char_heights)) - # charH_avg = np.sum(np.array(char_heights)) / len(char_heights) - # print(f"char_height_median: {charH_med}") - # print(f"char_height_avg: {charH_avg}") - - size_med = np.median(np.array(sizes)) - # size_avg = np.sum(np.array(sizes)) / len(sizes) - # print(f"size_median: {size_med}") - # print(f"size_avg: {size_avg}") - - # for i, page in enumerate(pdf.pages): - try: - page = page.within_bbox(bbox=(0, 0, page.width, page.height)) - # print(f"page.width, page.height: {page.width, page.height}") # (595.276, 807.874) - except Exception: - pass - selected_w_info = [] - words = page.extract_words() - for w in words: - selected_w_info.append( - { - # "page_number": i + 1, - "text": w["text"], - "size": w["bottom"] - w["top"], - "x0": w["x0"], - "x1": w["x1"], - "y0": page.height - w["bottom"], - "y1": (page.height - w["bottom"]) + (w["bottom"]) - w["top"], # y0 + size - "top": w["top"], - "bottom": w["bottom"], - "doctop": w["doctop"], - } - ) - - selected_info = [] - for c in page.chars: - selected_info.append( - { - "page_number": c["page_number"], - "text": c["text"], - "size": c["size"], - "adv": c["adv"], - # "upright": c["upright"], - "height": c["height"], - "width": c["width"], - "x0": c["x0"], - "x1": c["x1"], - "y0": c["y0"], - "y1": c["y1"], - "top": c["top"], - "bottom": c["bottom"], - "doctop": c["doctop"], - } - ) - horizontal_bottom = selected_info[0]["bottom"] - horizontal_top = selected_info[0]["top"] - char_right = selected_info[0]["x1"] - # char_left = selected_info[0]["x0"] - - table_char_idxes_groups = [] - - # tmp_text = "" - # print(f"page_tables: {table_bboxes}") - - # image bbox enlargement - based on intersection of extract_words bbox - for page_table in table_bboxes: - # print(f"\ntable {j}") - - # (xmin, ymin) == top left (from image bbox) - xmin, ymin, xmax, ymax = page_table - - # convert to pdf bbox - # (xmin, ymin) == bottom left (pdf bbox) - ymin2 = page.height - ymax - ymax2 = 0 + ymin2 + (ymax - ymin) - - xminL, yminL, xmaxL, ymaxL = xmin, ymin2, xmax, ymax2 - # print(f"xmin, ymin2, xmax, ymax2: {xmin, ymin2, xmax, ymax2}") - for w_ in selected_w_info: - """ - (x0, y1) (x1, y1) - 1 ___________ 2 - | | - | | - 0 |___________| 3 - (x0, y0) (x1, y0) - """ - # check if either word textbbox coor inside the table bbox - # if yes, enlarge the table bbox - if ( - ( - (w_["x0"] >= xmin and w_["x0"] <= xmax) - and (w_["y0"] >= ymin2 and w_["y0"] <= ymax2) - ) # bottom left - x0, y0 - or ( - (w_["x0"] >= xmin and w_["x0"] <= xmax) - and (w_["y1"] >= ymin2 and w_["y1"] <= ymax2) - ) # top left - x0, y1 - or ( - (w_["x1"] >= xmin and w_["x1"] <= xmax) - and (w_["y1"] >= ymin2 and w_["y1"] <= ymax2) - ) # top right - x1, y1 - or ( - (w_["x1"] >= xmin and w_["x1"] <= xmax) - and (w_["y0"] >= ymin2 and w_["y0"] <= ymax2) - ) # bottom right - x1, y0 - ): - xminL = min(w_["x0"], xminL) - yminL = min(w_["y0"], yminL) - xmaxL = max(w_["x1"], xmaxL) - ymaxL = max(w_["y1"], ymaxL) - - # print(f"xminL, yminL, xmaxL, ymaxL: {xminL, yminL, xmaxL, ymaxL}") - - table_char_idxes = [] - xmin, ymin, xmax, ymax = xminL, yminL, xmaxL, ymaxL - for k, c_ in enumerate(selected_info): - # check if either char bbox coor inside the enlarged table bbox - if ( - ( - (c_["x0"] >= xmin and c_["x0"] <= xmax) - and (c_["y0"] >= ymin2 and c_["y0"] <= ymax2) - ) # bottom left - x0, y0 - or ( - (c_["x0"] >= xmin and c_["x0"] <= xmax) - and (c_["y1"] >= ymin2 and c_["y1"] <= ymax2) - ) # top left - x0, y1 - or ( - (c_["x1"] >= xmin and c_["x1"] <= xmax) - and (c_["y1"] >= ymin2 and c_["y1"] <= ymax2) - ) # top right - x1, y1 - or ( - (c_["x1"] >= xmin and c_["x1"] <= xmax) - and (c_["y0"] >= ymin2 and c_["y0"] <= ymax2) - ) # bottom right - x1, y0 - ): - table_char_idxes.append(k) - # tmp_text += c_["text"] - # print(f"tmp_text: {tmp_text}") - table_char_idxes_groups.append(table_char_idxes) - table_start_idxes = [] - table_end_idxes = [] - for table_char_idxes_group in table_char_idxes_groups: - if len(table_char_idxes_group) > 0: - table_start_idxes.append(table_char_idxes_group[0]) - table_end_idxes.append(table_char_idxes_group[-1]) - - # print(f"table_start_idxes: {table_start_idxes}") - # print(f"table_end_idxes: {table_end_idxes}") - - for k, c_ in enumerate(selected_info): - if k in table_start_idxes: - full_text += "\n" - - if c_["top"] > (horizontal_bottom): - if (c_["bottom"] - horizontal_bottom) > page.height * 0.3: - # ex: CONTENTS - full_text += "\n" + c_["text"] - # elif (c_["x0"] - char_right) > charW_med * c_["adv"] * 1.9: - # # next word - # full_text += ("" if c_["text"] == " " else " ") + c_["text"] - elif (c_["x0"] - char_right) > charW_med * c_["adv"] * 1.9: - # next word - full_text += ("" if c_["text"] == " " else " ") + c_["text"] - else: - # next paragraph - full_text += ( - "\n\n" if (c_["top"] - horizontal_bottom) > charH_med * 0.8 else "\n" - ) + c_["text"] - elif c_["bottom"] < horizontal_top: - if c_["x0"] < char_right: - # ex: ANNUAL REPORT, Other Listed Company Directorship(s) - full_text += "\n\n" + c_["text"] - else: - # next column - full_text += "\n\n" + c_["text"] - elif c_["size"] > size_med * 1.7: # bigger text - # full_text += "<>" - if (c_["x0"] - char_right) > (c_["size"] / c_["adv"]): - # next word - full_text += ("" if c_["text"] == " " else " ") + c_["text"] - else: - full_text += c_["text"] # normal next char - elif c_["size"] < size_med * 1.3: # smaller text - # full_text += "<>" - if (c_["x0"] - char_right) > charH_med * 0.2: - # next word - full_text += ("" if c_["text"] == " " else " ") + c_["text"] - else: - full_text += c_["text"] # normal next char - # elif (c_["x0"] - char_right) > charW_med * 0.2: - elif (c_["x0"] - char_right) > charW_med * c_["adv"] * 1.9: - # next word - full_text += ("" if c_["text"] == " " else " ") + c_["text"] - else: - full_text += c_["text"] # normal next char - - if k in table_end_idxes: - full_text += "\n
    " - - horizontal_bottom = c_["bottom"] - horizontal_top = c_["top"] - char_right = c_["x1"] - # char_left = c_["x0"] - - pages_chars += selected_info - pages_words += selected_w_info - - # df = pd.DataFrame.from_records(pages_chars) - # df.to_csv("page_plumber.csv", float_format="%.2f") - # df = pd.DataFrame.from_records(pages_words) - # df.to_csv("page_plumber_text_words.csv", float_format="%.2f") - - return full_text - - def _extract_images_from_page(self, page: pdfplumber.page.Page) -> str: - """Extract images from page and get the text with RapidOCR.""" - if not self.extract_images: - return "" - - images = [] - for img in page.images: - if img["stream"]["Filter"].name in _PDF_FILTER_WITHOUT_LOSS: - images.append( - np.frombuffer(img["stream"].get_data(), dtype=np.uint8).reshape( - img["stream"]["Height"], img["stream"]["Width"], -1 - ) - ) - elif img["stream"]["Filter"].name in _PDF_FILTER_WITH_LOSS: - images.append(img["stream"].get_data()) - else: - warnings.warn("Unknown PDF Filter!", stacklevel=2) - - return extract_from_images_with_rapidocr(images) - - -class PDFPlumberLoader(BasePDFLoader): - """Load `PDF` files using `pdfplumber`.""" - - def __init__( - self, - file_path: str, - text_kwargs: Mapping[str, Any] | None = None, - dedupe: bool = False, - headers: dict | None = None, - extract_images: bool = False, - ) -> None: - """Initialize with a file path.""" - try: - import pdfplumber # noqa:F401 - except ImportError as e: - raise ImportError( - "pdfplumber package not found, please install it with " "`pip install pdfplumber`" - ) from e - - super().__init__(file_path, headers=headers) - self.text_kwargs = text_kwargs or {} - self.dedupe = dedupe - self.extract_images = extract_images - - def load(self) -> list[Document]: - """Load file.""" - - parser = PDFPlumberParser( - text_kwargs=self.text_kwargs, - dedupe=self.dedupe, - extract_images=self.extract_images, - ) - blob = Blob.from_path(self.file_path) - return parser.parse(blob) diff --git a/services/docio/src/docio/langchain/tsvloader.py b/services/docio/src/docio/langchain/tsvloader.py deleted file mode 100644 index 83d5dfa..0000000 --- a/services/docio/src/docio/langchain/tsvloader.py +++ /dev/null @@ -1,136 +0,0 @@ -import csv -from io import TextIOWrapper -from os.path import join -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Dict, Iterator, Optional, Sequence, Union - -import pandas as pd -from langchain_community.document_loaders.base import BaseLoader -from langchain_community.document_loaders.helpers import detect_file_encodings -from langchain_core.documents import Document -from loguru import logger - - -class TSVLoader(BaseLoader): - """ - Load a TSV file into a list of Documents. - - Each document represents one row of the TSV file. Every row is converted into a - key/value pair and outputted to a new line in the document's page_content. - - The source for each document loaded from the TSV file is set to the value of the - `file_path` argument for all documents by default. You can override this by setting - the `source_column` argument to the name of a column in the TSV file. The source of - each document will then be set to the value of the column with the name specified in - `source_column`. - - Output Example: - .. code-block:: txt - - column1: value1 - column2: value2 - column3: value3 - """ - - def __init__( - self, - file_path: Union[str, Path], - source_column: Optional[str] = None, - metadata_columns: Sequence[str] = (), - csv_args: Optional[Dict] = None, - encoding: Optional[str] = None, - autodetect_encoding: bool = False, - ): - """ - Initialize the TSVLoader. - - Args: - file_path: The path to the TSV file. - source_column: The name of the column in the TSV file to use as the source. - Optional. Defaults to None. - metadata_columns: A sequence of column names to use as metadata. Optional. - csv_args: A dictionary of arguments to pass to the csv.DictReader. - Optional. Defaults to None. - encoding: The encoding of the TSV file. Optional. Defaults to None. - autodetect_encoding: Whether to try to autodetect the file encoding. - """ - self.file_path = file_path - self.source_column = source_column - self.metadata_columns = metadata_columns - self.encoding = encoding - self.csv_args = csv_args or {} - self.autodetect_encoding = autodetect_encoding - - def lazy_load(self) -> Iterator[Document]: - """ - Lazily load documents from the TSV file. - - Yields: - Document: A document representing a row in the TSV file. - """ - try: - with open(self.file_path, newline="", encoding=self.encoding) as csvfile: - yield from self.__read_file(csvfile) - except UnicodeDecodeError as e: - if self.autodetect_encoding: - detected_encodings = detect_file_encodings(self.file_path) - for encoding in detected_encodings: - try: - with open( - self.file_path, newline="", encoding=encoding.encoding - ) as csvfile: - yield from self.__read_file(csvfile) - break - except UnicodeDecodeError: - continue - else: - raise RuntimeError(f"Error loading {self.file_path}") from e - except Exception as e: - raise RuntimeError(f"Error loading {self.file_path}") from e - - def __read_file(self, tsvfile: TextIOWrapper) -> Iterator[Document]: - """ - Read the TSV file and convert each row into a Document. - - Args: - tsvfile: A file object representing the TSV file. - - Yields: - Document: A document representing a row in the TSV file. - """ - with TemporaryDirectory() as tmp_dir_path: - tmp_csv_path = join(tmp_dir_path, "tmpfile.csv") - content = pd.read_csv(tsvfile, sep="\t") - content.to_csv(tmp_csv_path, index=False) - - logger.debug(f"Loading from temporary file: {tmp_csv_path}") - - with open(tmp_csv_path, "r") as tmp_csv: - csv_reader = csv.DictReader(tmp_csv, **self.csv_args) - - for i, row in enumerate(csv_reader): - try: - source = ( - row[self.source_column] - if self.source_column is not None - else str(self.file_path) - ) - except KeyError as e: - raise ValueError( - f"Source column '{self.source_column}' not found in TSV file." - ) from e - content = "\n".join( - f"{k.strip()}: {v.strip() if v is not None else v}" - for k, v in row.items() - if k not in self.metadata_columns - ) - metadata = {"source": source, "row": i} - for col in self.metadata_columns: - try: - metadata[col] = row[col] - except KeyError as e: - raise ValueError( - f"Metadata column '{col}' not found in TSV file." - ) from e - yield Document(page_content=content, metadata=metadata) diff --git a/services/docio/src/docio/protocol.py b/services/docio/src/docio/protocol.py deleted file mode 100644 index 93e41ef..0000000 --- a/services/docio/src/docio/protocol.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseModel - - -class Document(BaseModel): - """Document class for compatibility with LangChain.""" - - page_content: str - metadata: dict = {} diff --git a/services/docio/src/docio/routers/loader.py b/services/docio/src/docio/routers/loader.py deleted file mode 100644 index faaa49f..0000000 --- a/services/docio/src/docio/routers/loader.py +++ /dev/null @@ -1,118 +0,0 @@ -import sys -from os.path import join, splitext -from tempfile import TemporaryDirectory - -from fastapi import APIRouter, File, UploadFile -from fastapi.exceptions import RequestValidationError -from langchain_community import document_loaders as loaders -from loguru import logger -from pydantic import SecretStr -from pydantic_settings import BaseSettings, SettingsConfigDict - -from docio.langchain.jsonloader import JSONLoader -from docio.langchain.pdfplumber import PDFPlumberLoader -from docio.langchain.tsvloader import TSVLoader -from docio.protocol import Document - - -class Config(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - s3_key: str = "minioadmin" # MinIO key - s3_secret: SecretStr = "fasts3xystoragelabel" - s3_url: str = "http://10.103.68.103:9000" - unstructuredio_url: str = "http://unstructuredio:6989/general/v0/general" - unstructuredio_api_key: SecretStr = "ellm" - - @property - def s3_secret_plain(self): - return self.s3_secret.get_secret_value() - - @property - def unstructuredio_api_key_plain(self): - return self.unstructuredio_api_key.get_secret_value() - - -config = Config() -router = APIRouter() - - -# build a table mapping all non-printable characters to None -NOPRINT_TRANS_TABLE = { - i: None for i in range(0, sys.maxunicode + 1) if not chr(i).isprintable() and chr(i) != "\n" -} - - -def make_printable(s: str) -> str: - """ - Replace non-printable characters in a string using - `translate()` that removes characters that map to None. - - # https://stackoverflow.com/a/54451873 - """ - return s.translate(NOPRINT_TRANS_TABLE) - - -def load_file(file_path: str) -> list[Document]: - ext = splitext(file_path)[1].lower() - if ext in (".txt", ".md"): - loader = loaders.TextLoader(file_path) - elif ext == ".pdf": - loader = PDFPlumberLoader(file_path) - elif ext == ".csv": - loader = loaders.CSVLoader(file_path) - elif ext == ".tsv": - loader = TSVLoader(file_path) - elif ext == ".json": - loader = JSONLoader(file_path, text_content=False) - elif ext == ".jsonl": - loader = JSONLoader(file_path, text_content=False, json_lines=True) - else: - raise ValueError(f'Unsupported file type: "{ext}"') - - documents = loader.load() - logger.info(f"docio {str(documents)}") - documents = [ - Document( - # TODO: Probably can use regex for this - # Replace vertical tabs, form feed, Unicode replacement character - # page_content=d.page_content.replace("\x0c", " ") - # .replace("\x0b", " ") - # .replace("\uFFFD", ""), - # For now we use a more aggressive strategy - page_content=make_printable(d.page_content), - metadata={"page": d.metadata.get("page", 0), **d.metadata}, - ) - for d in documents - ] - return documents - - -@router.post("/v1/load_file") -async def load_file_api( - file: UploadFile = File( - description="File to be uploaded in the form of `multipart/form-data`." - ), -) -> list[Document]: - logger.info( - "Upload type: {content_type} {filename}", - content_type=file.content_type, - filename=file.filename, - ) - try: - ext = splitext(file.filename)[1] - with TemporaryDirectory() as tmp_dir_path: - tmp_path = join(tmp_dir_path, f"tmpfile{ext}") - with open(tmp_path, "wb") as tmp: - tmp.write(await file.read()) - tmp.flush() - logger.trace("Loading from temporary file: {name}", name=tmp_path) - documents = load_file(tmp_path) - for d in documents: - d.metadata["source"] = file.filename - d.metadata["document_id"] = file.filename - return documents - except RequestValidationError: - raise - except Exception: - logger.exception("Failed to load file.") - raise diff --git a/services/docio/src/docio/utils/logging.py b/services/docio/src/docio/utils/logging.py deleted file mode 100644 index 0254382..0000000 --- a/services/docio/src/docio/utils/logging.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Configure handlers and formats for application loggers. - -https://gist.github.com/nkhitrov/a3e31cfcc1b19cba8e1b626276148c49 -""" - -import inspect -import logging - -from loguru import logger - - -class InterceptHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - # https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging - # Get corresponding Loguru level if it exists. - level: str | int - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - # Find caller from where originated the logged message. - frame, depth = inspect.currentframe(), 0 - while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def replace_logging_handlers(names: list[str], include_submodules: bool = True): - """ - Replaces logging handlers with `InterceptHandler` for use with `loguru`. - """ - if not isinstance(names, (list, tuple)): - raise TypeError("`names` should be a list of str.") - logger_names = [] - for name in names: - if include_submodules: - logger_names += [n for n in logging.root.manager.loggerDict if n.startswith(name)] - else: - logger_names += [n for n in logging.root.manager.loggerDict if n == name] - logger.info(f"Replacing logger handlers: {logger_names}") - loggers = (logging.getLogger(n) for n in logger_names) - for lgg in loggers: - lgg.handlers = [InterceptHandler()] - # logging.getLogger(name).handlers = [InterceptHandler()] - - -def setup_logger_sinks(): - import sys - from copy import deepcopy - - from docio.config import LOGS - - 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}") diff --git a/services/docio/src/docio/version.py b/services/docio/src/docio/version.py deleted file mode 100644 index f102a9c..0000000 --- a/services/docio/src/docio/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.0.1" From 09dbc1cc667320fb519087834ab8525ab5487a58 Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Tue, 11 Nov 2025 06:15:42 +0000 Subject: [PATCH 2/6] Sync from private --- .github/workflows/cd.yml | 51 -------------------------------- .github/workflows/github_bot.yml | 45 ---------------------------- 2 files changed, 96 deletions(-) delete mode 100644 .github/workflows/cd.yml delete mode 100644 .github/workflows/github_bot.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 581583a..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: CD - Build and Push Docker Images - -on: - push: - branches: - - main - -permissions: - contents: read - packages: write - -jobs: - build_and_push: - name: Build and Push Docker Images - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - lfs: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Prepare HCL file - run: | - # Convert repository owner to lowercase - REPO_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - - # Get short SHA - SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) - - # Replace variables in HCL file - sed -i "s|AZURE_STORAGE_ACCOUNT_URL|${{ secrets.AZURE_STORAGE_ACCOUNT_URL }}|g" docker/compose.bake.hcl - sed -i "s|AZURE_STORAGE_ACCESS_KEY|${{ secrets.AZURE_STORAGE_ACCESS_KEY }}|g" docker/compose.bake.hcl - - # Add tags to HCL file - sed -i "/target \"owl\"/a \ tags = [\"ghcr.io/${REPO_OWNER}/jamaibase-owl:latest\", \"ghcr.io/${REPO_OWNER}/jamaibase-owl:${SHORT_SHA}\"]" docker/compose.bake.hcl - sed -i "/target \"jambu\"/a \ tags = [\"ghcr.io/${REPO_OWNER}/jamaibase-jambu:latest\", \"ghcr.io/${REPO_OWNER}/jamaibase-jambu:${SHORT_SHA}\"]" docker/compose.bake.hcl - - - name: Build and Push Images - run: | - # Build and push JamaiBase image with both latest and commit hash - docker buildx bake --file docker/compose.bake.hcl --push diff --git a/.github/workflows/github_bot.yml b/.github/workflows/github_bot.yml deleted file mode 100644 index baa6479..0000000 --- a/.github/workflows/github_bot.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: JambuBot - -on: - issues: - types: [opened, edited] - pull_request: - types: [opened, synchronize] - -# Cancel in-progress CI jobs if there is a new push -# https://stackoverflow.com/a/72408109 -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - github-bot: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - with: - submodules: true # Ensure submodules are checked out - - - # - name: Set up Go - # uses: actions/setup-go@v2 - # with: - # go-version: "1.18" - - # - name: Launching JambuBot - # run: | - # cd examples/github-bot/github-bot/src - # go build -o github_bot cmd/main.go - # ./github_bot - # env: - # TRIAGE_BOT_APP_ID: ${{ secrets.TRIAGE_BOT_APP_ID }} - # TRIAGE_BOT_INSTALLATION_ID: ${{ secrets.TRIAGE_BOT_INSTALLATION_ID }} - # TRIAGE_BOT_PRIVATE_KEY: ${{ secrets.TRIAGE_BOT_PRIVATE_KEY }} - # TRIAGE_BOT_JAMAI_KEY: ${{ secrets.TRIAGE_BOT_JAMAI_KEY }} - # TRIAGE_BOT_JAMAI_PROJECT_ID: ${{ secrets.TRIAGE_BOT_JAMAI_PROJECT_ID }} - # TRIAGE_BOT_NAME: ${{github.actor}} - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # GITHUB_EVENT_NAME: ${{ github.event_name }} - # GITHUB_EVENT_PATH: ${{ github.event_path }} From 6f1c14e0f07a01a6a7362c6001ed81299e250d98 Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Tue, 11 Nov 2025 06:23:40 +0000 Subject: [PATCH 3/6] Fix CI: Remove `cloud` from matrix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86518b6..76404e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: if: ${{ !(needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push') }} strategy: matrix: - jamai-mode: ["oss", "cloud"] + jamai-mode: ["oss"] test-group: [group1, group2, group3, group4] timeout-minutes: 2 steps: @@ -55,7 +55,7 @@ jobs: if: needs.check_changes.outputs.has-changes == 'true' || github.event_name == 'push' strategy: matrix: - jamai-mode: ["oss", "cloud"] + jamai-mode: ["oss"] test-group: [group1, group2, group3, group4] timeout-minutes: 90 From 2b80acf5c8abc83263200d58c2f8ce2fe8a0d615 Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Tue, 11 Nov 2025 06:52:11 +0000 Subject: [PATCH 4/6] Add parquet test files --- .../files/exports/export-v0.4-action.parquet | Bin 0 -> 16005 bytes .../exports/export-v0.4-chat-agent-1.parquet | Bin 0 -> 8679 bytes .../exports/export-v0.4-chat-agent.parquet | Bin 0 -> 5975 bytes .../files/exports/export-v0.4-knowledge.parquet | Bin 0 -> 20170 bytes .../export-v0.4-project-long-name.parquet | Bin 0 -> 20854 bytes .../files/exports/export-v0.4-project.parquet | Bin 0 -> 33649 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 services/api/tests/files/exports/export-v0.4-action.parquet create mode 100644 services/api/tests/files/exports/export-v0.4-chat-agent-1.parquet create mode 100644 services/api/tests/files/exports/export-v0.4-chat-agent.parquet create mode 100644 services/api/tests/files/exports/export-v0.4-knowledge.parquet create mode 100644 services/api/tests/files/exports/export-v0.4-project-long-name.parquet create mode 100644 services/api/tests/files/exports/export-v0.4-project.parquet diff --git a/services/api/tests/files/exports/export-v0.4-action.parquet b/services/api/tests/files/exports/export-v0.4-action.parquet new file mode 100644 index 0000000000000000000000000000000000000000..ef076fd62271839710bd63cc8767ac99b0dbe9a9 GIT binary patch literal 16005 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/files/exports/export-v0.4-chat-agent-1.parquet b/services/api/tests/files/exports/export-v0.4-chat-agent-1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9b00c7146588958483cd70341609b1bfc1c60e82 GIT binary patch literal 8679 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 B!5#nr literal 0 HcmV?d00001 diff --git a/services/api/tests/files/exports/export-v0.4-knowledge.parquet b/services/api/tests/files/exports/export-v0.4-knowledge.parquet new file mode 100644 index 0000000000000000000000000000000000000000..b878b716fb1650a8eaea6be998e6761ee5e00bf7 GIT binary patch literal 20170 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 CM4@T` literal 0 HcmV?d00001 diff --git a/services/api/tests/files/exports/export-v0.4-project-long-name.parquet b/services/api/tests/files/exports/export-v0.4-project-long-name.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2ab3e14c519d1535a09e3f49575c04042977b4e5 GIT binary patch literal 20854 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 literal 0 HcmV?d00001 diff --git a/services/api/tests/files/exports/export-v0.4-project.parquet b/services/api/tests/files/exports/export-v0.4-project.parquet new file mode 100644 index 0000000000000000000000000000000000000000..be3e8e9ced798d881615b4121e1dfa363dfd5e1c GIT binary patch literal 33649 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%0AyW65ck!HRVx)-cs(xS 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 Date: Wed, 12 Nov 2025 10:46:39 +0000 Subject: [PATCH 5/6] Fix CI --- .github/workflows/ci.yml | 122 ++++++++++++--------------------------- 1 file changed, 37 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76404e5..ba68280 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: lfs: true @@ -246,7 +246,7 @@ jobs: # Shut down owl to allow coverage data to be flushed docker compose -p jm -f docker/compose.ci.yml down # Copy Pytest coverage data - sudo cp -r docker_data docker_data_pytest + sudo cp -r docker_data docker_data_tmp sudo rm -rf docker_data # Relaunch @@ -265,8 +265,7 @@ jobs: sleep 5 # Run tests - cd services/api - # Use unique coverage file for Stripe tests + pushd services/api coverage run --data-file=coverage/.coverage.stripe --rcfile=pyproject.toml -m \ pytest \ --timeout 300 \ @@ -274,6 +273,10 @@ jobs: --junitxml=pytest_stripe.xml \ -m stripe \ tests + + # Move existing coverage data + popd + mv docker_data_tmp/owl/db/* docker_data/owl/db/. 2>/dev/null || true env: STRIPE_API_KEY: ${{ secrets.OWL_STRIPE_API_KEY }} OWL_STRIPE_API_KEY: ${{ secrets.OWL_STRIPE_API_KEY }} @@ -327,32 +330,14 @@ jobs: docker compose -p jm --env-file .env -f docker/compose.ci.yml down # Combine coverage data - if [ "${{ matrix.test-group }}" = "group1" ]; then - mkdir -p docker_data_pytest/owl/db - DIRS="docker_data_pytest/owl/db \ - docker_data/owl/db \ - services/api/coverage" - else - DIRS="docker_data/owl/db \ - services/api/coverage" - fi - coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ - $DIRS + docker_data/owl/db services/api/coverage # Merge JUnit XML files mkdir -p services/api/junit_xml junitparser merge --glob "services/api/pytest_*.xml" services/api/junit_xml/pytest-${{ matrix.jamai-mode }}-${{ matrix.test-group }}.xml - - name: Upload docker data pytest coverage file - uses: actions/upload-artifact@v4 - if: always() && matrix.test-group == 'group1' && steps.test_stripe.outcome == 'success' - with: - name: docker_data_pytest-coverage-data-${{ matrix.jamai-mode }} - path: docker_data_pytest/owl/db - include-hidden-files: true - if-no-files-found: error - - name: Upload docker data coverage file + - name: Upload coverage data uses: actions/upload-artifact@v4 if: always() && steps.merge_test_data.outcome == 'success' with: @@ -370,9 +355,6 @@ jobs: - name: Log coverage data files run: | - if [ "${{ matrix.test-group }}" = "group1" ]; then - find docker_data_pytest/owl/db -type f | head -50 - fi find docker_data/owl/db -type f | head -50 find services/api/coverage -type f | head -50 find services/api/junit_xml -type f | head -50 @@ -399,31 +381,38 @@ jobs: runs-on: ubuntu-latest needs: sdk_tests if: always() && (needs.sdk_tests.result == 'success' || needs.sdk_tests.result == 'skipped') + strategy: + matrix: + jamai-mode: ["oss"] steps: + - name: Skip coverage merge (no tests ran) + if: needs.sdk_tests.result == 'skipped' + run: echo "Skipping coverage merge - no tests were executed (no changes detected)" + - name: Checkout code + if: needs.sdk_tests.result == 'success' uses: actions/checkout@v4 with: lfs: true - name: Install uv + if: needs.sdk_tests.result == 'success' uses: astral-sh/setup-uv@v5 with: enable-cache: true - name: Set up Python + if: needs.sdk_tests.result == 'success' uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install jamaibase & owl + if: needs.sdk_tests.result == 'success' run: | pushd services/api uv pip install --system -e .[test] - - name: Skip coverage merge (no tests ran) - if: needs.sdk_tests.result == 'skipped' - run: echo "Skipping coverage merge - no tests were executed (no changes detected)" - - name: Download pytest coverage data artifacts uses: actions/download-artifact@v4 if: needs.sdk_tests.result == 'success' @@ -431,14 +420,7 @@ jobs: pattern: pytest-coverage-data-* path: ./ - - name: Download docker data pytest coverage data artifacts - uses: actions/download-artifact@v4 - if: needs.sdk_tests.result == 'success' - with: - pattern: docker_data_pytest-coverage-data-* - path: ./ - - - name: Download docker data coverage data artifacts + - name: Download coverage data artifacts uses: actions/download-artifact@v4 if: needs.sdk_tests.result == 'success' with: @@ -455,74 +437,44 @@ jobs: - name: Log coverage data files if: needs.sdk_tests.result == 'success' run: | - find docker_data_pytest-coverage-data-* -type f | head -50 find docker_data-coverage-data-* -type f | head -50 find pytest-coverage-data-* -type f | head -50 find junit-xml-data -type f | head -50 - - name: Merge Cloud JUnit XML and Coverage data - id: merge_cloud_test_data + - name: Merge JUnit XML and Coverage data (${{ matrix.jamai-mode }}) + id: merge_coverage if: needs.sdk_tests.result == 'success' run: | coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ - ./docker_data_pytest-coverage-data-cloud \ - ./docker_data-coverage-data-cloud-group[1-4] \ - ./pytest-coverage-data-cloud-group[1-4] + ./docker_data-coverage-data-${{ matrix.jamai-mode }}-group[1-4] \ + ./pytest-coverage-data-${{ matrix.jamai-mode }}-group[1-4] # Merge JUnit XML files - junitparser merge --glob "junit-xml-data/junit-xml-data-cloud-*/pytest_*.xml" junit-xml-data/pytest-cloud.xml + junitparser merge --glob "junit-xml-data/junit-xml-data-${{ matrix.jamai-mode }}-*/pytest_*.xml" \ + junit-xml-data/pytest-${{ matrix.jamai-mode }}.xml - - name: Generate cloud coverage reports - if: always() && steps.merge_cloud_test_data.outcome == 'success' + - name: Generate coverage report + if: always() && steps.merge_coverage.outcome == 'success' run: | cd services/api - coverage xml --data-file=coverage/.coverage -o coverage/coverage-cloud.xml + coverage xml --data-file=coverage/.coverage -o coverage/coverage-${{ matrix.jamai-mode }}.xml coverage report --data-file=coverage/.coverage - - name: Pytest cloud coverage comment - uses: MishaKav/pytest-coverage-comment@main - if: always() && github.event_name == 'pull_request' && steps.merge_cloud_test_data.outcome == 'success' - with: - title: Coverage Report (cloud) - pytest-xml-coverage-path: services/api/coverage/coverage-cloud.xml - junitxml-path: junit-xml-data/pytest-cloud.xml - unique-id-for-comment: coverage_report_comment_cloud - report-only-changed-files: true - - - name: Merge OSS JUnit XML and Coverage data - id: merge_oss_test_data - if: needs.sdk_tests.result == 'success' - run: | - coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ - ./docker_data-coverage-data-oss-group[1-4] \ - ./pytest-coverage-data-oss-group[1-4] - - # Merge JUnit XML files - junitparser merge --glob "junit-xml-data/junit-xml-data-oss-*/pytest_*.xml" junit-xml-data/pytest-oss.xml - - - name: Generate oss coverage reports - if: always() && steps.merge_oss_test_data.outcome == 'success' - run: | - cd services/api - coverage xml --data-file=coverage/.coverage -o coverage/coverage-oss.xml - coverage report --data-file=coverage/.coverage - - - name: Pytest oss coverage comment + - name: Pytest coverage comment uses: MishaKav/pytest-coverage-comment@main - if: always() && github.event_name == 'pull_request' && steps.merge_oss_test_data.outcome == 'success' + if: always() && github.event_name == 'pull_request' && steps.merge_coverage.outcome == 'success' with: - title: Coverage Report (oss) - pytest-xml-coverage-path: services/api/coverage/coverage-oss.xml - junitxml-path: junit-xml-data/pytest-oss.xml - unique-id-for-comment: coverage_report_comment_oss + title: Coverage Report (${{ matrix.jamai-mode }}) + pytest-xml-coverage-path: services/api/coverage/coverage-${{ matrix.jamai-mode }}.xml + junitxml-path: junit-xml-data/pytest-${{ matrix.jamai-mode }}.xml + unique-id-for-comment: coverage_report_comment_${{ matrix.jamai-mode }} report-only-changed-files: true - name: Merge All JUnit XML and Coverage data id: merge_all_test_data - if: needs.sdk_tests.result == 'success' + if: needs.sdk_tests.result == 'success' && matrix.jamai-mode == 'oss' run: | coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ - ./docker_data_pytest-coverage-data-cloud \ ./docker_data-coverage-data-{cloud,oss}-group[1-4] \ ./pytest-coverage-data-{cloud,oss}-group[1-4] From 785c94ed53aaeef85ffa7258f1be32435b47d51e Mon Sep 17 00:00:00 2001 From: Tan Jia Huei Date: Thu, 13 Nov 2025 06:31:42 +0000 Subject: [PATCH 6/6] Fix CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba68280..ddcb2b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,8 +475,8 @@ jobs: if: needs.sdk_tests.result == 'success' && matrix.jamai-mode == 'oss' run: | coverage combine --keep --data-file=services/api/coverage/.coverage --rcfile=services/api/pyproject.toml \ - ./docker_data-coverage-data-{cloud,oss}-group[1-4] \ - ./pytest-coverage-data-{cloud,oss}-group[1-4] + ./docker_data-coverage-data-oss-group[1-4] \ + ./pytest-coverage-data-oss-group[1-4] # Merge JUnit XML files junitparser merge --glob "junit-xml-data/junit-xml-data-*/pytest_*.xml" junit-xml-data/pytest.xml

    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